Merge pull request #2217 from deepseek-harness/agent/message-feedback-backend

feat(feedback): add durable message feedback backend
This commit is contained in:
Ziya
2026-08-11 13:29:20 +08:00
committed by GitHub
49 changed files with 3261 additions and 25 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md
2026-08-10-message-feedback-sidecar.md: 780cbaa840fcac7bcfa799468bfd61f5b08715cb
2026-08-10-message-feedback-sidecar.zh.md: 72ecc82717010f65d013418a45c3b38c6084aaf9
@@ -0,0 +1,43 @@
# Agent Note: Lifecycle-bound message feedback sidecar
Status: implemented
English | [中文](2026-08-10-message-feedback-sidecar.zh.md)
## Problem
The existing `/feedback` command records an immutable Session-level `feedback/record` event. That event can release a pending telemetry prefix under `FEEDBACK_ONLY`, so it is the wrong authority for an editable positive/negative rating and optional note attached to one assistant message. Message feedback needs independent update and delete semantics without entering the canonical Session log, changing a projection, reaching the model surface, or implicitly consenting to telemetry.
A sidecar keyed only by `SessionId` can outlive the log lifecycle it describes when an id is recreated with a different header identity. A Session-wide revision also makes unrelated message edits conflict, while plain storage-domain read/put has no cross-process compare-and-swap. Session disposal is only live-store detach, not durable deletion, and the current Session persistence seam exposes no deletion operation that could own a truthful cascade.
## Decision
`@deepseek-ai/dsh-message-feedback` owns the `ctx.messageFeedback` service and stores message feedback as one storage-domain sidecar row per Session. The sidecar is neither Session-log content nor a Session projection. It emits no `feedback/record` event and performs no telemetry handoff; the command-feedback and message-feedback contracts remain independent.
Every usable row is bound to the inspected Session header identity `{createdAt, cwd}`, not merely its `SessionId`. A lifecycle mismatch is treated as absence: `list` returns no items, and `put` may replace the stale row with one bound to the current identity. An id reused with a different header identity therefore cannot inherit stale feedback. A fork receives its own Session identity and no sidecar copy: even when the fork seed contains the same assistant messages, feedback remains attached to the Session in which the human recorded it.
`put` accepts a target only when `SessionPersistence.inspect()` observes a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Inspection is the cold-safe authority: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization.
Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references.
Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize.
A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. Plugin disposal closes admission, drains accepted queue work, and then closes the storage domain. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection.
`maxNoteBytes` is a required deployment choice and bounds the UTF-8 byte length of an optional note; the Web Host bundle sets it explicitly to `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` contract directly through `GatewayService` and `@Remote`. Client Remote aggregate mounting and UI remain separately owned and deferred; their later adapter stays a thin consumer of this Host contract.
The service performs no fake deletion cascade. `session/disposed` and `host/session-removed` describe detach from live ownership, not durable Session deletion, and Session persistence currently has no delete surface. Sidecar rows can therefore remain after out-of-band log removal; a different `{createdAt, cwd}` prevents such an orphan from becoming feedback for a later Session that reuses the id.
## Alternatives considered
**Append edits to the Session log and derive a projection.** Rejected because editable UI metadata would become canonical conversation-adjacent history, forks would replay and inherit it, deletion would require tombstones, and reusing `feedback/record` would silently couple a message rating to telemetry consent.
**Key feedback globally by `MessageId`, copy it on fork, or use one Session revision.** Rejected because message ids are meaningful only within a Session lifecycle, forked conversations need independent human judgments, and unrelated message mutations must not create false conflicts.
**Extend `KvTable` with cross-process compare-and-swap in this change.** Rejected because the shipped storage-domain backends expose no common conditional-write primitive. A process-local queue matches the supported one-Host topology; a real multi-process guarantee requires a backend-level atomic contract and is separate work.
**Delete feedback on Session disposal.** Rejected because disposal includes ordinary detach and rollback paths. Treating it as durable deletion would lose feedback while the Session log still exists; cleanup waits for a real Session deletion authority.
## Consequences
Message feedback is locally durable and independently editable without changing model-visible history or telemetry behavior. Concurrent callers in one Host receive per-message conflict detection and retry-safe outcomes, while deployments with multiple writers to the same storage root remain unsupported. A differing header identity treats a stale row as absent but does not reclaim it; a cloned log that retains the same `{createdAt, cwd}` is indistinguishable by this contract. The Host Remote contract is available now; client assembly and UI can remain thin consumers rather than taking ownership of persistence or concurrency semantics.
@@ -0,0 +1,43 @@
# Agent Note: 绑定生命周期的消息反馈伴随记录
Status: implemented
[English](2026-08-10-message-feedback-sidecar.md) | 中文
## 问题
现有 `/feedback` 命令记录不可变的 Session 级 `feedback/record` 事件。在 `FEEDBACK_ONLY` 下,该事件可以释放待处理的遥测前缀,因此它不适合作为挂在单条 assistant 消息上的可编辑好评/差评与可选备注的权威来源。消息反馈需要独立的更新与删除语义,且不得进入权威 Session 日志、改变投影、到达模型接口,或隐式表示遥测同意。
只按 `SessionId` 建索引的伴随记录可能在该 id 以不同 header 身份重建后,继续存活于其所描述的日志生命周期之外。Session 级 revision 还会让无关消息的编辑彼此冲突,而普通 storage-domain 读/写不提供跨进程 compare-and-swap。Session disposal 只是从 live store 脱离,并非持久删除;当前 Session 持久化 seam 也没有可拥有真实级联的删除操作。
## 决策
`@deepseek-ai/dsh-message-feedback` 拥有 `ctx.messageFeedback` 服务,并把消息反馈存为每个 Session 一条 storage-domain 伴随记录(sidecar)。该伴随记录既不是 Session 日志内容,也不是 Session 投影。它不发出 `feedback/record` 事件,也不执行遥测交接;command-feedback 与 message-feedback 契约保持独立。
每条可用记录都绑定到经检查的 Session header 身份 `{createdAt, cwd}`,而不只是其 `SessionId`。生命周期不匹配按不存在处理:`list` 返回空条目,`put` 可以用绑定当前身份的新记录替换陈旧行。因此,以不同 header 身份复用的 id 不会继承陈旧反馈。fork 拥有自己的 Session 身份,且不复制伴随记录:即使 fork 种子包含相同的 assistant 消息,反馈仍只属于人类记录它的那个 Session。
`put` 只接受由 `SessionPersistence.inspect()` 观测到的非空、append-origin `assistant/message`,且其 `MessageId` 必须与目标相同。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。检查使用 cold-safe 权威路径:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。
`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。
每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt``updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突会返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。
按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。Plugin disposal 会关闭接纳、排空已进入队列的工作,然后关闭 storage domain。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。
`maxNoteBytes` 是必填的部署选择,用于限制可选备注的 UTF-8 字节长度;Web Host bundle 将其显式设为 `8192`。该包通过 `GatewayService``@Remote` 直接发布 Host `messageFeedback.list``messageFeedback.put``messageFeedback.delete` 契约。客户端 Remote 聚合挂载与 UI 由各自边界负责并保持延后;后续适配层只是该 Host 契约的薄消费者。
服务不伪造删除级联。`session/disposed``host/session-removed` 表示脱离 live ownership,而非持久删除,Session persistence 当前也没有删除接口。因此在带外移除日志后,伴随记录可能继续存在;不同的 `{createdAt, cwd}` 可阻止此类孤儿记录变成后来复用该 id 的 Session 反馈。
## 考虑过的替代方案
**把编辑追加到 Session 日志并派生投影。** 不予采纳,因为可编辑 UI 元数据会变成权威且邻近对话的历史,fork 会回放并继承它,删除需要 tombstone,而复用 `feedback/record` 会把消息评分与遥测同意静默耦合。
**按全局 `MessageId` 建索引、在 fork 时复制,或使用一个 Session revision。** 不予采纳,因为消息 id 仅在某个 Session 生命周期内有意义,fork 后的对话需要独立的人类判断,而且无关消息的变更不应制造虚假冲突。
**在本次变更中为 `KvTable` 扩展跨进程 compare-and-swap。** 不予采纳,因为出厂 storage-domain 后端没有共同的条件写原语。进程内队列符合受支持的单 Host 拓扑;真实的多进程保证需要后端级原子契约,属于独立工作。
**在 Session disposal 时删除反馈。** 不予采纳,因为 disposal 包含普通 detach 与 rollback 路径。把它当成持久删除会在 Session 日志仍存在时丢失反馈;清理必须等待真正的 Session 删除权威。
## 后果
消息反馈在本地持久化并可独立编辑,且不改变模型可见历史或遥测行为。同一 Host 中的并发调用方获得逐消息冲突检测与可安全重试的结果;多个写入者共享同一存储根目录的部署仍不受支持。不同的 header 身份会让陈旧记录被视为不存在,但不会将其回收;本契约无法区分保留相同 `{createdAt, cwd}` 的克隆日志。Host Remote 契约现在可用;客户端组装与 UI 可以保持为薄消费者,而不接管持久化或并发语义。
@@ -0,0 +1,115 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
assertFixtureInventory,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
type WebScaffold,
} from './scaffold.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-protocol', import.meta.url))
const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json')
const SESSION_ID = 'message-feedback-protocol'
const MESSAGE_ID = '11111111-1111-4111-8111-111111111111'
interface ProtocolExchange {
readonly endpoint: string
readonly request: unknown
readonly status: number
readonly response: unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */
function createdVersion(response: unknown): string {
if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true
|| !isRecord(response.result.value) || response.result.value.ok !== true
|| !isRecord(response.result.value.value)
|| typeof response.result.value.value.version !== 'string') {
throw new Error('messageFeedback.put did not return a successful versioned item')
}
return response.result.value.value.version
}
/** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */
function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string {
return JSON.stringify(exchanges, (key, value: unknown) => {
if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}'
if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}'
return value
}, 2)
}
describe('message feedback Host Remote protocol', () => {
let scaffold: WebScaffold
beforeAll(async () => {
scaffold = await launchWebScaffold()
await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID)
})
afterAll(async () => {
await scaffold?.close()
})
it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => {
const exchanges: ProtocolExchange[] = []
const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise<unknown> => {
const payload = { args: { request } }
const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId,
method: endpoint,
payload,
}),
})
const body: unknown = await response.json()
exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body })
return body
}
await invoke('feedback-invalid', 'messageFeedback/put', {
sessionId: SESSION_ID,
messageId: MESSAGE_ID,
rating: 'invalid-rating',
ifVersion: null,
})
await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID })
const created = await invoke('feedback-put', 'messageFeedback/put', {
sessionId: SESSION_ID,
messageId: MESSAGE_ID,
rating: 'positive',
note: 'Useful answer',
ifVersion: null,
})
const version = createdVersion(created)
expect(version).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID })
await invoke('feedback-conflict', 'messageFeedback/put', {
sessionId: SESSION_ID,
messageId: MESSAGE_ID,
rating: 'negative',
ifVersion: null,
})
await invoke('feedback-delete', 'messageFeedback/delete', {
sessionId: SESSION_ID,
messageId: MESSAGE_ID,
ifVersion: version,
})
await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID })
expect(exchanges.every(exchange => exchange.status === 200)).toBe(true)
await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode)
await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.jsonl'])
})
})
@@ -0,0 +1,203 @@
[
{
"endpoint": "/api/messageFeedback/put",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol",
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "invalid-rating",
"ifVersion": null
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-invalid",
"result": {
"ok": false,
"error": {
"code": "internal",
"message": "typert gateway: messageFeedback/put: wire field \"request\" failed boundary validation",
"details": {}
}
}
}
},
{
"endpoint": "/api/messageFeedback/list",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol"
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-list-empty",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"items": []
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/put",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol",
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "positive",
"note": "Useful answer",
"ifVersion": null
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-put",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "positive",
"note": "Useful answer",
"version": "{{version}}",
"createdAt": "{{timestamp}}",
"updatedAt": "{{timestamp}}"
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/list",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol"
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-list-created",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"items": [
{
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "positive",
"note": "Useful answer",
"version": "{{version}}",
"createdAt": "{{timestamp}}",
"updatedAt": "{{timestamp}}"
}
]
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/put",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol",
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "negative",
"ifVersion": null
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-conflict",
"result": {
"ok": true,
"value": {
"ok": false,
"error": {
"code": "version-conflict",
"current": {
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "positive",
"note": "Useful answer",
"version": "{{version}}",
"createdAt": "{{timestamp}}",
"updatedAt": "{{timestamp}}"
}
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/delete",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol",
"messageId": "11111111-1111-4111-8111-111111111111",
"ifVersion": "{{version}}"
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-delete",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"absent": true
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/list",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol"
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-list-deleted",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"items": []
}
}
}
}
}
]
@@ -0,0 +1,7 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786406400000,"cwd":"{{cwd}}"}
{"type":"turn/start","seq":0,"time":1786406400001,"data":{"turn":1}}
{"type":"user/message","seq":1,"time":1786406400002,"data":{"role":"user","content":[{"type":"text","text":"Give one useful answer."}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1786406400003,"data":{"turn":1,"step":1}}
{"type":"assistant/message","seq":3,"time":1786406400004,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"A useful answer."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"11111111-1111-4111-8111-111111111111"},"usage":{"inputTokens":4,"outputTokens":4}},"surfaceOp":"append"}
{"type":"step/end","seq":4,"time":1786406400005,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":5,"time":1786406400006,"data":{"turn":1,"reason":{"kind":"completed"}}}
+1
View File
@@ -25,6 +25,7 @@
"tests/scaffold.ts",
"tests/scaffold-hermetic.e2e.ts",
"tests/minimal-preset.snapshot.ts",
"tests/message-feedback-protocol.snapshot.ts",
"tests/live-interactions.e2e.ts",
"tests/question-composer.e2e.ts",
"tests/approval-composer.e2e.ts",
+2 -2
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 docs/architecture.md
architecture.md: 8d1c5a1be391e2455aefc69db89d96027aaf3efa
architecture.zh.md: 53bf9f54a5503ae40aa62a348822f9d92162dda2
architecture.md: aeda7f9674e75a1e97549f25c13d571b3b37ee8c
architecture.zh.md: a25f20ba9babefeaab4636027e97d6f2d4ae8caf
+1
View File
@@ -42,6 +42,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services,
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls |
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals |
| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | lifecycle-bound editable feedback for individual assistant messages and its Host Remote contract |
| `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | durable session-log storage |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools |
| `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | log-backed fallbacks, one optional asynchronous provider |
+1
View File
@@ -42,6 +42,7 @@
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 |
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 |
| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | 绑定生命周期的单条 assistant 消息可编辑反馈及其 Host Remote 契约 |
| `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | 会话日志的持久化存储 |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 |
| `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | 基于日志的回退标题和单个可选异步提供方 |
+2 -2
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 docs/capability-seams.md
capability-seams.md: c102167aa76b9ba613b1b434cb0aa58765d26106
capability-seams.zh.md: 7c4eab8d5a2d890bdf4513bb9acdc81f55414642
capability-seams.md: 64d20b1bfb609aec3acb3673e4589516bb315bfa
capability-seams.zh.md: 10b5116d12991319df55c551d51840bb566ac898
+10 -3
View File
@@ -30,6 +30,7 @@ flowchart LR
pkg_session_query_sqlite["session-query-sqlite"]
pkg_subagent_inprocess["subagent-inprocess"]
pkg_invariants["invariants"]
pkg_message_feedback["message-feedback"]
svc_invariants["ctx.invariants<br/>Package-owned invariant registry"]
pkg_scope["scope"]
pkg_typert_registry["typert-registry"]
@@ -60,6 +61,7 @@ flowchart LR
pkg_storage_domain["storage-domain"]
svc_storageDomain["ctx.storageDomain<br/>Domain data facility"]
pkg_workspace["workspace"]
svc_messageFeedback["ctx.messageFeedback<br/>Lifecycle-bound message feedback"]
svc_workspace["ctx.workspace<br/>Workspace entity registry"]
svc_sessionQuery["ctx.sessionQuery<br/>Session reads, traces, filters, and search"]
pkg_session_reference["session-reference"]
@@ -218,6 +220,7 @@ flowchart LR
pkg_llm_deepseek --> svc_llm
pkg_llm_pi_ai --> svc_llm
pkg_llm_replay --> svc_llm
pkg_message_feedback --> svc_messageFeedback
pkg_modules --> svc_clientModuleHost
pkg_permission --> svc_permission
pkg_plan_mode --> svc_planMode
@@ -322,6 +325,7 @@ flowchart LR
svc_sessionPersistence --> pkg_agent_loop
svc_sessionPersistence --> pkg_hooks_claude
svc_sessionPersistence --> pkg_hooks_codex
svc_sessionPersistence --> pkg_message_feedback
svc_sessionPersistence --> pkg_session_query
svc_sessionPersistence --> pkg_session_query_sqlite
svc_sessionPersistence --> pkg_tool_bash
@@ -334,6 +338,7 @@ flowchart LR
svc_sessions --> pkg_agent
svc_sessions --> pkg_agent_loop
svc_sessions --> pkg_invariants
svc_sessions --> pkg_message_feedback
svc_sessions --> pkg_session_persistence
svc_sessions --> pkg_session_query
svc_sessions --> pkg_session_query_sqlite
@@ -344,6 +349,7 @@ flowchart LR
svc_skills --> pkg_tool_skill
svc_spillStore --> pkg_spill_policy
svc_storage --> pkg_storage_domain
svc_storageDomain --> pkg_message_feedback
svc_storageDomain --> pkg_workspace
svc_subagents --> pkg_tool_ralph
svc_subagents --> pkg_tool_subagent
@@ -392,16 +398,17 @@ flowchart LR
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. |
| `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. |
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. |
| `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. |
| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
| `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry. |
| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. |
| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. |
+10 -3
View File
@@ -32,6 +32,7 @@ flowchart LR
pkg_session_query_sqlite["session-query-sqlite"]
pkg_subagent_inprocess["subagent-inprocess"]
pkg_invariants["invariants"]
pkg_message_feedback["message-feedback"]
svc_invariants["ctx.invariants<br/>Package-owned invariant registry"]
pkg_scope["scope"]
pkg_typert_registry["typert-registry"]
@@ -62,6 +63,7 @@ flowchart LR
pkg_storage_domain["storage-domain"]
svc_storageDomain["ctx.storageDomain<br/>Domain data facility"]
pkg_workspace["workspace"]
svc_messageFeedback["ctx.messageFeedback<br/>Lifecycle-bound message feedback"]
svc_workspace["ctx.workspace<br/>Workspace entity registry"]
svc_sessionQuery["ctx.sessionQuery<br/>Session reads, traces, filters, and search"]
pkg_session_reference["session-reference"]
@@ -220,6 +222,7 @@ flowchart LR
pkg_llm_deepseek --> svc_llm
pkg_llm_pi_ai --> svc_llm
pkg_llm_replay --> svc_llm
pkg_message_feedback --> svc_messageFeedback
pkg_modules --> svc_clientModuleHost
pkg_permission --> svc_permission
pkg_plan_mode --> svc_planMode
@@ -324,6 +327,7 @@ flowchart LR
svc_sessionPersistence --> pkg_agent_loop
svc_sessionPersistence --> pkg_hooks_claude
svc_sessionPersistence --> pkg_hooks_codex
svc_sessionPersistence --> pkg_message_feedback
svc_sessionPersistence --> pkg_session_query
svc_sessionPersistence --> pkg_session_query_sqlite
svc_sessionPersistence --> pkg_tool_bash
@@ -336,6 +340,7 @@ flowchart LR
svc_sessions --> pkg_agent
svc_sessions --> pkg_agent_loop
svc_sessions --> pkg_invariants
svc_sessions --> pkg_message_feedback
svc_sessions --> pkg_session_persistence
svc_sessions --> pkg_session_query
svc_sessions --> pkg_session_query_sqlite
@@ -346,6 +351,7 @@ flowchart LR
svc_skills --> pkg_tool_skill
svc_spillStore --> pkg_spill_policy
svc_storage --> pkg_storage_domain
svc_storageDomain --> pkg_message_feedback
svc_storageDomain --> pkg_workspace
svc_subagents --> pkg_tool_ralph
svc_subagents --> pkg_tool_subagent
@@ -394,16 +400,17 @@ flowchart LR
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、[`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop)、[`compact-basic`](../packages/compact/compact-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 |
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 |
| `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 |
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop)、[`agent`](../packages/core/agent)、[`session-persistence`](../packages/session/session-persistence)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess)、[`invariants`](../packages/support/invariants) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 |
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop)、[`agent`](../packages/core/agent)、[`session-persistence`](../packages/session/session-persistence)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess)、[`invariants`](../packages/support/invariants)、[`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 |
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session)、[`agent`](../packages/core/agent)、[`scope`](../packages/core/scope)、[`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 |
| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader)、[`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 |
| `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl)、[`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop)、[`tool-bash`](../packages/bash/tool-bash)、[`hooks-claude`](../packages/hooks/hooks-claude)、[`hooks-codex`](../packages/hooks/hooks-codex)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl)、[`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop)、[`tool-bash`](../packages/bash/tool-bash)、[`hooks-claude`](../packages/hooks/hooks-claude)、[`hooks-codex`](../packages/hooks/hooks-codex)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 |
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、`apiproxy` | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、`apiproxy` | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 |
| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json)、[`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 |
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 |
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace)、[`message-feedback`](../packages/feedback/message-feedback) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 |
| `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有本地逐 assistant 消息反馈、生命周期与目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约,且不进入 Session 历史或遥测。 |
| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference)、[`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 |
| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 |
+2 -2
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 docs/config-catalog.md
config-catalog.md: 85338a0ead89f729573af2b747d92d68544cff84
config-catalog.zh.md: 8c74940b3aad350f720361e197a389055784b445
config-catalog.md: 6188fec1cfe65369e59c10b851629122c393af1e
config-catalog.zh.md: 1aefcaff17d272d4767a22a5ba72a7ab6dc0b914
+14
View File
@@ -1129,6 +1129,20 @@ export interface ReconnectConfig {
Source: [`packages/mcp/mcp-client/src/index.ts:98`](../packages/mcp/mcp-client/src/index.ts)
## `@deepseek-ai/dsh-message-feedback`
Requires: `storageDomain` · `sessionPersistence` · `sessions`
```ts config-catalog
/** Required deployment policy for optional notes. */
export interface Config {
/** Maximum UTF-8 byte length accepted for one note. */
readonly maxNoteBytes: number
}
```
Source: [`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts)
## `@deepseek-ai/dsh-permission`
Requires: `bash` · `approval` · `sessions`
+14
View File
@@ -1131,6 +1131,20 @@ export interface ReconnectConfig {
来源:[`packages/mcp/mcp-client/src/index.ts:94`](../packages/mcp/mcp-client/src/index.ts)
## `@deepseek-ai/dsh-message-feedback`
需要:`storageDomain` · `sessionPersistence` · `sessions`
```ts config-catalog
/** Required deployment policy for optional notes. */
export interface Config {
/** Maximum UTF-8 byte length accepted for one note. */
readonly maxNoteBytes: number
}
```
来源:[`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts)
## `@deepseek-ai/dsh-permission`
需要:`bash` · `approval` · `sessions`
+2 -2
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 docs/module-graph.md
module-graph.md: 2e7fdcc2028cf17d88f986efef3fb580839be18b
module-graph.zh.md: 71bf4b361d8b639d54b91e6264f21f19df6fce13
module-graph.md: 859a449f63bd36177a9c666ac9d894cb495b224f
module-graph.zh.md: cedf087810cf309200368b43c6d53757426c58cd
+9
View File
@@ -201,6 +201,7 @@ flowchart TD
end
subgraph group_feedback["packages/feedback"]
pkg_command_feedback["command-feedback"]
pkg_message_feedback["message-feedback"]
end
subgraph group_guard["packages/guard"]
pkg_repeat_tool_guard["repeat-tool-guard"]
@@ -571,6 +572,13 @@ flowchart TD
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
pkg_time_context --> pkg_session
pkg_message_feedback --> pkg_brand
pkg_message_feedback --> pkg_invariants
pkg_message_feedback --> pkg_llm
pkg_message_feedback --> pkg_session
pkg_message_feedback --> pkg_session_persistence
pkg_message_feedback --> pkg_storage_domain
pkg_message_feedback --> pkg_type_meta
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_invariants
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
@@ -1349,6 +1357,7 @@ flowchart TD
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
+9
View File
@@ -203,6 +203,7 @@ flowchart TD
end
subgraph group_feedback["packages/feedback"]
pkg_command_feedback["command-feedback"]
pkg_message_feedback["message-feedback"]
end
subgraph group_guard["packages/guard"]
pkg_repeat_tool_guard["repeat-tool-guard"]
@@ -573,6 +574,13 @@ flowchart TD
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
pkg_time_context --> pkg_session
pkg_message_feedback --> pkg_brand
pkg_message_feedback --> pkg_invariants
pkg_message_feedback --> pkg_llm
pkg_message_feedback --> pkg_session
pkg_message_feedback --> pkg_session_persistence
pkg_message_feedback --> pkg_storage_domain
pkg_message_feedback --> pkg_type_meta
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_invariants
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
@@ -1351,6 +1359,7 @@ flowchart TD
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
+2 -2
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 docs/subsystems/README.md
README.md: fddbf460c8e9e7c6f9ed1d3375bdabe65947661f
README.zh.md: febc5a97426fef5ec4b2b80d9677957369994a3b
README.md: 560851eeda607b762456fe20c874b4497704709f
README.zh.md: 4acba4372e995b54a3bb326bec0cf9606f997773
+1
View File
@@ -18,6 +18,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits |
| [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers |
| [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages |
| [feedback.md](feedback.md) | lifecycle-bound per-message feedback records, optimistic versions, sidecar persistence, and the Host Remote contract |
| [session-title.md](session-title.md) | durable title snapshots, cited source-message seqs, and the asynchronous provider contract |
| [session-reference.md](session-reference.md) | structured cross-session references: `SessionReferenceInput`/`Candidate`, prepared message contexts, the stable error taxonomy |
| [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly |
+1
View File
@@ -18,6 +18,7 @@
| [settings.md](settings.md) | 用户设置 seam`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 |
| [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 |
| [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 |
| [feedback.md](feedback.md) | 绑定生命周期的逐消息反馈记录、乐观版本、伴随记录持久化与 Host Remote 契约 |
| [session-title.md](session-title.md) | 持久标题快照、被引用的来源消息 seq 与异步提供方约定 |
| [session-reference.md](session-reference.md) | 结构化跨会话引用:`SessionReferenceInput`/`Candidate`、prepared 消息上下文、稳定错误分类 |
| [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 |
+6
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 docs/subsystems/feedback.md
feedback.md: 76a29f7d6ba604fa07ed56429c9b066e22639671
feedback.zh.md: 5a409832de68b6d0bc9688a907c0f22edd3b0a43
+256
View File
@@ -0,0 +1,256 @@
# Message Feedback
English | [中文](feedback.zh.md)
[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback) owns editable feedback for individual assistant messages. It is deliberately separate from the immutable Session-level `feedback/record` event: message feedback is a local storage-domain sidecar, not Session-log content or a projection, and it performs no telemetry handoff.
Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts)
## Public types
```ts type-equiv
/** Opaque compare-and-set token for one exact feedback item revision. */
type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'>
```
```ts type-equiv
/** The human's overall judgment of one assistant message. */
type MessageFeedbackRating = 'positive' | 'negative'
```
```ts type-equiv
/** One current feedback value and its opaque mutation token. */
interface MessageFeedbackItem {
/** Stable identity of the assistant message inside the owning Session. */
readonly messageId: MessageId
/** Overall positive or negative judgment. */
readonly rating: MessageFeedbackRating
/** Optional explanation, preserved verbatim after validation. */
readonly note?: string
/** Equality-only token replaced by every material create or update. */
readonly version: MessageFeedbackVersion
/** Host-assigned creation time in Unix epoch milliseconds. */
readonly createdAt: number
/** Host-assigned time of the most recent material update. */
readonly updatedAt: number
}
```
```ts type-equiv
/** Read all message feedback belonging to one persisted Session lifecycle. */
interface MessageFeedbackListRequest {
/** Persisted Session whose sidecar should be read. */
readonly sessionId: SessionId
}
```
```ts type-equiv
/** Current feedback values for one Session, in first-creation order. */
interface MessageFeedbackListValue {
/** Fresh immutable item snapshots. */
readonly items: readonly MessageFeedbackItem[]
}
```
```ts type-equiv
/** Create or replace feedback for one assistant message. */
interface MessageFeedbackPutRequest {
/** Persisted Session that owns the target message. */
readonly sessionId: SessionId
/** Target assistant-message identity. */
readonly messageId: MessageId
/** Desired overall judgment. */
readonly rating: MessageFeedbackRating
/** Optional non-blank explanation. */
readonly note?: string
/** Observed item version, or `null` to require that no item exists. */
readonly ifVersion: MessageFeedbackVersion | null
}
```
```ts type-equiv
/** Delete feedback for one message after observing its current version. */
interface MessageFeedbackDeleteRequest {
/** Persisted Session that owns the sidecar. */
readonly sessionId: SessionId
/** Message whose feedback should be absent after this operation. */
readonly messageId: MessageId
/** Observed item version; ignored when the item is already absent. */
readonly ifVersion: MessageFeedbackVersion
}
```
```ts type-equiv
/** Idempotent deletion acknowledgement. */
interface MessageFeedbackDeleteValue {
/** Stable postcondition shared by the first deletion and every retry. */
readonly absent: true
}
```
```ts type-equiv
/** No persisted Session header exists for the requested id. */
interface MessageFeedbackSessionNotFound {
readonly code: 'session-not-found'
readonly sessionId: SessionId
}
```
```ts type-equiv
/** The id does not name a derived, append-origin assistant message. */
interface MessageFeedbackTargetNotFound {
readonly code: 'target-not-found'
readonly sessionId: SessionId
readonly messageId: MessageId
}
```
```ts type-equiv
/** A material mutation did not match the addressed item's current version. */
interface MessageFeedbackVersionConflict {
readonly code: 'version-conflict'
/** Authoritative current item, or `null` when it does not exist. */
readonly current: MessageFeedbackItem | null
}
```
```ts type-equiv
/** A supplied note contains no non-whitespace character. */
interface MessageFeedbackNoteBlank {
readonly code: 'note-blank'
}
```
```ts type-equiv
/** A supplied note exceeds the configured UTF-8 byte limit. */
interface MessageFeedbackNoteTooLarge {
readonly code: 'note-too-large'
readonly maxBytes: number
readonly actualBytes: number
}
```
```ts type-equiv
/** Failures shared by the public message-feedback operations. */
type MessageFeedbackFailure =
| MessageFeedbackSessionNotFound
| MessageFeedbackTargetNotFound
| MessageFeedbackVersionConflict
| MessageFeedbackNoteBlank
| MessageFeedbackNoteTooLarge
```
```ts type-equiv
/** Successful public operation result. */
interface MessageFeedbackSuccess<T> {
readonly ok: true
readonly value: T
}
```
```ts type-equiv
/** Rejected public operation result with a stable business failure. */
interface MessageFeedbackRejected<E extends MessageFeedbackFailure> {
readonly ok: false
readonly error: E
}
```
```ts type-equiv
/** Result returned by the message-feedback `list` operation. */
type MessageFeedbackListResult =
| MessageFeedbackSuccess<MessageFeedbackListValue>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound>
```
```ts type-equiv
/** Result returned by the message-feedback `put` operation. */
type MessageFeedbackPutResult =
| MessageFeedbackSuccess<MessageFeedbackItem>
| MessageFeedbackRejected<
| MessageFeedbackSessionNotFound
| MessageFeedbackTargetNotFound
| MessageFeedbackVersionConflict
| MessageFeedbackNoteBlank
| MessageFeedbackNoteTooLarge
>
```
```ts type-equiv
/** Result returned by the message-feedback `delete` operation. */
type MessageFeedbackDeleteResult =
| MessageFeedbackSuccess<MessageFeedbackDeleteValue>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackVersionConflict>
```
## Data and concurrency
One Session sidecar row contains its header identity `{createdAt, cwd}` and feedback items keyed by `MessageId`. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them.
`put` uses strict optimistic concurrency: every request for an existing item must match its current `ifVersion`, including a no-op. A conflict returns the authoritative current item (or `null`), so a caller can reconcile a lost response or a concurrent edit without another read. Deleting an already absent item succeeds. A per-Session queue encloses inspection, read, conflict evaluation, and whole-row write, so these guarantees cover concurrent calls in one Host process.
## Target and lifecycle authority
`SessionPersistence.inspect()` supplies the target Session observation without publishing or resuming an Agent and without committing cold repair. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session propagates as infrastructure failure. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin, usage-only empty, and non-assistant records are not feedback targets.
The stored `{createdAt, cwd}` identity must match the inspected header. A mismatch is treated as absence: `list` returns no items, while `put` may replace the stale row with one bound to the current header identity. Forks use a new Session identity and receive no sidecar copy even when their seed contains the same messages.
## Persistence and Remote contract
The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; both live and cold paths are then physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `GatewayService` and `@Remote`; the generated Cordis surface below is the method-level authority.
Plugin disposal closes mutation admission, drains accepted per-Session queue work, and then closes the storage domain.
## Boundaries and limitations
- The client Remote aggregate mount and UI consumer are separately owned and deferred.
- The mutation queue is process-local. Storage-domain has no cross-process conditional write, so multiple Host writers to one storage root have no compare-and-swap or lost-update guarantee.
- Session persistence has no durable deletion surface. The service does not treat `session/disposed` or `host/session-removed` as deletion and therefore performs no fake cascade; orphan sidecar rows may remain after out-of-band log removal.
- A request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization.
- Cold requests scan the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. One Session row also has no item-count or aggregate-byte cap; `maxNoteBytes` bounds only each note until a concrete consumer owns a row policy.
- Header identity detects a reused id only when `{createdAt, cwd}` differs; a cloned log retaining the same header identity is indistinguishable by this contract.
- The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis surface
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxmessagefeedback--messagefeedbackservice"></a>
### `ctx.messageFeedback` — `MessageFeedbackService`
Storage-domain sidecar service. It inspects persisted Session history and never creates or resumes an Agent or Session.
```ts cordis-catalog
/**
* Read feedback belonging to the current persisted Session lifecycle.
* A stale row from a reused Session id is invisible.
* @param request - Session identity to inspect and list.
* @returns current immutable items or `session-not-found`.
*/
@Remote('list') async list(request: MessageFeedbackListRequest): Promise<MessageFeedbackListResult>
/**
* 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>
/**
* 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>
```
Source: [`packages/feedback/message-feedback/src/index.ts:150`](../../packages/feedback/message-feedback/src/index.ts)
<!-- END GENERATED cordis-surface -->
+256
View File
@@ -0,0 +1,256 @@
# 消息反馈
[English](feedback.md) | 中文
[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback)拥有针对单条 assistant 消息的可编辑反馈。它刻意与不可变的 Session 级 `feedback/record` 事件分离:message feedback 是本地 storage-domain 伴随记录(sidecar),不是 Session 日志内容或投影,也不执行遥测交接。
来源:[`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts)
## 公开类型
```ts type-equiv
/** Opaque compare-and-set token for one exact feedback item revision. */
type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'>
```
```ts type-equiv
/** The human's overall judgment of one assistant message. */
type MessageFeedbackRating = 'positive' | 'negative'
```
```ts type-equiv
/** One current feedback value and its opaque mutation token. */
interface MessageFeedbackItem {
/** Stable identity of the assistant message inside the owning Session. */
readonly messageId: MessageId
/** Overall positive or negative judgment. */
readonly rating: MessageFeedbackRating
/** Optional explanation, preserved verbatim after validation. */
readonly note?: string
/** Equality-only token replaced by every material create or update. */
readonly version: MessageFeedbackVersion
/** Host-assigned creation time in Unix epoch milliseconds. */
readonly createdAt: number
/** Host-assigned time of the most recent material update. */
readonly updatedAt: number
}
```
```ts type-equiv
/** Read all message feedback belonging to one persisted Session lifecycle. */
interface MessageFeedbackListRequest {
/** Persisted Session whose sidecar should be read. */
readonly sessionId: SessionId
}
```
```ts type-equiv
/** Current feedback values for one Session, in first-creation order. */
interface MessageFeedbackListValue {
/** Fresh immutable item snapshots. */
readonly items: readonly MessageFeedbackItem[]
}
```
```ts type-equiv
/** Create or replace feedback for one assistant message. */
interface MessageFeedbackPutRequest {
/** Persisted Session that owns the target message. */
readonly sessionId: SessionId
/** Target assistant-message identity. */
readonly messageId: MessageId
/** Desired overall judgment. */
readonly rating: MessageFeedbackRating
/** Optional non-blank explanation. */
readonly note?: string
/** Observed item version, or `null` to require that no item exists. */
readonly ifVersion: MessageFeedbackVersion | null
}
```
```ts type-equiv
/** Delete feedback for one message after observing its current version. */
interface MessageFeedbackDeleteRequest {
/** Persisted Session that owns the sidecar. */
readonly sessionId: SessionId
/** Message whose feedback should be absent after this operation. */
readonly messageId: MessageId
/** Observed item version; ignored when the item is already absent. */
readonly ifVersion: MessageFeedbackVersion
}
```
```ts type-equiv
/** Idempotent deletion acknowledgement. */
interface MessageFeedbackDeleteValue {
/** Stable postcondition shared by the first deletion and every retry. */
readonly absent: true
}
```
```ts type-equiv
/** No persisted Session header exists for the requested id. */
interface MessageFeedbackSessionNotFound {
readonly code: 'session-not-found'
readonly sessionId: SessionId
}
```
```ts type-equiv
/** The id does not name a derived, append-origin assistant message. */
interface MessageFeedbackTargetNotFound {
readonly code: 'target-not-found'
readonly sessionId: SessionId
readonly messageId: MessageId
}
```
```ts type-equiv
/** A material mutation did not match the addressed item's current version. */
interface MessageFeedbackVersionConflict {
readonly code: 'version-conflict'
/** Authoritative current item, or `null` when it does not exist. */
readonly current: MessageFeedbackItem | null
}
```
```ts type-equiv
/** A supplied note contains no non-whitespace character. */
interface MessageFeedbackNoteBlank {
readonly code: 'note-blank'
}
```
```ts type-equiv
/** A supplied note exceeds the configured UTF-8 byte limit. */
interface MessageFeedbackNoteTooLarge {
readonly code: 'note-too-large'
readonly maxBytes: number
readonly actualBytes: number
}
```
```ts type-equiv
/** Failures shared by the public message-feedback operations. */
type MessageFeedbackFailure =
| MessageFeedbackSessionNotFound
| MessageFeedbackTargetNotFound
| MessageFeedbackVersionConflict
| MessageFeedbackNoteBlank
| MessageFeedbackNoteTooLarge
```
```ts type-equiv
/** Successful public operation result. */
interface MessageFeedbackSuccess<T> {
readonly ok: true
readonly value: T
}
```
```ts type-equiv
/** Rejected public operation result with a stable business failure. */
interface MessageFeedbackRejected<E extends MessageFeedbackFailure> {
readonly ok: false
readonly error: E
}
```
```ts type-equiv
/** Result returned by the message-feedback `list` operation. */
type MessageFeedbackListResult =
| MessageFeedbackSuccess<MessageFeedbackListValue>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound>
```
```ts type-equiv
/** Result returned by the message-feedback `put` operation. */
type MessageFeedbackPutResult =
| MessageFeedbackSuccess<MessageFeedbackItem>
| MessageFeedbackRejected<
| MessageFeedbackSessionNotFound
| MessageFeedbackTargetNotFound
| MessageFeedbackVersionConflict
| MessageFeedbackNoteBlank
| MessageFeedbackNoteTooLarge
>
```
```ts type-equiv
/** Result returned by the message-feedback `delete` operation. */
type MessageFeedbackDeleteResult =
| MessageFeedbackSuccess<MessageFeedbackDeleteValue>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackVersionConflict>
```
## 数据与并发
每个 Session 的一条伴随记录包含 header 身份 `{createdAt, cwd}` 和以 `MessageId` 为键的反馈条目。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。
`put` 采用严格乐观并发:已有条目的每次请求都必须匹配当前 `ifVersion`,即使请求不会改变目标值。冲突会返回权威当前条目(不存在时为 `null`),因此调用方无需额外读取,即可协调丢失响应或并发编辑。删除已经不存在的条目同样成功。按 Session 划分的队列覆盖检查、读取、冲突判断与整行写入,因此这些保证适用于单个 Host 进程中的并发调用。
## 目标与生命周期权威
`SessionPersistence.inspect()` 提供目标 Session 的观测,且不会发布或恢复 Agent,也不会提交 cold repair。cold 路径先由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,会按基础设施故障原样传播。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`replacement-origin、仅承载 usage 的空记录和非 assistant 记录都不是反馈目标。
存储的 `{createdAt, cwd}` 身份必须与检查所得 header 匹配。不匹配按不存在处理:`list` 返回空条目,`put` 则可用绑定当前 header 身份的新记录替换陈旧行。fork 使用新的 Session 身份,即使种子包含相同消息,也不获得伴随记录副本。
## 持久化与 Remote 契约
服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。
Plugin disposal 会先关闭变更接纳,排空已进入各 Session 队列的工作,然后才关闭 storage domain。
## 边界与限制
- 客户端 Remote 聚合挂载与 UI 消费方由各自边界负责并保持延后。
- 变更队列仅在进程内生效。storage-domain 没有跨进程条件写,因此多个 Host 写入同一存储根目录时,不提供 compare-and-swap 或防止丢失更新的保证。
- Session persistence 没有持久删除接口。服务不把 `session/disposed` 或 `host/session-removed` 当作删除,因此不伪造级联;在带外移除日志后,孤儿伴随记录可能继续存在。
- 请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。
- 由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。单个 Session 行也没有条目数或聚合字节上限;在具体消费方拥有行策略之前,`maxNoteBytes` 只限制每条备注。
- 只有 `{createdAt, cwd}` 不同时,header 身份才能识别复用的 id;本契约无法区分保留相同 header 身份的克隆日志。
- Host 契约不记录已认证的 actor 或审计身份,因此假设调用方边界可信。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis surface
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxmessagefeedback--messagefeedbackservice"></a>
### `ctx.messageFeedback` — `MessageFeedbackService`
Storage-domain sidecar service. It inspects persisted Session history and never creates or resumes an Agent or Session.
```ts cordis-catalog
/**
* Read feedback belonging to the current persisted Session lifecycle.
* A stale row from a reused Session id is invisible.
* @param request - Session identity to inspect and list.
* @returns current immutable items or `session-not-found`.
*/
@Remote('list') async list(request: MessageFeedbackListRequest): Promise<MessageFeedbackListResult>
/**
* 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>
/**
* 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>
```
Source: [`packages/feedback/message-feedback/src/index.ts:150`](../../packages/feedback/message-feedback/src/index.ts)
<!-- END GENERATED cordis-surface -->
+5
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'
+1
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:^",
+2 -2
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
+5 -2
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.
+5 -2
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 消费方由各自边界负责,并保持延后。
@@ -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
@@ -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.
@@ -0,0 +1,84 @@
# @deepseek-ai/dsh-message-feedback
[English](README.md) | 中文
本包提供由 Host 拥有、针对单条已完成 assistant 消息的可编辑反馈。它注册 `ctx.messageFeedback`,在 storage-domain 中为每个 Session 持久化一条绑定生命周期的伴随记录(sidecar),并发布 Host `messageFeedback.list``messageFeedback.put``messageFeedback.delete` 一元 Remote 契约。它与不可变的 Session 级 `feedback/record` 事件相互独立,不执行遥测交接。[消息反馈伴随记录 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md)拥有其设计边界。
公开的请求、值、版本与失败类型从包根入口及 `@deepseek-ai/dsh-message-feedback/types` 导出;其源码为 [`src/types.ts`](src/types.ts)。
## 配置
| 键 | 含义 |
|---|---|
| `maxNoteBytes` | 必填正 safe integer:一条可选备注的最大 UTF-8 字节长度。 |
备注必须包含至少一个非空白字符,但通过校验的文本按原样存储,不会 trim。省略 `note` 表示目标值不含备注,因此 version 匹配的实质 `put` 会清除已有备注。备注校验早于 Session 查找,因此即使 Session 不存在,也可能在不访问持久化的情况下返回 `note-blank``note-too-large`
```yaml
- id: message-feedback
name: '@deepseek-ai/dsh-message-feedback'
config:
maxNoteBytes: 8192
```
服务注入 `storageDomain``sessionPersistence``sessions`。其持久存储域为 `message_feedback`,其中 `sessions` 表按 `SessionId` 每个一行。
## 数据、生命周期与持久性
`MessageFeedbackItem` 包含 `messageId``rating: 'positive' | 'negative'`、可选 `note`、只能做相等比较的 opaque `version`,以及由 Host 分配、以 Unix 毫秒表示的 `createdAt`/`updatedAt` 时间戳。实质更新保留 `createdAt`、替换 `version`,并保证 `updatedAt` 不倒退。`list` 按首次创建顺序返回新的不可变快照;更新条目时保留其位置,删除后再创建则追加为新条目。
每条存储行都携带检查所得 Session header 身份 `{createdAt, cwd}`。不匹配按不存在处理:`list` 返回空 `items` 数组,`delete` 返回已不存在的后置条件,`put` 可以用绑定当前身份的新行替换陈旧行。这会在复用的 `SessionId` 具有不同 header 身份时形成隔离。fork 使用独立的 Session 身份,不复制反馈伴随记录。
`SessionPersistence.inspect()` 提供 cold-safe 观测,不发布或恢复 Agent,也不提交 cold repair。对于没有 live owner 的 Session,系统先用 `listSnapshots()` 判定明确不存在;已进入目录的 Session 若 `inspect()` 失败,仍属于基础设施故障,不会被猜测成 `session-not-found``put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`replacement-origin 消息、仅承载 usage 的空 assistant 记录与非 assistant 记录都返回 `target-not-found`
初步校验后,`put` 在写入伴随记录前建立 durability barrier。身份匹配的 live Session 先通过权威 `ctx.sessions.flush` checkpoint 提交,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录提交,因此持久反馈绝不会先于其持久目标消息。
message feedback 不是 Session 日志内容或 Session 投影。它不发出 `feedback/record` 事件,不进入模型历史,也不触发 `FEEDBACK_ONLY` 遥测释放。
## 服务与 Host Remote 契约
`GatewayService``@Remote``MessageFeedbackService` 的同三个方法发布出去;Host endpoint 名称为 `messageFeedback.list``messageFeedback.put``messageFeedback.delete`。每个方法都返回判别式业务 union:`{ ok: true, value }``{ ok: false, error }`。存储、损坏或缺少 durability listener 等操作故障会产生 reject,不会被误标为业务错误。
| 方法 | 请求 | 成功 `value` | 拒绝的 `error.code` |
|---|---|---|---|
| `list` | `MessageFeedbackListRequest { sessionId }` | `MessageFeedbackListValue { items }` | `session-not-found` |
| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | 已提交的 `MessageFeedbackItem` | `session-not-found``target-not-found``version-conflict``note-blank``note-too-large` |
| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found``version-conflict` |
`MessageFeedbackVersionConflict` 返回权威 `current` 条目;条目不存在时为 `null`。调用方无需额外执行 `list`,即可协调当前 rating、note 与 version。`MessageFeedbackNoteTooLarge` 同时返回 `maxBytes``actualBytes`。客户端 Remote 聚合尚未挂载生成的客户端 contribution;Host 调用方无需该客户端组装即可使用 service/Remote 契约。
## Compare-and-set 与幂等性
`ifVersion: null` 表示仅当条目不存在时才创建;已有条目的每次请求都必须与其当前 version 完全一致,即使目标值已经相同、不会产生实质更新。检查按消息而非按 Session 进行,因此修改一个条目不会与另一个条目冲突。每次实质创建或更新都会分配新的 opaque UUID token,防止陈旧写入穿过 ABA 值循环。
携带匹配 version 的无变化请求会返回已存条目,version 与时间戳均不变。成功响应丢失后,使用旧 token 重试会得到 `version-conflict.current`;调用方无需额外读取,即可把权威当前值与目标值比较。条目已不存在时,`delete` 忽略 `ifVersion`;成功后始终返回稳定的 `{ absent: true }` 后置条件。
按 Session 划分的 promise 队列覆盖检查、持久性校验、伴随记录读取、比较与整行写入。这些语义会串行化经由同一服务实例的并发变更;storage-domain 自身没有跨进程条件写。
Plugin disposal 会先关闭变更接纳,排空已进入各个 Session 队列的所有操作,然后才关闭 storage domain。disposal 开始后提交的变更会以生命周期故障拒绝,不会进入正在关闭的 domain。
## 模型体验
### 本地消息反馈状态
#### 模型看到的内容
无。`ctx.messageFeedback` 不注册工具、提示词段落、模型可见上下文或 Session 事件;除非另一个具有独立文档的 Consumer 显式公开反馈,否则它只留在 Host 拥有的伴随记录中。
#### Token 影响
为零。本包的请求、结果、评分、备注、时间戳或失败都不会进入模型请求。
#### KV Cache 影响
相互独立。读取或变更消息反馈不会触碰模型请求前缀,也不会使本可复用的提供方缓存条目失效。
## 已知局限与延后工作
- **缺少客户端聚合与 UI**——Host Remote 契约已经发布,但客户端 Remote 聚合 contribution 与任何 UI 消费方由各自边界负责并保持延后。
- **Compare-and-set 仅限单进程**——按 Session 划分的队列只串行化一个服务实例;storage-domain 不提供跨进程条件写,因此多个 Host 进程写入同一存储根目录时仍可能丢失更新。
- **没有持久 Session 删除级联**——Session persistence 没有删除接口,且 `session/disposed`/`host/session-removed` 表示 detach 而非持久删除。因此服务会保留空行,并可能在带外移除日志后留下孤儿行,而不会在 detach 时删除仍有效的反馈。
- **Detach/catalog retirement 窗口**——请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。
- **Header 身份不是内容指纹**——只有 `{createdAt, cwd}` 不同时才能识别复用;本契约无法区分保留相同 header 身份的克隆日志。
- **调用方边界受信任**——`list`/`put`/`delete` 不携带已认证的 actor 或审计身份。在加入授权与归属信息前,部署方必须只通过受信任或另行认证的边界暴露 Host gateway。
- **目录与行边界**——由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。`maxNoteBytes` 只限制单条备注,单个 Session 行的条目数和聚合保留字节尚无上限;按索引读取元数据和由部署决定的行边界,延后到具体消费方明确策略时处理。
@@ -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:^"
}
}
@@ -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
@@ -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 */
@@ -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),
},
})
@@ -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>
@@ -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 })
},
}
}
@@ -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()
}
})
})
@@ -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,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] }] },
})
})
})
@@ -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. 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.',
@@ -2335,6 +2353,82 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'Message',
declaration: 'export interface Message {\n readonly id: MessageId;\n readonly role: \'system\' | \'user\' | \'assistant\';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n}',
},
{
name: 'MessageFeedbackDeleteRequest',
declaration: 'export interface MessageFeedbackDeleteRequest {\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly ifVersion: MessageFeedbackVersion;\n}',
},
{
name: 'MessageFeedbackDeleteResult',
declaration: 'export type MessageFeedbackDeleteResult = MessageFeedbackSuccess<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\'>;',
+52
View File
@@ -1608,6 +1608,9 @@ importers:
'@deepseek-ai/dsh-host-webserver':
specifier: workspace:^
version: link:../../host/webserver
'@deepseek-ai/dsh-message-feedback':
specifier: workspace:^
version: link:../../feedback/message-feedback
'@deepseek-ai/dsh-session-projection-cache':
specifier: workspace:^
version: link:../../session/session-projection-cache
@@ -3829,6 +3832,55 @@ importers:
specifier: workspace:^
version: link:../../session/user-id
packages/feedback/message-feedback:
dependencies:
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
zod:
specifier: ^4.4.3
version: 4.4.3
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/cordis-plugin-include':
specifier: workspace:^
version: link:../../../vendor/include
'@deepseek-ai/cordis-plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-session-persistence':
specifier: workspace:^
version: link:../../session/session-persistence
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session/session-persistence-jsonl
'@deepseek-ai/dsh-storage':
specifier: workspace:^
version: link:../../storage/storage
'@deepseek-ai/dsh-storage-domain':
specifier: workspace:^
version: link:../../storage/storage-domain
'@deepseek-ai/dsh-storage-json':
specifier: workspace:^
version: link:../../storage/storage-json
'@deepseek-ai/dsh-type-meta':
specifier: workspace:^
version: link:../../typert/type-meta
packages/fs/fs:
devDependencies:
'@deepseek-ai/cordis':
+20
View File
@@ -63,6 +63,7 @@ export const SERVICE_PAGE: Record<string, string> = {
httpServer: 'http-server.md',
invariants: 'invariants.md',
llm: 'llm-streaming.md',
messageFeedback: 'feedback.md',
permission: 'permission.md',
planMode: 'plan.md',
pty: 'pty.md',
@@ -229,6 +230,25 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
ResolvedRetryPolicy: 'llm-streaming.md',
Message: 'llm-streaming.md',
MessageSource: 'llm-streaming.md',
MessageFeedbackDeleteRequest: 'feedback.md',
MessageFeedbackDeleteResult: 'feedback.md',
MessageFeedbackDeleteValue: 'feedback.md',
MessageFeedbackFailure: 'feedback.md',
MessageFeedbackItem: 'feedback.md',
MessageFeedbackListRequest: 'feedback.md',
MessageFeedbackListResult: 'feedback.md',
MessageFeedbackListValue: 'feedback.md',
MessageFeedbackNoteBlank: 'feedback.md',
MessageFeedbackNoteTooLarge: 'feedback.md',
MessageFeedbackPutRequest: 'feedback.md',
MessageFeedbackPutResult: 'feedback.md',
MessageFeedbackRating: 'feedback.md',
MessageFeedbackRejected: 'feedback.md',
MessageFeedbackSessionNotFound: 'feedback.md',
MessageFeedbackSuccess: 'feedback.md',
MessageFeedbackTargetNotFound: 'feedback.md',
MessageFeedbackVersion: 'feedback.md',
MessageFeedbackVersionConflict: 'feedback.md',
UserMessage: 'session.md',
PreStepDecision: 'core.md',
PreStepContext: 'core.md',
+10 -3
View File
@@ -135,7 +135,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'session',
title: 'In-memory session store',
mode: 'core',
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'],
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants', 'message-feedback'],
note: 'Owns append-only Session instances and emits the durable session event feed.',
},
{
@@ -167,7 +167,7 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Durable session persistence seam',
mode: 'seam',
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'],
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'],
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
},
{
@@ -211,9 +211,16 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'storage-domain',
title: 'Domain data facility',
mode: 'core',
consumers: ['workspace'],
consumers: ['workspace', 'message-feedback'],
note: 'Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state.',
},
{
key: 'messageFeedback',
pkg: 'message-feedback',
title: 'Lifecycle-bound message feedback',
mode: 'core',
note: 'Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry.',
},
{
key: 'workspace',
pkg: 'workspace',
+95
View File
@@ -1739,6 +1739,101 @@
"doc": "docs/subsystems/core.md",
"symbol": "AgentOptions",
"source": "packages/core/agent/src/runtime-types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackVersion",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackRating",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackItem",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackListRequest",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackListValue",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackPutRequest",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackDeleteRequest",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackDeleteValue",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackSessionNotFound",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackTargetNotFound",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackVersionConflict",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackNoteBlank",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackNoteTooLarge",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackFailure",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackSuccess",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackRejected",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackListResult",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackPutResult",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackDeleteResult",
"source": "packages/feedback/message-feedback/src/types.ts"
}
]
}
+2
View File
@@ -14,6 +14,7 @@
"apps/web/tests/support.ts",
"apps/web/tests/scaffold-hermetic.e2e.ts",
"apps/web/tests/minimal-preset.snapshot.ts",
"apps/web/tests/message-feedback-protocol.snapshot.ts",
"apps/web/tests/live-interactions.e2e.ts",
"apps/web/tests/question-composer.e2e.ts",
"apps/web/tests/approval-composer.e2e.ts",
@@ -135,6 +136,7 @@
{ "path": "./packages/storage/storage-json" },
{ "path": "./packages/storage/storage-sqlite" },
{ "path": "./packages/storage/storage-domain" },
{ "path": "./packages/feedback/message-feedback" },
{ "path": "./packages/workspace/workspace" },
{ "path": "./packages/session/session-title" },
{ "path": "./packages/session/session-title-llm" },