Merge pull request #2217 from deepseek-harness/agent/message-feedback-backend
feat(feedback): add durable message feedback backend
This commit is contained in:
@@ -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"}}}
|
||||
@@ -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,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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,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
|
||||
@@ -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. |
|
||||
|
||||
@@ -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,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
|
||||
@@ -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`
|
||||
|
||||
@@ -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,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
|
||||
@@ -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) |
|
||||
|
||||
@@ -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,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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 |
|
||||
|
||||
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/feedback/README.md
|
||||
README.md: af8e9d5c4903594299284d09f880aa8929f5e051
|
||||
README.zh.md: 9156ff1c8da8ed1128488fcacaf454425468163a
|
||||
README.md: 152db65bd7ac179bb8d475d446535734b9f8238e
|
||||
README.zh.md: 64202a3c4a0258f9b41a6cd96e7bbbf011d0338d
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The feedback family lets a human record a remark about the session without acting on it. Feedback is durable session-log content, separate from the model conversation and from any policy that might later read it.
|
||||
The feedback family exposes two deliberately separate contracts: an immutable remark in the canonical Session log, and editable feedback attached to one assistant message in a local sidecar. Neither form enters the model conversation.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — |
|
||||
| `message-feedback/` | Lifecycle-bound per-message rating/note sidecar plus Host `messageFeedback.list/put/delete` Remote contract | `messageFeedback` |
|
||||
|
||||
A recorded remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../session/session-telemetry-otel) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy.
|
||||
A command feedback remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../session/session-telemetry-otel) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy.
|
||||
|
||||
Message feedback is not a Session event or projection. It remains in the storage-domain sidecar and causes no telemetry handoff. The Host Remote contract ships with the service; the client Remote aggregate mount and UI consumer are separately owned and deferred.
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
反馈家族让人类记录对会话的评价,但不据此采取任何动作。反馈属于持久的会话日志内容,与模型对话以及后续可能读取它的任何策略相互独立。
|
||||
反馈家族公开两份刻意分离的契约:写入权威 Session 日志的不可变评价,以及挂在单条 assistant 消息上的可编辑本地伴随记录(sidecar)反馈。两者都不会进入模型对话。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 |
|
||||
| `message-feedback/` | 绑定生命周期的逐消息评分/备注伴随记录,以及 Host `messageFeedback.list/put/delete` Remote 契约 | `messageFeedback` |
|
||||
|
||||
被记录的评价仅写入日志:它绝不会进入模型接口或派生历史。挂载后,[`dsh-session-telemetry-otel`](../session/session-telemetry-otel) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。
|
||||
command feedback 评价仅写入日志:它绝不会进入模型接口或派生历史。挂载后,[`dsh-session-telemetry-otel`](../session/session-telemetry-otel) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。
|
||||
|
||||
message feedback 不是 Session 事件或投影。它只保留在 storage-domain 伴随记录中,不触发任何遥测交接。服务随附 Host Remote 契约;客户端 Remote 聚合挂载与 UI 消费方由各自边界负责,并保持延后。
|
||||
@@ -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\'>;',
|
||||
|
||||
Generated
+52
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user