feat(feedback): add durable message feedback backend
This commit is contained in:
@@ -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'
|
||||
|
||||
|
||||
@@ -82,6 +82,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:^",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 消费方由各自边界负责,并保持延后。
|
||||
|
||||
6
packages/feedback/message-feedback/README.i18n.yaml
Normal file
6
packages/feedback/message-feedback/README.i18n.yaml
Normal 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: 0fda4fb535e5186c252377383520671cd574b364
|
||||
README.zh.md: 151db5c10048e6ff32d3f7a4ddbcba986d87597a
|
||||
81
packages/feedback/message-feedback/README.md
Normal file
81
packages/feedback/message-feedback/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# @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 an authorized material `put` clears an existing note.
|
||||
|
||||
```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; a catalogued cold Session is physically re-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 cold 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 caller's `expected` token and the current `actual` token, each nullable where absence is meaningful. `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; a material update requires the exact current item version. 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.
|
||||
|
||||
An exact desired-value retry is recognized before `ifVersion` comparison. It returns the already stored item with unchanged version and timestamps, so a caller may safely retry after losing a success response even with the now-stale token or original `null`. `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.
|
||||
|
||||
## 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.
|
||||
81
packages/feedback/message-feedback/README.zh.md
Normal file
81
packages/feedback/message-feedback/README.zh.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# @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` 表示目标值不含备注,因此通过授权的实质 `put` 会清除已有备注。
|
||||
|
||||
```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 提交;已进入目录的 cold Session 则通过 `SessionPersistence.readFrom` 从序列零做物理复读。随后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或 cold 物理读取失败都会阻止伴随记录提交,因此持久反馈绝不会先于其持久目标消息。
|
||||
|
||||
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` 返回调用方的 `expected` token 与当前 `actual` token;在表示不存在时,两者可以为 null。`MessageFeedbackNoteTooLarge` 同时返回 `maxBytes` 与 `actualBytes`。客户端 Remote 聚合尚未挂载生成的客户端 contribution;Host 调用方无需该客户端组装即可使用 service/Remote 契约。
|
||||
|
||||
## Compare-and-set 与幂等性
|
||||
|
||||
`ifVersion: null` 表示仅当条目不存在时才创建;实质更新要求与当前条目 version 完全一致。检查按消息而非按 Session 进行,因此修改一个条目不会与另一个条目冲突。每次实质创建或更新都会分配新的 opaque UUID token。
|
||||
|
||||
系统会在比较 `ifVersion` 之前识别与目标值完全相同的重试。它返回已存条目,version 与时间戳均不变,因此调用方在成功响应丢失后,即使用当前已陈旧的 token 或原始 `null` 也可安全重试。条目已不存在时,`delete` 忽略 `ifVersion`;成功后始终返回稳定的 `{ absent: true }` 后置条件。
|
||||
|
||||
按 Session 划分的 promise 队列覆盖检查、持久性校验、伴随记录读取、比较与整行写入。这些语义会串行化经由同一服务实例的并发变更;storage-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。
|
||||
82
packages/feedback/message-feedback/package.json
Normal file
82
packages/feedback/message-feedback/package.json
Normal 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",
|
||||
"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:^"
|
||||
}
|
||||
}
|
||||
380
packages/feedback/message-feedback/src/index.ts
Normal file
380
packages/feedback/message-feedback/src/index.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 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>>()
|
||||
|
||||
/**
|
||||
* @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(() => () => 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. An exact desired-value retry returns the stored item before its
|
||||
* stale or `null` version is considered a conflict.
|
||||
* @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 (existing !== undefined
|
||||
&& existing.rating === request.rating
|
||||
&& existing.note === note.value) {
|
||||
return success(snapshotItem(existing))
|
||||
}
|
||||
if (request.ifVersion !== (existing?.version ?? null)) {
|
||||
return rejected(this.versionConflict(request, existing?.version ?? null))
|
||||
}
|
||||
|
||||
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(request, existing.version))
|
||||
}
|
||||
|
||||
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)) {
|
||||
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 inspection
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
/** Build a conflict branch without exposing an orderable version. */
|
||||
private versionConflict(
|
||||
request: Pick<MessageFeedbackPutRequest, 'sessionId' | 'messageId' | 'ifVersion'>,
|
||||
actual: MessageFeedbackVersion | null,
|
||||
): MessageFeedbackVersionConflict {
|
||||
return {
|
||||
code: 'version-conflict',
|
||||
sessionId: request.sessionId,
|
||||
messageId: request.messageId,
|
||||
expected: request.ifVersion,
|
||||
actual,
|
||||
}
|
||||
}
|
||||
|
||||
/** Queue a complete read/compare/write mutation behind this Session's prior mutation. */
|
||||
private enqueue<T>(sessionId: SessionId, operation: () => Promise<T>): Promise<T> {
|
||||
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
|
||||
27
packages/feedback/message-feedback/src/invariant.ts
Normal file
27
packages/feedback/message-feedback/src/invariant.ts
Normal 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 */
|
||||
88
packages/feedback/message-feedback/src/spec.ts
Normal file
88
packages/feedback/message-feedback/src/spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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. */
|
||||
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),
|
||||
},
|
||||
})
|
||||
151
packages/feedback/message-feedback/src/types.ts
Normal file
151
packages/feedback/message-feedback/src/types.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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'
|
||||
readonly sessionId: SessionId
|
||||
readonly messageId: MessageId
|
||||
/** Version supplied by the caller (`null` means create-only). */
|
||||
readonly expected: MessageFeedbackVersion | null
|
||||
/** Current version, or `null` when the item does not exist. */
|
||||
readonly actual: MessageFeedbackVersion | 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>
|
||||
206
packages/feedback/message-feedback/tests/helpers.ts
Normal file
206
packages/feedback/message-feedback/tests/helpers.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
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) | 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)
|
||||
}
|
||||
|
||||
readFrom(
|
||||
id: SessionId,
|
||||
fromSeq: number,
|
||||
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
this.readFromCalls += 1
|
||||
this.onReadFrom?.()
|
||||
const stored = this.durable.get(id)
|
||||
return stored === undefined
|
||||
? Promise.reject(new Error(`test persistence: session '${id}' not found`))
|
||||
: Promise.resolve({ meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) })
|
||||
}
|
||||
|
||||
list(): Promise<SessionHeader[]> {
|
||||
return Promise.resolve([...this.durable.values()].map(value => value.meta))
|
||||
}
|
||||
|
||||
listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
|
||||
return Promise.resolve([...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
|
||||
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()
|
||||
try {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(TestPersistence)
|
||||
await ctx.plugin(Storage)
|
||||
await ctx.plugin(StorageJson, { root })
|
||||
await ctx.plugin(StorageDomain, { backend: 'json' })
|
||||
await ctx.plugin(MessageFeedbackService, { maxNoteBytes })
|
||||
} catch (error) {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
ctx,
|
||||
persistence: ctx.sessionPersistence as unknown as TestPersistence,
|
||||
root,
|
||||
async dispose() {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,546 @@
|
||||
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('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',
|
||||
sessionId: fixture.session.id,
|
||||
messageId: fixture.assistantMessageIds[0],
|
||||
expected,
|
||||
actual: 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: null,
|
||||
}))
|
||||
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',
|
||||
sessionId: fixture.session.id,
|
||||
messageId: firstId,
|
||||
expected: first.version,
|
||||
actual: updated.version,
|
||||
},
|
||||
})
|
||||
|
||||
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('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.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'version-conflict', actual: created.version },
|
||||
})
|
||||
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.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'version-conflict', actual: recreated.version },
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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 a live target checkpoint before the sidecar write without a cold-log reread', 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('unexpected:cold-read') }
|
||||
|
||||
expectItem(await ctx.messageFeedback.put({
|
||||
sessionId: session.id,
|
||||
messageId: fixture.assistantMessageIds[0],
|
||||
rating: 'positive',
|
||||
ifVersion: null,
|
||||
}))
|
||||
expect(order).toEqual(['session:durable', 'sidecar:durable'])
|
||||
expect(persistence.readFromCalls).toBe(0)
|
||||
expect(persistence.durable.get(session.id)?.events).toContainEqual(
|
||||
expect.objectContaining({ type: 'assistant/message' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when a live checkpoint fails or has no participant', 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: [] },
|
||||
})
|
||||
})
|
||||
|
||||
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(0)
|
||||
await expect(ctx.messageFeedback.list({ sessionId: session.id })).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { items: [{ messageId: fixture.assistantMessageIds[0] }] },
|
||||
})
|
||||
})
|
||||
})
|
||||
45
packages/feedback/message-feedback/tsconfig.json
Normal file
45
packages/feedback/message-feedback/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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. An exact desired-value retry returns the stored item before its\n * stale or `null` version is considered a conflict.\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.',
|
||||
@@ -2331,6 +2349,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 sessionId: SessionId;\n readonly messageId: MessageId;\n readonly expected: MessageFeedbackVersion | null;\n readonly actual: MessageFeedbackVersion | null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'MessageId',
|
||||
declaration: 'export type MessageId = Branded<\'MessageId\'>;',
|
||||
|
||||
Reference in New Issue
Block a user