Merge remote-tracking branch 'origin/master' into feat/web-session-log-export

This commit is contained in:
_Kerman
2026-08-11 13:46:46 +08:00
142 changed files with 3934 additions and 262 deletions

View File

@@ -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'

View File

@@ -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:^",

View File

@@ -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

View File

@@ -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.

View File

@@ -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))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。

View File

@@ -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

View File

@@ -123,7 +123,7 @@ export function ConversationRoot({
</div>
)
// 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

View File

@@ -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;

View File

@@ -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<HTMLTextAreaElement>): 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<HTMLTextAreaElement>): 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<HTMLTextAreaElement>, 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<HTMLTextAreaElement>): 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}
</div>
)}
{/* 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 && <div className={css.error} role="alert">{dropError}</div>}
<div
className={clsx(css.card, dragActive && css.dragActive)}
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
data-composer-card
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
onPointerDown={workspaceTrigger ? (e) => { 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')

View File

@@ -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()
})

View File

@@ -60,6 +60,9 @@ interface BenchOptions {
running?: boolean
subagent?: Exclude<ConversationSnapshot['subagent'], null>
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('描述你的任务以生成计划')

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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.

View File

@@ -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 消费方由各自边界负责,并保持延后。

View File

@@ -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

View File

@@ -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.

View File

@@ -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 聚合尚未挂载生成的客户端 contributionHost 调用方无需该客户端组装即可使用 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 行的条目数和聚合保留字节尚无上限;按索引读取元数据和由部署决定的行边界,延后到具体消费方明确策略时处理。

View File

@@ -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:^"
}
}

View File

@@ -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<T>(value: T): MessageFeedbackSuccess<T> {
return Object.freeze({ ok: true, value })
}
/** Build a frozen business-failure branch. */
function rejected<E extends MessageFeedbackFailure>(error: E): MessageFeedbackRejected<E> {
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<SessionInspection>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound>
/** Validated note or one explicit request failure. */
type ResolvedNote =
| MessageFeedbackSuccess<string | undefined>
| MessageFeedbackRejected<MessageFeedbackNoteBlank | MessageFeedbackNoteTooLarge>
/**
* 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<Config> = s.object({
maxNoteBytes: s.number().step(1).min(1).required(),
})
private readonly maxNoteBytes: number
private table?: KvTable<SessionId, MessageFeedbackRow>
private readonly operationTails = new Map<SessionId, Promise<void>>()
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<void> {
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<MessageFeedbackListResult> {
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<MessageFeedbackPutResult> {
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<MessageFeedbackDeleteResult> {
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<MessageFeedbackDeleteValue>(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<MessageFeedbackDeleteValue>(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<KnownSession> {
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<SessionInspection> {
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<T>(sessionId: SessionId, operation: () => Promise<T>): Promise<T> {
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<SessionId, MessageFeedbackRow> {
if (this.table === undefined) {
throw new Error('message-feedback: durable domain is not initialized')
}
return this.table
}
}
export default MessageFeedbackService

View File

@@ -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 */

View File

@@ -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<MessageFeedbackRating>
/** 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<MessageFeedbackItem>
/** 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<typeof messageFeedbackSessionIdentitySchema>
/**
* 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<string>()
const versions = new Set<string>()
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<typeof messageFeedbackRowSchema>
/** One lifecycle-bound sidecar record per Session id. */
export const messageFeedbackDomainSpec = defineDomain({
name: 'message_feedback',
version: 0,
tables: {
sessions: domainTable<SessionId, MessageFeedbackRow>(messageFeedbackRowSchema),
},
})

View File

@@ -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<T> {
readonly ok: true
readonly value: T
}
/** Rejected public operation result with a stable business failure. */
export interface MessageFeedbackRejected<E extends MessageFeedbackFailure> {
readonly ok: false
readonly error: E
}
/** Result returned by the message-feedback `list` operation. */
export type MessageFeedbackListResult =
| MessageFeedbackSuccess<MessageFeedbackListValue>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound>
/** Result returned by the message-feedback `put` operation. */
export type MessageFeedbackPutResult =
| MessageFeedbackSuccess<MessageFeedbackItem>
| MessageFeedbackRejected<
| MessageFeedbackSessionNotFound
| MessageFeedbackTargetNotFound
| MessageFeedbackVersionConflict
| MessageFeedbackNoteBlank
| MessageFeedbackNoteTooLarge
>
/** Result returned by the message-feedback `delete` operation. */
export type MessageFeedbackDeleteResult =
| MessageFeedbackSuccess<MessageFeedbackDeleteValue>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackVersionConflict>

View File

@@ -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<MessageFixture, 'session'> {
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<SessionId, SessionInspection>()
readonly logical = new Map<SessionId, SessionInspection>()
inspectFailure: Error | undefined
inspectCalls = 0
readFromCalls = 0
onReadFrom: (() => void | Promise<void>) | undefined
onListSnapshots: (() => void | Promise<void>) | undefined
locate(_meta: SessionHeader): SessionLocation | undefined { return undefined }
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
load(id: SessionId): Promise<SessionInspection> {
return this.readFrom(id, 0)
}
inspect(id: SessionId): Promise<SessionInspection> {
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<SessionHeader[]> {
return Promise.resolve([...this.durable.values()].map(value => value.meta))
}
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
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<void>
dispose(): Promise<void>
}
/** Compose the service over the real storage hub/domain/JSON backend. */
export async function setupHarness(maxNoteBytes = 64): Promise<TestHarness> {
const root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-test-'))
const ctx = new Context()
let disposeFeedback: (() => Promise<void>) | 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 })
},
}
}

View File

@@ -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()
}
})
})

View File

@@ -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<Context> {
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<string, unknown>([
['@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<typeof ctx.loader.internal>
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()
})
})

View File

@@ -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<TestHarness> {
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<ReturnType<TestHarness['ctx']['messageFeedback']['put']>>,
): 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<undefined>()
const release = Promise.withResolvers<undefined>()
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<undefined>()
const release = Promise.withResolvers<undefined>()
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<undefined>()
const release = Promise.withResolvers<undefined>()
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] }] },
})
})
})

View File

@@ -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"
}
]
}

View File

@@ -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',
}
/**

View File

@@ -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)`,
)
})

View File

@@ -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<MessageFeedbackListResult>',
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<MessageFeedbackPutResult>',
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<MessageFeedbackDeleteResult>',
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.',
@@ -2339,6 +2357,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<MessageFeedbackDeleteValue> | MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackVersionConflict>;',
},
{
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<MessageFeedbackListValue> | MessageFeedbackRejected<MessageFeedbackSessionNotFound>;',
},
{
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<MessageFeedbackItem> | MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackTargetNotFound | MessageFeedbackVersionConflict | MessageFeedbackNoteBlank | MessageFeedbackNoteTooLarge>;',
},
{
name: 'MessageFeedbackRating',
declaration: 'export type MessageFeedbackRating = \'positive\' | \'negative\';',
},
{
name: 'MessageFeedbackRejected',
declaration: 'export interface MessageFeedbackRejected<E extends MessageFeedbackFailure> {\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<T> {\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\'>;',

View File

@@ -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:

View File

@@ -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: 28f649ef54bbf88feda24a9ce197c2c366f8349b
README.zh.md: 595c5e5e7fffc367f2e3fd8142b779dc22fb3b79
README.md: b9f65befab71edc0685f1a6cca767c803afcb76c
README.zh.md: f58975815742ccbe790ca1cf096449831144a2f3

View File

@@ -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

View File

@@ -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 并发调用同一提供方:每次启动或准备都拥有各自的可变状态和取消路径,一项操作的失败、结果或清理不得使另一项操作结算或释放。提供方可以在内部按自身容量排队,但不得改变这项独立性约定。
## 持久化描述符

View File

@@ -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<SubagentRun>
/**
@@ -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<ContinuableCreateSpec>
}

View File

@@ -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: ac3ec0563cce9128608ca31860b034a103dc1a3a
README.zh.md: d64831d7cf64800ad3307ce6cb7f294500a0a6f0
README.md: 578ea4786e1d996251360c4aed92f2e882553681
README.zh.md: baf91664530a637df96f1e7a51e6f0adabc0a8ae

View File

@@ -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

View File

@@ -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)。
## 模型体验

View File

@@ -330,6 +330,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) {

View File

@@ -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> | 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<SubagentResult>((resolve) => {
const gate = Promise.resolve(this.config.onStart?.(request))
const result = gate.then(() => new Promise<SubagentResult>((resolve) => {
setTimeout(() => { resolve(resultFor()) }, 0)
}).finally(() => {
})).finally(() => {
request.signal.removeEventListener('abort', onAbort)
})

View File

@@ -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<void>((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<undefined>()
const releasePreparations = Promise.withResolvers<undefined>()
const cancelled = new AbortController()
let preparationCount = 0
let cancelledChildId: ReturnType<typeof SessionId> | undefined
let survivingChildId: ReturnType<typeof SessionId> | 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)', () => {