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/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml new file mode 100644 index 0000000000..20b1a4cfd5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.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/architecture/2026-08-10-message-feedback-sidecar.md +2026-08-10-message-feedback-sidecar.md: 780cbaa840fcac7bcfa799468bfd61f5b08715cb +2026-08-10-message-feedback-sidecar.zh.md: 72ecc82717010f65d013418a45c3b38c6084aaf9 diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md new file mode 100644 index 0000000000..780cbaa840 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md @@ -0,0 +1,43 @@ +# Agent Note: Lifecycle-bound message feedback sidecar + +Status: implemented + +English | [中文](2026-08-10-message-feedback-sidecar.zh.md) + +## Problem + +The existing `/feedback` command records an immutable Session-level `feedback/record` event. That event can release a pending telemetry prefix under `FEEDBACK_ONLY`, so it is the wrong authority for an editable positive/negative rating and optional note attached to one assistant message. Message feedback needs independent update and delete semantics without entering the canonical Session log, changing a projection, reaching the model surface, or implicitly consenting to telemetry. + +A sidecar keyed only by `SessionId` can outlive the log lifecycle it describes when an id is recreated with a different header identity. A Session-wide revision also makes unrelated message edits conflict, while plain storage-domain read/put has no cross-process compare-and-swap. Session disposal is only live-store detach, not durable deletion, and the current Session persistence seam exposes no deletion operation that could own a truthful cascade. + +## Decision + +`@deepseek-ai/dsh-message-feedback` owns the `ctx.messageFeedback` service and stores message feedback as one storage-domain sidecar row per Session. The sidecar is neither Session-log content nor a Session projection. It emits no `feedback/record` event and performs no telemetry handoff; the command-feedback and message-feedback contracts remain independent. + +Every usable row is bound to the inspected Session header identity `{createdAt, cwd}`, not merely its `SessionId`. A lifecycle mismatch is treated as absence: `list` returns no items, and `put` may replace the stale row with one bound to the current identity. An id reused with a different header identity therefore cannot inherit stale feedback. A fork receives its own Session identity and no sidecar copy: even when the fork seed contains the same assistant messages, feedback remains attached to the Session in which the human recorded it. + +`put` accepts a target only when `SessionPersistence.inspect()` observes a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Inspection is the cold-safe authority: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization. + +Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references. + +Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize. + +A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. Plugin disposal closes admission, drains accepted queue work, and then closes the storage domain. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection. + +`maxNoteBytes` is a required deployment choice and bounds the UTF-8 byte length of an optional note; the Web Host bundle sets it explicitly to `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` contract directly through `GatewayService` and `@Remote`. Client Remote aggregate mounting and UI remain separately owned and deferred; their later adapter stays a thin consumer of this Host contract. + +The service performs no fake deletion cascade. `session/disposed` and `host/session-removed` describe detach from live ownership, not durable Session deletion, and Session persistence currently has no delete surface. Sidecar rows can therefore remain after out-of-band log removal; a different `{createdAt, cwd}` prevents such an orphan from becoming feedback for a later Session that reuses the id. + +## Alternatives considered + +**Append edits to the Session log and derive a projection.** Rejected because editable UI metadata would become canonical conversation-adjacent history, forks would replay and inherit it, deletion would require tombstones, and reusing `feedback/record` would silently couple a message rating to telemetry consent. + +**Key feedback globally by `MessageId`, copy it on fork, or use one Session revision.** Rejected because message ids are meaningful only within a Session lifecycle, forked conversations need independent human judgments, and unrelated message mutations must not create false conflicts. + +**Extend `KvTable` with cross-process compare-and-swap in this change.** Rejected because the shipped storage-domain backends expose no common conditional-write primitive. A process-local queue matches the supported one-Host topology; a real multi-process guarantee requires a backend-level atomic contract and is separate work. + +**Delete feedback on Session disposal.** Rejected because disposal includes ordinary detach and rollback paths. Treating it as durable deletion would lose feedback while the Session log still exists; cleanup waits for a real Session deletion authority. + +## Consequences + +Message feedback is locally durable and independently editable without changing model-visible history or telemetry behavior. Concurrent callers in one Host receive per-message conflict detection and retry-safe outcomes, while deployments with multiple writers to the same storage root remain unsupported. A differing header identity treats a stale row as absent but does not reclaim it; a cloned log that retains the same `{createdAt, cwd}` is indistinguishable by this contract. The Host Remote contract is available now; client assembly and UI can remain thin consumers rather than taking ownership of persistence or concurrency semantics. diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md new file mode 100644 index 0000000000..72ecc82717 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 绑定生命周期的消息反馈伴随记录 + +Status: implemented + +[English](2026-08-10-message-feedback-sidecar.md) | 中文 + +## 问题 + +现有 `/feedback` 命令记录不可变的 Session 级 `feedback/record` 事件。在 `FEEDBACK_ONLY` 下,该事件可以释放待处理的遥测前缀,因此它不适合作为挂在单条 assistant 消息上的可编辑好评/差评与可选备注的权威来源。消息反馈需要独立的更新与删除语义,且不得进入权威 Session 日志、改变投影、到达模型接口,或隐式表示遥测同意。 + +只按 `SessionId` 建索引的伴随记录可能在该 id 以不同 header 身份重建后,继续存活于其所描述的日志生命周期之外。Session 级 revision 还会让无关消息的编辑彼此冲突,而普通 storage-domain 读/写不提供跨进程 compare-and-swap。Session disposal 只是从 live store 脱离,并非持久删除;当前 Session 持久化 seam 也没有可拥有真实级联的删除操作。 + +## 决策 + +`@deepseek-ai/dsh-message-feedback` 拥有 `ctx.messageFeedback` 服务,并把消息反馈存为每个 Session 一条 storage-domain 伴随记录(sidecar)。该伴随记录既不是 Session 日志内容,也不是 Session 投影。它不发出 `feedback/record` 事件,也不执行遥测交接;command-feedback 与 message-feedback 契约保持独立。 + +每条可用记录都绑定到经检查的 Session header 身份 `{createdAt, cwd}`,而不只是其 `SessionId`。生命周期不匹配按不存在处理:`list` 返回空条目,`put` 可以用绑定当前身份的新记录替换陈旧行。因此,以不同 header 身份复用的 id 不会继承陈旧反馈。fork 拥有自己的 Session 身份,且不复制伴随记录:即使 fork 种子包含相同的 assistant 消息,反馈仍只属于人类记录它的那个 Session。 + +`put` 只接受由 `SessionPersistence.inspect()` 观测到的非空、append-origin `assistant/message`,且其 `MessageId` 必须与目标相同。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。检查使用 cold-safe 权威路径:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。 + +`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。 + +每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt` 和 `updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突会返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。 + +按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。Plugin disposal 会关闭接纳、排空已进入队列的工作,然后关闭 storage domain。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。 + +`maxNoteBytes` 是必填的部署选择,用于限制可选备注的 UTF-8 字节长度;Web Host bundle 将其显式设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 直接发布 Host `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete` 契约。客户端 Remote 聚合挂载与 UI 由各自边界负责并保持延后;后续适配层只是该 Host 契约的薄消费者。 + +服务不伪造删除级联。`session/disposed` 与 `host/session-removed` 表示脱离 live ownership,而非持久删除,Session persistence 当前也没有删除接口。因此在带外移除日志后,伴随记录可能继续存在;不同的 `{createdAt, cwd}` 可阻止此类孤儿记录变成后来复用该 id 的 Session 反馈。 + +## 考虑过的替代方案 + +**把编辑追加到 Session 日志并派生投影。** 不予采纳,因为可编辑 UI 元数据会变成权威且邻近对话的历史,fork 会回放并继承它,删除需要 tombstone,而复用 `feedback/record` 会把消息评分与遥测同意静默耦合。 + +**按全局 `MessageId` 建索引、在 fork 时复制,或使用一个 Session revision。** 不予采纳,因为消息 id 仅在某个 Session 生命周期内有意义,fork 后的对话需要独立的人类判断,而且无关消息的变更不应制造虚假冲突。 + +**在本次变更中为 `KvTable` 扩展跨进程 compare-and-swap。** 不予采纳,因为出厂 storage-domain 后端没有共同的条件写原语。进程内队列符合受支持的单 Host 拓扑;真实的多进程保证需要后端级原子契约,属于独立工作。 + +**在 Session disposal 时删除反馈。** 不予采纳,因为 disposal 包含普通 detach 与 rollback 路径。把它当成持久删除会在 Session 日志仍存在时丢失反馈;清理必须等待真正的 Session 删除权威。 + +## 后果 + +消息反馈在本地持久化并可独立编辑,且不改变模型可见历史或遥测行为。同一 Host 中的并发调用方获得逐消息冲突检测与可安全重试的结果;多个写入者共享同一存储根目录的部署仍不受支持。不同的 header 身份会让陈旧记录被视为不存在,但不会将其回收;本契约无法区分保留相同 `{createdAt, cwd}` 的克隆日志。Host Remote 契约现在可用;客户端组装与 UI 可以保持为薄消费者,而不接管持久化或并发语义。 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml index 527dd9bead..830b6467af 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md -2026-07-10-parallel-tool-call-execution.md: 830691b7596accd1c5746fc51e38a9022f09e501 -2026-07-10-parallel-tool-call-execution.zh.md: bd336919adbf9b96f64477c1e24964172cde6d92 +2026-07-10-parallel-tool-call-execution.md: 47a95e88c3d0d8bb9e3ddd820b1e63849effd914 +2026-07-10-parallel-tool-call-execution.zh.md: 024e5d4e4cdbf9d1d980bc67db5969fb34941752 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md index 830691b759..47a95e88c3 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -60,7 +60,7 @@ Any shared state touched during execution must be concurrency-safe. This include `maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../../docs/config-catalog.md). -The shipped declarations are conservative. Web search, web fetch, and filesystem read opt in. Filesystem writes and edits, bash tools, subagent delegation, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. A subagent may share its parent's workspace or external resources, and the unary classifier cannot prove that sibling delegations have disjoint effects. Bash has no proven input-sensitive classifier and remains exclusive. +The shipped declarations are conservative. Web search, web fetch, filesystem read, the session-query trace/read tools, and subagent delegation opt in — delegation because a child works in its own session and its run never mutates the parent session, with sibling workspace coordination owned by the model ([parallel subagent Agent Note](2026-08-09-parallel-subagent-delegations.md)). Filesystem writes and edits, bash tools, the session-query search tools, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. Bash has no proven input-sensitive classifier and remains exclusive. Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`. diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md index bd336919ad..024e5d4e4c 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md @@ -60,7 +60,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c `maxParallelToolCalls` 是 AgentLoop 的正整数部署上限,由工厂创建的所有 agent(智能体)共享。默认值为 `10`;`1` 保持串行执行。字段和默认值的精确定义见生成的[配置目录](../../../../docs/config-catalog.md)。 -当前实现中的声明保持保守。Web 搜索、Web 获取和文件系统读取选择并行。文件系统写入与编辑、bash 工具、subagent 委派、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。subagent 可能共享父级的工作区或外部资源,而一元分类器无法证明并列委派的作用互不重叠。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。 +当前实现中的声明保持保守。Web 搜索、Web 获取、文件系统读取、会话查询的 trace/read 工具和 subagent 委派选择并行;委派之所以并行,是因为子 agent 在自己的会话中工作,其运行绝不变更父会话,并列委派间的工作区协调由模型负责([并行 subagent Agent Note](2026-08-09-parallel-subagent-delegations.md))。文件系统写入与编辑、bash 工具、会话查询的 search 工具、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。 文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 `FS_STALE_VERSION`。 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-09-parallel-subagent-delegations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.i18n.yaml new file mode 100644 index 0000000000..65e73bdff8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.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-09-parallel-subagent-delegations.md +2026-08-09-parallel-subagent-delegations.md: 01dc2043c9a79666857c4f5aa988fc45dc7cdf56 +2026-08-09-parallel-subagent-delegations.zh.md: 2c2bd700e362000b41adbd1f3cec9ba26ff60b93 diff --git a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md new file mode 100644 index 0000000000..01dc2043c9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md @@ -0,0 +1,47 @@ +# Agent Note: Parallel subagent delegations + +Status: implemented + +English | [中文](2026-08-09-parallel-subagent-delegations.zh.md) + +## Problem + +A model that wants fan-out batches several `subagent` calls into one assistant message — that batch is the parallel intent. The delegation tool declared no `isConcurrencySafe` classifier, so the fail-closed scheduler ([parallel tool-call Agent Note](2026-07-10-parallel-tool-call-execution.md)) treated every foreground delegation as an exclusive barrier: nine cards in the GUI, one child running, eight queued behind it for its full runtime. + +The original conservative stance — a unary classifier cannot prove that sibling delegations have disjoint workspace effects — had stopped protecting anything. `run_in_background: true` and continuable delegations already overlap with every later call, including writes; `dsh-workflow-workerthread` already runs up to its concurrency ceiling of children through the same `ctx.subagents.start()` providers against the shared workspace. Only the foreground variant was serialized. + +## Decision + +`dsh-tool-subagent` declares `isConcurrencySafe: () => true` for every call form (foreground, one-shot background, continuable), so sibling delegations in one assistant step overlap under the loop's rolling pool up to `maxParallelToolCalls`, with results still committed in model order. + +The declaration satisfies the scheduler's safety contract structurally: a child works in its own session, a run never mutates the parent session (the start-time appends — `sandbox/mode`, `approval/policy`, `subagent/descriptor` — land only in the child's own log), and the tool returns its outputs to the loop for ordered commit. The one-shot background form's one parent-owned write is registering a Task through `tasks.start` — a synchronous, commutative insertion that satisfies the scheduler note's shared-state clause rather than the stronger no-mutation property. The provider seam requires concurrent starts and continuable preparations for distinct children to isolate operation-local state, cancellation, settlement, and cleanup. The bundled providers satisfy that contract: spawn and fork keep no mutable state between starts, fork reads only the parent's completed-turn prefix, out-of-process providers allocate state per run, and the continuation manager reserves a unique child identity and lock for each preparation. + +Coordinating sibling workspace effects is the model's responsibility, the stance the product already takes for background, continuable, and workflow children. Peer harnesses agree: Claude Code's Task tool is unconditionally concurrency-safe (cap 10), oh-my-pi's task tool defaults to its overlapping `shared` class, opencode's task tool runs unbounded under its SDK, and Codex sidesteps the question by making delegation an asynchronous spawn/wait mailbox. + +Capacity stays where the scheduler note put it: `maxParallelToolCalls` caps one step's unsettled tool calls — and therefore concurrently running foreground children — while background and continuable calls settle at start and free their pool slot, so children they leave running are not capped by it. LLM providers own their own capacity controls. + +## Testing + +Package tests pin the classifier for both call forms. A gate test drives the registry directly with two children that each block until both have started, proving the half the declaration depends on: the tool body and provider start path tolerate concurrent dispatch — hidden serialization in that stack would deadlock instead of passing silently. A continuable gate holds two provider preparations at the same await, cancels one caller before publication, and proves that the cancelled child leaves no Agent or durable Session while its sibling reaches inbox acceptance and persists independently. The scheduling half, classification actually producing overlap, is owned by the classifier pin and the snapshot below. + +The authored `subagent-parallel` snapshot pins the assembled-app transcript: one assistant message carries two subagent calls, the parent log records `tool/call, tool/call, tool/result, tool/result` (serial execution would interleave call/result pairs), and both children complete as separate sessions. Its twin delegations are deliberately identical: `dsh-llm-replay` binds child scripts by first-call order and the harvester orders children by `createdAt`, and neither is deterministic across concurrent children (the `XXX(concurrent-subagents)` marker), so only interchangeable twins replay race-free today. + +## Alternatives considered + +**Keep delegations exclusive.** The status quo protected nothing: background and workflow children already overlap freely with writes, so serializing the foreground variant only added latency and contradicted the model's explicit batching intent. + +**An input-sensitive classifier.** The call's arguments are a free-text description and prompt; nothing in them distinguishes a safe delegation from an unsafe one, so a conditional classifier would be theater. + +**A Codex-style asynchronous spawn/wait redesign.** Continuable children plus `send_message` already provide the asynchronous channel; rebuilding the foreground contract around a mailbox would discard a working synchronous result path to solve a scheduling problem one declaration fixes. + +**A per-instance `concurrencySafe` config knob.** No consumer needs a serial deployment: `maxParallelToolCalls: 1` already restores global serial execution, and peer-harness prior art defaults delegation to concurrency-safe. + +## Consequences + +Sibling children can race on shared workspace or external resources; the model owns that coordination, as it already does for every other overlapping child. Concurrent children also compete for LLM provider quota; `maxParallelToolCalls` caps only unsettled calls, not children a background or continuable call left running. + +Two one-shot background delegations in one message acquire their model-visible task ids (`subagent-`) in dispatch-race order. The ids are logged, so replay stays valid, but a snapshot scenario that distinguishes its background children would inherit the same determinism constraint as twin child sessions. + +Ordered commits may hold a fast child's result behind a slow earlier sibling — the trade the [scheduler note](2026-07-10-parallel-tool-call-execution.md) already accepted; live surfaces still show each child's own progress. + +A concurrent-children snapshot scenario with distinct prompts still needs replay-harness support (deterministic child-script binding and harvest ordering); until then such scenarios must use interchangeable twin delegations. diff --git a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md new file mode 100644 index 0000000000..2c2bd700e3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 并行 subagent 委派 + +Status: implemented + +[English](2026-08-09-parallel-subagent-delegations.md) | 中文 + +## 问题 + +想要扇出的模型会把多个 `subagent` 调用合并进同一条 assistant 消息:这个批次本身就是并行意图。委派工具此前没有声明 `isConcurrencySafe` 分类器,按安全侧原则设计的调度器([并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.md))便把每个前台委派都当作独占屏障:GUI 里显示九张卡片,却只有一个子 agent(智能体)在运行,其余八个要在它的整个运行期间排在其后等待。 + +最初的保守立场(一元分类器无法证明同级委派的工作区效果互不相交)已经不再保护任何东西:`run_in_background: true` 和可继续委派本来就会与其后的每个调用重叠执行,包括写入;`dsh-workflow-workerthread` 也早已通过同样的 `ctx.subagents.start()` 提供方在共享工作区上并发运行子 agent,数量可达其并发上限。只有前台形态被串行化。 + +## 决策 + +`dsh-tool-subagent` 为每种调用形态(前台、一次性后台、可继续)都声明 `isConcurrencySafe: () => true`,因此同一 assistant 步骤中的同级委派会在循环的滚动池下重叠执行,上限为 `maxParallelToolCalls`,结果仍按模型顺序提交。 + +该声明在结构上满足调度器的安全约定:子 agent 在自己的会话中工作,运行绝不变更父会话(启动时追加的 `sandbox/mode`、`approval/policy`、`subagent/descriptor` 只落在子 agent 自己的日志里),工具把输出返回给循环,由循环按顺序提交。一次性后台形态对父级拥有状态的唯一写入是通过 `tasks.start` 注册一个 Task——这是一次同步、可交换的插入,满足的是调度器 Agent Note 中的共享状态条款,而非更强的「无变更」性质。提供方 seam 要求针对不同子 agent 的并发启动和可继续准备分别隔离操作局部状态、取消、结算和清理。内置提供方满足这项约定:spawn 和 fork 在各次启动之间不保留可变状态,fork 只读取父级已完成轮次的前缀,进程外提供方按每次运行分配状态,继续执行管理器则为每次准备预留唯一的子 agent 身份和锁。 + +协调同级工作区效果是模型的职责,产品对后台、可继续和工作流子 agent 已经采取同样的立场。同类 harness 的做法一致:Claude Code 的 Task 工具无条件并发安全(上限 10);oh-my-pi 的 task 工具默认归入其可重叠的 `shared` 类别;opencode 的 task 工具在其 SDK 下不设上限地运行;Codex 则把委派做成异步 spawn/wait 信箱,绕开了这个问题。 + +容量控制仍保持在调度器 Agent Note 所定的位置:`maxParallelToolCalls` 限制单个步骤中未结算的工具调用数量——因而也限制并发运行的前台子 agent 数量——而后台和可继续调用在启动时即结算并释放池位,它们留下运行的子 agent 不受该上限约束。LLM(大语言模型)提供方负责自身的容量控制。 + +## 测试 + +包测试固定了两种调用形态的分类器。一个门控测试直接驱动注册表,其两个子 agent 各自阻塞,直到两者都已启动,以此证明该声明所依赖的那一半:工具体和提供方启动路径能容忍并发分发——这条栈中任何隐藏的串行化都会造成死锁,而不是静默通过。一个可继续门控测试让两项提供方准备停在同一个 await 上,在发布前取消其中一个调用方,并证明已取消的子 agent 不会留下 agent 或持久会话,而其同级则到达 inbox 接受状态并独立持久化。另一半(分类真正产生重叠执行)由分类器 pin 测试和下述快照负责。 + +人工编写的 `subagent-parallel` 快照固定了组装后应用的 transcript(文本记录):一条 assistant 消息携带两个 subagent 调用,父级日志记录为 `tool/call, tool/call, tool/result, tool/result`(串行执行会让调用/结果成对交错出现),两个子 agent 各自作为独立会话完成。其中的孪生委派刻意做成完全相同:`dsh-llm-replay` 按首次调用顺序绑定子脚本,harvester 按 `createdAt` 对子 agent 排序,二者在并发子 agent 之间都不具确定性(即 `XXX(concurrent-subagents)` 标记),因此目前只有可互换的孪生委派才能无竞态地回放。 + +## 备选方案 + +**保持委派独占。** 现状没有保护任何东西:后台和工作流子 agent 本来就可以带着写入自由重叠,串行化前台形态只会增加延迟,还违背模型显式表达的批量意图。 + +**使用输入敏感的分类器。** 该调用的参数只有自由文本的描述和提示词;其中没有任何内容能区分安全委派与不安全委派,因此条件式分类器只会流于形式。 + +**按 Codex 风格重新设计为异步 spawn/wait。** 可继续子 agent 加上 `send_message` 已经提供了异步通道;围绕信箱重建前台约定,等于为了解决一条声明就能修复的调度问题,丢弃一条可用的同步结果路径。 + +**按实例提供 `concurrencySafe` 配置开关。** 没有消费方需要串行部署:`maxParallelToolCalls: 1` 已能恢复全局串行执行,同类 harness 的先例也默认委派并发安全。 + +## 影响 + +同级子 agent 可能在共享工作区或外部资源上发生竞态;这项协调由模型负责,正如模型对其他所有重叠子 agent 已经承担的那样。并发子 agent 还会争用 LLM 提供方配额;`maxParallelToolCalls` 只限制未结算的调用,不限制后台或可继续调用留下运行的子 agent。 + +同一条消息中的两个一次性后台委派按分发竞态顺序获得各自模型可见的 task id(`subagent-`)。这些 id 已被记录,因此回放仍然有效;但需要区分后台子 agent 的快照场景会继承与孪生子会话相同的确定性约束。 + +有序提交可能让快速子 agent 的结果排在更早的缓慢同级之后等待,这是[调度器 Agent Note](2026-07-10-parallel-tool-call-execution.md)已经接受的取舍;实时界面仍会展示每个子 agent 各自的进度。 + +使用不同提示词的并发子 agent 快照场景仍需要回放 harness 的支持(确定性的子脚本绑定与收集排序);在此之前,此类场景必须使用可互换的孪生委派。 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/message-feedback-protocol.snapshot.ts b/apps/web/tests/message-feedback-protocol.snapshot.ts new file mode 100644 index 0000000000..9fbc556f77 --- /dev/null +++ b/apps/web/tests/message-feedback-protocol.snapshot.ts @@ -0,0 +1,115 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + assertFixtureInventory, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + type WebScaffold, +} from './scaffold.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-protocol', import.meta.url)) +const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json') +const SESSION_ID = 'message-feedback-protocol' +const MESSAGE_ID = '11111111-1111-4111-8111-111111111111' + +interface ProtocolExchange { + readonly endpoint: string + readonly request: unknown + readonly status: number + readonly response: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */ +function createdVersion(response: unknown): string { + if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true + || !isRecord(response.result.value) || response.result.value.ok !== true + || !isRecord(response.result.value.value) + || typeof response.result.value.value.version !== 'string') { + throw new Error('messageFeedback.put did not return a successful versioned item') + } + return response.result.value.value.version +} + +/** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */ +function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string { + return JSON.stringify(exchanges, (key, value: unknown) => { + if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}' + if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}' + return value + }, 2) +} + +describe('message feedback Host Remote protocol', () => { + let scaffold: WebScaffold + + beforeAll(async () => { + scaffold = await launchWebScaffold() + await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID) + }) + + afterAll(async () => { + await scaffold?.close() + }) + + it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => { + const exchanges: ProtocolExchange[] = [] + const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise => { + const payload = { args: { request } } + const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId, + method: endpoint, + payload, + }), + }) + const body: unknown = await response.json() + exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body }) + return body + } + + await invoke('feedback-invalid', 'messageFeedback/put', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + rating: 'invalid-rating', + ifVersion: null, + }) + await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID }) + const created = await invoke('feedback-put', 'messageFeedback/put', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + rating: 'positive', + note: 'Useful answer', + ifVersion: null, + }) + const version = createdVersion(created) + expect(version).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID }) + await invoke('feedback-conflict', 'messageFeedback/put', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + rating: 'negative', + ifVersion: null, + }) + await invoke('feedback-delete', 'messageFeedback/delete', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + ifVersion: version, + }) + await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID }) + + expect(exchanges.every(exchange => exchange.status === 200)).toBe(true) + await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode) + await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.jsonl']) + }) +}) diff --git a/apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json b/apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json new file mode 100644 index 0000000000..302e4876df --- /dev/null +++ b/apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json @@ -0,0 +1,203 @@ +[ + { + "endpoint": "/api/messageFeedback/put", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "invalid-rating", + "ifVersion": null + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-invalid", + "result": { + "ok": false, + "error": { + "code": "internal", + "message": "typert gateway: messageFeedback/put: wire field \"request\" failed boundary validation", + "details": {} + } + } + } + }, + { + "endpoint": "/api/messageFeedback/list", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-list-empty", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "items": [] + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/put", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "ifVersion": null + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-put", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "version": "{{version}}", + "createdAt": "{{timestamp}}", + "updatedAt": "{{timestamp}}" + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/list", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-list-created", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "items": [ + { + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "version": "{{version}}", + "createdAt": "{{timestamp}}", + "updatedAt": "{{timestamp}}" + } + ] + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/put", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "negative", + "ifVersion": null + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-conflict", + "result": { + "ok": true, + "value": { + "ok": false, + "error": { + "code": "version-conflict", + "current": { + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "version": "{{version}}", + "createdAt": "{{timestamp}}", + "updatedAt": "{{timestamp}}" + } + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/delete", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "ifVersion": "{{version}}" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-delete", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "absent": true + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/list", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-list-deleted", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "items": [] + } + } + } + } + } +] diff --git a/apps/web/tests/snapshots/message-feedback-protocol/session.jsonl b/apps/web/tests/snapshots/message-feedback-protocol/session.jsonl new file mode 100644 index 0000000000..d970fb8cb4 --- /dev/null +++ b/apps/web/tests/snapshots/message-feedback-protocol/session.jsonl @@ -0,0 +1,7 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786406400000,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":1786406400001,"data":{"turn":1}} +{"type":"user/message","seq":1,"time":1786406400002,"data":{"role":"user","content":[{"type":"text","text":"Give one useful answer."}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1786406400003,"data":{"turn":1,"step":1}} +{"type":"assistant/message","seq":3,"time":1786406400004,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"A useful answer."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"11111111-1111-4111-8111-111111111111"},"usage":{"inputTokens":4,"outputTokens":4}},"surfaceOp":"append"} +{"type":"step/end","seq":4,"time":1786406400005,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":5,"time":1786406400006,"data":{"turn":1,"reason":{"kind":"completed"}}} 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 b80a2f6e2b..f96a1f8dd1 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -25,6 +25,7 @@ "tests/scaffold.ts", "tests/scaffold-hermetic.e2e.ts", "tests/minimal-preset.snapshot.ts", + "tests/message-feedback-protocol.snapshot.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", "tests/approval-composer.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 344bac8145..ea8b934c79 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 8d1c5a1be391e2455aefc69db89d96027aaf3efa -architecture.zh.md: 53bf9f54a5503ae40aa62a348822f9d92162dda2 +architecture.md: aeda7f9674e75a1e97549f25c13d571b3b37ee8c +architecture.zh.md: a25f20ba9babefeaab4636027e97d6f2d4ae8caf diff --git a/docs/architecture.md b/docs/architecture.md index 8d1c5a1be3..aeda7f9674 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | +| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | lifecycle-bound editable feedback for individual assistant messages and its Host Remote contract | | `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | durable session-log storage | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools | | `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | log-backed fallbacks, one optional asynchronous provider | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 53bf9f54a5..a25f20ba9b 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -42,6 +42,7 @@ | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 | +| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | 绑定生命周期的单条 assistant 消息可编辑反馈及其 Host Remote 契约 | | `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | 会话日志的持久化存储 | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 | | `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | 基于日志的回退标题和单个可选异步提供方 | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index d6c4832447..c0091e400e 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: c102167aa76b9ba613b1b434cb0aa58765d26106 -capability-seams.zh.md: 7c4eab8d5a2d890bdf4513bb9acdc81f55414642 +capability-seams.md: 64d20b1bfb609aec3acb3673e4589516bb315bfa +capability-seams.zh.md: 10b5116d12991319df55c551d51840bb566ac898 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index c102167aa7..64d20b1bfb 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -30,6 +30,7 @@ flowchart LR pkg_session_query_sqlite["session-query-sqlite"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] + pkg_message_feedback["message-feedback"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -60,6 +61,7 @@ flowchart LR pkg_storage_domain["storage-domain"] svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] + svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"] svc_workspace["ctx.workspace
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] @@ -218,6 +220,7 @@ flowchart LR pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm pkg_llm_replay --> svc_llm + pkg_message_feedback --> svc_messageFeedback pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode @@ -322,6 +325,7 @@ flowchart LR svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude svc_sessionPersistence --> pkg_hooks_codex + svc_sessionPersistence --> pkg_message_feedback svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash @@ -334,6 +338,7 @@ flowchart LR svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants + svc_sessions --> pkg_message_feedback svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite @@ -344,6 +349,7 @@ flowchart LR svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain + svc_storageDomain --> pkg_message_feedback svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent @@ -392,16 +398,17 @@ flowchart LR | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | -| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | +| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | +| `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry. | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 7c4eab8d5a..10b5116d12 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -32,6 +32,7 @@ flowchart LR pkg_session_query_sqlite["session-query-sqlite"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] + pkg_message_feedback["message-feedback"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -62,6 +63,7 @@ flowchart LR pkg_storage_domain["storage-domain"] svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] + svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"] svc_workspace["ctx.workspace
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] @@ -220,6 +222,7 @@ flowchart LR pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm pkg_llm_replay --> svc_llm + pkg_message_feedback --> svc_messageFeedback pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode @@ -324,6 +327,7 @@ flowchart LR svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude svc_sessionPersistence --> pkg_hooks_codex + svc_sessionPersistence --> pkg_message_feedback svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash @@ -336,6 +340,7 @@ flowchart LR svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants + svc_sessions --> pkg_message_feedback svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite @@ -346,6 +351,7 @@ flowchart LR svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain + svc_storageDomain --> pkg_message_feedback svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent @@ -394,16 +400,17 @@ flowchart LR | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、[`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop)、[`compact-basic`](../packages/compact/compact-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop)、[`agent`](../packages/core/agent)、[`session-persistence`](../packages/session/session-persistence)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess)、[`invariants`](../packages/support/invariants) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop)、[`agent`](../packages/core/agent)、[`session-persistence`](../packages/session/session-persistence)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess)、[`invariants`](../packages/support/invariants)、[`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session)、[`agent`](../packages/core/agent)、[`scope`](../packages/core/scope)、[`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader)、[`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl)、[`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop)、[`tool-bash`](../packages/bash/tool-bash)、[`hooks-claude`](../packages/hooks/hooks-claude)、[`hooks-codex`](../packages/hooks/hooks-codex)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl)、[`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop)、[`tool-bash`](../packages/bash/tool-bash)、[`hooks-claude`](../packages/hooks/hooks-claude)、[`hooks-codex`](../packages/hooks/hooks-codex)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、`apiproxy` | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、`apiproxy` | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json)、[`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 | -| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 | +| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace)、[`message-feedback`](../packages/feedback/message-feedback) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 | +| `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有本地逐 assistant 消息反馈、生命周期与目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约,且不进入 Session 历史或遥测。 | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference)、[`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4831a38704..b61dc9b7b1 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 71a828164b47387f6617dd5abaf7211f39257044 -config-catalog.zh.md: 65a363c2deefbec8b8f01ddcff3dacb29b141b69 +config-catalog.md: 28d486852789eb7d95642f174d12e973635456c0 +config-catalog.zh.md: 08d0b3f0d1552c51b9ef3964bc48919105cd50b2 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 71a828164b..28d4868527 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1129,6 +1129,20 @@ export interface ReconnectConfig { Source: [`packages/mcp/mcp-client/src/index.ts:98`](../packages/mcp/mcp-client/src/index.ts) +## `@deepseek-ai/dsh-message-feedback` + +Requires: `storageDomain` · `sessionPersistence` · `sessions` + +```ts config-catalog +/** Required deployment policy for optional notes. */ +export interface Config { + /** Maximum UTF-8 byte length accepted for one note. */ + readonly maxNoteBytes: number +} +``` + +Source: [`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts) + ## `@deepseek-ai/dsh-permission` Requires: `bash` · `approval` · `sessions` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 65a363c2de..08d0b3f0d1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1131,6 +1131,20 @@ export interface ReconnectConfig { 来源:[`packages/mcp/mcp-client/src/index.ts:94`](../packages/mcp/mcp-client/src/index.ts) +## `@deepseek-ai/dsh-message-feedback` + +需要:`storageDomain` · `sessionPersistence` · `sessions` + +```ts config-catalog +/** Required deployment policy for optional notes. */ +export interface Config { + /** Maximum UTF-8 byte length accepted for one note. */ + readonly maxNoteBytes: number +} +``` + +来源:[`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts) + ## `@deepseek-ai/dsh-permission` 需要:`bash` · `approval` · `sessions` 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.i18n.yaml b/docs/module-graph.i18n.yaml index 9804824123..ecaffc802d 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 7514e79fbba0fc9d0ce9d3dc63921ca7241916bc -module-graph.zh.md: 86307235d88d98c4e2d7c6333e36630af4cce5c1 +module-graph.md: 22ac416d01cc281f84c75fda77262c04a2a1a0e8 +module-graph.zh.md: 43c05b511edd8802b8b65cf6e9d2c4ad3d7e717d diff --git a/docs/module-graph.md b/docs/module-graph.md index 7514e79fbb..22ac416d01 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -201,6 +201,7 @@ flowchart TD end subgraph group_feedback["packages/feedback"] pkg_command_feedback["command-feedback"] + pkg_message_feedback["message-feedback"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] @@ -571,6 +572,13 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_invariants + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_storage_domain + pkg_message_feedback --> pkg_type_meta pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse @@ -1350,6 +1358,7 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 86307235d8..43c05b511e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -203,6 +203,7 @@ flowchart TD end subgraph group_feedback["packages/feedback"] pkg_command_feedback["command-feedback"] + pkg_message_feedback["message-feedback"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] @@ -573,6 +574,13 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_invariants + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_storage_domain + pkg_message_feedback --> pkg_type_meta pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse @@ -1352,6 +1360,7 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 9e578f7886..ebdccb923a 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: fddbf460c8e9e7c6f9ed1d3375bdabe65947661f -README.zh.md: febc5a97426fef5ec4b2b80d9677957369994a3b +README.md: 560851eeda607b762456fe20c874b4497704709f +README.zh.md: 4acba4372e995b54a3bb326bec0cf9606f997773 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index fddbf460c8..560851eeda 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -18,6 +18,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | | [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | +| [feedback.md](feedback.md) | lifecycle-bound per-message feedback records, optimistic versions, sidecar persistence, and the Host Remote contract | | [session-title.md](session-title.md) | durable title snapshots, cited source-message seqs, and the asynchronous provider contract | | [session-reference.md](session-reference.md) | structured cross-session references: `SessionReferenceInput`/`Candidate`, prepared message contexts, the stable error taxonomy | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index febc5a9742..4acba4372e 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -18,6 +18,7 @@ | [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | | [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | +| [feedback.md](feedback.md) | 绑定生命周期的逐消息反馈记录、乐观版本、伴随记录持久化与 Host Remote 契约 | | [session-title.md](session-title.md) | 持久标题快照、被引用的来源消息 seq 与异步提供方约定 | | [session-reference.md](session-reference.md) | 结构化跨会话引用:`SessionReferenceInput`/`Candidate`、prepared 消息上下文、稳定错误分类 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml new file mode 100644 index 0000000000..f6de2debe9 --- /dev/null +++ b/docs/subsystems/feedback.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/subsystems/feedback.md +feedback.md: 76a29f7d6ba604fa07ed56429c9b066e22639671 +feedback.zh.md: 5a409832de68b6d0bc9688a907c0f22edd3b0a43 diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md new file mode 100644 index 0000000000..76a29f7d6b --- /dev/null +++ b/docs/subsystems/feedback.md @@ -0,0 +1,256 @@ +# Message Feedback + +English | [中文](feedback.zh.md) + +[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback) owns editable feedback for individual assistant messages. It is deliberately separate from the immutable Session-level `feedback/record` event: message feedback is a local storage-domain sidecar, not Session-log content or a projection, and it performs no telemetry handoff. + +Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) + +## Public types + +```ts type-equiv +/** Opaque compare-and-set token for one exact feedback item revision. */ +type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'> +``` + +```ts type-equiv +/** The human's overall judgment of one assistant message. */ +type MessageFeedbackRating = 'positive' | 'negative' +``` + +```ts type-equiv +/** One current feedback value and its opaque mutation token. */ +interface MessageFeedbackItem { + /** Stable identity of the assistant message inside the owning Session. */ + readonly messageId: MessageId + /** Overall positive or negative judgment. */ + readonly rating: MessageFeedbackRating + /** Optional explanation, preserved verbatim after validation. */ + readonly note?: string + /** Equality-only token replaced by every material create or update. */ + readonly version: MessageFeedbackVersion + /** Host-assigned creation time in Unix epoch milliseconds. */ + readonly createdAt: number + /** Host-assigned time of the most recent material update. */ + readonly updatedAt: number +} +``` + +```ts type-equiv +/** Read all message feedback belonging to one persisted Session lifecycle. */ +interface MessageFeedbackListRequest { + /** Persisted Session whose sidecar should be read. */ + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** Current feedback values for one Session, in first-creation order. */ +interface MessageFeedbackListValue { + /** Fresh immutable item snapshots. */ + readonly items: readonly MessageFeedbackItem[] +} +``` + +```ts type-equiv +/** Create or replace feedback for one assistant message. */ +interface MessageFeedbackPutRequest { + /** Persisted Session that owns the target message. */ + readonly sessionId: SessionId + /** Target assistant-message identity. */ + readonly messageId: MessageId + /** Desired overall judgment. */ + readonly rating: MessageFeedbackRating + /** Optional non-blank explanation. */ + readonly note?: string + /** Observed item version, or `null` to require that no item exists. */ + readonly ifVersion: MessageFeedbackVersion | null +} +``` + +```ts type-equiv +/** Delete feedback for one message after observing its current version. */ +interface MessageFeedbackDeleteRequest { + /** Persisted Session that owns the sidecar. */ + readonly sessionId: SessionId + /** Message whose feedback should be absent after this operation. */ + readonly messageId: MessageId + /** Observed item version; ignored when the item is already absent. */ + readonly ifVersion: MessageFeedbackVersion +} +``` + +```ts type-equiv +/** Idempotent deletion acknowledgement. */ +interface MessageFeedbackDeleteValue { + /** Stable postcondition shared by the first deletion and every retry. */ + readonly absent: true +} +``` + +```ts type-equiv +/** No persisted Session header exists for the requested id. */ +interface MessageFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** The id does not name a derived, append-origin assistant message. */ +interface MessageFeedbackTargetNotFound { + readonly code: 'target-not-found' + readonly sessionId: SessionId + readonly messageId: MessageId +} +``` + +```ts type-equiv +/** A material mutation did not match the addressed item's current version. */ +interface MessageFeedbackVersionConflict { + readonly code: 'version-conflict' + /** Authoritative current item, or `null` when it does not exist. */ + readonly current: MessageFeedbackItem | null +} +``` + +```ts type-equiv +/** A supplied note contains no non-whitespace character. */ +interface MessageFeedbackNoteBlank { + readonly code: 'note-blank' +} +``` + +```ts type-equiv +/** A supplied note exceeds the configured UTF-8 byte limit. */ +interface MessageFeedbackNoteTooLarge { + readonly code: 'note-too-large' + readonly maxBytes: number + readonly actualBytes: number +} +``` + +```ts type-equiv +/** Failures shared by the public message-feedback operations. */ +type MessageFeedbackFailure = + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge +``` + +```ts type-equiv +/** Successful public operation result. */ +interface MessageFeedbackSuccess { + readonly ok: true + readonly value: T +} +``` + +```ts type-equiv +/** Rejected public operation result with a stable business failure. */ +interface MessageFeedbackRejected { + readonly ok: false + readonly error: E +} +``` + +```ts type-equiv +/** Result returned by the message-feedback `list` operation. */ +type MessageFeedbackListResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +```ts type-equiv +/** Result returned by the message-feedback `put` operation. */ +type MessageFeedbackPutResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected< + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + > +``` + +```ts type-equiv +/** Result returned by the message-feedback `delete` operation. */ +type MessageFeedbackDeleteResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +## Data and concurrency + +One Session sidecar row contains its header identity `{createdAt, cwd}` and feedback items keyed by `MessageId`. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them. + +`put` uses strict optimistic concurrency: every request for an existing item must match its current `ifVersion`, including a no-op. A conflict returns the authoritative current item (or `null`), so a caller can reconcile a lost response or a concurrent edit without another read. Deleting an already absent item succeeds. A per-Session queue encloses inspection, read, conflict evaluation, and whole-row write, so these guarantees cover concurrent calls in one Host process. + +## Target and lifecycle authority + +`SessionPersistence.inspect()` supplies the target Session observation without publishing or resuming an Agent and without committing cold repair. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session propagates as infrastructure failure. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin, usage-only empty, and non-assistant records are not feedback targets. + +The stored `{createdAt, cwd}` identity must match the inspected header. A mismatch is treated as absence: `list` returns no items, while `put` may replace the stale row with one bound to the current header identity. Forks use a new Session identity and receive no sidecar copy even when their seed contains the same messages. + +## Persistence and Remote contract + +The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; both live and cold paths are then physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `GatewayService` and `@Remote`; the generated Cordis surface below is the method-level authority. + +Plugin disposal closes mutation admission, drains accepted per-Session queue work, and then closes the storage domain. + +## Boundaries and limitations + +- The client Remote aggregate mount and UI consumer are separately owned and deferred. +- The mutation queue is process-local. Storage-domain has no cross-process conditional write, so multiple Host writers to one storage root have no compare-and-swap or lost-update guarantee. +- Session persistence has no durable deletion surface. The service does not treat `session/disposed` or `host/session-removed` as deletion and therefore performs no fake cascade; orphan sidecar rows may remain after out-of-band log removal. +- A request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. +- Cold requests scan the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. One Session row also has no item-count or aggregate-byte cap; `maxNoteBytes` bounds only each note until a concrete consumer owns a row policy. +- Header identity detects a reused id only when `{createdAt, cwd}` differs; a cloned log retaining the same header identity is indistinguishable by this contract. +- The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary. + + + + + +## Cordis surface + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.messageFeedback` — `MessageFeedbackService` + +Storage-domain sidecar service. It inspects persisted Session history and never creates or resumes an Agent or Session. + +```ts cordis-catalog +/** + * Read feedback belonging to the current persisted Session lifecycle. + * A stale row from a reused Session id is invisible. + * @param request - Session identity to inspect and list. + * @returns current immutable items or `session-not-found`. + */ +@Remote('list') async list(request: MessageFeedbackListRequest): Promise + +/** + * Create or replace feedback for one derived append-origin assistant + * message. Every request must match the addressed item's current version; + * a matching no-op returns the stored item without changing its revision. + * @param request - target, desired value, and observed item version. + * @returns the committed item or an explicit business failure. + */ +@Remote('put') put(request: MessageFeedbackPutRequest): Promise + +/** + * Delete one feedback item. Absence is successful regardless of the + * supplied version; an existing item requires an exact version match. + * @param request - Session, message, and observed item version. + * @returns the stable absent postcondition, or an explicit failure. + */ +@Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise +``` + +Source: [`packages/feedback/message-feedback/src/index.ts:150`](../../packages/feedback/message-feedback/src/index.ts) + diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md new file mode 100644 index 0000000000..5a409832de --- /dev/null +++ b/docs/subsystems/feedback.zh.md @@ -0,0 +1,256 @@ +# 消息反馈 + +[English](feedback.md) | 中文 + +[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback)拥有针对单条 assistant 消息的可编辑反馈。它刻意与不可变的 Session 级 `feedback/record` 事件分离:message feedback 是本地 storage-domain 伴随记录(sidecar),不是 Session 日志内容或投影,也不执行遥测交接。 + +来源:[`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) + +## 公开类型 + +```ts type-equiv +/** Opaque compare-and-set token for one exact feedback item revision. */ +type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'> +``` + +```ts type-equiv +/** The human's overall judgment of one assistant message. */ +type MessageFeedbackRating = 'positive' | 'negative' +``` + +```ts type-equiv +/** One current feedback value and its opaque mutation token. */ +interface MessageFeedbackItem { + /** Stable identity of the assistant message inside the owning Session. */ + readonly messageId: MessageId + /** Overall positive or negative judgment. */ + readonly rating: MessageFeedbackRating + /** Optional explanation, preserved verbatim after validation. */ + readonly note?: string + /** Equality-only token replaced by every material create or update. */ + readonly version: MessageFeedbackVersion + /** Host-assigned creation time in Unix epoch milliseconds. */ + readonly createdAt: number + /** Host-assigned time of the most recent material update. */ + readonly updatedAt: number +} +``` + +```ts type-equiv +/** Read all message feedback belonging to one persisted Session lifecycle. */ +interface MessageFeedbackListRequest { + /** Persisted Session whose sidecar should be read. */ + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** Current feedback values for one Session, in first-creation order. */ +interface MessageFeedbackListValue { + /** Fresh immutable item snapshots. */ + readonly items: readonly MessageFeedbackItem[] +} +``` + +```ts type-equiv +/** Create or replace feedback for one assistant message. */ +interface MessageFeedbackPutRequest { + /** Persisted Session that owns the target message. */ + readonly sessionId: SessionId + /** Target assistant-message identity. */ + readonly messageId: MessageId + /** Desired overall judgment. */ + readonly rating: MessageFeedbackRating + /** Optional non-blank explanation. */ + readonly note?: string + /** Observed item version, or `null` to require that no item exists. */ + readonly ifVersion: MessageFeedbackVersion | null +} +``` + +```ts type-equiv +/** Delete feedback for one message after observing its current version. */ +interface MessageFeedbackDeleteRequest { + /** Persisted Session that owns the sidecar. */ + readonly sessionId: SessionId + /** Message whose feedback should be absent after this operation. */ + readonly messageId: MessageId + /** Observed item version; ignored when the item is already absent. */ + readonly ifVersion: MessageFeedbackVersion +} +``` + +```ts type-equiv +/** Idempotent deletion acknowledgement. */ +interface MessageFeedbackDeleteValue { + /** Stable postcondition shared by the first deletion and every retry. */ + readonly absent: true +} +``` + +```ts type-equiv +/** No persisted Session header exists for the requested id. */ +interface MessageFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** The id does not name a derived, append-origin assistant message. */ +interface MessageFeedbackTargetNotFound { + readonly code: 'target-not-found' + readonly sessionId: SessionId + readonly messageId: MessageId +} +``` + +```ts type-equiv +/** A material mutation did not match the addressed item's current version. */ +interface MessageFeedbackVersionConflict { + readonly code: 'version-conflict' + /** Authoritative current item, or `null` when it does not exist. */ + readonly current: MessageFeedbackItem | null +} +``` + +```ts type-equiv +/** A supplied note contains no non-whitespace character. */ +interface MessageFeedbackNoteBlank { + readonly code: 'note-blank' +} +``` + +```ts type-equiv +/** A supplied note exceeds the configured UTF-8 byte limit. */ +interface MessageFeedbackNoteTooLarge { + readonly code: 'note-too-large' + readonly maxBytes: number + readonly actualBytes: number +} +``` + +```ts type-equiv +/** Failures shared by the public message-feedback operations. */ +type MessageFeedbackFailure = + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge +``` + +```ts type-equiv +/** Successful public operation result. */ +interface MessageFeedbackSuccess { + readonly ok: true + readonly value: T +} +``` + +```ts type-equiv +/** Rejected public operation result with a stable business failure. */ +interface MessageFeedbackRejected { + readonly ok: false + readonly error: E +} +``` + +```ts type-equiv +/** Result returned by the message-feedback `list` operation. */ +type MessageFeedbackListResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +```ts type-equiv +/** Result returned by the message-feedback `put` operation. */ +type MessageFeedbackPutResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected< + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + > +``` + +```ts type-equiv +/** Result returned by the message-feedback `delete` operation. */ +type MessageFeedbackDeleteResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +## 数据与并发 + +每个 Session 的一条伴随记录包含 header 身份 `{createdAt, cwd}` 和以 `MessageId` 为键的反馈条目。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。 + +`put` 采用严格乐观并发:已有条目的每次请求都必须匹配当前 `ifVersion`,即使请求不会改变目标值。冲突会返回权威当前条目(不存在时为 `null`),因此调用方无需额外读取,即可协调丢失响应或并发编辑。删除已经不存在的条目同样成功。按 Session 划分的队列覆盖检查、读取、冲突判断与整行写入,因此这些保证适用于单个 Host 进程中的并发调用。 + +## 目标与生命周期权威 + +`SessionPersistence.inspect()` 提供目标 Session 的观测,且不会发布或恢复 Agent,也不会提交 cold repair。cold 路径先由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,会按基础设施故障原样传播。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin、仅承载 usage 的空记录和非 assistant 记录都不是反馈目标。 + +存储的 `{createdAt, cwd}` 身份必须与检查所得 header 匹配。不匹配按不存在处理:`list` 返回空条目,`put` 则可用绑定当前 header 身份的新记录替换陈旧行。fork 使用新的 Session 身份,即使种子包含相同消息,也不获得伴随记录副本。 + +## 持久化与 Remote 契约 + +服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。 + +Plugin disposal 会先关闭变更接纳,排空已进入各 Session 队列的工作,然后才关闭 storage domain。 + +## 边界与限制 + +- 客户端 Remote 聚合挂载与 UI 消费方由各自边界负责并保持延后。 +- 变更队列仅在进程内生效。storage-domain 没有跨进程条件写,因此多个 Host 写入同一存储根目录时,不提供 compare-and-swap 或防止丢失更新的保证。 +- Session persistence 没有持久删除接口。服务不把 `session/disposed` 或 `host/session-removed` 当作删除,因此不伪造级联;在带外移除日志后,孤儿伴随记录可能继续存在。 +- 请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 +- 由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。单个 Session 行也没有条目数或聚合字节上限;在具体消费方拥有行策略之前,`maxNoteBytes` 只限制每条备注。 +- 只有 `{createdAt, cwd}` 不同时,header 身份才能识别复用的 id;本契约无法区分保留相同 header 身份的克隆日志。 +- Host 契约不记录已认证的 actor 或审计身份,因此假设调用方边界可信。 + + + + + +## Cordis surface + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.messageFeedback` — `MessageFeedbackService` + +Storage-domain sidecar service. It inspects persisted Session history and never creates or resumes an Agent or Session. + +```ts cordis-catalog +/** + * Read feedback belonging to the current persisted Session lifecycle. + * A stale row from a reused Session id is invisible. + * @param request - Session identity to inspect and list. + * @returns current immutable items or `session-not-found`. + */ +@Remote('list') async list(request: MessageFeedbackListRequest): Promise + +/** + * Create or replace feedback for one derived append-origin assistant + * message. Every request must match the addressed item's current version; + * a matching no-op returns the stored item without changing its revision. + * @param request - target, desired value, and observed item version. + * @returns the committed item or an explicit business failure. + */ +@Remote('put') put(request: MessageFeedbackPutRequest): Promise + +/** + * Delete one feedback item. Absence is successful regardless of the + * supplied version; an existing item requires an exact version match. + * @param request - Session, message, and observed item version. + * @returns the stable absent postcondition, or an explicit failure. + */ +@Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise +``` + +Source: [`packages/feedback/message-feedback/src/index.ts:150`](../../packages/feedback/message-feedback/src/index.ts) + 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/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 18c848a35b..7c195c00f0 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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/subagent.md -subagent.md: f6f778ae09677ceebaa35c577dd2e6b2aae35c21 -subagent.zh.md: dd91e3789407fcd986a8e58c58a9f1f53bae87c0 +subagent.md: 71ad9bbb6d3093aefd6c0ebcf891330cc0ed707e +subagent.zh.md: 13754acc0606354e823abe7ba1d17ec295ec6670 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index f6f778ae09..71ad9bbb6d 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -411,7 +411,10 @@ Each provider is a named child-agent transport, and multiple providers may coexi /** * One registered transport for running child agents. Providers are trusted * same-process implementations; callers treat descriptors and returned values - * as borrowed immutable data. + * as borrowed immutable data. The service may call one provider concurrently + * for distinct children. Providers isolate operation-local mutable state; a + * shared capacity controller may delay an operation but must not couple its + * settlement or cleanup to a sibling. */ interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ @@ -432,7 +435,8 @@ interface SubagentProvider { * initial turn. Before fulfillment, the provider owns setup and cleans any * unpublished partial resources before rejecting. Ownership transfers on * fulfillment; subsequent turn or infrastructure failure settles through - * the returned run. + * the returned run. Distinct starts may overlap; cancellation, failure, + * result settlement, and disposal remain independent for each run. */ start(request: ResolvedSubagentStartRequest): Promise /** @@ -447,6 +451,8 @@ interface SubagentProvider { * continuation manager owns identity reservation, composition, Agent * creation, prompt delivery, cold resume, ownership, and disposal, so a * provider never sees the child's Agent, handle, turns, or teardown. + * Distinct preparations may overlap; each follows its own signal and returns + * data belonging only to `request.sessionId`. */ prepareContinuable?(request: ContinuableCreateRequest): Promise } diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index dd91e37894..13754acc06 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -413,7 +413,10 @@ interface SubagentRun { /** * One registered transport for running child agents. Providers are trusted * same-process implementations; callers treat descriptors and returned values - * as borrowed immutable data. + * as borrowed immutable data. The service may call one provider concurrently + * for distinct children. Providers isolate operation-local mutable state; a + * shared capacity controller may delay an operation but must not couple its + * settlement or cleanup to a sibling. */ interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ @@ -434,7 +437,8 @@ interface SubagentProvider { * initial turn. Before fulfillment, the provider owns setup and cleans any * unpublished partial resources before rejecting. Ownership transfers on * fulfillment; subsequent turn or infrastructure failure settles through - * the returned run. + * the returned run. Distinct starts may overlap; cancellation, failure, + * result settlement, and disposal remain independent for each run. */ start(request: ResolvedSubagentStartRequest): Promise /** @@ -449,6 +453,8 @@ interface SubagentProvider { * continuation manager owns identity reservation, composition, Agent * creation, prompt delivery, cold resume, ownership, and disposal, so a * provider never sees the child's Agent, handle, turns, or teardown. + * Distinct preparations may overlap; each follows its own signal and returns + * data belonging only to `request.sessionId`. */ prepareContinuable?(request: ContinuableCreateRequest): Promise } 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/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 44e26824bb..0cddc9a504 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -375,6 +375,12 @@ const SCENARIOS: Scenario[] = [ // parent's tool result must retain that assistant output and stop reason. { name: 'subagent-max-tokens-partial', hasModelTurn: true, recorded: false }, { name: 'subagent-multi', hasModelTurn: true, recorded: true }, + // Authored keyless replay: one assistant message carries two subagent calls + // and the parent log pins call/call/result/result instead of the serial + // interleaving. The twin delegations must stay identical: replay binds child + // scripts and harvest order nondeterministically across concurrent children + // (XXX(concurrent-subagents) in dsh-llm-replay). + { name: 'subagent-parallel', hasModelTurn: true, recorded: false }, { name: 'subagent-fork', hasModelTurn: true, recorded: true }, { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, // Authored continuable-subagent transcript: a background delegation returns diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/input.json b/examples/acp-agent/tests/snapshots/subagent-parallel/input.json new file mode 100644 index 0000000000..86e72676ee --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the subagent tool TWICE in the SAME assistant message (two parallel tool calls in one response), each delegating the identical subtask: 'Reply with exactly the word ALPHA and nothing else.' Give both calls the description 'Say the word ALPHA'. After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl new file mode 100644 index 0000000000..d5505a1866 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"bbbbbbbb-0000-4000-8000-000000000002","createdAt":1783352127000,"cwd":"{{cwd}}","parentSession":"aaaaaaaa-0000-4000-8000-000000000001","origin":"subagent","delegationDepth":1} +{"type":"approval/policy","seq":0,"time":1786373947132,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786373947134,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a4921868-38d9-4bd3-8b1b-6e64fc09d87c"}]}} +{"type":"turn/start","seq":2,"time":1786373947134,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786373947134,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786373947165,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} +{"type":"step/start","seq":5,"time":1786373947168,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1786338530759,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a4921868-38d9-4bd3-8b1b-6e64fc09d87c"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786373947168,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"d3abbbd1-28fb-4f34-94b3-5296d172ab8b"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786373947168,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1786338530759,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1786338530759,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1786338530769,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1786338530769,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":13,"time":1786338530769,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":1786338530769,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4e491719-55ea-4f2d-ba02-46ccea23799a"}},"sourceEventSeqs":[11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1786338530769,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":1786338530769,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl new file mode 100644 index 0000000000..5875110ee5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"cccccccc-0000-4000-8000-000000000003","createdAt":1783352127001,"cwd":"{{cwd}}","parentSession":"aaaaaaaa-0000-4000-8000-000000000001","origin":"subagent","delegationDepth":1} +{"type":"approval/policy","seq":0,"time":1786373947132,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786373947133,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"bcfd299a-16a3-47f9-a16f-83164c7c8882"}]}} +{"type":"turn/start","seq":2,"time":1786373947133,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786373947134,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786373947170,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} +{"type":"step/start","seq":5,"time":1786373947173,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1786338530749,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"bcfd299a-16a3-47f9-a16f-83164c7c8882"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786373947174,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"72f0af20-7d45-4223-9fb6-01e6503fb72b"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786373947174,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1786338530749,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1786338530749,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1786338530759,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1786338530759,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":13,"time":1786338530759,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":1786338530759,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c4551cb0-5d41-40ba-a425-95281c05ba52"}},"sourceEventSeqs":[11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1786338530760,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":1786338530760,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.jsonl new file mode 100644 index 0000000000..7e2d0e05bb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":0,"id":"aaaaaaaa-0000-4000-8000-000000000001","createdAt":1783352126000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786338530687,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the subagent tool TWICE in the SAME assistant message (two parallel tool calls in one response), each delegating the identical subtask: 'Reply with exactly the word ALPHA and nothing else.' Give both calls the description 'Say the word ALPHA'. After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"02062dd0-83d4-4b40-ab23-2fbcb0a8be96"}]}} +{"type":"turn/start","seq":1,"time":1786338530688,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786338530688,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786338530716,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786338530716,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE in the SAME assistant message (two parallel tool calls in one response), each delegating the identical subtask: 'Reply with exactly the word ALPHA and nothing else.' Give both calls the description 'Say the word ALPHA'. After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"02062dd0-83d4-4b40-ab23-2fbcb0a8be96"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786338530716,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"2dd192ab-72ed-4c20-a487-40aa14bd5c07"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786338530716,"data":{"title":"Use the subagent tool TWICE","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786338530717,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786338530717,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_parallel_alpha_1","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":13,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1786338530723,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_parallel_alpha_1","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"},{"type":"tool-call","id":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6ff33634-55af-4c37-a491-dd5b8673923f"}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1786338530723,"data":{"turn":1,"step":1,"callId":"call_parallel_alpha_1","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/call","seq":16,"time":1786338530723,"data":{"turn":1,"step":1,"callId":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":17,"time":1786338530770,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parallel_alpha_1"},"content":[{"type":"tool-result","toolCallId":"call_parallel_alpha_1","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"9fa4fa59-7326-4bc8-8ea2-120b37d91ea3"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":18,"time":1786338530770,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parallel_alpha_2"},"content":[{"type":"tool-result","toolCallId":"call_parallel_alpha_2","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"895bb620-534e-4857-9743-68273126eccf"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1786338530770,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1786338530778,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1786338530782,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1786338530782,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":23,"time":1786338530782,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1786338530782,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"84586dd8-4985-4286-ab4b-fa9965803fb8"}},"sourceEventSeqs":[21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1786338530782,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1786338530782,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl new file mode 100644 index 0000000000..a460e019d4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} 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 1c06b23157..cd6d752166 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -60,6 +60,11 @@ config: backend: json + - id: message-feedback + name: '@deepseek-ai/dsh-message-feedback' + config: + maxNoteBytes: 8192 + - id: workspace name: '@deepseek-ai/dsh-workspace' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 3dcfc8ee6a..95e7428f0a 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -83,6 +83,7 @@ "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", 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/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 649133c042..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'] @@ -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/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index d9c3c2837d..be5cd3be28 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -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/feedback/README.i18n.yaml b/packages/feedback/README.i18n.yaml index fce2946cff..bab1cf0db2 100644 --- a/packages/feedback/README.i18n.yaml +++ b/packages/feedback/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/feedback/README.md -README.md: af8e9d5c4903594299284d09f880aa8929f5e051 -README.zh.md: 9156ff1c8da8ed1128488fcacaf454425468163a +README.md: 152db65bd7ac179bb8d475d446535734b9f8238e +README.zh.md: 64202a3c4a0258f9b41a6cd96e7bbbf011d0338d diff --git a/packages/feedback/README.md b/packages/feedback/README.md index af8e9d5c49..152db65bd7 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -2,10 +2,13 @@ English | [中文](README.zh.md) -The feedback family lets a human record a remark about the session without acting on it. Feedback is durable session-log content, separate from the model conversation and from any policy that might later read it. +The feedback family exposes two deliberately separate contracts: an immutable remark in the canonical Session log, and editable feedback attached to one assistant message in a local sidecar. Neither form enters the model conversation. | Package | Role | ctx key | |---|---|---| | `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — | +| `message-feedback/` | Lifecycle-bound per-message rating/note sidecar plus Host `messageFeedback.list/put/delete` Remote contract | `messageFeedback` | -A recorded remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../session/session-telemetry-otel) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy. +A command feedback remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../session/session-telemetry-otel) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy. + +Message feedback is not a Session event or projection. It remains in the storage-domain sidecar and causes no telemetry handoff. The Host Remote contract ships with the service; the client Remote aggregate mount and UI consumer are separately owned and deferred. diff --git a/packages/feedback/README.zh.md b/packages/feedback/README.zh.md index 9156ff1c8d..64202a3c4a 100644 --- a/packages/feedback/README.zh.md +++ b/packages/feedback/README.zh.md @@ -2,10 +2,13 @@ [English](README.md) | 中文 -反馈家族让人类记录对会话的评价,但不据此采取任何动作。反馈属于持久的会话日志内容,与模型对话以及后续可能读取它的任何策略相互独立。 +反馈家族公开两份刻意分离的契约:写入权威 Session 日志的不可变评价,以及挂在单条 assistant 消息上的可编辑本地伴随记录(sidecar)反馈。两者都不会进入模型对话。 | 包 | 职责 | ctx 键 | |---|---|---| | `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 | +| `message-feedback/` | 绑定生命周期的逐消息评分/备注伴随记录,以及 Host `messageFeedback.list/put/delete` Remote 契约 | `messageFeedback` | -被记录的评价仅写入日志:它绝不会进入模型接口或派生历史。挂载后,[`dsh-session-telemetry-otel`](../session/session-telemetry-otel) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。 +command feedback 评价仅写入日志:它绝不会进入模型接口或派生历史。挂载后,[`dsh-session-telemetry-otel`](../session/session-telemetry-otel) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。 + +message feedback 不是 Session 事件或投影。它只保留在 storage-domain 伴随记录中,不触发任何遥测交接。服务随附 Host Remote 契约;客户端 Remote 聚合挂载与 UI 消费方由各自边界负责,并保持延后。 diff --git a/packages/feedback/message-feedback/README.i18n.yaml b/packages/feedback/message-feedback/README.i18n.yaml new file mode 100644 index 0000000000..edf8ec947b --- /dev/null +++ b/packages/feedback/message-feedback/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/feedback/message-feedback/README.md +README.md: a9ebad25907e32435c8d2a65eb4b2cd9eaa89eeb +README.zh.md: 29cbee0c1ec2ee810948d895c762d2e6320e9b66 diff --git a/packages/feedback/message-feedback/README.md b/packages/feedback/message-feedback/README.md new file mode 100644 index 0000000000..a9ebad2590 --- /dev/null +++ b/packages/feedback/message-feedback/README.md @@ -0,0 +1,84 @@ +# @deepseek-ai/dsh-message-feedback + +English | [中文](README.zh.md) + +Host-owned editable feedback for one finalized assistant message. The package registers `ctx.messageFeedback`, persists one lifecycle-bound sidecar row per Session in storage-domain, and publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract. It is separate from the immutable Session-level `feedback/record` event and performs no telemetry handoff. The [message-feedback sidecar Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md) owns the design boundary. + +Public request, value, version, and failure types are exported from the package root and `@deepseek-ai/dsh-message-feedback/types`; [`src/types.ts`](src/types.ts) is their source. + +## Configuration + +| key | meaning | +|---|---| +| `maxNoteBytes` | Required positive safe integer: maximum UTF-8 byte length of one optional note. | + +Notes must contain at least one non-whitespace character, but accepted text is stored verbatim rather than trimmed. Omitting `note` means the desired value has no note, so a version-matched material `put` clears an existing note. Note validation precedes Session lookup and can therefore return `note-blank` or `note-too-large` for a missing Session without touching persistence. + +```yaml +- id: message-feedback + name: '@deepseek-ai/dsh-message-feedback' + config: + maxNoteBytes: 8192 +``` + +The service injects `storageDomain`, `sessionPersistence`, and `sessions`. Its durable domain is `message_feedback`, with one `sessions` table row per `SessionId`. + +## Data, lifecycle, and durability + +`MessageFeedbackItem` contains `messageId`, `rating: 'positive' | 'negative'`, optional `note`, an opaque equality-only `version`, and Host-assigned `createdAt`/`updatedAt` Unix-millisecond timestamps. A material update preserves `createdAt`, replaces `version`, and keeps `updatedAt` from moving backward. `list` returns fresh immutable snapshots in first-creation order; updating an item retains its place, while deleting and later recreating it appends a new item. + +Each stored row carries the inspected Session header identity `{createdAt, cwd}`. A mismatch is treated as absence: `list` returns an empty `items` array, `delete` returns the absent postcondition, and `put` may replace the stale row with one bound to the current identity. This fences a reused `SessionId` when its header identity differs. Forks use a distinct Session identity and receive no feedback-row copy. + +`SessionPersistence.inspect()` supplies a cold-safe observation without publishing or resuming an Agent and without committing cold repair. For a Session without a live owner, `listSnapshots()` first decides definite absence; an `inspect()` failure for a catalogued Session remains an infrastructure failure rather than being guessed into `session-not-found`. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin messages, empty usage-only assistant records, and non-assistant records return `target-not-found`. + +After initial validation, `put` establishes a durability barrier before writing the sidecar. A matching live Session commits through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are validated again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar commit, so durable feedback never precedes the durable target message. + +Message feedback is not Session-log content or a Session projection. It emits no `feedback/record` event, does not enter model history, and does not trigger `FEEDBACK_ONLY` telemetry release. + +## Service and Host Remote contract + +The same three `MessageFeedbackService` methods are published by `GatewayService` and `@Remote`; the Host endpoint names are `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete`. Every method returns a discriminated business union: `{ ok: true, value }` or `{ ok: false, error }`. Operational storage, corruption, or missing-durability-listener failures reject instead of being mislabeled as business errors. + +| Method | Request | Success `value` | Rejected `error.code` | +|---|---|---|---| +| `list` | `MessageFeedbackListRequest { sessionId }` | `MessageFeedbackListValue { items }` | `session-not-found` | +| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | committed `MessageFeedbackItem` | `session-not-found`, `target-not-found`, `version-conflict`, `note-blank`, `note-too-large` | +| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`, `version-conflict` | + +`MessageFeedbackVersionConflict` returns the authoritative `current` item, or `null` when no item exists. This lets a caller reconcile the current rating, note, and version without a second `list` request. `MessageFeedbackNoteTooLarge` returns both `maxBytes` and `actualBytes`. The Client Remote aggregate does not mount the generated client contribution yet; Host callers can use the service/Remote contract without that client assembly. + +## Compare-and-set and idempotency + +`ifVersion: null` requests creation only; every request for an existing item requires its exact current version, including a no-op whose desired value already matches. The check is per message rather than per Session, so changing one item does not conflict with another. Every material create or update assigns a fresh opaque UUID token, preventing stale writes from crossing an ABA value cycle. + +A matching-version no-op returns the already stored item with unchanged version and timestamps. After a lost success response, a retry with the old token receives `version-conflict.current`; the caller can compare that authoritative item with its desired value without an extra read. `delete` ignores `ifVersion` when the item is already absent and always returns the stable `{ absent: true }` postcondition after success. + +A per-Session promise queue encloses inspection, durability validation, sidecar read, comparison, and whole-row write. These semantics serialize concurrent mutations through one service instance; storage-domain itself has no cross-process conditional write. + +Plugin disposal closes mutation admission, drains every operation already accepted into the per-Session queues, and only then closes the storage domain. A mutation submitted after disposal begins rejects as a lifecycle failure instead of entering a closing domain. + +## Model Experience + +### Local message-feedback state + +#### What the model sees + +Nothing. `ctx.messageFeedback` registers no tool, prompt section, model-facing context, or Session event; feedback stays in a Host-owned sidecar unless a separately documented Consumer explicitly exposes it. + +#### Token effect + +Zero. No request, result, rating, note, timestamp, or failure from this package enters a model request. + +#### KV Cache effect + +Independent. Listing or mutating message feedback does not touch a model request prefix and cannot invalidate an otherwise reusable provider cache entry. + +## Known Limitations and Deferred Work + +- **Client aggregate and UI are absent** — the Host Remote contract ships, but the Client Remote aggregate contribution and any UI consumer are separately owned and deferred. +- **Compare-and-set is single-process** — the per-Session queue serializes one service instance only; multiple Host processes writing one storage root can still lose updates because storage-domain exposes no cross-process conditional write. +- **No durable Session deletion cascade** — Session persistence has no deletion surface, and `session/disposed`/`host/session-removed` mean detach rather than durable deletion. The service therefore retains empty rows and may leave orphan rows after out-of-band log removal instead of deleting valid feedback on detach. +- **Detach/catalog retirement window** — a request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. +- **Header identity is not a content fingerprint** — `{createdAt, cwd}` detects reuse only when those fields differ; a cloned log retaining the same header identity is indistinguishable. +- **Trusted caller boundary** — `list`/`put`/`delete` carry no authenticated actor or audit identity. A deployment must expose the Host gateway only through its trusted or separately authenticated boundary until authorization and attribution are added. +- **Catalog and row bounds** — a cold request scans the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. `maxNoteBytes` bounds one note, but the item count and aggregate retained bytes of one Session row are not capped; an indexed metadata read and deployment-owned row bound remain deferred until a concrete consumer defines their policy. diff --git a/packages/feedback/message-feedback/README.zh.md b/packages/feedback/message-feedback/README.zh.md new file mode 100644 index 0000000000..29cbee0c1e --- /dev/null +++ b/packages/feedback/message-feedback/README.zh.md @@ -0,0 +1,84 @@ +# @deepseek-ai/dsh-message-feedback + +[English](README.md) | 中文 + +本包提供由 Host 拥有、针对单条已完成 assistant 消息的可编辑反馈。它注册 `ctx.messageFeedback`,在 storage-domain 中为每个 Session 持久化一条绑定生命周期的伴随记录(sidecar),并发布 Host `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete` 一元 Remote 契约。它与不可变的 Session 级 `feedback/record` 事件相互独立,不执行遥测交接。[消息反馈伴随记录 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md)拥有其设计边界。 + +公开的请求、值、版本与失败类型从包根入口及 `@deepseek-ai/dsh-message-feedback/types` 导出;其源码为 [`src/types.ts`](src/types.ts)。 + +## 配置 + +| 键 | 含义 | +|---|---| +| `maxNoteBytes` | 必填正 safe integer:一条可选备注的最大 UTF-8 字节长度。 | + +备注必须包含至少一个非空白字符,但通过校验的文本按原样存储,不会 trim。省略 `note` 表示目标值不含备注,因此 version 匹配的实质 `put` 会清除已有备注。备注校验早于 Session 查找,因此即使 Session 不存在,也可能在不访问持久化的情况下返回 `note-blank` 或 `note-too-large`。 + +```yaml +- id: message-feedback + name: '@deepseek-ai/dsh-message-feedback' + config: + maxNoteBytes: 8192 +``` + +服务注入 `storageDomain`、`sessionPersistence` 与 `sessions`。其持久存储域为 `message_feedback`,其中 `sessions` 表按 `SessionId` 每个一行。 + +## 数据、生命周期与持久性 + +`MessageFeedbackItem` 包含 `messageId`、`rating: 'positive' | 'negative'`、可选 `note`、只能做相等比较的 opaque `version`,以及由 Host 分配、以 Unix 毫秒表示的 `createdAt`/`updatedAt` 时间戳。实质更新保留 `createdAt`、替换 `version`,并保证 `updatedAt` 不倒退。`list` 按首次创建顺序返回新的不可变快照;更新条目时保留其位置,删除后再创建则追加为新条目。 + +每条存储行都携带检查所得 Session header 身份 `{createdAt, cwd}`。不匹配按不存在处理:`list` 返回空 `items` 数组,`delete` 返回已不存在的后置条件,`put` 可以用绑定当前身份的新行替换陈旧行。这会在复用的 `SessionId` 具有不同 header 身份时形成隔离。fork 使用独立的 Session 身份,不复制反馈伴随记录。 + +`SessionPersistence.inspect()` 提供 cold-safe 观测,不发布或恢复 Agent,也不提交 cold repair。对于没有 live owner 的 Session,系统先用 `listSnapshots()` 判定明确不存在;已进入目录的 Session 若 `inspect()` 失败,仍属于基础设施故障,不会被猜测成 `session-not-found`。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin 消息、仅承载 usage 的空 assistant 记录与非 assistant 记录都返回 `target-not-found`。 + +初步校验后,`put` 在写入伴随记录前建立 durability barrier。身份匹配的 live Session 先通过权威 `ctx.sessions.flush` checkpoint 提交,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录提交,因此持久反馈绝不会先于其持久目标消息。 + +message feedback 不是 Session 日志内容或 Session 投影。它不发出 `feedback/record` 事件,不进入模型历史,也不触发 `FEEDBACK_ONLY` 遥测释放。 + +## 服务与 Host Remote 契约 + +`GatewayService` 与 `@Remote` 将 `MessageFeedbackService` 的同三个方法发布出去;Host endpoint 名称为 `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete`。每个方法都返回判别式业务 union:`{ ok: true, value }` 或 `{ ok: false, error }`。存储、损坏或缺少 durability listener 等操作故障会产生 reject,不会被误标为业务错误。 + +| 方法 | 请求 | 成功 `value` | 拒绝的 `error.code` | +|---|---|---|---| +| `list` | `MessageFeedbackListRequest { sessionId }` | `MessageFeedbackListValue { items }` | `session-not-found` | +| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | 已提交的 `MessageFeedbackItem` | `session-not-found`、`target-not-found`、`version-conflict`、`note-blank`、`note-too-large` | +| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`、`version-conflict` | + +`MessageFeedbackVersionConflict` 返回权威 `current` 条目;条目不存在时为 `null`。调用方无需额外执行 `list`,即可协调当前 rating、note 与 version。`MessageFeedbackNoteTooLarge` 同时返回 `maxBytes` 与 `actualBytes`。客户端 Remote 聚合尚未挂载生成的客户端 contribution;Host 调用方无需该客户端组装即可使用 service/Remote 契约。 + +## Compare-and-set 与幂等性 + +`ifVersion: null` 表示仅当条目不存在时才创建;已有条目的每次请求都必须与其当前 version 完全一致,即使目标值已经相同、不会产生实质更新。检查按消息而非按 Session 进行,因此修改一个条目不会与另一个条目冲突。每次实质创建或更新都会分配新的 opaque UUID token,防止陈旧写入穿过 ABA 值循环。 + +携带匹配 version 的无变化请求会返回已存条目,version 与时间戳均不变。成功响应丢失后,使用旧 token 重试会得到 `version-conflict.current`;调用方无需额外读取,即可把权威当前值与目标值比较。条目已不存在时,`delete` 忽略 `ifVersion`;成功后始终返回稳定的 `{ absent: true }` 后置条件。 + +按 Session 划分的 promise 队列覆盖检查、持久性校验、伴随记录读取、比较与整行写入。这些语义会串行化经由同一服务实例的并发变更;storage-domain 自身没有跨进程条件写。 + +Plugin disposal 会先关闭变更接纳,排空已进入各个 Session 队列的所有操作,然后才关闭 storage domain。disposal 开始后提交的变更会以生命周期故障拒绝,不会进入正在关闭的 domain。 + +## 模型体验 + +### 本地消息反馈状态 + +#### 模型看到的内容 + +无。`ctx.messageFeedback` 不注册工具、提示词段落、模型可见上下文或 Session 事件;除非另一个具有独立文档的 Consumer 显式公开反馈,否则它只留在 Host 拥有的伴随记录中。 + +#### Token 影响 + +为零。本包的请求、结果、评分、备注、时间戳或失败都不会进入模型请求。 + +#### KV Cache 影响 + +相互独立。读取或变更消息反馈不会触碰模型请求前缀,也不会使本可复用的提供方缓存条目失效。 + +## 已知局限与延后工作 + +- **缺少客户端聚合与 UI**——Host Remote 契约已经发布,但客户端 Remote 聚合 contribution 与任何 UI 消费方由各自边界负责并保持延后。 +- **Compare-and-set 仅限单进程**——按 Session 划分的队列只串行化一个服务实例;storage-domain 不提供跨进程条件写,因此多个 Host 进程写入同一存储根目录时仍可能丢失更新。 +- **没有持久 Session 删除级联**——Session persistence 没有删除接口,且 `session/disposed`/`host/session-removed` 表示 detach 而非持久删除。因此服务会保留空行,并可能在带外移除日志后留下孤儿行,而不会在 detach 时删除仍有效的反馈。 +- **Detach/catalog retirement 窗口**——请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 +- **Header 身份不是内容指纹**——只有 `{createdAt, cwd}` 不同时才能识别复用;本契约无法区分保留相同 header 身份的克隆日志。 +- **调用方边界受信任**——`list`/`put`/`delete` 不携带已认证的 actor 或审计身份。在加入授权与归属信息前,部署方必须只通过受信任或另行认证的边界暴露 Host gateway。 +- **目录与行边界**——由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。`maxNoteBytes` 只限制单条备注,单个 Session 行的条目数和聚合保留字节尚无上限;按索引读取元数据和由部署决定的行边界,延后到具体消费方明确策略时处理。 diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json new file mode 100644 index 0000000000..fe0caa18fb --- /dev/null +++ b/packages/feedback/message-feedback/package.json @@ -0,0 +1,82 @@ +{ + "name": "@deepseek-ai/dsh-message-feedback", + "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/feedback/message-feedback" + }, + "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" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-storage-json": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/feedback/message-feedback/src/index.ts b/packages/feedback/message-feedback/src/index.ts new file mode 100644 index 0000000000..6126e837e1 --- /dev/null +++ b/packages/feedback/message-feedback/src/index.ts @@ -0,0 +1,383 @@ +/** + * Durable, lifecycle-bound feedback for finalized assistant messages. + * @module @deepseek-ai/dsh-message-feedback + */ + +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { Context, Service } from '@deepseek-ai/cordis' +import s from '@deepseek-ai/schemastery' +import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' +import type { KvTable } from '@deepseek-ai/dsh-storage-domain' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' +import { messageFeedbackDomainSpec } from './spec.ts' +import type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts' +import type { + MessageFeedbackDeleteRequest, + MessageFeedbackDeleteResult, + MessageFeedbackDeleteValue, + MessageFeedbackFailure, + MessageFeedbackItem, + MessageFeedbackListRequest, + MessageFeedbackListResult, + MessageFeedbackListValue, + MessageFeedbackNoteBlank, + MessageFeedbackNoteTooLarge, + MessageFeedbackPutRequest, + MessageFeedbackPutResult, + MessageFeedbackRejected, + MessageFeedbackSessionNotFound, + MessageFeedbackSuccess, + MessageFeedbackVersion, + MessageFeedbackVersionConflict, +} from './types.ts' + +export type * from './types.ts' +export { + messageFeedbackDomainSpec, + messageFeedbackItemSchema, + messageFeedbackRatingSchema, + messageFeedbackRowSchema, + messageFeedbackSessionIdentitySchema, + messageFeedbackVersionSchema, +} from './spec.ts' +export type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts' + +/** Required deployment policy for optional notes. */ +export interface Config { + /** Maximum UTF-8 byte length accepted for one note. */ + readonly maxNoteBytes: number +} + +declare module '@deepseek-ai/cordis' { + interface Context { + messageFeedback: MessageFeedbackService + } +} + +/** Immutable empty list reused only as an input to caller-owned copying. */ +const EMPTY_ITEMS: readonly MessageFeedbackItem[] = Object.freeze([]) + +/** Validate the one deployment-varying limit at the configuration boundary. */ +function resolveMaxNoteBytes(value: number): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new TypeError( + `message-feedback: maxNoteBytes must be a positive safe integer, got ${String(value)}`, + ) + } + return value +} + +/** Copy and freeze one item before it crosses the service boundary. */ +function snapshotItem(item: MessageFeedbackItem): MessageFeedbackItem { + return Object.freeze({ + messageId: item.messageId, + rating: item.rating, + ...(item.note === undefined ? {} : { note: item.note }), + version: item.version, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }) +} + +/** Copy and freeze a list response. */ +function snapshotList(items: readonly MessageFeedbackItem[]): MessageFeedbackListValue { + return Object.freeze({ items: Object.freeze(items.map(snapshotItem)) }) +} + +/** Build a frozen success branch. */ +function success(value: T): MessageFeedbackSuccess { + return Object.freeze({ ok: true, value }) +} + +/** Build a frozen business-failure branch. */ +function rejected(error: E): MessageFeedbackRejected { + return Object.freeze({ ok: false, error: Object.freeze(error) }) +} + +/** Project the Session fields that distinguish one persisted log lifecycle. */ +function identityOf(header: SessionHeader): MessageFeedbackSessionIdentity { + return Object.freeze({ + createdAt: header.createdAt, + ...(header.cwd === undefined ? {} : { cwd: header.cwd }), + }) +} + +/** Whether a stored row belongs to the inspected Session lifecycle. */ +function sameIdentity(row: MessageFeedbackRow, header: SessionHeader): boolean { + return row.session.createdAt === header.createdAt && row.session.cwd === header.cwd +} + +/** Whether two observations name the same persisted Session lifecycle. */ +function sameHeaderIdentity(left: SessionHeader, right: SessionHeader): boolean { + return left.id === right.id && left.createdAt === right.createdAt && left.cwd === right.cwd +} + +/** Freeze the replacement row so storage-domain never exposes mutable aliases. */ +function rowSnapshot( + session: MessageFeedbackSessionIdentity, + items: readonly MessageFeedbackItem[], +): MessageFeedbackRow { + const copiedItems = items.map(snapshotItem) + Object.freeze(copiedItems) + return Object.freeze({ + session, + items: copiedItems, + }) +} + +/** Generate an opaque equality token for one material mutation. */ +function nextVersion(): MessageFeedbackVersion { + return randomUUID() as MessageFeedbackVersion +} + +/** Session inspection result that keeps absence inside the business union. */ +type KnownSession = + | MessageFeedbackSuccess + | MessageFeedbackRejected + +/** Validated note or one explicit request failure. */ +type ResolvedNote = + | MessageFeedbackSuccess + | MessageFeedbackRejected + +/** + * Storage-domain sidecar service. It inspects persisted Session history and + * never creates or resumes an Agent or Session. + */ +export class MessageFeedbackService extends GatewayService { + static inject = ['storageDomain', 'sessionPersistence', 'sessions'] + + /** Loader validation for the required note-size policy. */ + static Config: s = s.object({ + maxNoteBytes: s.number().step(1).min(1).required(), + }) + + private readonly maxNoteBytes: number + private table?: KvTable + private readonly operationTails = new Map>() + private mutationAdmissionOpen = true + + /** + * @param ctx - Host context carrying persistence and the storage-domain form. + * @param config - Required note-size policy. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'messageFeedback') + this.maxNoteBytes = resolveMaxNoteBytes(config.maxNoteBytes) + } + + /** Open and own the one message-feedback sidecar domain. */ + protected async [Service.init](): Promise { + const domain = await this.ctx.storageDomain.open(messageFeedbackDomainSpec) + this.ctx.effect(() => async () => { + this.mutationAdmissionOpen = false + await Promise.all(this.operationTails.values()) + await domain.close() + }, 'message-feedback.domainClose') + this.table = domain.table('sessions') + } + + /** + * Read feedback belonging to the current persisted Session lifecycle. + * A stale row from a reused Session id is invisible. + * @param request - Session identity to inspect and list. + * @returns current immutable items or `session-not-found`. + */ + @Remote('list') + async list(request: MessageFeedbackListRequest): Promise { + const known = await this.inspectSession(request.sessionId) + if (!known.ok) return known + const row = this.requireTable().get(request.sessionId) + const items = row !== undefined && sameIdentity(row, known.value.meta) ? row.items : EMPTY_ITEMS + return success(snapshotList(items)) + } + + /** + * Create or replace feedback for one derived append-origin assistant + * message. Every request must match the addressed item's current version; + * a matching no-op returns the stored item without changing its revision. + * @param request - target, desired value, and observed item version. + * @returns the committed item or an explicit business failure. + */ + @Remote('put') + put(request: MessageFeedbackPutRequest): Promise { + const note = this.resolveNote(request.note) + if (!note.ok) return Promise.resolve(note) + return this.enqueue(request.sessionId, async () => { + const known = await this.inspectSession(request.sessionId) + if (!known.ok) return known + if (!this.hasFeedbackTarget(known.value, request.messageId)) { + return rejected({ + code: 'target-not-found', + sessionId: request.sessionId, + messageId: request.messageId, + }) + } + + const durable = await this.ensureTargetDurable(known.value) + if (!sameHeaderIdentity(durable.meta, known.value.meta) + || !this.hasFeedbackTarget(durable, request.messageId)) { + return rejected({ + code: 'target-not-found', + sessionId: request.sessionId, + messageId: request.messageId, + }) + } + + const table = this.requireTable() + const stored = table.get(request.sessionId) + const current = stored !== undefined && sameIdentity(stored, durable.meta) ? stored : undefined + const items = current?.items ?? EMPTY_ITEMS + const index = items.findIndex(item => item.messageId === request.messageId) + const existing = items[index] + if (request.ifVersion !== (existing?.version ?? null)) { + return rejected(this.versionConflict(existing ?? null)) + } + if (existing !== undefined + && existing.rating === request.rating + && existing.note === note.value) { + return success(snapshotItem(existing)) + } + + const now = Date.now() + const item = snapshotItem({ + messageId: request.messageId, + rating: request.rating, + ...(note.value === undefined ? {} : { note: note.value }), + version: nextVersion(), + createdAt: existing?.createdAt ?? now, + updatedAt: existing === undefined ? now : Math.max(now, existing.updatedAt), + }) + const nextItems = [...items] + if (index === -1) nextItems.push(item) + else nextItems[index] = item + await table.put( + request.sessionId, + rowSnapshot(identityOf(durable.meta), nextItems), + ) + return success(snapshotItem(item)) + }) + } + + /** + * Delete one feedback item. Absence is successful regardless of the + * supplied version; an existing item requires an exact version match. + * @param request - Session, message, and observed item version. + * @returns the stable absent postcondition, or an explicit failure. + */ + @Remote('delete') + delete(request: MessageFeedbackDeleteRequest): Promise { + return this.enqueue(request.sessionId, async () => { + const known = await this.inspectSession(request.sessionId) + if (!known.ok) return known + + const table = this.requireTable() + const stored = table.get(request.sessionId) + const current = stored !== undefined && sameIdentity(stored, known.value.meta) ? stored : undefined + const items = current?.items ?? EMPTY_ITEMS + const existing = items.find(item => item.messageId === request.messageId) + if (existing === undefined) { + return success(Object.freeze({ absent: true })) + } + if (request.ifVersion !== existing.version) { + return rejected(this.versionConflict(existing)) + } + + await table.put( + request.sessionId, + rowSnapshot(identityOf(known.value.meta), items.filter(item => item !== existing)), + ) + return success(Object.freeze({ absent: true })) + }) + } + + /** + * Resolve a live owner directly; otherwise use the storage catalog as the + * existence authority before inspecting the log. Inspection failures for a + * catalogued Session remain infrastructure failures rather than being + * guessed into the business `session-not-found` branch. + */ + private async inspectSession(sessionId: SessionId): Promise { + if (this.ctx.sessions.get(sessionId) === undefined) { + const snapshots = await this.ctx.sessionPersistence.listSnapshots() + if (!snapshots.some(snapshot => snapshot.header.id === sessionId) + && this.ctx.sessions.get(sessionId) === undefined) { + return rejected({ code: 'session-not-found', sessionId }) + } + } + return success(await this.ctx.sessionPersistence.inspect(sessionId)) + } + + /** Require the exact finalized append-origin assistant message projection. */ + private hasFeedbackTarget(inspection: SessionInspection, messageId: MessageFeedbackItem['messageId']): boolean { + return inspection.events.some((event) => { + if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) return false + const message = deriveEventMessage(event) + return message?.role === 'assistant' && message.id === messageId + }) + } + + /** + * Put the target log prefix behind a durability barrier before its sidecar. + * A live owner flushes through the SessionStore's canonical checkpoint; a + * cold owner is re-read from the physical durable prefix. + */ + private async ensureTargetDurable(inspection: SessionInspection): Promise { + const live = this.ctx.sessions.get(inspection.meta.id) + if (live !== undefined && sameHeaderIdentity(live.header, inspection.meta)) { + if (!(await this.ctx.sessions.flush(live))) { + throw new Error( + `message-feedback: no durability listener participated for live session '${inspection.meta.id}'`, + ) + } + return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0) + } + return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0) + } + + /** Validate optional-note semantics and the configured complete UTF-8 byte bound. */ + private resolveNote(note: string | undefined): ResolvedNote { + if (note === undefined) return success(undefined) + if (note.trim().length === 0) return rejected({ code: 'note-blank' }) + const actualBytes = Buffer.byteLength(note, 'utf8') + if (actualBytes > this.maxNoteBytes) { + return rejected({ code: 'note-too-large', maxBytes: this.maxNoteBytes, actualBytes }) + } + return success(note) + } + + /** Return the authoritative item needed to reconcile one failed comparison. */ + private versionConflict(current: MessageFeedbackItem | null): MessageFeedbackVersionConflict { + return { + code: 'version-conflict', + current: current === null ? null : snapshotItem(current), + } + } + + /** Queue a complete read/compare/write mutation behind this Session's prior mutation. */ + private enqueue(sessionId: SessionId, operation: () => Promise): Promise { + if (!this.mutationAdmissionOpen) { + return Promise.reject(new Error('message-feedback: service is disposing')) + } + const previous = this.operationTails.get(sessionId) ?? Promise.resolve() + const result = previous.then(operation) + const tail = result.then(() => undefined, () => undefined) + this.operationTails.set(sessionId, tail) + return result.finally(() => { + if (this.operationTails.get(sessionId) === tail) this.operationTails.delete(sessionId) + }) + } + + /** Resolve the initialized durable table or fail a broken service lifecycle. */ + private requireTable(): KvTable { + if (this.table === undefined) { + throw new Error('message-feedback: durable domain is not initialized') + } + return this.table + } +} + +export default MessageFeedbackService diff --git a/packages/feedback/message-feedback/src/invariant.ts b/packages/feedback/message-feedback/src/invariant.ts new file mode 100644 index 0000000000..5433f318f1 --- /dev/null +++ b/packages/feedback/message-feedback/src/invariant.ts @@ -0,0 +1,27 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-message-feedback/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-message-feedback' + +/** Cordis companion plugin name. */ +export const name = 'message-feedback-invariant' +/** Services required before the companion can reserve and check package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the private typed writer owns current row mutations, + * the domain schema validates rows on reopen, and no second authority exists. + */ +const install: InvariantInstaller = Object.assign(() => {}, { inject: ['messageFeedback'] }) + +/** + * 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/feedback/message-feedback/src/spec.ts b/packages/feedback/message-feedback/src/spec.ts new file mode 100644 index 0000000000..d08a536d06 --- /dev/null +++ b/packages/feedback/message-feedback/src/spec.ts @@ -0,0 +1,90 @@ +/** + * Durable storage-domain declaration for lifecycle-bound message feedback. + * @module @deepseek-ai/dsh-message-feedback/src/spec + */ + +import { z } from 'zod' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' +import type { MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion } from './types.ts' + +const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + +/** Runtime schema for the closed rating vocabulary. */ +export const messageFeedbackRatingSchema = z.union([ + z.literal('positive'), + z.literal('negative'), +]) satisfies z.ZodType + +/** Runtime schema for one opaque item version stored on disk. */ +export const messageFeedbackVersionSchema = z.uuid() + .transform(value => value as MessageFeedbackVersion) + +/** Runtime schema for one current feedback item. */ +// Zod infers transformed branded fields structurally, so it cannot name the +// public interface even though every branded output is created below. +export const messageFeedbackItemSchema = z.object({ + messageId: z.string().min(1).transform(value => value as MessageId), + rating: messageFeedbackRatingSchema, + note: z.string().refine(note => note.trim().length > 0, { + message: 'message feedback note must contain a non-whitespace character', + }).optional(), + version: messageFeedbackVersionSchema, + createdAt: nonNegativeSafeInteger, + updatedAt: nonNegativeSafeInteger, +}).refine(item => item.updatedAt >= item.createdAt, { + path: ['updatedAt'], + message: 'message feedback updatedAt must not precede createdAt', +}) as unknown as z.ZodType + +/** Persisted Session fields that fence a sidecar row to one log lifecycle. */ +export const messageFeedbackSessionIdentitySchema = z.object({ + createdAt: nonNegativeSafeInteger, + cwd: z.string().optional(), +}) + +/** Persisted lifecycle identity inferred from its durable schema. */ +export type MessageFeedbackSessionIdentity = z.infer + +/** + * One whole-Session sidecar. Duplicate message ids would make item lookup + * ambiguous; duplicate versions would break their independent identity. + */ +export const messageFeedbackRowSchema = z.object({ + session: messageFeedbackSessionIdentitySchema, + items: z.array(messageFeedbackItemSchema), +}).superRefine((row, ctx) => { + const messageIds = new Set() + const versions = new Set() + row.items.forEach((item, index) => { + if (messageIds.has(item.messageId)) { + ctx.addIssue({ + code: 'custom', + path: ['items', index, 'messageId'], + message: `duplicate message feedback id '${item.messageId}'`, + }) + } + messageIds.add(item.messageId) + if (versions.has(item.version)) { + ctx.addIssue({ + code: 'custom', + path: ['items', index, 'version'], + message: `duplicate message feedback version '${item.version}'`, + }) + } + versions.add(item.version) + }) +}) + +/** Durable sidecar row inferred from {@link messageFeedbackRowSchema}. */ +export type MessageFeedbackRow = z.infer + +/** One lifecycle-bound sidecar record per Session id. */ +export const messageFeedbackDomainSpec = defineDomain({ + name: 'message_feedback', + version: 0, + tables: { + sessions: domainTable(messageFeedbackRowSchema), + }, +}) diff --git a/packages/feedback/message-feedback/src/types.ts b/packages/feedback/message-feedback/src/types.ts new file mode 100644 index 0000000000..0be57b17b4 --- /dev/null +++ b/packages/feedback/message-feedback/src/types.ts @@ -0,0 +1,147 @@ +/** + * Public request, value, and failure vocabulary for per-message feedback. + * This module contains types only so generated Remote clients can consume it + * without importing Host runtime code. + * @module @deepseek-ai/dsh-message-feedback/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Opaque compare-and-set token for one exact feedback item revision. */ +export type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'> + +/** The human's overall judgment of one assistant message. */ +export type MessageFeedbackRating = 'positive' | 'negative' + +/** One current feedback value and its opaque mutation token. */ +export interface MessageFeedbackItem { + /** Stable identity of the assistant message inside the owning Session. */ + readonly messageId: MessageId + /** Overall positive or negative judgment. */ + readonly rating: MessageFeedbackRating + /** Optional explanation, preserved verbatim after validation. */ + readonly note?: string + /** Equality-only token replaced by every material create or update. */ + readonly version: MessageFeedbackVersion + /** Host-assigned creation time in Unix epoch milliseconds. */ + readonly createdAt: number + /** Host-assigned time of the most recent material update. */ + readonly updatedAt: number +} + +/** Read all message feedback belonging to one persisted Session lifecycle. */ +export interface MessageFeedbackListRequest { + /** Persisted Session whose sidecar should be read. */ + readonly sessionId: SessionId +} + +/** Current feedback values for one Session, in first-creation order. */ +export interface MessageFeedbackListValue { + /** Fresh immutable item snapshots. */ + readonly items: readonly MessageFeedbackItem[] +} + +/** Create or replace feedback for one assistant message. */ +export interface MessageFeedbackPutRequest { + /** Persisted Session that owns the target message. */ + readonly sessionId: SessionId + /** Target assistant-message identity. */ + readonly messageId: MessageId + /** Desired overall judgment. */ + readonly rating: MessageFeedbackRating + /** Optional non-blank explanation. */ + readonly note?: string + /** Observed item version, or `null` to require that no item exists. */ + readonly ifVersion: MessageFeedbackVersion | null +} + +/** Delete feedback for one message after observing its current version. */ +export interface MessageFeedbackDeleteRequest { + /** Persisted Session that owns the sidecar. */ + readonly sessionId: SessionId + /** Message whose feedback should be absent after this operation. */ + readonly messageId: MessageId + /** Observed item version; ignored when the item is already absent. */ + readonly ifVersion: MessageFeedbackVersion +} + +/** Idempotent deletion acknowledgement. */ +export interface MessageFeedbackDeleteValue { + /** Stable postcondition shared by the first deletion and every retry. */ + readonly absent: true +} + +/** No persisted Session header exists for the requested id. */ +export interface MessageFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} + +/** The id does not name a derived, append-origin assistant message. */ +export interface MessageFeedbackTargetNotFound { + readonly code: 'target-not-found' + readonly sessionId: SessionId + readonly messageId: MessageId +} + +/** A material mutation did not match the addressed item's current version. */ +export interface MessageFeedbackVersionConflict { + readonly code: 'version-conflict' + /** Authoritative current item, or `null` when it does not exist. */ + readonly current: MessageFeedbackItem | null +} + +/** A supplied note contains no non-whitespace character. */ +export interface MessageFeedbackNoteBlank { + readonly code: 'note-blank' +} + +/** A supplied note exceeds the configured UTF-8 byte limit. */ +export interface MessageFeedbackNoteTooLarge { + readonly code: 'note-too-large' + readonly maxBytes: number + readonly actualBytes: number +} + +/** Failures shared by the public message-feedback operations. */ +export type MessageFeedbackFailure = + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + +/** Successful public operation result. */ +export interface MessageFeedbackSuccess { + readonly ok: true + readonly value: T +} + +/** Rejected public operation result with a stable business failure. */ +export interface MessageFeedbackRejected { + readonly ok: false + readonly error: E +} + +/** Result returned by the message-feedback `list` operation. */ +export type MessageFeedbackListResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected + +/** Result returned by the message-feedback `put` operation. */ +export type MessageFeedbackPutResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected< + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + > + +/** Result returned by the message-feedback `delete` operation. */ +export type MessageFeedbackDeleteResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts new file mode 100644 index 0000000000..1dfaa24396 --- /dev/null +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -0,0 +1,213 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import SessionStore, { + SESSION_FORMAT_VERSION, + Session, + SessionId, + type SessionEvent, + type SessionHeader, +} from '@deepseek-ai/dsh-session' +import SessionPersistence, { + SessionPersistenceRevision, + type SessionInspection, + type SessionLocation, + type SessionPersistenceSnapshot, +} from '@deepseek-ai/dsh-session-persistence' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' +import MessageFeedbackService from '../src/index.ts' + +export interface MessageFixture { + readonly session: Session + readonly userMessageId: MessageId + readonly assistantMessageIds: readonly [MessageId, MessageId] + readonly emptyAssistantMessageId: MessageId + readonly replacementAssistantMessageId: MessageId +} + +/** Append one deterministic transcript surface used by target-validation tests. */ +export function appendMessageFixture(session: Session): Omit { + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + const user = createUserMessage({ + content: [{ type: 'text', text: 'Question' }], + source: { kind: 'user' }, + }) + session.append('user/message', user, { surfaceOp: 'append' }) + + const first = createAssistantMessage({ + content: [{ type: 'text', text: 'First answer' }], + source: { provider: 'test', model: 'test' }, + }) + const firstEvent = session.append('assistant/message', { + turn: 1, + step: 1, + message: first, + }, { surfaceOp: 'append' }) + const second = createAssistantMessage({ + content: [{ type: 'text', text: 'Second answer' }], + source: { provider: 'test', model: 'test' }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: second, + }, { surfaceOp: 'append' }) + const empty = createAssistantMessage({ + content: [], + source: { provider: 'test', model: 'test' }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: empty, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const replacement = createAssistantMessage({ + content: [{ type: 'text', text: 'Model-only replacement' }], + source: { provider: 'test', model: 'test' }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: replacement, + }, { + surfaceOp: { op: 'replace', start: firstEvent.seq, end: firstEvent.seq }, + sourceEventSeqs: [firstEvent.seq], + }) + + return { + userMessageId: user.id, + assistantMessageIds: [first.id, second.id], + emptyAssistantMessageId: empty.id, + replacementAssistantMessageId: replacement.id, + } +} + +/** Construct one cold persistence fixture without publishing a live Session. */ +export function messageFixture( + rawId: string, + options: { readonly createdAt?: number; readonly cwd?: string } = {}, +): MessageFixture { + const id = SessionId(rawId) + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id, + createdAt: options.createdAt ?? 1_700_000_000_000, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + } + const session = Session.create(id, [], header) + return { session, ...appendMessageFixture(session) } +} + +/** Minimal controllable persistence provider for service-level tests. */ +class TestPersistence extends SessionPersistence { + static inject = ['sessions'] + + readonly durable = new Map() + readonly logical = new Map() + inspectFailure: Error | undefined + inspectCalls = 0 + readFromCalls = 0 + onReadFrom: (() => void | Promise) | undefined + onListSnapshots: (() => void | Promise) | undefined + + locate(_meta: SessionHeader): SessionLocation | undefined { return undefined } + create(_meta: SessionHeader): Promise { return Promise.resolve() } + append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } + + load(id: SessionId): Promise { + return this.readFrom(id, 0) + } + + inspect(id: SessionId): Promise { + this.inspectCalls += 1 + if (this.inspectFailure !== undefined) return Promise.reject(this.inspectFailure) + const explicit = this.logical.get(id) + if (explicit !== undefined) return Promise.resolve(explicit) + const live = this.ctx.sessions.get(id) + if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.events }) + const stored = this.durable.get(id) + return stored === undefined + ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) + : Promise.resolve(stored) + } + + async readFrom( + id: SessionId, + fromSeq: number, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + this.readFromCalls += 1 + await this.onReadFrom?.() + const stored = this.durable.get(id) + return stored === undefined + ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) + : { meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) } + } + + list(): Promise { + return Promise.resolve([...this.durable.values()].map(value => value.meta)) + } + + async listSnapshots(): Promise { + await this.onListSnapshots?.() + return [...this.durable.values()].map((value, index) => ({ + header: value.meta, + revision: SessionPersistenceRevision(`test:${index}:${value.events.length}`), + })) + } + + persist(session: Session): void { + this.durable.set(session.id, { meta: session.header, events: session.events }) + } + + setDurable(inspection: SessionInspection): void { + this.durable.set(inspection.meta.id, inspection) + } +} + +export interface TestHarness { + readonly ctx: Context + readonly persistence: TestPersistence + readonly root: string + disposeFeedback(): Promise + dispose(): Promise +} + +/** Compose the service over the real storage hub/domain/JSON backend. */ +export async function setupHarness(maxNoteBytes = 64): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-test-')) + const ctx = new Context() + let disposeFeedback: (() => Promise) | undefined + try { + await ctx.plugin(SessionStore) + await ctx.plugin(TestPersistence) + await ctx.plugin(Storage) + await ctx.plugin(StorageJson, { root }) + await ctx.plugin(StorageDomain, { backend: 'json' }) + const feedbackFiber = await ctx.plugin(MessageFeedbackService, { maxNoteBytes }) + disposeFeedback = feedbackFiber.dispose + } catch (error) { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + throw error + } + if (disposeFeedback === undefined) throw new Error('message feedback test plugin did not load') + return { + ctx, + persistence: ctx.sessionPersistence as unknown as TestPersistence, + root, + disposeFeedback, + async dispose() { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + }, + } +} diff --git a/packages/feedback/message-feedback/tests/invariant.spec.ts b/packages/feedback/message-feedback/tests/invariant.spec.ts new file mode 100644 index 0000000000..e88131e147 --- /dev/null +++ b/packages/feedback/message-feedback/tests/invariant.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as MessageFeedbackInvariant from '../src/invariant.ts' +import { setupHarness } from './helpers.ts' + +describe('message-feedback invariant companion', () => { + it('removes its registry contribution when its fiber is disposed (HMR safety)', async () => { + const harness = await setupHarness() + try { + await harness.ctx.plugin(InvariantService) + const fiber = await harness.ctx.plugin(MessageFeedbackInvariant) + + expect(() => { + harness.ctx.invariants.register('@deepseek-ai/dsh-message-feedback', () => {}) + }).toThrow(/already registered/u) + + await fiber.dispose() + await expect(harness.ctx.plugin(MessageFeedbackInvariant).await()).resolves.toBeDefined() + } finally { + await harness.dispose() + } + }) +}) diff --git a/packages/feedback/message-feedback/tests/loader-composition.spec.ts b/packages/feedback/message-feedback/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..98a7f29243 --- /dev/null +++ b/packages/feedback/message-feedback/tests/loader-composition.spec.ts @@ -0,0 +1,115 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import MessageFeedbackService from '../src/index.ts' +import { appendMessageFixture } from './helpers.ts' + +let root: string | undefined +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) +async function loadComposition(configPath: string): Promise { + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(root as string).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-persistence-jsonl', SessionPersistenceJsonl], + ['@deepseek-ai/dsh-storage', Storage], + ['@deepseek-ai/dsh-storage-json', StorageJson], + ['@deepseek-ai/dsh-storage-domain', StorageDomain], + ['@deepseek-ai/dsh-message-feedback', MessageFeedbackService], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await ctx.loader.await() + const unloaded = [...ctx.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + return ctx +} + +describe('message feedback through a real Loader composition', () => { + it('persists a checkpointed target and its sidecar across a cold restart', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-persistence-jsonl'", + ' config:', + ` root: ${JSON.stringify(join(root, 'sessions'))}`, + ' compression: none', + ' writeBatchMaxDelayMs: 1', + "- name: '@deepseek-ai/dsh-storage'", + "- name: '@deepseek-ai/dsh-storage-json'", + ' config:', + ` root: ${JSON.stringify(join(root, 'storage'))}`, + "- name: '@deepseek-ai/dsh-storage-domain'", + ' config:', + ' backend: json', + "- name: '@deepseek-ai/dsh-message-feedback'", + ' config:', + ' maxNoteBytes: 32', + '', + ].join('\n')) + + const first = await loadComposition(configPath) + expect(first.messageFeedback.typertGateway.namespace).toBe('messageFeedback') + expect(remoteMethods(first.messageFeedback).map(marker => marker.method)) + .toEqual(['list', 'put', 'delete']) + + const session = first.sessions.create(SessionId('loader-feedback'), { + meta: { cwd: root }, + }) + const fixture = appendMessageFixture(session) + const put = await first.messageFeedback.put({ + sessionId: session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + note: 'survives restart', + ifVersion: null, + }) + if (!put.ok) throw new Error(`expected put success, got ${put.error.code}`) + const durable = await first.sessionPersistence.readFrom(session.id, 0) + expect(durable.events.some(event => + event.type === 'assistant/message' + && event.data.message.id === fixture.assistantMessageIds[0])).toBe(true) + + await first.fiber.dispose() + contexts.splice(contexts.indexOf(first), 1) + + const second = await loadComposition(configPath) + await expect(second.messageFeedback.list({ sessionId: session.id })).resolves.toEqual({ + ok: true, + value: { items: [put.value] }, + }) + expect(second.sessions.get(session.id)).toBeUndefined() + }) +}) diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts new file mode 100644 index 0000000000..f4478ec202 --- /dev/null +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -0,0 +1,655 @@ +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import MessageFeedbackService, { messageFeedbackRowSchema } from '../src/index.ts' +import type { + MessageFeedbackItem, + MessageFeedbackVersion, +} from '../src/index.ts' +import { + appendMessageFixture, + messageFixture, + setupHarness, + type TestHarness, +} from './helpers.ts' + +const harnesses: TestHarness[] = [] + +async function harness(maxNoteBytes = 64): Promise { + const value = await setupHarness(maxNoteBytes) + harnesses.push(value) + return value +} + +afterEach(async () => { + vi.useRealTimers() + await Promise.all(harnesses.splice(0).map(value => value.dispose())) +}) + +function staleVersion(): MessageFeedbackVersion { + return randomUUID() as MessageFeedbackVersion +} + +function expectItem( + result: Awaited>, +): MessageFeedbackItem { + if (!result.ok) throw new Error(`expected feedback item, got ${result.error.code}`) + return result.value +} + +describe('MessageFeedbackService public contract', () => { + it('publishes the exact Gateway namespace and Remote method names', async () => { + const { ctx } = await harness() + const binding = ctx.messageFeedback.typertGateway + expect(binding.serviceKey).toBe('messageFeedback') + expect(binding.namespace).toBe('messageFeedback') + expect(remoteMethods(ctx.messageFeedback)).toEqual([ + { method: 'list', invocation: { kind: 'direct' } }, + { method: 'put', invocation: { kind: 'direct' } }, + { method: 'delete', invocation: { kind: 'direct' } }, + ]) + }) + + it('returns session-not-found only for a definite persistence miss', async () => { + const { ctx, persistence } = await harness() + const missing = SessionId('missing-session') + await expect(ctx.messageFeedback.list({ sessionId: missing })).resolves.toEqual({ + ok: false, + error: { code: 'session-not-found', sessionId: missing }, + }) + + const fixture = messageFixture('corrupt-session') + persistence.setDurable({ meta: fixture.session.header, events: fixture.session.events }) + const corruption = new Error('stored log checksum mismatch') + persistence.inspectFailure = corruption + await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption) + }) + + it('rechecks live ownership before returning a cold catalog miss', async () => { + const { ctx, persistence } = await harness() + const sessionId = SessionId('catalog-live-race') + const listed = Promise.withResolvers() + const release = Promise.withResolvers() + persistence.onListSnapshots = async () => { + listed.resolve(undefined) + await release.promise + } + + const pending = ctx.messageFeedback.list({ sessionId }) + await listed.promise + ctx.sessions.create(sessionId, { meta: { createdAt: 1_700_000_000_001 } }) + release.resolve(undefined) + + await expect(pending).resolves.toEqual({ ok: true, value: { items: [] } }) + expect(persistence.inspectCalls).toBe(1) + }) + + it('returns session-not-found from mutations and conflicts on an observed version for an absent item', async () => { + const { ctx, persistence } = await harness() + const missing = SessionId('missing-mutations') + const missingMessage = 'missing-message' as MessageId + await expect(ctx.messageFeedback.put({ + sessionId: missing, + messageId: missingMessage, + rating: 'positive', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { code: 'session-not-found', sessionId: missing }, + }) + await expect(ctx.messageFeedback.delete({ + sessionId: missing, + messageId: missingMessage, + ifVersion: staleVersion(), + })).resolves.toEqual({ + ok: false, + error: { code: 'session-not-found', sessionId: missing }, + }) + + const fixture = messageFixture('absent-version-conflict') + persistence.persist(fixture.session) + const expected = staleVersion() + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: expected, + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current: null }, + }) + }) + + it('creates, updates, and retry-reads immutable items with monotonic Host times', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('timestamps') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + + vi.useFakeTimers() + vi.setSystemTime(1_700_000_001_000) + const created = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: ' exact prose ', + ifVersion: null, + })) + expect(created).toMatchObject({ + messageId, + rating: 'positive', + note: ' exact prose ', + createdAt: 1_700_000_001_000, + updatedAt: 1_700_000_001_000, + }) + expect(created.version).toMatch(/^[0-9a-f-]{36}$/u) + expect(Object.isFrozen(created)).toBe(true) + + vi.setSystemTime(1_700_000_000_000) + const updated = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: created.version, + })) + expect(updated).toMatchObject({ + messageId, + rating: 'negative', + createdAt: created.createdAt, + updatedAt: created.updatedAt, + }) + expect(updated.version).not.toBe(created.version) + + const retry = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: updated.version, + })) + expect(retry).toEqual(updated) + + const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id }) + if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`) + expect(listed.value.items).toEqual([updated]) + expect(listed.value.items[0]).not.toBe(updated) + expect(Object.isFrozen(listed.value)).toBe(true) + expect(Object.isFrozen(listed.value.items)).toBe(true) + expect(Object.isFrozen(listed.value.items[0])).toBe(true) + }) + + it('reports non-blank and complete UTF-8 byte limits without touching persistence', async () => { + const { ctx, persistence } = await harness(4) + const fixture = messageFixture('note-limits') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + const before = persistence.inspectCalls + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: ' \n\t ', + ifVersion: null, + })).resolves.toEqual({ ok: false, error: { code: 'note-blank' } }) + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: 'ééé', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { code: 'note-too-large', maxBytes: 4, actualBytes: 6 }, + }) + expect(persistence.inspectCalls).toBe(before) + + expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: '😀', + ifVersion: null, + })) + }) + + it('accepts only non-empty append-origin assistant projections as targets', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('targets') + persistence.persist(fixture.session) + const rejectedTargets: MessageId[] = [ + fixture.userMessageId, + fixture.emptyAssistantMessageId, + fixture.replacementAssistantMessageId, + ] + for (const messageId of rejectedTargets) { + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { + code: 'target-not-found', + sessionId: fixture.session.id, + messageId, + }, + }) + } + expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })) + }) + + it('fails invalid direct configuration and a read before domain initialization', async () => { + const invalidCtx = new Context() + expect(() => new MessageFeedbackService(invalidCtx, { maxNoteBytes: 0 })) + .toThrow(/positive safe integer/u) + await invalidCtx.fiber.dispose() + + const fixture = messageFixture('uninitialized-domain') + const rawCtx = new Context() + rawCtx.provide('sessions', { get: () => undefined } as never) + rawCtx.provide('sessionPersistence', { + listSnapshots: () => Promise.resolve([{ header: fixture.session.header, revision: 'test' }]), + inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.events }), + } as never) + const raw = new MessageFeedbackService(rawCtx, { maxNoteBytes: 1 }) + await expect(raw.list({ sessionId: fixture.session.id })) + .rejects.toThrow(/durable domain is not initialized/u) + await rawCtx.fiber.dispose() + }) + + it('rejects durable rows with duplicate message ids or reused item versions', () => { + const version = staleVersion() + const duplicate = messageFeedbackRowSchema.safeParse({ + session: { createdAt: 1 }, + items: [ + { + messageId: 'same-message', + rating: 'positive', + version, + createdAt: 1, + updatedAt: 1, + }, + { + messageId: 'same-message', + rating: 'negative', + version, + createdAt: 1, + updatedAt: 1, + }, + ], + }) + expect(duplicate.success).toBe(false) + if (duplicate.success) throw new Error('expected duplicate row rejection') + expect(duplicate.error.issues.map(issue => issue.path.join('.'))) + .toEqual(['items.1.messageId', 'items.1.version']) + }) +}) + +describe('MessageFeedbackService item concurrency', () => { + it('serializes whole-row writes while keeping versions independent per message', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('concurrent-items') + persistence.persist(fixture.session) + const [firstId, secondId] = fixture.assistantMessageIds + + const [firstResult, secondResult] = await Promise.all([ + ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: firstId, + rating: 'positive', + ifVersion: null, + }), + ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: secondId, + rating: 'negative', + ifVersion: null, + }), + ]) + const first = expectItem(firstResult) + const second = expectItem(secondResult) + const updated = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: firstId, + rating: 'negative', + note: 'changed', + ifVersion: first.version, + })) + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: firstId, + rating: 'positive', + note: 'stale change', + ifVersion: first.version, + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current: updated }, + }) + + const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id }) + if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`) + expect(listed.value.items).toEqual([updated, second]) + expect(listed.value.items[1]?.version).toBe(second.version) + }) + + it('rejects a stale put even when the current value has returned to the same state', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('put-aba') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + const first = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: null, + })) + const second = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: first.version, + })) + const current = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: second.version, + })) + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: first.version, + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current }, + }) + }) + + it('makes delete retries stable and prevents delete/recreate ABA', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('delete-aba') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + const created = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: null, + })) + + await expect(ctx.messageFeedback.delete({ + sessionId: fixture.session.id, + messageId, + ifVersion: staleVersion(), + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current: created }, + }) + const request = { + sessionId: fixture.session.id, + messageId, + ifVersion: created.version, + } + await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({ + ok: true, + value: { absent: true }, + }) + await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({ + ok: true, + value: { absent: true }, + }) + + const recreated = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: null, + })) + expect(recreated.version).not.toBe(created.version) + await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current: recreated }, + }) + }) + + it('fences a reused Session id and lets the new lifecycle start cleanly', async () => { + const { ctx, persistence } = await harness() + const old = messageFixture('reused-session', { createdAt: 10, cwd: '/old' }) + persistence.persist(old.session) + const oldItem = expectItem(await ctx.messageFeedback.put({ + sessionId: old.session.id, + messageId: old.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })) + + const replacement = Session.create( + old.session.id, + old.session.events, + { ...old.session.header, createdAt: 20, cwd: '/new' }, + ) + persistence.persist(replacement) + await expect(ctx.messageFeedback.list({ sessionId: replacement.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + await expect(ctx.messageFeedback.delete({ + sessionId: replacement.id, + messageId: old.assistantMessageIds[0], + ifVersion: oldItem.version, + })).resolves.toEqual({ ok: true, value: { absent: true } }) + + const newItem = expectItem(await ctx.messageFeedback.put({ + sessionId: replacement.id, + messageId: old.assistantMessageIds[0], + rating: 'negative', + ifVersion: null, + })) + expect(newItem.version).not.toBe(oldItem.version) + }) + + it('drains admitted mutations before domain close and rejects later admission', async () => { + const current = await harness() + const { ctx, persistence } = current + const fixture = messageFixture('dispose-quiescence') + persistence.persist(fixture.session) + const service = ctx.messageFeedback + const lifecycle = service as unknown as { readonly mutationAdmissionOpen: boolean } + const started = Promise.withResolvers() + const release = Promise.withResolvers() + let physicalReads = 0 + let committed = 0 + persistence.onReadFrom = async () => { + physicalReads += 1 + if (physicalReads !== 1) return + started.resolve(undefined) + await release.promise + } + ctx.on('domain/changed', (change) => { + if (change.domain === 'message_feedback') committed += 1 + }) + + const first = service.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + }) + await started.promise + const second = service.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[1], + rating: 'negative', + ifVersion: null, + }) + const disposal = current.disposeFeedback() + await vi.waitFor(() => { expect(lifecycle.mutationAdmissionOpen).toBe(false) }) + + await expect(service.delete({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + ifVersion: staleVersion(), + })).rejects.toThrow('message-feedback: service is disposing') + release.resolve(undefined) + + expectItem(await first) + expectItem(await second) + await disposal + expect(physicalReads).toBe(2) + expect(committed).toBe(2) + }) +}) + +describe('MessageFeedbackService durability ordering', () => { + it('rejects a logical target missing from the cold physical durable prefix', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('cold-prefix') + persistence.logical.set(fixture.session.id, { + meta: fixture.session.header, + events: fixture.session.events, + }) + persistence.setDurable({ meta: fixture.session.header, events: [] }) + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { + code: 'target-not-found', + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + }, + }) + expect(persistence.readFromCalls).toBe(1) + await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + }) + + it('commits and physically verifies a live target checkpoint before the sidecar write', async () => { + const { ctx, persistence } = await harness() + const session = ctx.sessions.create(SessionId('live-checkpoint'), { + meta: { createdAt: 30, cwd: '/live' }, + }) + const fixture = appendMessageFixture(session) + const order: string[] = [] + ctx.on('session/flush', (current) => { + order.push('session:durable') + persistence.persist(current) + }) + ctx.on('domain/changed', (change) => { + if (change.domain === 'message_feedback') order.push('sidecar:durable') + }) + persistence.onReadFrom = () => { order.push('session:verified') } + + expectItem(await ctx.messageFeedback.put({ + sessionId: session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })) + expect(order).toEqual(['session:durable', 'session:verified', 'sidecar:durable']) + expect(persistence.readFromCalls).toBe(1) + expect(persistence.durable.get(session.id)?.events).toContainEqual( + expect.objectContaining({ type: 'assistant/message' }), + ) + }) + + it('fails closed when a live checkpoint fails, has no participant, or is not physically durable', async () => { + const failed = await harness() + const failedSession = failed.ctx.sessions.create(SessionId('live-flush-failure')) + const failedFixture = appendMessageFixture(failedSession) + const diskFailure = new Error('disk unavailable') + failed.ctx.on('session/flush', () => { throw diskFailure }) + await expect(failed.ctx.messageFeedback.put({ + sessionId: failedSession.id, + messageId: failedFixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).rejects.toBe(diskFailure) + await expect(failed.ctx.messageFeedback.list({ sessionId: failedSession.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + + const absent = await harness() + const absentSession = absent.ctx.sessions.create(SessionId('live-no-flush')) + const absentFixture = appendMessageFixture(absentSession) + await expect(absent.ctx.messageFeedback.put({ + sessionId: absentSession.id, + messageId: absentFixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).rejects.toThrow(/no durability listener participated/u) + await expect(absent.ctx.messageFeedback.list({ sessionId: absentSession.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + + const noDurability = await harness() + const unpersistedSession = noDurability.ctx.sessions.create(SessionId('live-unpersisted')) + const unpersistedFixture = appendMessageFixture(unpersistedSession) + noDurability.ctx.on('session/flush', () => {}) + await expect(noDurability.ctx.messageFeedback.put({ + sessionId: unpersistedSession.id, + messageId: unpersistedFixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).rejects.toThrow(/not found/u) + expect(noDurability.persistence.durable.has(unpersistedSession.id)).toBe(false) + await expect(noDurability.ctx.messageFeedback.list({ sessionId: unpersistedSession.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + }) + + it('finishes the captured live checkpoint when the Session detaches mid-flush', async () => { + const { ctx, persistence } = await harness() + const session = ctx.sessions.prepare(SessionId('detach-during-flush'), { + meta: { createdAt: 40, cwd: '/detach' }, + }) + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + const fixture = appendMessageFixture(session) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('session/flush', async (current) => { + started.resolve(undefined) + await release.promise + persistence.persist(current) + }) + + const pending = ctx.messageFeedback.put({ + sessionId: session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + }) + await started.promise + detach() + expect(ctx.sessions.get(session.id)).toBeUndefined() + release.resolve(undefined) + expectItem(await pending) + expect(persistence.readFromCalls).toBe(1) + await expect(ctx.messageFeedback.list({ sessionId: session.id })).resolves.toMatchObject({ + ok: true, + value: { items: [{ messageId: fixture.assistantMessageIds[0] }] }, + }) + }) +}) diff --git a/packages/feedback/message-feedback/tsconfig.json b/packages/feedback/message-feedback/tsconfig.json new file mode 100644 index 0000000000..e406c4c00e --- /dev/null +++ b/packages/feedback/message-feedback/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session/session-persistence" + }, + { + "path": "../../storage/storage" + }, + { + "path": "../../storage/storage-domain" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../support/invariants" + } + ] +} 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 3052b6895d..732ab3fa00 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -586,6 +586,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'messageFeedback', + summary: 'Storage-domain sidecar service.', + methods: [ + { + signature: '@Remote(\'list\') async list(request: MessageFeedbackListRequest): Promise', + jsDoc: '/**\n * Read feedback belonging to the current persisted Session lifecycle.\n * A stale row from a reused Session id is invisible.\n * @param request - Session identity to inspect and list.\n * @returns current immutable items or `session-not-found`.\n */', + }, + { + signature: '@Remote(\'put\') put(request: MessageFeedbackPutRequest): Promise', + jsDoc: '/**\n * Create or replace feedback for one derived append-origin assistant\n * message. Every request must match the addressed item\'s current version;\n * a matching no-op returns the stored item without changing its revision.\n * @param request - target, desired value, and observed item version.\n * @returns the committed item or an explicit business failure.\n */', + }, + { + signature: '@Remote(\'delete\') delete(request: MessageFeedbackDeleteRequest): Promise', + jsDoc: '/**\n * Delete one feedback item. Absence is successful regardless of the\n * supplied version; an existing item requires an exact version match.\n * @param request - Session, message, and observed item version.\n * @returns the stable absent postcondition, or an explicit failure.\n */', + }, + ], + }, { key: 'permission', summary: 'Owns the deployment\'s permission presets and their write path.', @@ -2335,6 +2353,82 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Message', declaration: 'export interface Message {\n readonly id: MessageId;\n readonly role: \'system\' | \'user\' | \'assistant\';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n}', }, + { + name: 'MessageFeedbackDeleteRequest', + declaration: 'export interface MessageFeedbackDeleteRequest {\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly ifVersion: MessageFeedbackVersion;\n}', + }, + { + name: 'MessageFeedbackDeleteResult', + declaration: 'export type MessageFeedbackDeleteResult = MessageFeedbackSuccess | MessageFeedbackRejected;', + }, + { + name: 'MessageFeedbackDeleteValue', + declaration: 'export interface MessageFeedbackDeleteValue {\n readonly absent: true;\n}', + }, + { + name: 'MessageFeedbackFailure', + declaration: 'export type MessageFeedbackFailure = MessageFeedbackSessionNotFound | MessageFeedbackTargetNotFound | MessageFeedbackVersionConflict | MessageFeedbackNoteBlank | MessageFeedbackNoteTooLarge;', + }, + { + name: 'MessageFeedbackItem', + declaration: 'export interface MessageFeedbackItem {\n readonly messageId: MessageId;\n readonly rating: MessageFeedbackRating;\n readonly note?: string;\n readonly version: MessageFeedbackVersion;\n readonly createdAt: number;\n readonly updatedAt: number;\n}', + }, + { + name: 'MessageFeedbackListRequest', + declaration: 'export interface MessageFeedbackListRequest {\n readonly sessionId: SessionId;\n}', + }, + { + name: 'MessageFeedbackListResult', + declaration: 'export type MessageFeedbackListResult = MessageFeedbackSuccess | MessageFeedbackRejected;', + }, + { + name: 'MessageFeedbackListValue', + declaration: 'export interface MessageFeedbackListValue {\n readonly items: readonly MessageFeedbackItem[];\n}', + }, + { + name: 'MessageFeedbackNoteBlank', + declaration: 'export interface MessageFeedbackNoteBlank {\n readonly code: \'note-blank\';\n}', + }, + { + name: 'MessageFeedbackNoteTooLarge', + declaration: 'export interface MessageFeedbackNoteTooLarge {\n readonly code: \'note-too-large\';\n readonly maxBytes: number;\n readonly actualBytes: number;\n}', + }, + { + name: 'MessageFeedbackPutRequest', + declaration: 'export interface MessageFeedbackPutRequest {\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly rating: MessageFeedbackRating;\n readonly note?: string;\n readonly ifVersion: MessageFeedbackVersion | null;\n}', + }, + { + name: 'MessageFeedbackPutResult', + declaration: 'export type MessageFeedbackPutResult = MessageFeedbackSuccess | MessageFeedbackRejected;', + }, + { + name: 'MessageFeedbackRating', + declaration: 'export type MessageFeedbackRating = \'positive\' | \'negative\';', + }, + { + name: 'MessageFeedbackRejected', + declaration: 'export interface MessageFeedbackRejected {\n readonly ok: false;\n readonly error: E;\n}', + }, + { + name: 'MessageFeedbackSessionNotFound', + declaration: 'export interface MessageFeedbackSessionNotFound {\n readonly code: \'session-not-found\';\n readonly sessionId: SessionId;\n}', + }, + { + name: 'MessageFeedbackSuccess', + declaration: 'export interface MessageFeedbackSuccess {\n readonly ok: true;\n readonly value: T;\n}', + }, + { + name: 'MessageFeedbackTargetNotFound', + declaration: 'export interface MessageFeedbackTargetNotFound {\n readonly code: \'target-not-found\';\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n}', + }, + { + name: 'MessageFeedbackVersion', + declaration: 'export type MessageFeedbackVersion = Branded<\'MessageFeedbackVersion\'>;', + }, + { + name: 'MessageFeedbackVersionConflict', + declaration: 'export interface MessageFeedbackVersionConflict {\n readonly code: \'version-conflict\';\n readonly current: MessageFeedbackItem | null;\n}', + }, { name: 'MessageId', declaration: 'export type MessageId = Branded<\'MessageId\'>;', 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/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 065751f04e..c42fc3fa16 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 036f86b338ddbf2933703c431d6523bb325be563 -README.zh.md: 2ad25aabe9090eac507b127f7f25d280dd45d905 +README.md: 27b01188cc1663d9cf59d3d24c91b3dbb35bea5a +README.zh.md: e28a8794cfb227dd475ea2b4c42605a90ec7fa1d diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 036f86b338..27b01188cc 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -44,7 +44,7 @@ Every in-process child is composed by one call, `applyChildComposition(childCtx, `childSessionMeta()` records the joined preset id on the child's durable header for the same reason a top-level session records its own: the preset decides the tool schemas and prompt sections the model saw, so a cold read of the child's history has to rebuild that composition rather than the deployment default. It is read from the parent's live scope chain, not from the parent header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one. -Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation. +Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation. The service may invoke one provider concurrently for distinct siblings: each start or preparation owns its mutable state and cancellation path, and one operation's failure, result, or cleanup must not settle or release another. A provider may queue its own capacity internally without changing that independence contract. ## The durable descriptor diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 2ad25aabe9..e28a8794cf 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -44,7 +44,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `childSessionMeta()` 把所加入的 preset id 记在子 agent 的持久化 header 上,理由与顶层会话记录自己的那一个相同:preset 决定了模型所见的工具 schema 与提示段,因此冷读子 agent 的历史时必须重建那份组装,而不是部署默认值。该值从父方**活着的** scope 链读取,而不是从父方 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。 -可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。 +可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。服务可以针对不同的同级子 agent 并发调用同一提供方:每次启动或准备都拥有各自的可变状态和取消路径,一项操作的失败、结果或清理不得使另一项操作结算或释放。提供方可以在内部按自身容量排队,但不得改变这项独立性约定。 ## 持久化描述符 diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 63a890176e..dffea92fbd 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -277,7 +277,10 @@ export interface SubagentRun { /** * One registered transport for running child agents. Providers are trusted * same-process implementations; callers treat descriptors and returned values - * as borrowed immutable data. + * as borrowed immutable data. The service may call one provider concurrently + * for distinct children. Providers isolate operation-local mutable state; a + * shared capacity controller may delay an operation but must not couple its + * settlement or cleanup to a sibling. */ export interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ @@ -298,7 +301,8 @@ export interface SubagentProvider { * initial turn. Before fulfillment, the provider owns setup and cleans any * unpublished partial resources before rejecting. Ownership transfers on * fulfillment; subsequent turn or infrastructure failure settles through - * the returned run. + * the returned run. Distinct starts may overlap; cancellation, failure, + * result settlement, and disposal remain independent for each run. */ start(request: ResolvedSubagentStartRequest): Promise /** @@ -313,6 +317,8 @@ export interface SubagentProvider { * continuation manager owns identity reservation, composition, Agent * creation, prompt delivery, cold resume, ownership, and disposal, so a * provider never sees the child's Agent, handle, turns, or teardown. + * Distinct preparations may overlap; each follows its own signal and returns + * data belonging only to `request.sessionId`. */ prepareContinuable?(request: ContinuableCreateRequest): Promise } diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 0292c0a51c..9cd884c016 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/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/subagent/tool-subagent/README.md -README.md: a46300cc5dffcbc420ed9525d8f081c670771294 -README.zh.md: 985d33beb2ac7de60527b457c4786a0257232611 +README.md: d4c472d54f87d6006aef96079050cf14885ed8fb +README.zh.md: 36f79ac879067dc74fcc6b96122088bb68d53321 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index a46300cc5d..d4c472d54f 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -29,7 +29,7 @@ With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` r ## Concurrency -Foreground and background calls are exclusive. Children may share the parent's workspace or external resources, and a unary classifier cannot prove that sibling delegations have disjoint effects. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md). +Foreground and background calls are concurrency-safe: sibling delegations in one assistant message overlap under the loop's rolling pool (`maxParallelToolCalls`), and results still commit in model order. Children work in their own sessions and a run never mutates the parent session; the one-shot background form's one parent-owned write — registering a Task — is a synchronous, commutative insertion that tolerates concurrent dispatch, so overlapping background calls acquire their task ids in dispatch-race order. Coordinating sibling workspace effects belongs to the model, exactly as it already does for background and continuable children. See the [parallel subagent Agent Note](../../../.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md) and the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md). ## Model Experience diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 985d33beb2..36f79ac879 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -29,7 +29,7 @@ ## 并发 -前台调用和后台调用均互斥。子 agent 可能共享父级工作区或外部资源,一元分类器无法证明同级委派的效果彼此不相交。见 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 +前台调用和后台调用均并发安全:同一条 assistant 消息中的同级委派会在循环的滚动池(`maxParallelToolCalls`)下重叠执行,结果仍按模型顺序提交。子 agent 在各自的会话中工作,一次运行绝不变更父会话;一次性后台形态对父级拥有状态的唯一写入是注册一个 Task——这是一次同步、可交换、能容忍并发分发的插入,因此重叠的后台调用按分发竞态顺序获得各自的 task id。协调同级工作区效果由模型负责,正如模型已经对后台和可继续子 agent 所承担的那样。见 [并行 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md) 和 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 ## 模型体验 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index bda7f8ae89..b84ee28da9 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -331,6 +331,9 @@ export function apply(ctx: Context, config: Config): void { : outputValueText(value.output), }], }, + // Children never mutate the parent session; the one parent-owned write + // (tasks.start) is a synchronous commutative insertion. + isConcurrencySafe: () => true, async execute(args, exec) { const parent = exec.agent if (!parent) { diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts index df35e6dbdd..0be724a8bb 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -33,6 +33,8 @@ export interface Config { inheritsParentContext?: boolean /** Structured value returned when the request asks for one. */ structured?: unknown + /** Observes each start; the child's result additionally waits for the returned promise. */ + onStart?: (request: SubagentStartRequest) => Promise | void } /** Scripted provider whose result aborts if its signal or disposer wins first. */ @@ -68,9 +70,10 @@ class ScriptedSubagentProvider implements SubagentProvider { ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, stopReason: state.cancelled ? 'aborted' : stopReason, }) - const result = new Promise((resolve) => { + const gate = Promise.resolve(this.config.onStart?.(request)) + const result = gate.then(() => new Promise((resolve) => { setTimeout(() => { resolve(resultFor()) }, 0) - }).finally(() => { + })).finally(() => { request.signal.removeEventListener('abort', onAbort) }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 20cb11db2b..6cf8dee746 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -128,20 +128,42 @@ describe('dsh-tool-subagent', () => { expect(foreground.isError).toBe(false) }) - it('keeps foreground and background calls exclusive', async () => { + it('classifies foreground and background calls concurrency-safe (sibling delegations overlap)', async () => { const ctx = await setup({ provider: 'mock' }) expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('subagent-foreground'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK' }, - })).toEqual({ kind: 'exclusive' }) + })).toEqual({ kind: 'parallel' }) expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('subagent-background'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true }, - })).toEqual({ kind: 'exclusive' }) + })).toEqual({ kind: 'parallel' }) + }) + + it('overlaps sibling foreground delegations dispatched concurrently', async () => { + // Two children each block until both have started: hidden serialization + // in the tool body, registry pipeline, or provider start path would + // deadlock here instead of passing silently. + const started: string[] = [] + let releaseBoth!: () => void + const bothStarted = new Promise((resolve) => { releaseBoth = resolve }) + const ctx = await setup({ provider: 'mock', enableRunInBackground: false }, { + onStart: (request: SubagentStartRequest) => { + started.push(request.label ?? '(unlabeled)') + if (started.length === 2) releaseBoth() + return bothStarted + }, + }) + const results = await Promise.all([ + callSubagent(ctx, { description: 'first', prompt: 'p1' }), + callSubagent(ctx, { description: 'second', prompt: 'p2' }), + ]) + expect(started.sort()).toEqual(['first', 'second']) + for (const result of results) expect(result.isError).toBe(false) }) it.each([ @@ -957,6 +979,16 @@ describe('dsh-tool-subagent continuable background mode', () => { return { ctx, parent } } + it('classifies continuable background calls concurrency-safe', async () => { + const { ctx } = await continuableSetup() + expect(ctx.tools.executionMode({ + signal: testToolSignal, + callId: CallId('subagent-continuable'), + name: 'subagent', + arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true }, + })).toEqual({ kind: 'parallel' }) + }) + it('starts a continuable child and returns only its durable id, creating no Task', async () => { const { ctx, parent } = await continuableSetup() const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! @@ -986,6 +1018,69 @@ describe('dsh-tool-subagent continuable background mode', () => { expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true) }) + it('isolates a cancelled continuable preparation from a concurrent sibling', async () => { + const { ctx, parent } = await continuableSetup() + const bothPreparing = Promise.withResolvers() + const releasePreparations = Promise.withResolvers() + const cancelled = new AbortController() + let preparationCount = 0 + let cancelledChildId: ReturnType | undefined + let survivingChildId: ReturnType | undefined + ctx.subagents.registerProvider({ + name: 'gated', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: false, + start: async () => { throw new Error('continuable policy must not start a one-shot child') }, + prepareContinuable: async (request) => { + preparationCount += 1 + if (request.signal === cancelled.signal) cancelledChildId = request.sessionId + else survivingChildId = request.sessionId + if (preparationCount === 2) bothPreparing.resolve(undefined) + await releasePreparations.promise + return {} + }, + }) + tool.apply(ctx, { + provider: 'gated', + toolName: 'subagent_gated', + backgroundMode: 'continuable', + maxDepth: 3, + }) + + const execute = (callId: string, description: string, signal: AbortSignal) => ctx.tools.execute({ + signal, + callId: CallId(callId), + name: 'subagent_gated', + arguments: { description, prompt: 'work', run_in_background: true }, + agent: parent, + }) + const cancelledResult = execute('continuable-cancelled', 'cancelled sibling', cancelled.signal) + const survivingResult = execute('continuable-surviving', 'surviving sibling', testToolSignal) + await bothPreparing.promise + cancelled.abort() + releasePreparations.resolve(undefined) + + const [failed, succeeded] = await Promise.all([cancelledResult, survivingResult]) + expect(preparationCount).toBe(2) + expect(failed.isError).toBe(true) + expect(succeeded.isError).toBe(false) + expect(cancelledChildId).toBeDefined() + expect(survivingChildId).toBeDefined() + expect(ctx.agents.get(cancelledChildId!)).toBeUndefined() + await expect(ctx.sessionPersistence.load(cancelledChildId!)).rejects.toThrow(/not found/) + + expect(succeeded.isError ? undefined : succeeded.value).toEqual({ + kind: 'continuable', + subagentId: survivingChildId, + }) + await vi.waitFor(() => { + expect(ctx.agents.get(survivingChildId!)).toBeUndefined() + }, { timeout: 5_000 }) + const loaded = await ctx.sessionPersistence.load(survivingChildId!) + expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) + expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true) + }) + }) describe('background preflight failure (no orphaned child, by construction)', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe021d4031..7e314749e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1608,6 +1608,9 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver + '@deepseek-ai/dsh-message-feedback': + specifier: workspace:^ + version: link:../../feedback/message-feedback '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session/session-projection-cache @@ -3829,6 +3832,55 @@ importers: specifier: workspace:^ version: link:../../session/user-id + packages/feedback/message-feedback: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:^ + version: link:../../storage/storage-json + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + packages/fs/fs: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b6a315d7ad..a32117bab8 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -63,6 +63,7 @@ export const SERVICE_PAGE: Record = { httpServer: 'http-server.md', invariants: 'invariants.md', llm: 'llm-streaming.md', + messageFeedback: 'feedback.md', permission: 'permission.md', planMode: 'plan.md', pty: 'pty.md', @@ -229,6 +230,25 @@ export const LINK_MAP: Readonly> = { ResolvedRetryPolicy: 'llm-streaming.md', Message: 'llm-streaming.md', MessageSource: 'llm-streaming.md', + MessageFeedbackDeleteRequest: 'feedback.md', + MessageFeedbackDeleteResult: 'feedback.md', + MessageFeedbackDeleteValue: 'feedback.md', + MessageFeedbackFailure: 'feedback.md', + MessageFeedbackItem: 'feedback.md', + MessageFeedbackListRequest: 'feedback.md', + MessageFeedbackListResult: 'feedback.md', + MessageFeedbackListValue: 'feedback.md', + MessageFeedbackNoteBlank: 'feedback.md', + MessageFeedbackNoteTooLarge: 'feedback.md', + MessageFeedbackPutRequest: 'feedback.md', + MessageFeedbackPutResult: 'feedback.md', + MessageFeedbackRating: 'feedback.md', + MessageFeedbackRejected: 'feedback.md', + MessageFeedbackSessionNotFound: 'feedback.md', + MessageFeedbackSuccess: 'feedback.md', + MessageFeedbackTargetNotFound: 'feedback.md', + MessageFeedbackVersion: 'feedback.md', + MessageFeedbackVersionConflict: 'feedback.md', UserMessage: 'session.md', PreStepDecision: 'core.md', PreStepContext: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 699bcae707..5b950782b1 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -135,7 +135,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session', title: 'In-memory session store', mode: 'core', - consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'], + consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants', 'message-feedback'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, { @@ -167,7 +167,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Durable session persistence seam', mode: 'seam', implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'], - consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'], + consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, { @@ -211,9 +211,16 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'storage-domain', title: 'Domain data facility', mode: 'core', - consumers: ['workspace'], + consumers: ['workspace', 'message-feedback'], note: 'Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state.', }, + { + key: 'messageFeedback', + pkg: 'message-feedback', + title: 'Lifecycle-bound message feedback', + mode: 'core', + note: 'Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry.', + }, { key: 'workspace', pkg: 'workspace', 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/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 9bf4d8ceb0..f8985ce305 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1744,6 +1744,101 @@ "doc": "docs/subsystems/core.md", "symbol": "AgentOptions", "source": "packages/core/agent/src/runtime-types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackVersion", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackRating", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackItem", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackListRequest", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackListValue", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackPutRequest", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackDeleteRequest", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackDeleteValue", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackSessionNotFound", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackTargetNotFound", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackVersionConflict", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackNoteBlank", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackNoteTooLarge", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackFailure", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackSuccess", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackRejected", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackListResult", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackPutResult", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackDeleteResult", + "source": "packages/feedback/message-feedback/src/types.ts" } ] } 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.host.json b/tsconfig.host.json index 690656e150..2beed0b1c3 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -14,6 +14,7 @@ "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", "apps/web/tests/minimal-preset.snapshot.ts", + "apps/web/tests/message-feedback-protocol.snapshot.ts", "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", "apps/web/tests/approval-composer.e2e.ts", @@ -135,6 +136,7 @@ { "path": "./packages/storage/storage-json" }, { "path": "./packages/storage/storage-sqlite" }, { "path": "./packages/storage/storage-domain" }, + { "path": "./packages/feedback/message-feedback" }, { "path": "./packages/workspace/workspace" }, { "path": "./packages/session/session-title" }, { "path": "./packages/session/session-title-llm" }, 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', },