diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml new file mode 100644 index 0000000000..20b1a4cfd5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md +2026-08-10-message-feedback-sidecar.md: 780cbaa840fcac7bcfa799468bfd61f5b08715cb +2026-08-10-message-feedback-sidecar.zh.md: 72ecc82717010f65d013418a45c3b38c6084aaf9 diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md new file mode 100644 index 0000000000..780cbaa840 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md @@ -0,0 +1,43 @@ +# Agent Note: Lifecycle-bound message feedback sidecar + +Status: implemented + +English | [中文](2026-08-10-message-feedback-sidecar.zh.md) + +## Problem + +The existing `/feedback` command records an immutable Session-level `feedback/record` event. That event can release a pending telemetry prefix under `FEEDBACK_ONLY`, so it is the wrong authority for an editable positive/negative rating and optional note attached to one assistant message. Message feedback needs independent update and delete semantics without entering the canonical Session log, changing a projection, reaching the model surface, or implicitly consenting to telemetry. + +A sidecar keyed only by `SessionId` can outlive the log lifecycle it describes when an id is recreated with a different header identity. A Session-wide revision also makes unrelated message edits conflict, while plain storage-domain read/put has no cross-process compare-and-swap. Session disposal is only live-store detach, not durable deletion, and the current Session persistence seam exposes no deletion operation that could own a truthful cascade. + +## Decision + +`@deepseek-ai/dsh-message-feedback` owns the `ctx.messageFeedback` service and stores message feedback as one storage-domain sidecar row per Session. The sidecar is neither Session-log content nor a Session projection. It emits no `feedback/record` event and performs no telemetry handoff; the command-feedback and message-feedback contracts remain independent. + +Every usable row is bound to the inspected Session header identity `{createdAt, cwd}`, not merely its `SessionId`. A lifecycle mismatch is treated as absence: `list` returns no items, and `put` may replace the stale row with one bound to the current identity. An id reused with a different header identity therefore cannot inherit stale feedback. A fork receives its own Session identity and no sidecar copy: even when the fork seed contains the same assistant messages, feedback remains attached to the Session in which the human recorded it. + +`put` accepts a target only when `SessionPersistence.inspect()` observes a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Inspection is the cold-safe authority: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization. + +Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references. + +Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize. + +A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. Plugin disposal closes admission, drains accepted queue work, and then closes the storage domain. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection. + +`maxNoteBytes` is a required deployment choice and bounds the UTF-8 byte length of an optional note; the Web Host bundle sets it explicitly to `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` contract directly through `GatewayService` and `@Remote`. Client Remote aggregate mounting and UI remain separately owned and deferred; their later adapter stays a thin consumer of this Host contract. + +The service performs no fake deletion cascade. `session/disposed` and `host/session-removed` describe detach from live ownership, not durable Session deletion, and Session persistence currently has no delete surface. Sidecar rows can therefore remain after out-of-band log removal; a different `{createdAt, cwd}` prevents such an orphan from becoming feedback for a later Session that reuses the id. + +## Alternatives considered + +**Append edits to the Session log and derive a projection.** Rejected because editable UI metadata would become canonical conversation-adjacent history, forks would replay and inherit it, deletion would require tombstones, and reusing `feedback/record` would silently couple a message rating to telemetry consent. + +**Key feedback globally by `MessageId`, copy it on fork, or use one Session revision.** Rejected because message ids are meaningful only within a Session lifecycle, forked conversations need independent human judgments, and unrelated message mutations must not create false conflicts. + +**Extend `KvTable` with cross-process compare-and-swap in this change.** Rejected because the shipped storage-domain backends expose no common conditional-write primitive. A process-local queue matches the supported one-Host topology; a real multi-process guarantee requires a backend-level atomic contract and is separate work. + +**Delete feedback on Session disposal.** Rejected because disposal includes ordinary detach and rollback paths. Treating it as durable deletion would lose feedback while the Session log still exists; cleanup waits for a real Session deletion authority. + +## Consequences + +Message feedback is locally durable and independently editable without changing model-visible history or telemetry behavior. Concurrent callers in one Host receive per-message conflict detection and retry-safe outcomes, while deployments with multiple writers to the same storage root remain unsupported. A differing header identity treats a stale row as absent but does not reclaim it; a cloned log that retains the same `{createdAt, cwd}` is indistinguishable by this contract. The Host Remote contract is available now; client assembly and UI can remain thin consumers rather than taking ownership of persistence or concurrency semantics. diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md new file mode 100644 index 0000000000..72ecc82717 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 绑定生命周期的消息反馈伴随记录 + +Status: implemented + +[English](2026-08-10-message-feedback-sidecar.md) | 中文 + +## 问题 + +现有 `/feedback` 命令记录不可变的 Session 级 `feedback/record` 事件。在 `FEEDBACK_ONLY` 下,该事件可以释放待处理的遥测前缀,因此它不适合作为挂在单条 assistant 消息上的可编辑好评/差评与可选备注的权威来源。消息反馈需要独立的更新与删除语义,且不得进入权威 Session 日志、改变投影、到达模型接口,或隐式表示遥测同意。 + +只按 `SessionId` 建索引的伴随记录可能在该 id 以不同 header 身份重建后,继续存活于其所描述的日志生命周期之外。Session 级 revision 还会让无关消息的编辑彼此冲突,而普通 storage-domain 读/写不提供跨进程 compare-and-swap。Session disposal 只是从 live store 脱离,并非持久删除;当前 Session 持久化 seam 也没有可拥有真实级联的删除操作。 + +## 决策 + +`@deepseek-ai/dsh-message-feedback` 拥有 `ctx.messageFeedback` 服务,并把消息反馈存为每个 Session 一条 storage-domain 伴随记录(sidecar)。该伴随记录既不是 Session 日志内容,也不是 Session 投影。它不发出 `feedback/record` 事件,也不执行遥测交接;command-feedback 与 message-feedback 契约保持独立。 + +每条可用记录都绑定到经检查的 Session header 身份 `{createdAt, cwd}`,而不只是其 `SessionId`。生命周期不匹配按不存在处理:`list` 返回空条目,`put` 可以用绑定当前身份的新记录替换陈旧行。因此,以不同 header 身份复用的 id 不会继承陈旧反馈。fork 拥有自己的 Session 身份,且不复制伴随记录:即使 fork 种子包含相同的 assistant 消息,反馈仍只属于人类记录它的那个 Session。 + +`put` 只接受由 `SessionPersistence.inspect()` 观测到的非空、append-origin `assistant/message`,且其 `MessageId` 必须与目标相同。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。检查使用 cold-safe 权威路径:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。 + +`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。 + +每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt` 和 `updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突会返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。 + +按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。Plugin disposal 会关闭接纳、排空已进入队列的工作,然后关闭 storage domain。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。 + +`maxNoteBytes` 是必填的部署选择,用于限制可选备注的 UTF-8 字节长度;Web Host bundle 将其显式设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 直接发布 Host `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete` 契约。客户端 Remote 聚合挂载与 UI 由各自边界负责并保持延后;后续适配层只是该 Host 契约的薄消费者。 + +服务不伪造删除级联。`session/disposed` 与 `host/session-removed` 表示脱离 live ownership,而非持久删除,Session persistence 当前也没有删除接口。因此在带外移除日志后,伴随记录可能继续存在;不同的 `{createdAt, cwd}` 可阻止此类孤儿记录变成后来复用该 id 的 Session 反馈。 + +## 考虑过的替代方案 + +**把编辑追加到 Session 日志并派生投影。** 不予采纳,因为可编辑 UI 元数据会变成权威且邻近对话的历史,fork 会回放并继承它,删除需要 tombstone,而复用 `feedback/record` 会把消息评分与遥测同意静默耦合。 + +**按全局 `MessageId` 建索引、在 fork 时复制,或使用一个 Session revision。** 不予采纳,因为消息 id 仅在某个 Session 生命周期内有意义,fork 后的对话需要独立的人类判断,而且无关消息的变更不应制造虚假冲突。 + +**在本次变更中为 `KvTable` 扩展跨进程 compare-and-swap。** 不予采纳,因为出厂 storage-domain 后端没有共同的条件写原语。进程内队列符合受支持的单 Host 拓扑;真实的多进程保证需要后端级原子契约,属于独立工作。 + +**在 Session disposal 时删除反馈。** 不予采纳,因为 disposal 包含普通 detach 与 rollback 路径。把它当成持久删除会在 Session 日志仍存在时丢失反馈;清理必须等待真正的 Session 删除权威。 + +## 后果 + +消息反馈在本地持久化并可独立编辑,且不改变模型可见历史或遥测行为。同一 Host 中的并发调用方获得逐消息冲突检测与可安全重试的结果;多个写入者共享同一存储根目录的部署仍不受支持。不同的 header 身份会让陈旧记录被视为不存在,但不会将其回收;本契约无法区分保留相同 `{createdAt, cwd}` 的克隆日志。Host Remote 契约现在可用;客户端组装与 UI 可以保持为薄消费者,而不接管持久化或并发语义。 diff --git a/apps/web/tests/message-feedback-protocol.snapshot.ts b/apps/web/tests/message-feedback-protocol.snapshot.ts new file mode 100644 index 0000000000..9fbc556f77 --- /dev/null +++ b/apps/web/tests/message-feedback-protocol.snapshot.ts @@ -0,0 +1,115 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + assertFixtureInventory, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + type WebScaffold, +} from './scaffold.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-protocol', import.meta.url)) +const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json') +const SESSION_ID = 'message-feedback-protocol' +const MESSAGE_ID = '11111111-1111-4111-8111-111111111111' + +interface ProtocolExchange { + readonly endpoint: string + readonly request: unknown + readonly status: number + readonly response: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */ +function createdVersion(response: unknown): string { + if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true + || !isRecord(response.result.value) || response.result.value.ok !== true + || !isRecord(response.result.value.value) + || typeof response.result.value.value.version !== 'string') { + throw new Error('messageFeedback.put did not return a successful versioned item') + } + return response.result.value.value.version +} + +/** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */ +function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string { + return JSON.stringify(exchanges, (key, value: unknown) => { + if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}' + if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}' + return value + }, 2) +} + +describe('message feedback Host Remote protocol', () => { + let scaffold: WebScaffold + + beforeAll(async () => { + scaffold = await launchWebScaffold() + await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID) + }) + + afterAll(async () => { + await scaffold?.close() + }) + + it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => { + const exchanges: ProtocolExchange[] = [] + const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise => { + const payload = { args: { request } } + const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId, + method: endpoint, + payload, + }), + }) + const body: unknown = await response.json() + exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body }) + return body + } + + await invoke('feedback-invalid', 'messageFeedback/put', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + rating: 'invalid-rating', + ifVersion: null, + }) + await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID }) + const created = await invoke('feedback-put', 'messageFeedback/put', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + rating: 'positive', + note: 'Useful answer', + ifVersion: null, + }) + const version = createdVersion(created) + expect(version).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID }) + await invoke('feedback-conflict', 'messageFeedback/put', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + rating: 'negative', + ifVersion: null, + }) + await invoke('feedback-delete', 'messageFeedback/delete', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + ifVersion: version, + }) + await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID }) + + expect(exchanges.every(exchange => exchange.status === 200)).toBe(true) + await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode) + await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.jsonl']) + }) +}) diff --git a/apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json b/apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json new file mode 100644 index 0000000000..302e4876df --- /dev/null +++ b/apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json @@ -0,0 +1,203 @@ +[ + { + "endpoint": "/api/messageFeedback/put", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "invalid-rating", + "ifVersion": null + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-invalid", + "result": { + "ok": false, + "error": { + "code": "internal", + "message": "typert gateway: messageFeedback/put: wire field \"request\" failed boundary validation", + "details": {} + } + } + } + }, + { + "endpoint": "/api/messageFeedback/list", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-list-empty", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "items": [] + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/put", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "ifVersion": null + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-put", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "version": "{{version}}", + "createdAt": "{{timestamp}}", + "updatedAt": "{{timestamp}}" + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/list", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-list-created", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "items": [ + { + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "version": "{{version}}", + "createdAt": "{{timestamp}}", + "updatedAt": "{{timestamp}}" + } + ] + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/put", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "negative", + "ifVersion": null + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-conflict", + "result": { + "ok": true, + "value": { + "ok": false, + "error": { + "code": "version-conflict", + "current": { + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "version": "{{version}}", + "createdAt": "{{timestamp}}", + "updatedAt": "{{timestamp}}" + } + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/delete", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "ifVersion": "{{version}}" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-delete", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "absent": true + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/list", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-list-deleted", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "items": [] + } + } + } + } + } +] diff --git a/apps/web/tests/snapshots/message-feedback-protocol/session.jsonl b/apps/web/tests/snapshots/message-feedback-protocol/session.jsonl new file mode 100644 index 0000000000..d970fb8cb4 --- /dev/null +++ b/apps/web/tests/snapshots/message-feedback-protocol/session.jsonl @@ -0,0 +1,7 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786406400000,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":1786406400001,"data":{"turn":1}} +{"type":"user/message","seq":1,"time":1786406400002,"data":{"role":"user","content":[{"type":"text","text":"Give one useful answer."}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1786406400003,"data":{"turn":1,"step":1}} +{"type":"assistant/message","seq":3,"time":1786406400004,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"A useful answer."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"11111111-1111-4111-8111-111111111111"},"usage":{"inputTokens":4,"outputTokens":4}},"surfaceOp":"append"} +{"type":"step/end","seq":4,"time":1786406400005,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":5,"time":1786406400006,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b80a2f6e2b..f96a1f8dd1 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -25,6 +25,7 @@ "tests/scaffold.ts", "tests/scaffold-hermetic.e2e.ts", "tests/minimal-preset.snapshot.ts", + "tests/message-feedback-protocol.snapshot.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", "tests/approval-composer.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 344bac8145..ea8b934c79 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 8d1c5a1be391e2455aefc69db89d96027aaf3efa -architecture.zh.md: 53bf9f54a5503ae40aa62a348822f9d92162dda2 +architecture.md: aeda7f9674e75a1e97549f25c13d571b3b37ee8c +architecture.zh.md: a25f20ba9babefeaab4636027e97d6f2d4ae8caf diff --git a/docs/architecture.md b/docs/architecture.md index 8d1c5a1be3..aeda7f9674 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | +| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | lifecycle-bound editable feedback for individual assistant messages and its Host Remote contract | | `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | durable session-log storage | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools | | `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | log-backed fallbacks, one optional asynchronous provider | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 53bf9f54a5..a25f20ba9b 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -42,6 +42,7 @@ | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 | +| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | 绑定生命周期的单条 assistant 消息可编辑反馈及其 Host Remote 契约 | | `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | 会话日志的持久化存储 | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 | | `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | 基于日志的回退标题和单个可选异步提供方 | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index d6c4832447..c0091e400e 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: c102167aa76b9ba613b1b434cb0aa58765d26106 -capability-seams.zh.md: 7c4eab8d5a2d890bdf4513bb9acdc81f55414642 +capability-seams.md: 64d20b1bfb609aec3acb3673e4589516bb315bfa +capability-seams.zh.md: 10b5116d12991319df55c551d51840bb566ac898 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index c102167aa7..64d20b1bfb 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -30,6 +30,7 @@ flowchart LR pkg_session_query_sqlite["session-query-sqlite"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] + pkg_message_feedback["message-feedback"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -60,6 +61,7 @@ flowchart LR pkg_storage_domain["storage-domain"] svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] + svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"] svc_workspace["ctx.workspace
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] @@ -218,6 +220,7 @@ flowchart LR pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm pkg_llm_replay --> svc_llm + pkg_message_feedback --> svc_messageFeedback pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode @@ -322,6 +325,7 @@ flowchart LR svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude svc_sessionPersistence --> pkg_hooks_codex + svc_sessionPersistence --> pkg_message_feedback svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash @@ -334,6 +338,7 @@ flowchart LR svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants + svc_sessions --> pkg_message_feedback svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite @@ -344,6 +349,7 @@ flowchart LR svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain + svc_storageDomain --> pkg_message_feedback svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent @@ -392,16 +398,17 @@ flowchart LR | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | -| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | +| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | +| `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry. | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 7c4eab8d5a..10b5116d12 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -32,6 +32,7 @@ flowchart LR pkg_session_query_sqlite["session-query-sqlite"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] + pkg_message_feedback["message-feedback"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -62,6 +63,7 @@ flowchart LR pkg_storage_domain["storage-domain"] svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] + svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"] svc_workspace["ctx.workspace
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] @@ -220,6 +222,7 @@ flowchart LR pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm pkg_llm_replay --> svc_llm + pkg_message_feedback --> svc_messageFeedback pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode @@ -324,6 +327,7 @@ flowchart LR svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude svc_sessionPersistence --> pkg_hooks_codex + svc_sessionPersistence --> pkg_message_feedback svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash @@ -336,6 +340,7 @@ flowchart LR svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants + svc_sessions --> pkg_message_feedback svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite @@ -346,6 +351,7 @@ flowchart LR svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain + svc_storageDomain --> pkg_message_feedback svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent @@ -394,16 +400,17 @@ flowchart LR | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、[`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop)、[`compact-basic`](../packages/compact/compact-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop)、[`agent`](../packages/core/agent)、[`session-persistence`](../packages/session/session-persistence)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess)、[`invariants`](../packages/support/invariants) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop)、[`agent`](../packages/core/agent)、[`session-persistence`](../packages/session/session-persistence)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess)、[`invariants`](../packages/support/invariants)、[`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session)、[`agent`](../packages/core/agent)、[`scope`](../packages/core/scope)、[`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader)、[`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl)、[`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop)、[`tool-bash`](../packages/bash/tool-bash)、[`hooks-claude`](../packages/hooks/hooks-claude)、[`hooks-codex`](../packages/hooks/hooks-codex)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl)、[`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop)、[`tool-bash`](../packages/bash/tool-bash)、[`hooks-claude`](../packages/hooks/hooks-claude)、[`hooks-codex`](../packages/hooks/hooks-codex)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、`apiproxy` | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、`apiproxy` | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json)、[`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 | -| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 | +| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace)、[`message-feedback`](../packages/feedback/message-feedback) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 | +| `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有本地逐 assistant 消息反馈、生命周期与目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约,且不进入 Session 历史或遥测。 | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference)、[`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index f12b6e3637..a20a76a7ef 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 85338a0ead89f729573af2b747d92d68544cff84 -config-catalog.zh.md: 8c74940b3aad350f720361e197a389055784b445 +config-catalog.md: 6188fec1cfe65369e59c10b851629122c393af1e +config-catalog.zh.md: 1aefcaff17d272d4767a22a5ba72a7ab6dc0b914 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 85338a0ead..6188fec1cf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1129,6 +1129,20 @@ export interface ReconnectConfig { Source: [`packages/mcp/mcp-client/src/index.ts:98`](../packages/mcp/mcp-client/src/index.ts) +## `@deepseek-ai/dsh-message-feedback` + +Requires: `storageDomain` · `sessionPersistence` · `sessions` + +```ts config-catalog +/** Required deployment policy for optional notes. */ +export interface Config { + /** Maximum UTF-8 byte length accepted for one note. */ + readonly maxNoteBytes: number +} +``` + +Source: [`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts) + ## `@deepseek-ai/dsh-permission` Requires: `bash` · `approval` · `sessions` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8c74940b3a..1aefcaff17 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1131,6 +1131,20 @@ export interface ReconnectConfig { 来源:[`packages/mcp/mcp-client/src/index.ts:94`](../packages/mcp/mcp-client/src/index.ts) +## `@deepseek-ai/dsh-message-feedback` + +需要:`storageDomain` · `sessionPersistence` · `sessions` + +```ts config-catalog +/** Required deployment policy for optional notes. */ +export interface Config { + /** Maximum UTF-8 byte length accepted for one note. */ + readonly maxNoteBytes: number +} +``` + +来源:[`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts) + ## `@deepseek-ai/dsh-permission` 需要:`bash` · `approval` · `sessions` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a44c4ac247..3f5820ec3e 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 2e7fdcc2028cf17d88f986efef3fb580839be18b -module-graph.zh.md: 71bf4b361d8b639d54b91e6264f21f19df6fce13 +module-graph.md: 859a449f63bd36177a9c666ac9d894cb495b224f +module-graph.zh.md: cedf087810cf309200368b43c6d53757426c58cd diff --git a/docs/module-graph.md b/docs/module-graph.md index 2e7fdcc202..859a449f63 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -201,6 +201,7 @@ flowchart TD end subgraph group_feedback["packages/feedback"] pkg_command_feedback["command-feedback"] + pkg_message_feedback["message-feedback"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] @@ -571,6 +572,13 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_invariants + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_storage_domain + pkg_message_feedback --> pkg_type_meta pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse @@ -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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 71bf4b361d..cedf087810 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -203,6 +203,7 @@ flowchart TD end subgraph group_feedback["packages/feedback"] pkg_command_feedback["command-feedback"] + pkg_message_feedback["message-feedback"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] @@ -573,6 +574,13 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_invariants + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_storage_domain + pkg_message_feedback --> pkg_type_meta pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse @@ -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) | diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 9e578f7886..ebdccb923a 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: fddbf460c8e9e7c6f9ed1d3375bdabe65947661f -README.zh.md: febc5a97426fef5ec4b2b80d9677957369994a3b +README.md: 560851eeda607b762456fe20c874b4497704709f +README.zh.md: 4acba4372e995b54a3bb326bec0cf9606f997773 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index fddbf460c8..560851eeda 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -18,6 +18,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | | [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | +| [feedback.md](feedback.md) | lifecycle-bound per-message feedback records, optimistic versions, sidecar persistence, and the Host Remote contract | | [session-title.md](session-title.md) | durable title snapshots, cited source-message seqs, and the asynchronous provider contract | | [session-reference.md](session-reference.md) | structured cross-session references: `SessionReferenceInput`/`Candidate`, prepared message contexts, the stable error taxonomy | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index febc5a9742..4acba4372e 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -18,6 +18,7 @@ | [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | | [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | +| [feedback.md](feedback.md) | 绑定生命周期的逐消息反馈记录、乐观版本、伴随记录持久化与 Host Remote 契约 | | [session-title.md](session-title.md) | 持久标题快照、被引用的来源消息 seq 与异步提供方约定 | | [session-reference.md](session-reference.md) | 结构化跨会话引用:`SessionReferenceInput`/`Candidate`、prepared 消息上下文、稳定错误分类 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml new file mode 100644 index 0000000000..f6de2debe9 --- /dev/null +++ b/docs/subsystems/feedback.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/subsystems/feedback.md +feedback.md: 76a29f7d6ba604fa07ed56429c9b066e22639671 +feedback.zh.md: 5a409832de68b6d0bc9688a907c0f22edd3b0a43 diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md new file mode 100644 index 0000000000..76a29f7d6b --- /dev/null +++ b/docs/subsystems/feedback.md @@ -0,0 +1,256 @@ +# Message Feedback + +English | [中文](feedback.zh.md) + +[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback) owns editable feedback for individual assistant messages. It is deliberately separate from the immutable Session-level `feedback/record` event: message feedback is a local storage-domain sidecar, not Session-log content or a projection, and it performs no telemetry handoff. + +Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) + +## Public types + +```ts type-equiv +/** Opaque compare-and-set token for one exact feedback item revision. */ +type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'> +``` + +```ts type-equiv +/** The human's overall judgment of one assistant message. */ +type MessageFeedbackRating = 'positive' | 'negative' +``` + +```ts type-equiv +/** One current feedback value and its opaque mutation token. */ +interface MessageFeedbackItem { + /** Stable identity of the assistant message inside the owning Session. */ + readonly messageId: MessageId + /** Overall positive or negative judgment. */ + readonly rating: MessageFeedbackRating + /** Optional explanation, preserved verbatim after validation. */ + readonly note?: string + /** Equality-only token replaced by every material create or update. */ + readonly version: MessageFeedbackVersion + /** Host-assigned creation time in Unix epoch milliseconds. */ + readonly createdAt: number + /** Host-assigned time of the most recent material update. */ + readonly updatedAt: number +} +``` + +```ts type-equiv +/** Read all message feedback belonging to one persisted Session lifecycle. */ +interface MessageFeedbackListRequest { + /** Persisted Session whose sidecar should be read. */ + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** Current feedback values for one Session, in first-creation order. */ +interface MessageFeedbackListValue { + /** Fresh immutable item snapshots. */ + readonly items: readonly MessageFeedbackItem[] +} +``` + +```ts type-equiv +/** Create or replace feedback for one assistant message. */ +interface MessageFeedbackPutRequest { + /** Persisted Session that owns the target message. */ + readonly sessionId: SessionId + /** Target assistant-message identity. */ + readonly messageId: MessageId + /** Desired overall judgment. */ + readonly rating: MessageFeedbackRating + /** Optional non-blank explanation. */ + readonly note?: string + /** Observed item version, or `null` to require that no item exists. */ + readonly ifVersion: MessageFeedbackVersion | null +} +``` + +```ts type-equiv +/** Delete feedback for one message after observing its current version. */ +interface MessageFeedbackDeleteRequest { + /** Persisted Session that owns the sidecar. */ + readonly sessionId: SessionId + /** Message whose feedback should be absent after this operation. */ + readonly messageId: MessageId + /** Observed item version; ignored when the item is already absent. */ + readonly ifVersion: MessageFeedbackVersion +} +``` + +```ts type-equiv +/** Idempotent deletion acknowledgement. */ +interface MessageFeedbackDeleteValue { + /** Stable postcondition shared by the first deletion and every retry. */ + readonly absent: true +} +``` + +```ts type-equiv +/** No persisted Session header exists for the requested id. */ +interface MessageFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** The id does not name a derived, append-origin assistant message. */ +interface MessageFeedbackTargetNotFound { + readonly code: 'target-not-found' + readonly sessionId: SessionId + readonly messageId: MessageId +} +``` + +```ts type-equiv +/** A material mutation did not match the addressed item's current version. */ +interface MessageFeedbackVersionConflict { + readonly code: 'version-conflict' + /** Authoritative current item, or `null` when it does not exist. */ + readonly current: MessageFeedbackItem | null +} +``` + +```ts type-equiv +/** A supplied note contains no non-whitespace character. */ +interface MessageFeedbackNoteBlank { + readonly code: 'note-blank' +} +``` + +```ts type-equiv +/** A supplied note exceeds the configured UTF-8 byte limit. */ +interface MessageFeedbackNoteTooLarge { + readonly code: 'note-too-large' + readonly maxBytes: number + readonly actualBytes: number +} +``` + +```ts type-equiv +/** Failures shared by the public message-feedback operations. */ +type MessageFeedbackFailure = + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge +``` + +```ts type-equiv +/** Successful public operation result. */ +interface MessageFeedbackSuccess { + readonly ok: true + readonly value: T +} +``` + +```ts type-equiv +/** Rejected public operation result with a stable business failure. */ +interface MessageFeedbackRejected { + readonly ok: false + readonly error: E +} +``` + +```ts type-equiv +/** Result returned by the message-feedback `list` operation. */ +type MessageFeedbackListResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +```ts type-equiv +/** Result returned by the message-feedback `put` operation. */ +type MessageFeedbackPutResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected< + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + > +``` + +```ts type-equiv +/** Result returned by the message-feedback `delete` operation. */ +type MessageFeedbackDeleteResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +## Data and concurrency + +One Session sidecar row contains its header identity `{createdAt, cwd}` and feedback items keyed by `MessageId`. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them. + +`put` uses strict optimistic concurrency: every request for an existing item must match its current `ifVersion`, including a no-op. A conflict returns the authoritative current item (or `null`), so a caller can reconcile a lost response or a concurrent edit without another read. Deleting an already absent item succeeds. A per-Session queue encloses inspection, read, conflict evaluation, and whole-row write, so these guarantees cover concurrent calls in one Host process. + +## Target and lifecycle authority + +`SessionPersistence.inspect()` supplies the target Session observation without publishing or resuming an Agent and without committing cold repair. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session propagates as infrastructure failure. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin, usage-only empty, and non-assistant records are not feedback targets. + +The stored `{createdAt, cwd}` identity must match the inspected header. A mismatch is treated as absence: `list` returns no items, while `put` may replace the stale row with one bound to the current header identity. Forks use a new Session identity and receive no sidecar copy even when their seed contains the same messages. + +## Persistence and Remote contract + +The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; both live and cold paths are then physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `GatewayService` and `@Remote`; the generated Cordis surface below is the method-level authority. + +Plugin disposal closes mutation admission, drains accepted per-Session queue work, and then closes the storage domain. + +## Boundaries and limitations + +- The client Remote aggregate mount and UI consumer are separately owned and deferred. +- The mutation queue is process-local. Storage-domain has no cross-process conditional write, so multiple Host writers to one storage root have no compare-and-swap or lost-update guarantee. +- Session persistence has no durable deletion surface. The service does not treat `session/disposed` or `host/session-removed` as deletion and therefore performs no fake cascade; orphan sidecar rows may remain after out-of-band log removal. +- A request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. +- Cold requests scan the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. One Session row also has no item-count or aggregate-byte cap; `maxNoteBytes` bounds only each note until a concrete consumer owns a row policy. +- Header identity detects a reused id only when `{createdAt, cwd}` differs; a cloned log retaining the same header identity is indistinguishable by this contract. +- The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary. + + + + + +## Cordis surface + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.messageFeedback` — `MessageFeedbackService` + +Storage-domain sidecar service. It inspects persisted Session history and never creates or resumes an Agent or Session. + +```ts cordis-catalog +/** + * Read feedback belonging to the current persisted Session lifecycle. + * A stale row from a reused Session id is invisible. + * @param request - Session identity to inspect and list. + * @returns current immutable items or `session-not-found`. + */ +@Remote('list') async list(request: MessageFeedbackListRequest): Promise + +/** + * Create or replace feedback for one derived append-origin assistant + * message. Every request must match the addressed item's current version; + * a matching no-op returns the stored item without changing its revision. + * @param request - target, desired value, and observed item version. + * @returns the committed item or an explicit business failure. + */ +@Remote('put') put(request: MessageFeedbackPutRequest): Promise + +/** + * Delete one feedback item. Absence is successful regardless of the + * supplied version; an existing item requires an exact version match. + * @param request - Session, message, and observed item version. + * @returns the stable absent postcondition, or an explicit failure. + */ +@Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise +``` + +Source: [`packages/feedback/message-feedback/src/index.ts:150`](../../packages/feedback/message-feedback/src/index.ts) + diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md new file mode 100644 index 0000000000..5a409832de --- /dev/null +++ b/docs/subsystems/feedback.zh.md @@ -0,0 +1,256 @@ +# 消息反馈 + +[English](feedback.md) | 中文 + +[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback)拥有针对单条 assistant 消息的可编辑反馈。它刻意与不可变的 Session 级 `feedback/record` 事件分离:message feedback 是本地 storage-domain 伴随记录(sidecar),不是 Session 日志内容或投影,也不执行遥测交接。 + +来源:[`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) + +## 公开类型 + +```ts type-equiv +/** Opaque compare-and-set token for one exact feedback item revision. */ +type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'> +``` + +```ts type-equiv +/** The human's overall judgment of one assistant message. */ +type MessageFeedbackRating = 'positive' | 'negative' +``` + +```ts type-equiv +/** One current feedback value and its opaque mutation token. */ +interface MessageFeedbackItem { + /** Stable identity of the assistant message inside the owning Session. */ + readonly messageId: MessageId + /** Overall positive or negative judgment. */ + readonly rating: MessageFeedbackRating + /** Optional explanation, preserved verbatim after validation. */ + readonly note?: string + /** Equality-only token replaced by every material create or update. */ + readonly version: MessageFeedbackVersion + /** Host-assigned creation time in Unix epoch milliseconds. */ + readonly createdAt: number + /** Host-assigned time of the most recent material update. */ + readonly updatedAt: number +} +``` + +```ts type-equiv +/** Read all message feedback belonging to one persisted Session lifecycle. */ +interface MessageFeedbackListRequest { + /** Persisted Session whose sidecar should be read. */ + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** Current feedback values for one Session, in first-creation order. */ +interface MessageFeedbackListValue { + /** Fresh immutable item snapshots. */ + readonly items: readonly MessageFeedbackItem[] +} +``` + +```ts type-equiv +/** Create or replace feedback for one assistant message. */ +interface MessageFeedbackPutRequest { + /** Persisted Session that owns the target message. */ + readonly sessionId: SessionId + /** Target assistant-message identity. */ + readonly messageId: MessageId + /** Desired overall judgment. */ + readonly rating: MessageFeedbackRating + /** Optional non-blank explanation. */ + readonly note?: string + /** Observed item version, or `null` to require that no item exists. */ + readonly ifVersion: MessageFeedbackVersion | null +} +``` + +```ts type-equiv +/** Delete feedback for one message after observing its current version. */ +interface MessageFeedbackDeleteRequest { + /** Persisted Session that owns the sidecar. */ + readonly sessionId: SessionId + /** Message whose feedback should be absent after this operation. */ + readonly messageId: MessageId + /** Observed item version; ignored when the item is already absent. */ + readonly ifVersion: MessageFeedbackVersion +} +``` + +```ts type-equiv +/** Idempotent deletion acknowledgement. */ +interface MessageFeedbackDeleteValue { + /** Stable postcondition shared by the first deletion and every retry. */ + readonly absent: true +} +``` + +```ts type-equiv +/** No persisted Session header exists for the requested id. */ +interface MessageFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** The id does not name a derived, append-origin assistant message. */ +interface MessageFeedbackTargetNotFound { + readonly code: 'target-not-found' + readonly sessionId: SessionId + readonly messageId: MessageId +} +``` + +```ts type-equiv +/** A material mutation did not match the addressed item's current version. */ +interface MessageFeedbackVersionConflict { + readonly code: 'version-conflict' + /** Authoritative current item, or `null` when it does not exist. */ + readonly current: MessageFeedbackItem | null +} +``` + +```ts type-equiv +/** A supplied note contains no non-whitespace character. */ +interface MessageFeedbackNoteBlank { + readonly code: 'note-blank' +} +``` + +```ts type-equiv +/** A supplied note exceeds the configured UTF-8 byte limit. */ +interface MessageFeedbackNoteTooLarge { + readonly code: 'note-too-large' + readonly maxBytes: number + readonly actualBytes: number +} +``` + +```ts type-equiv +/** Failures shared by the public message-feedback operations. */ +type MessageFeedbackFailure = + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge +``` + +```ts type-equiv +/** Successful public operation result. */ +interface MessageFeedbackSuccess { + readonly ok: true + readonly value: T +} +``` + +```ts type-equiv +/** Rejected public operation result with a stable business failure. */ +interface MessageFeedbackRejected { + readonly ok: false + readonly error: E +} +``` + +```ts type-equiv +/** Result returned by the message-feedback `list` operation. */ +type MessageFeedbackListResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +```ts type-equiv +/** Result returned by the message-feedback `put` operation. */ +type MessageFeedbackPutResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected< + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + > +``` + +```ts type-equiv +/** Result returned by the message-feedback `delete` operation. */ +type MessageFeedbackDeleteResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +## 数据与并发 + +每个 Session 的一条伴随记录包含 header 身份 `{createdAt, cwd}` 和以 `MessageId` 为键的反馈条目。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。 + +`put` 采用严格乐观并发:已有条目的每次请求都必须匹配当前 `ifVersion`,即使请求不会改变目标值。冲突会返回权威当前条目(不存在时为 `null`),因此调用方无需额外读取,即可协调丢失响应或并发编辑。删除已经不存在的条目同样成功。按 Session 划分的队列覆盖检查、读取、冲突判断与整行写入,因此这些保证适用于单个 Host 进程中的并发调用。 + +## 目标与生命周期权威 + +`SessionPersistence.inspect()` 提供目标 Session 的观测,且不会发布或恢复 Agent,也不会提交 cold repair。cold 路径先由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,会按基础设施故障原样传播。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin、仅承载 usage 的空记录和非 assistant 记录都不是反馈目标。 + +存储的 `{createdAt, cwd}` 身份必须与检查所得 header 匹配。不匹配按不存在处理:`list` 返回空条目,`put` 则可用绑定当前 header 身份的新记录替换陈旧行。fork 使用新的 Session 身份,即使种子包含相同消息,也不获得伴随记录副本。 + +## 持久化与 Remote 契约 + +服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。 + +Plugin disposal 会先关闭变更接纳,排空已进入各 Session 队列的工作,然后才关闭 storage domain。 + +## 边界与限制 + +- 客户端 Remote 聚合挂载与 UI 消费方由各自边界负责并保持延后。 +- 变更队列仅在进程内生效。storage-domain 没有跨进程条件写,因此多个 Host 写入同一存储根目录时,不提供 compare-and-swap 或防止丢失更新的保证。 +- Session persistence 没有持久删除接口。服务不把 `session/disposed` 或 `host/session-removed` 当作删除,因此不伪造级联;在带外移除日志后,孤儿伴随记录可能继续存在。 +- 请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 +- 由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。单个 Session 行也没有条目数或聚合字节上限;在具体消费方拥有行策略之前,`maxNoteBytes` 只限制每条备注。 +- 只有 `{createdAt, cwd}` 不同时,header 身份才能识别复用的 id;本契约无法区分保留相同 header 身份的克隆日志。 +- Host 契约不记录已认证的 actor 或审计身份,因此假设调用方边界可信。 + + + + + +## Cordis surface + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.messageFeedback` — `MessageFeedbackService` + +Storage-domain sidecar service. It inspects persisted Session history and never creates or resumes an Agent or Session. + +```ts cordis-catalog +/** + * Read feedback belonging to the current persisted Session lifecycle. + * A stale row from a reused Session id is invisible. + * @param request - Session identity to inspect and list. + * @returns current immutable items or `session-not-found`. + */ +@Remote('list') async list(request: MessageFeedbackListRequest): Promise + +/** + * Create or replace feedback for one derived append-origin assistant + * message. Every request must match the addressed item's current version; + * a matching no-op returns the stored item without changing its revision. + * @param request - target, desired value, and observed item version. + * @returns the committed item or an explicit business failure. + */ +@Remote('put') put(request: MessageFeedbackPutRequest): Promise + +/** + * Delete one feedback item. Absence is successful regardless of the + * supplied version; an existing item requires an exact version match. + * @param request - Session, message, and observed item version. + * @returns the stable absent postcondition, or an explicit failure. + */ +@Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise +``` + +Source: [`packages/feedback/message-feedback/src/index.ts:150`](../../packages/feedback/message-feedback/src/index.ts) + diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 1c06b23157..cd6d752166 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -60,6 +60,11 @@ config: backend: json + - id: message-feedback + name: '@deepseek-ai/dsh-message-feedback' + config: + maxNoteBytes: 8192 + - id: workspace name: '@deepseek-ai/dsh-workspace' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 3dcfc8ee6a..95e7428f0a 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -83,6 +83,7 @@ "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", diff --git a/packages/feedback/README.i18n.yaml b/packages/feedback/README.i18n.yaml index fce2946cff..bab1cf0db2 100644 --- a/packages/feedback/README.i18n.yaml +++ b/packages/feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/feedback/README.md -README.md: af8e9d5c4903594299284d09f880aa8929f5e051 -README.zh.md: 9156ff1c8da8ed1128488fcacaf454425468163a +README.md: 152db65bd7ac179bb8d475d446535734b9f8238e +README.zh.md: 64202a3c4a0258f9b41a6cd96e7bbbf011d0338d diff --git a/packages/feedback/README.md b/packages/feedback/README.md index af8e9d5c49..152db65bd7 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -2,10 +2,13 @@ English | [中文](README.zh.md) -The feedback family lets a human record a remark about the session without acting on it. Feedback is durable session-log content, separate from the model conversation and from any policy that might later read it. +The feedback family exposes two deliberately separate contracts: an immutable remark in the canonical Session log, and editable feedback attached to one assistant message in a local sidecar. Neither form enters the model conversation. | Package | Role | ctx key | |---|---|---| | `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — | +| `message-feedback/` | Lifecycle-bound per-message rating/note sidecar plus Host `messageFeedback.list/put/delete` Remote contract | `messageFeedback` | -A recorded remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../session/session-telemetry-otel) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy. +A command feedback remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../session/session-telemetry-otel) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy. + +Message feedback is not a Session event or projection. It remains in the storage-domain sidecar and causes no telemetry handoff. The Host Remote contract ships with the service; the client Remote aggregate mount and UI consumer are separately owned and deferred. diff --git a/packages/feedback/README.zh.md b/packages/feedback/README.zh.md index 9156ff1c8d..64202a3c4a 100644 --- a/packages/feedback/README.zh.md +++ b/packages/feedback/README.zh.md @@ -2,10 +2,13 @@ [English](README.md) | 中文 -反馈家族让人类记录对会话的评价,但不据此采取任何动作。反馈属于持久的会话日志内容,与模型对话以及后续可能读取它的任何策略相互独立。 +反馈家族公开两份刻意分离的契约:写入权威 Session 日志的不可变评价,以及挂在单条 assistant 消息上的可编辑本地伴随记录(sidecar)反馈。两者都不会进入模型对话。 | 包 | 职责 | ctx 键 | |---|---|---| | `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 | +| `message-feedback/` | 绑定生命周期的逐消息评分/备注伴随记录,以及 Host `messageFeedback.list/put/delete` Remote 契约 | `messageFeedback` | -被记录的评价仅写入日志:它绝不会进入模型接口或派生历史。挂载后,[`dsh-session-telemetry-otel`](../session/session-telemetry-otel) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。 +command feedback 评价仅写入日志:它绝不会进入模型接口或派生历史。挂载后,[`dsh-session-telemetry-otel`](../session/session-telemetry-otel) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。 + +message feedback 不是 Session 事件或投影。它只保留在 storage-domain 伴随记录中,不触发任何遥测交接。服务随附 Host Remote 契约;客户端 Remote 聚合挂载与 UI 消费方由各自边界负责,并保持延后。 diff --git a/packages/feedback/message-feedback/README.i18n.yaml b/packages/feedback/message-feedback/README.i18n.yaml new file mode 100644 index 0000000000..edf8ec947b --- /dev/null +++ b/packages/feedback/message-feedback/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/feedback/message-feedback/README.md +README.md: a9ebad25907e32435c8d2a65eb4b2cd9eaa89eeb +README.zh.md: 29cbee0c1ec2ee810948d895c762d2e6320e9b66 diff --git a/packages/feedback/message-feedback/README.md b/packages/feedback/message-feedback/README.md new file mode 100644 index 0000000000..a9ebad2590 --- /dev/null +++ b/packages/feedback/message-feedback/README.md @@ -0,0 +1,84 @@ +# @deepseek-ai/dsh-message-feedback + +English | [中文](README.zh.md) + +Host-owned editable feedback for one finalized assistant message. The package registers `ctx.messageFeedback`, persists one lifecycle-bound sidecar row per Session in storage-domain, and publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract. It is separate from the immutable Session-level `feedback/record` event and performs no telemetry handoff. The [message-feedback sidecar Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md) owns the design boundary. + +Public request, value, version, and failure types are exported from the package root and `@deepseek-ai/dsh-message-feedback/types`; [`src/types.ts`](src/types.ts) is their source. + +## Configuration + +| key | meaning | +|---|---| +| `maxNoteBytes` | Required positive safe integer: maximum UTF-8 byte length of one optional note. | + +Notes must contain at least one non-whitespace character, but accepted text is stored verbatim rather than trimmed. Omitting `note` means the desired value has no note, so a version-matched material `put` clears an existing note. Note validation precedes Session lookup and can therefore return `note-blank` or `note-too-large` for a missing Session without touching persistence. + +```yaml +- id: message-feedback + name: '@deepseek-ai/dsh-message-feedback' + config: + maxNoteBytes: 8192 +``` + +The service injects `storageDomain`, `sessionPersistence`, and `sessions`. Its durable domain is `message_feedback`, with one `sessions` table row per `SessionId`. + +## Data, lifecycle, and durability + +`MessageFeedbackItem` contains `messageId`, `rating: 'positive' | 'negative'`, optional `note`, an opaque equality-only `version`, and Host-assigned `createdAt`/`updatedAt` Unix-millisecond timestamps. A material update preserves `createdAt`, replaces `version`, and keeps `updatedAt` from moving backward. `list` returns fresh immutable snapshots in first-creation order; updating an item retains its place, while deleting and later recreating it appends a new item. + +Each stored row carries the inspected Session header identity `{createdAt, cwd}`. A mismatch is treated as absence: `list` returns an empty `items` array, `delete` returns the absent postcondition, and `put` may replace the stale row with one bound to the current identity. This fences a reused `SessionId` when its header identity differs. Forks use a distinct Session identity and receive no feedback-row copy. + +`SessionPersistence.inspect()` supplies a cold-safe observation without publishing or resuming an Agent and without committing cold repair. For a Session without a live owner, `listSnapshots()` first decides definite absence; an `inspect()` failure for a catalogued Session remains an infrastructure failure rather than being guessed into `session-not-found`. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin messages, empty usage-only assistant records, and non-assistant records return `target-not-found`. + +After initial validation, `put` establishes a durability barrier before writing the sidecar. A matching live Session commits through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are validated again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar commit, so durable feedback never precedes the durable target message. + +Message feedback is not Session-log content or a Session projection. It emits no `feedback/record` event, does not enter model history, and does not trigger `FEEDBACK_ONLY` telemetry release. + +## Service and Host Remote contract + +The same three `MessageFeedbackService` methods are published by `GatewayService` and `@Remote`; the Host endpoint names are `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete`. Every method returns a discriminated business union: `{ ok: true, value }` or `{ ok: false, error }`. Operational storage, corruption, or missing-durability-listener failures reject instead of being mislabeled as business errors. + +| Method | Request | Success `value` | Rejected `error.code` | +|---|---|---|---| +| `list` | `MessageFeedbackListRequest { sessionId }` | `MessageFeedbackListValue { items }` | `session-not-found` | +| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | committed `MessageFeedbackItem` | `session-not-found`, `target-not-found`, `version-conflict`, `note-blank`, `note-too-large` | +| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`, `version-conflict` | + +`MessageFeedbackVersionConflict` returns the authoritative `current` item, or `null` when no item exists. This lets a caller reconcile the current rating, note, and version without a second `list` request. `MessageFeedbackNoteTooLarge` returns both `maxBytes` and `actualBytes`. The Client Remote aggregate does not mount the generated client contribution yet; Host callers can use the service/Remote contract without that client assembly. + +## Compare-and-set and idempotency + +`ifVersion: null` requests creation only; every request for an existing item requires its exact current version, including a no-op whose desired value already matches. The check is per message rather than per Session, so changing one item does not conflict with another. Every material create or update assigns a fresh opaque UUID token, preventing stale writes from crossing an ABA value cycle. + +A matching-version no-op returns the already stored item with unchanged version and timestamps. After a lost success response, a retry with the old token receives `version-conflict.current`; the caller can compare that authoritative item with its desired value without an extra read. `delete` ignores `ifVersion` when the item is already absent and always returns the stable `{ absent: true }` postcondition after success. + +A per-Session promise queue encloses inspection, durability validation, sidecar read, comparison, and whole-row write. These semantics serialize concurrent mutations through one service instance; storage-domain itself has no cross-process conditional write. + +Plugin disposal closes mutation admission, drains every operation already accepted into the per-Session queues, and only then closes the storage domain. A mutation submitted after disposal begins rejects as a lifecycle failure instead of entering a closing domain. + +## Model Experience + +### Local message-feedback state + +#### What the model sees + +Nothing. `ctx.messageFeedback` registers no tool, prompt section, model-facing context, or Session event; feedback stays in a Host-owned sidecar unless a separately documented Consumer explicitly exposes it. + +#### Token effect + +Zero. No request, result, rating, note, timestamp, or failure from this package enters a model request. + +#### KV Cache effect + +Independent. Listing or mutating message feedback does not touch a model request prefix and cannot invalidate an otherwise reusable provider cache entry. + +## Known Limitations and Deferred Work + +- **Client aggregate and UI are absent** — the Host Remote contract ships, but the Client Remote aggregate contribution and any UI consumer are separately owned and deferred. +- **Compare-and-set is single-process** — the per-Session queue serializes one service instance only; multiple Host processes writing one storage root can still lose updates because storage-domain exposes no cross-process conditional write. +- **No durable Session deletion cascade** — Session persistence has no deletion surface, and `session/disposed`/`host/session-removed` mean detach rather than durable deletion. The service therefore retains empty rows and may leave orphan rows after out-of-band log removal instead of deleting valid feedback on detach. +- **Detach/catalog retirement window** — a request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. +- **Header identity is not a content fingerprint** — `{createdAt, cwd}` detects reuse only when those fields differ; a cloned log retaining the same header identity is indistinguishable. +- **Trusted caller boundary** — `list`/`put`/`delete` carry no authenticated actor or audit identity. A deployment must expose the Host gateway only through its trusted or separately authenticated boundary until authorization and attribution are added. +- **Catalog and row bounds** — a cold request scans the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. `maxNoteBytes` bounds one note, but the item count and aggregate retained bytes of one Session row are not capped; an indexed metadata read and deployment-owned row bound remain deferred until a concrete consumer defines their policy. diff --git a/packages/feedback/message-feedback/README.zh.md b/packages/feedback/message-feedback/README.zh.md new file mode 100644 index 0000000000..29cbee0c1e --- /dev/null +++ b/packages/feedback/message-feedback/README.zh.md @@ -0,0 +1,84 @@ +# @deepseek-ai/dsh-message-feedback + +[English](README.md) | 中文 + +本包提供由 Host 拥有、针对单条已完成 assistant 消息的可编辑反馈。它注册 `ctx.messageFeedback`,在 storage-domain 中为每个 Session 持久化一条绑定生命周期的伴随记录(sidecar),并发布 Host `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete` 一元 Remote 契约。它与不可变的 Session 级 `feedback/record` 事件相互独立,不执行遥测交接。[消息反馈伴随记录 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md)拥有其设计边界。 + +公开的请求、值、版本与失败类型从包根入口及 `@deepseek-ai/dsh-message-feedback/types` 导出;其源码为 [`src/types.ts`](src/types.ts)。 + +## 配置 + +| 键 | 含义 | +|---|---| +| `maxNoteBytes` | 必填正 safe integer:一条可选备注的最大 UTF-8 字节长度。 | + +备注必须包含至少一个非空白字符,但通过校验的文本按原样存储,不会 trim。省略 `note` 表示目标值不含备注,因此 version 匹配的实质 `put` 会清除已有备注。备注校验早于 Session 查找,因此即使 Session 不存在,也可能在不访问持久化的情况下返回 `note-blank` 或 `note-too-large`。 + +```yaml +- id: message-feedback + name: '@deepseek-ai/dsh-message-feedback' + config: + maxNoteBytes: 8192 +``` + +服务注入 `storageDomain`、`sessionPersistence` 与 `sessions`。其持久存储域为 `message_feedback`,其中 `sessions` 表按 `SessionId` 每个一行。 + +## 数据、生命周期与持久性 + +`MessageFeedbackItem` 包含 `messageId`、`rating: 'positive' | 'negative'`、可选 `note`、只能做相等比较的 opaque `version`,以及由 Host 分配、以 Unix 毫秒表示的 `createdAt`/`updatedAt` 时间戳。实质更新保留 `createdAt`、替换 `version`,并保证 `updatedAt` 不倒退。`list` 按首次创建顺序返回新的不可变快照;更新条目时保留其位置,删除后再创建则追加为新条目。 + +每条存储行都携带检查所得 Session header 身份 `{createdAt, cwd}`。不匹配按不存在处理:`list` 返回空 `items` 数组,`delete` 返回已不存在的后置条件,`put` 可以用绑定当前身份的新行替换陈旧行。这会在复用的 `SessionId` 具有不同 header 身份时形成隔离。fork 使用独立的 Session 身份,不复制反馈伴随记录。 + +`SessionPersistence.inspect()` 提供 cold-safe 观测,不发布或恢复 Agent,也不提交 cold repair。对于没有 live owner 的 Session,系统先用 `listSnapshots()` 判定明确不存在;已进入目录的 Session 若 `inspect()` 失败,仍属于基础设施故障,不会被猜测成 `session-not-found`。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin 消息、仅承载 usage 的空 assistant 记录与非 assistant 记录都返回 `target-not-found`。 + +初步校验后,`put` 在写入伴随记录前建立 durability barrier。身份匹配的 live Session 先通过权威 `ctx.sessions.flush` checkpoint 提交,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录提交,因此持久反馈绝不会先于其持久目标消息。 + +message feedback 不是 Session 日志内容或 Session 投影。它不发出 `feedback/record` 事件,不进入模型历史,也不触发 `FEEDBACK_ONLY` 遥测释放。 + +## 服务与 Host Remote 契约 + +`GatewayService` 与 `@Remote` 将 `MessageFeedbackService` 的同三个方法发布出去;Host endpoint 名称为 `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete`。每个方法都返回判别式业务 union:`{ ok: true, value }` 或 `{ ok: false, error }`。存储、损坏或缺少 durability listener 等操作故障会产生 reject,不会被误标为业务错误。 + +| 方法 | 请求 | 成功 `value` | 拒绝的 `error.code` | +|---|---|---|---| +| `list` | `MessageFeedbackListRequest { sessionId }` | `MessageFeedbackListValue { items }` | `session-not-found` | +| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | 已提交的 `MessageFeedbackItem` | `session-not-found`、`target-not-found`、`version-conflict`、`note-blank`、`note-too-large` | +| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`、`version-conflict` | + +`MessageFeedbackVersionConflict` 返回权威 `current` 条目;条目不存在时为 `null`。调用方无需额外执行 `list`,即可协调当前 rating、note 与 version。`MessageFeedbackNoteTooLarge` 同时返回 `maxBytes` 与 `actualBytes`。客户端 Remote 聚合尚未挂载生成的客户端 contribution;Host 调用方无需该客户端组装即可使用 service/Remote 契约。 + +## Compare-and-set 与幂等性 + +`ifVersion: null` 表示仅当条目不存在时才创建;已有条目的每次请求都必须与其当前 version 完全一致,即使目标值已经相同、不会产生实质更新。检查按消息而非按 Session 进行,因此修改一个条目不会与另一个条目冲突。每次实质创建或更新都会分配新的 opaque UUID token,防止陈旧写入穿过 ABA 值循环。 + +携带匹配 version 的无变化请求会返回已存条目,version 与时间戳均不变。成功响应丢失后,使用旧 token 重试会得到 `version-conflict.current`;调用方无需额外读取,即可把权威当前值与目标值比较。条目已不存在时,`delete` 忽略 `ifVersion`;成功后始终返回稳定的 `{ absent: true }` 后置条件。 + +按 Session 划分的 promise 队列覆盖检查、持久性校验、伴随记录读取、比较与整行写入。这些语义会串行化经由同一服务实例的并发变更;storage-domain 自身没有跨进程条件写。 + +Plugin disposal 会先关闭变更接纳,排空已进入各个 Session 队列的所有操作,然后才关闭 storage domain。disposal 开始后提交的变更会以生命周期故障拒绝,不会进入正在关闭的 domain。 + +## 模型体验 + +### 本地消息反馈状态 + +#### 模型看到的内容 + +无。`ctx.messageFeedback` 不注册工具、提示词段落、模型可见上下文或 Session 事件;除非另一个具有独立文档的 Consumer 显式公开反馈,否则它只留在 Host 拥有的伴随记录中。 + +#### Token 影响 + +为零。本包的请求、结果、评分、备注、时间戳或失败都不会进入模型请求。 + +#### KV Cache 影响 + +相互独立。读取或变更消息反馈不会触碰模型请求前缀,也不会使本可复用的提供方缓存条目失效。 + +## 已知局限与延后工作 + +- **缺少客户端聚合与 UI**——Host Remote 契约已经发布,但客户端 Remote 聚合 contribution 与任何 UI 消费方由各自边界负责并保持延后。 +- **Compare-and-set 仅限单进程**——按 Session 划分的队列只串行化一个服务实例;storage-domain 不提供跨进程条件写,因此多个 Host 进程写入同一存储根目录时仍可能丢失更新。 +- **没有持久 Session 删除级联**——Session persistence 没有删除接口,且 `session/disposed`/`host/session-removed` 表示 detach 而非持久删除。因此服务会保留空行,并可能在带外移除日志后留下孤儿行,而不会在 detach 时删除仍有效的反馈。 +- **Detach/catalog retirement 窗口**——请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 +- **Header 身份不是内容指纹**——只有 `{createdAt, cwd}` 不同时才能识别复用;本契约无法区分保留相同 header 身份的克隆日志。 +- **调用方边界受信任**——`list`/`put`/`delete` 不携带已认证的 actor 或审计身份。在加入授权与归属信息前,部署方必须只通过受信任或另行认证的边界暴露 Host gateway。 +- **目录与行边界**——由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。`maxNoteBytes` 只限制单条备注,单个 Session 行的条目数和聚合保留字节尚无上限;按索引读取元数据和由部署决定的行边界,延后到具体消费方明确策略时处理。 diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json new file mode 100644 index 0000000000..fe0caa18fb --- /dev/null +++ b/packages/feedback/message-feedback/package.json @@ -0,0 +1,82 @@ +{ + "name": "@deepseek-ai/dsh-message-feedback", + "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/feedback/message-feedback" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-storage-json": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/feedback/message-feedback/src/index.ts b/packages/feedback/message-feedback/src/index.ts new file mode 100644 index 0000000000..6126e837e1 --- /dev/null +++ b/packages/feedback/message-feedback/src/index.ts @@ -0,0 +1,383 @@ +/** + * Durable, lifecycle-bound feedback for finalized assistant messages. + * @module @deepseek-ai/dsh-message-feedback + */ + +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { Context, Service } from '@deepseek-ai/cordis' +import s from '@deepseek-ai/schemastery' +import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' +import type { KvTable } from '@deepseek-ai/dsh-storage-domain' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' +import { messageFeedbackDomainSpec } from './spec.ts' +import type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts' +import type { + MessageFeedbackDeleteRequest, + MessageFeedbackDeleteResult, + MessageFeedbackDeleteValue, + MessageFeedbackFailure, + MessageFeedbackItem, + MessageFeedbackListRequest, + MessageFeedbackListResult, + MessageFeedbackListValue, + MessageFeedbackNoteBlank, + MessageFeedbackNoteTooLarge, + MessageFeedbackPutRequest, + MessageFeedbackPutResult, + MessageFeedbackRejected, + MessageFeedbackSessionNotFound, + MessageFeedbackSuccess, + MessageFeedbackVersion, + MessageFeedbackVersionConflict, +} from './types.ts' + +export type * from './types.ts' +export { + messageFeedbackDomainSpec, + messageFeedbackItemSchema, + messageFeedbackRatingSchema, + messageFeedbackRowSchema, + messageFeedbackSessionIdentitySchema, + messageFeedbackVersionSchema, +} from './spec.ts' +export type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts' + +/** Required deployment policy for optional notes. */ +export interface Config { + /** Maximum UTF-8 byte length accepted for one note. */ + readonly maxNoteBytes: number +} + +declare module '@deepseek-ai/cordis' { + interface Context { + messageFeedback: MessageFeedbackService + } +} + +/** Immutable empty list reused only as an input to caller-owned copying. */ +const EMPTY_ITEMS: readonly MessageFeedbackItem[] = Object.freeze([]) + +/** Validate the one deployment-varying limit at the configuration boundary. */ +function resolveMaxNoteBytes(value: number): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new TypeError( + `message-feedback: maxNoteBytes must be a positive safe integer, got ${String(value)}`, + ) + } + return value +} + +/** Copy and freeze one item before it crosses the service boundary. */ +function snapshotItem(item: MessageFeedbackItem): MessageFeedbackItem { + return Object.freeze({ + messageId: item.messageId, + rating: item.rating, + ...(item.note === undefined ? {} : { note: item.note }), + version: item.version, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }) +} + +/** Copy and freeze a list response. */ +function snapshotList(items: readonly MessageFeedbackItem[]): MessageFeedbackListValue { + return Object.freeze({ items: Object.freeze(items.map(snapshotItem)) }) +} + +/** Build a frozen success branch. */ +function success(value: T): MessageFeedbackSuccess { + return Object.freeze({ ok: true, value }) +} + +/** Build a frozen business-failure branch. */ +function rejected(error: E): MessageFeedbackRejected { + return Object.freeze({ ok: false, error: Object.freeze(error) }) +} + +/** Project the Session fields that distinguish one persisted log lifecycle. */ +function identityOf(header: SessionHeader): MessageFeedbackSessionIdentity { + return Object.freeze({ + createdAt: header.createdAt, + ...(header.cwd === undefined ? {} : { cwd: header.cwd }), + }) +} + +/** Whether a stored row belongs to the inspected Session lifecycle. */ +function sameIdentity(row: MessageFeedbackRow, header: SessionHeader): boolean { + return row.session.createdAt === header.createdAt && row.session.cwd === header.cwd +} + +/** Whether two observations name the same persisted Session lifecycle. */ +function sameHeaderIdentity(left: SessionHeader, right: SessionHeader): boolean { + return left.id === right.id && left.createdAt === right.createdAt && left.cwd === right.cwd +} + +/** Freeze the replacement row so storage-domain never exposes mutable aliases. */ +function rowSnapshot( + session: MessageFeedbackSessionIdentity, + items: readonly MessageFeedbackItem[], +): MessageFeedbackRow { + const copiedItems = items.map(snapshotItem) + Object.freeze(copiedItems) + return Object.freeze({ + session, + items: copiedItems, + }) +} + +/** Generate an opaque equality token for one material mutation. */ +function nextVersion(): MessageFeedbackVersion { + return randomUUID() as MessageFeedbackVersion +} + +/** Session inspection result that keeps absence inside the business union. */ +type KnownSession = + | MessageFeedbackSuccess + | MessageFeedbackRejected + +/** Validated note or one explicit request failure. */ +type ResolvedNote = + | MessageFeedbackSuccess + | MessageFeedbackRejected + +/** + * Storage-domain sidecar service. It inspects persisted Session history and + * never creates or resumes an Agent or Session. + */ +export class MessageFeedbackService extends GatewayService { + static inject = ['storageDomain', 'sessionPersistence', 'sessions'] + + /** Loader validation for the required note-size policy. */ + static Config: s = s.object({ + maxNoteBytes: s.number().step(1).min(1).required(), + }) + + private readonly maxNoteBytes: number + private table?: KvTable + private readonly operationTails = new Map>() + private mutationAdmissionOpen = true + + /** + * @param ctx - Host context carrying persistence and the storage-domain form. + * @param config - Required note-size policy. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'messageFeedback') + this.maxNoteBytes = resolveMaxNoteBytes(config.maxNoteBytes) + } + + /** Open and own the one message-feedback sidecar domain. */ + protected async [Service.init](): Promise { + const domain = await this.ctx.storageDomain.open(messageFeedbackDomainSpec) + this.ctx.effect(() => async () => { + this.mutationAdmissionOpen = false + await Promise.all(this.operationTails.values()) + await domain.close() + }, 'message-feedback.domainClose') + this.table = domain.table('sessions') + } + + /** + * Read feedback belonging to the current persisted Session lifecycle. + * A stale row from a reused Session id is invisible. + * @param request - Session identity to inspect and list. + * @returns current immutable items or `session-not-found`. + */ + @Remote('list') + async list(request: MessageFeedbackListRequest): Promise { + const known = await this.inspectSession(request.sessionId) + if (!known.ok) return known + const row = this.requireTable().get(request.sessionId) + const items = row !== undefined && sameIdentity(row, known.value.meta) ? row.items : EMPTY_ITEMS + return success(snapshotList(items)) + } + + /** + * Create or replace feedback for one derived append-origin assistant + * message. Every request must match the addressed item's current version; + * a matching no-op returns the stored item without changing its revision. + * @param request - target, desired value, and observed item version. + * @returns the committed item or an explicit business failure. + */ + @Remote('put') + put(request: MessageFeedbackPutRequest): Promise { + const note = this.resolveNote(request.note) + if (!note.ok) return Promise.resolve(note) + return this.enqueue(request.sessionId, async () => { + const known = await this.inspectSession(request.sessionId) + if (!known.ok) return known + if (!this.hasFeedbackTarget(known.value, request.messageId)) { + return rejected({ + code: 'target-not-found', + sessionId: request.sessionId, + messageId: request.messageId, + }) + } + + const durable = await this.ensureTargetDurable(known.value) + if (!sameHeaderIdentity(durable.meta, known.value.meta) + || !this.hasFeedbackTarget(durable, request.messageId)) { + return rejected({ + code: 'target-not-found', + sessionId: request.sessionId, + messageId: request.messageId, + }) + } + + const table = this.requireTable() + const stored = table.get(request.sessionId) + const current = stored !== undefined && sameIdentity(stored, durable.meta) ? stored : undefined + const items = current?.items ?? EMPTY_ITEMS + const index = items.findIndex(item => item.messageId === request.messageId) + const existing = items[index] + if (request.ifVersion !== (existing?.version ?? null)) { + return rejected(this.versionConflict(existing ?? null)) + } + if (existing !== undefined + && existing.rating === request.rating + && existing.note === note.value) { + return success(snapshotItem(existing)) + } + + const now = Date.now() + const item = snapshotItem({ + messageId: request.messageId, + rating: request.rating, + ...(note.value === undefined ? {} : { note: note.value }), + version: nextVersion(), + createdAt: existing?.createdAt ?? now, + updatedAt: existing === undefined ? now : Math.max(now, existing.updatedAt), + }) + const nextItems = [...items] + if (index === -1) nextItems.push(item) + else nextItems[index] = item + await table.put( + request.sessionId, + rowSnapshot(identityOf(durable.meta), nextItems), + ) + return success(snapshotItem(item)) + }) + } + + /** + * Delete one feedback item. Absence is successful regardless of the + * supplied version; an existing item requires an exact version match. + * @param request - Session, message, and observed item version. + * @returns the stable absent postcondition, or an explicit failure. + */ + @Remote('delete') + delete(request: MessageFeedbackDeleteRequest): Promise { + return this.enqueue(request.sessionId, async () => { + const known = await this.inspectSession(request.sessionId) + if (!known.ok) return known + + const table = this.requireTable() + const stored = table.get(request.sessionId) + const current = stored !== undefined && sameIdentity(stored, known.value.meta) ? stored : undefined + const items = current?.items ?? EMPTY_ITEMS + const existing = items.find(item => item.messageId === request.messageId) + if (existing === undefined) { + return success(Object.freeze({ absent: true })) + } + if (request.ifVersion !== existing.version) { + return rejected(this.versionConflict(existing)) + } + + await table.put( + request.sessionId, + rowSnapshot(identityOf(known.value.meta), items.filter(item => item !== existing)), + ) + return success(Object.freeze({ absent: true })) + }) + } + + /** + * Resolve a live owner directly; otherwise use the storage catalog as the + * existence authority before inspecting the log. Inspection failures for a + * catalogued Session remain infrastructure failures rather than being + * guessed into the business `session-not-found` branch. + */ + private async inspectSession(sessionId: SessionId): Promise { + if (this.ctx.sessions.get(sessionId) === undefined) { + const snapshots = await this.ctx.sessionPersistence.listSnapshots() + if (!snapshots.some(snapshot => snapshot.header.id === sessionId) + && this.ctx.sessions.get(sessionId) === undefined) { + return rejected({ code: 'session-not-found', sessionId }) + } + } + return success(await this.ctx.sessionPersistence.inspect(sessionId)) + } + + /** Require the exact finalized append-origin assistant message projection. */ + private hasFeedbackTarget(inspection: SessionInspection, messageId: MessageFeedbackItem['messageId']): boolean { + return inspection.events.some((event) => { + if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) return false + const message = deriveEventMessage(event) + return message?.role === 'assistant' && message.id === messageId + }) + } + + /** + * Put the target log prefix behind a durability barrier before its sidecar. + * A live owner flushes through the SessionStore's canonical checkpoint; a + * cold owner is re-read from the physical durable prefix. + */ + private async ensureTargetDurable(inspection: SessionInspection): Promise { + const live = this.ctx.sessions.get(inspection.meta.id) + if (live !== undefined && sameHeaderIdentity(live.header, inspection.meta)) { + if (!(await this.ctx.sessions.flush(live))) { + throw new Error( + `message-feedback: no durability listener participated for live session '${inspection.meta.id}'`, + ) + } + return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0) + } + return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0) + } + + /** Validate optional-note semantics and the configured complete UTF-8 byte bound. */ + private resolveNote(note: string | undefined): ResolvedNote { + if (note === undefined) return success(undefined) + if (note.trim().length === 0) return rejected({ code: 'note-blank' }) + const actualBytes = Buffer.byteLength(note, 'utf8') + if (actualBytes > this.maxNoteBytes) { + return rejected({ code: 'note-too-large', maxBytes: this.maxNoteBytes, actualBytes }) + } + return success(note) + } + + /** Return the authoritative item needed to reconcile one failed comparison. */ + private versionConflict(current: MessageFeedbackItem | null): MessageFeedbackVersionConflict { + return { + code: 'version-conflict', + current: current === null ? null : snapshotItem(current), + } + } + + /** Queue a complete read/compare/write mutation behind this Session's prior mutation. */ + private enqueue(sessionId: SessionId, operation: () => Promise): Promise { + if (!this.mutationAdmissionOpen) { + return Promise.reject(new Error('message-feedback: service is disposing')) + } + const previous = this.operationTails.get(sessionId) ?? Promise.resolve() + const result = previous.then(operation) + const tail = result.then(() => undefined, () => undefined) + this.operationTails.set(sessionId, tail) + return result.finally(() => { + if (this.operationTails.get(sessionId) === tail) this.operationTails.delete(sessionId) + }) + } + + /** Resolve the initialized durable table or fail a broken service lifecycle. */ + private requireTable(): KvTable { + if (this.table === undefined) { + throw new Error('message-feedback: durable domain is not initialized') + } + return this.table + } +} + +export default MessageFeedbackService diff --git a/packages/feedback/message-feedback/src/invariant.ts b/packages/feedback/message-feedback/src/invariant.ts new file mode 100644 index 0000000000..5433f318f1 --- /dev/null +++ b/packages/feedback/message-feedback/src/invariant.ts @@ -0,0 +1,27 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-message-feedback/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-message-feedback' + +/** Cordis companion plugin name. */ +export const name = 'message-feedback-invariant' +/** Services required before the companion can reserve and check package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the private typed writer owns current row mutations, + * the domain schema validates rows on reopen, and no second authority exists. + */ +const install: InvariantInstaller = Object.assign(() => {}, { inject: ['messageFeedback'] }) + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/feedback/message-feedback/src/spec.ts b/packages/feedback/message-feedback/src/spec.ts new file mode 100644 index 0000000000..d08a536d06 --- /dev/null +++ b/packages/feedback/message-feedback/src/spec.ts @@ -0,0 +1,90 @@ +/** + * Durable storage-domain declaration for lifecycle-bound message feedback. + * @module @deepseek-ai/dsh-message-feedback/src/spec + */ + +import { z } from 'zod' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' +import type { MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion } from './types.ts' + +const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + +/** Runtime schema for the closed rating vocabulary. */ +export const messageFeedbackRatingSchema = z.union([ + z.literal('positive'), + z.literal('negative'), +]) satisfies z.ZodType + +/** Runtime schema for one opaque item version stored on disk. */ +export const messageFeedbackVersionSchema = z.uuid() + .transform(value => value as MessageFeedbackVersion) + +/** Runtime schema for one current feedback item. */ +// Zod infers transformed branded fields structurally, so it cannot name the +// public interface even though every branded output is created below. +export const messageFeedbackItemSchema = z.object({ + messageId: z.string().min(1).transform(value => value as MessageId), + rating: messageFeedbackRatingSchema, + note: z.string().refine(note => note.trim().length > 0, { + message: 'message feedback note must contain a non-whitespace character', + }).optional(), + version: messageFeedbackVersionSchema, + createdAt: nonNegativeSafeInteger, + updatedAt: nonNegativeSafeInteger, +}).refine(item => item.updatedAt >= item.createdAt, { + path: ['updatedAt'], + message: 'message feedback updatedAt must not precede createdAt', +}) as unknown as z.ZodType + +/** Persisted Session fields that fence a sidecar row to one log lifecycle. */ +export const messageFeedbackSessionIdentitySchema = z.object({ + createdAt: nonNegativeSafeInteger, + cwd: z.string().optional(), +}) + +/** Persisted lifecycle identity inferred from its durable schema. */ +export type MessageFeedbackSessionIdentity = z.infer + +/** + * One whole-Session sidecar. Duplicate message ids would make item lookup + * ambiguous; duplicate versions would break their independent identity. + */ +export const messageFeedbackRowSchema = z.object({ + session: messageFeedbackSessionIdentitySchema, + items: z.array(messageFeedbackItemSchema), +}).superRefine((row, ctx) => { + const messageIds = new Set() + const versions = new Set() + row.items.forEach((item, index) => { + if (messageIds.has(item.messageId)) { + ctx.addIssue({ + code: 'custom', + path: ['items', index, 'messageId'], + message: `duplicate message feedback id '${item.messageId}'`, + }) + } + messageIds.add(item.messageId) + if (versions.has(item.version)) { + ctx.addIssue({ + code: 'custom', + path: ['items', index, 'version'], + message: `duplicate message feedback version '${item.version}'`, + }) + } + versions.add(item.version) + }) +}) + +/** Durable sidecar row inferred from {@link messageFeedbackRowSchema}. */ +export type MessageFeedbackRow = z.infer + +/** One lifecycle-bound sidecar record per Session id. */ +export const messageFeedbackDomainSpec = defineDomain({ + name: 'message_feedback', + version: 0, + tables: { + sessions: domainTable(messageFeedbackRowSchema), + }, +}) diff --git a/packages/feedback/message-feedback/src/types.ts b/packages/feedback/message-feedback/src/types.ts new file mode 100644 index 0000000000..0be57b17b4 --- /dev/null +++ b/packages/feedback/message-feedback/src/types.ts @@ -0,0 +1,147 @@ +/** + * Public request, value, and failure vocabulary for per-message feedback. + * This module contains types only so generated Remote clients can consume it + * without importing Host runtime code. + * @module @deepseek-ai/dsh-message-feedback/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Opaque compare-and-set token for one exact feedback item revision. */ +export type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'> + +/** The human's overall judgment of one assistant message. */ +export type MessageFeedbackRating = 'positive' | 'negative' + +/** One current feedback value and its opaque mutation token. */ +export interface MessageFeedbackItem { + /** Stable identity of the assistant message inside the owning Session. */ + readonly messageId: MessageId + /** Overall positive or negative judgment. */ + readonly rating: MessageFeedbackRating + /** Optional explanation, preserved verbatim after validation. */ + readonly note?: string + /** Equality-only token replaced by every material create or update. */ + readonly version: MessageFeedbackVersion + /** Host-assigned creation time in Unix epoch milliseconds. */ + readonly createdAt: number + /** Host-assigned time of the most recent material update. */ + readonly updatedAt: number +} + +/** Read all message feedback belonging to one persisted Session lifecycle. */ +export interface MessageFeedbackListRequest { + /** Persisted Session whose sidecar should be read. */ + readonly sessionId: SessionId +} + +/** Current feedback values for one Session, in first-creation order. */ +export interface MessageFeedbackListValue { + /** Fresh immutable item snapshots. */ + readonly items: readonly MessageFeedbackItem[] +} + +/** Create or replace feedback for one assistant message. */ +export interface MessageFeedbackPutRequest { + /** Persisted Session that owns the target message. */ + readonly sessionId: SessionId + /** Target assistant-message identity. */ + readonly messageId: MessageId + /** Desired overall judgment. */ + readonly rating: MessageFeedbackRating + /** Optional non-blank explanation. */ + readonly note?: string + /** Observed item version, or `null` to require that no item exists. */ + readonly ifVersion: MessageFeedbackVersion | null +} + +/** Delete feedback for one message after observing its current version. */ +export interface MessageFeedbackDeleteRequest { + /** Persisted Session that owns the sidecar. */ + readonly sessionId: SessionId + /** Message whose feedback should be absent after this operation. */ + readonly messageId: MessageId + /** Observed item version; ignored when the item is already absent. */ + readonly ifVersion: MessageFeedbackVersion +} + +/** Idempotent deletion acknowledgement. */ +export interface MessageFeedbackDeleteValue { + /** Stable postcondition shared by the first deletion and every retry. */ + readonly absent: true +} + +/** No persisted Session header exists for the requested id. */ +export interface MessageFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} + +/** The id does not name a derived, append-origin assistant message. */ +export interface MessageFeedbackTargetNotFound { + readonly code: 'target-not-found' + readonly sessionId: SessionId + readonly messageId: MessageId +} + +/** A material mutation did not match the addressed item's current version. */ +export interface MessageFeedbackVersionConflict { + readonly code: 'version-conflict' + /** Authoritative current item, or `null` when it does not exist. */ + readonly current: MessageFeedbackItem | null +} + +/** A supplied note contains no non-whitespace character. */ +export interface MessageFeedbackNoteBlank { + readonly code: 'note-blank' +} + +/** A supplied note exceeds the configured UTF-8 byte limit. */ +export interface MessageFeedbackNoteTooLarge { + readonly code: 'note-too-large' + readonly maxBytes: number + readonly actualBytes: number +} + +/** Failures shared by the public message-feedback operations. */ +export type MessageFeedbackFailure = + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + +/** Successful public operation result. */ +export interface MessageFeedbackSuccess { + readonly ok: true + readonly value: T +} + +/** Rejected public operation result with a stable business failure. */ +export interface MessageFeedbackRejected { + readonly ok: false + readonly error: E +} + +/** Result returned by the message-feedback `list` operation. */ +export type MessageFeedbackListResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected + +/** Result returned by the message-feedback `put` operation. */ +export type MessageFeedbackPutResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected< + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + > + +/** Result returned by the message-feedback `delete` operation. */ +export type MessageFeedbackDeleteResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts new file mode 100644 index 0000000000..1dfaa24396 --- /dev/null +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -0,0 +1,213 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import SessionStore, { + SESSION_FORMAT_VERSION, + Session, + SessionId, + type SessionEvent, + type SessionHeader, +} from '@deepseek-ai/dsh-session' +import SessionPersistence, { + SessionPersistenceRevision, + type SessionInspection, + type SessionLocation, + type SessionPersistenceSnapshot, +} from '@deepseek-ai/dsh-session-persistence' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' +import MessageFeedbackService from '../src/index.ts' + +export interface MessageFixture { + readonly session: Session + readonly userMessageId: MessageId + readonly assistantMessageIds: readonly [MessageId, MessageId] + readonly emptyAssistantMessageId: MessageId + readonly replacementAssistantMessageId: MessageId +} + +/** Append one deterministic transcript surface used by target-validation tests. */ +export function appendMessageFixture(session: Session): Omit { + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + const user = createUserMessage({ + content: [{ type: 'text', text: 'Question' }], + source: { kind: 'user' }, + }) + session.append('user/message', user, { surfaceOp: 'append' }) + + const first = createAssistantMessage({ + content: [{ type: 'text', text: 'First answer' }], + source: { provider: 'test', model: 'test' }, + }) + const firstEvent = session.append('assistant/message', { + turn: 1, + step: 1, + message: first, + }, { surfaceOp: 'append' }) + const second = createAssistantMessage({ + content: [{ type: 'text', text: 'Second answer' }], + source: { provider: 'test', model: 'test' }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: second, + }, { surfaceOp: 'append' }) + const empty = createAssistantMessage({ + content: [], + source: { provider: 'test', model: 'test' }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: empty, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const replacement = createAssistantMessage({ + content: [{ type: 'text', text: 'Model-only replacement' }], + source: { provider: 'test', model: 'test' }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: replacement, + }, { + surfaceOp: { op: 'replace', start: firstEvent.seq, end: firstEvent.seq }, + sourceEventSeqs: [firstEvent.seq], + }) + + return { + userMessageId: user.id, + assistantMessageIds: [first.id, second.id], + emptyAssistantMessageId: empty.id, + replacementAssistantMessageId: replacement.id, + } +} + +/** Construct one cold persistence fixture without publishing a live Session. */ +export function messageFixture( + rawId: string, + options: { readonly createdAt?: number; readonly cwd?: string } = {}, +): MessageFixture { + const id = SessionId(rawId) + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id, + createdAt: options.createdAt ?? 1_700_000_000_000, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + } + const session = Session.create(id, [], header) + return { session, ...appendMessageFixture(session) } +} + +/** Minimal controllable persistence provider for service-level tests. */ +class TestPersistence extends SessionPersistence { + static inject = ['sessions'] + + readonly durable = new Map() + readonly logical = new Map() + inspectFailure: Error | undefined + inspectCalls = 0 + readFromCalls = 0 + onReadFrom: (() => void | Promise) | undefined + onListSnapshots: (() => void | Promise) | undefined + + locate(_meta: SessionHeader): SessionLocation | undefined { return undefined } + create(_meta: SessionHeader): Promise { return Promise.resolve() } + append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } + + load(id: SessionId): Promise { + return this.readFrom(id, 0) + } + + inspect(id: SessionId): Promise { + this.inspectCalls += 1 + if (this.inspectFailure !== undefined) return Promise.reject(this.inspectFailure) + const explicit = this.logical.get(id) + if (explicit !== undefined) return Promise.resolve(explicit) + const live = this.ctx.sessions.get(id) + if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.events }) + const stored = this.durable.get(id) + return stored === undefined + ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) + : Promise.resolve(stored) + } + + async readFrom( + id: SessionId, + fromSeq: number, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + this.readFromCalls += 1 + await this.onReadFrom?.() + const stored = this.durable.get(id) + return stored === undefined + ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) + : { meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) } + } + + list(): Promise { + return Promise.resolve([...this.durable.values()].map(value => value.meta)) + } + + async listSnapshots(): Promise { + await this.onListSnapshots?.() + return [...this.durable.values()].map((value, index) => ({ + header: value.meta, + revision: SessionPersistenceRevision(`test:${index}:${value.events.length}`), + })) + } + + persist(session: Session): void { + this.durable.set(session.id, { meta: session.header, events: session.events }) + } + + setDurable(inspection: SessionInspection): void { + this.durable.set(inspection.meta.id, inspection) + } +} + +export interface TestHarness { + readonly ctx: Context + readonly persistence: TestPersistence + readonly root: string + disposeFeedback(): Promise + dispose(): Promise +} + +/** Compose the service over the real storage hub/domain/JSON backend. */ +export async function setupHarness(maxNoteBytes = 64): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-test-')) + const ctx = new Context() + let disposeFeedback: (() => Promise) | undefined + try { + await ctx.plugin(SessionStore) + await ctx.plugin(TestPersistence) + await ctx.plugin(Storage) + await ctx.plugin(StorageJson, { root }) + await ctx.plugin(StorageDomain, { backend: 'json' }) + const feedbackFiber = await ctx.plugin(MessageFeedbackService, { maxNoteBytes }) + disposeFeedback = feedbackFiber.dispose + } catch (error) { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + throw error + } + if (disposeFeedback === undefined) throw new Error('message feedback test plugin did not load') + return { + ctx, + persistence: ctx.sessionPersistence as unknown as TestPersistence, + root, + disposeFeedback, + async dispose() { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + }, + } +} diff --git a/packages/feedback/message-feedback/tests/invariant.spec.ts b/packages/feedback/message-feedback/tests/invariant.spec.ts new file mode 100644 index 0000000000..e88131e147 --- /dev/null +++ b/packages/feedback/message-feedback/tests/invariant.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as MessageFeedbackInvariant from '../src/invariant.ts' +import { setupHarness } from './helpers.ts' + +describe('message-feedback invariant companion', () => { + it('removes its registry contribution when its fiber is disposed (HMR safety)', async () => { + const harness = await setupHarness() + try { + await harness.ctx.plugin(InvariantService) + const fiber = await harness.ctx.plugin(MessageFeedbackInvariant) + + expect(() => { + harness.ctx.invariants.register('@deepseek-ai/dsh-message-feedback', () => {}) + }).toThrow(/already registered/u) + + await fiber.dispose() + await expect(harness.ctx.plugin(MessageFeedbackInvariant).await()).resolves.toBeDefined() + } finally { + await harness.dispose() + } + }) +}) diff --git a/packages/feedback/message-feedback/tests/loader-composition.spec.ts b/packages/feedback/message-feedback/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..98a7f29243 --- /dev/null +++ b/packages/feedback/message-feedback/tests/loader-composition.spec.ts @@ -0,0 +1,115 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import MessageFeedbackService from '../src/index.ts' +import { appendMessageFixture } from './helpers.ts' + +let root: string | undefined +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) +async function loadComposition(configPath: string): Promise { + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(root as string).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-persistence-jsonl', SessionPersistenceJsonl], + ['@deepseek-ai/dsh-storage', Storage], + ['@deepseek-ai/dsh-storage-json', StorageJson], + ['@deepseek-ai/dsh-storage-domain', StorageDomain], + ['@deepseek-ai/dsh-message-feedback', MessageFeedbackService], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await ctx.loader.await() + const unloaded = [...ctx.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + return ctx +} + +describe('message feedback through a real Loader composition', () => { + it('persists a checkpointed target and its sidecar across a cold restart', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-persistence-jsonl'", + ' config:', + ` root: ${JSON.stringify(join(root, 'sessions'))}`, + ' compression: none', + ' writeBatchMaxDelayMs: 1', + "- name: '@deepseek-ai/dsh-storage'", + "- name: '@deepseek-ai/dsh-storage-json'", + ' config:', + ` root: ${JSON.stringify(join(root, 'storage'))}`, + "- name: '@deepseek-ai/dsh-storage-domain'", + ' config:', + ' backend: json', + "- name: '@deepseek-ai/dsh-message-feedback'", + ' config:', + ' maxNoteBytes: 32', + '', + ].join('\n')) + + const first = await loadComposition(configPath) + expect(first.messageFeedback.typertGateway.namespace).toBe('messageFeedback') + expect(remoteMethods(first.messageFeedback).map(marker => marker.method)) + .toEqual(['list', 'put', 'delete']) + + const session = first.sessions.create(SessionId('loader-feedback'), { + meta: { cwd: root }, + }) + const fixture = appendMessageFixture(session) + const put = await first.messageFeedback.put({ + sessionId: session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + note: 'survives restart', + ifVersion: null, + }) + if (!put.ok) throw new Error(`expected put success, got ${put.error.code}`) + const durable = await first.sessionPersistence.readFrom(session.id, 0) + expect(durable.events.some(event => + event.type === 'assistant/message' + && event.data.message.id === fixture.assistantMessageIds[0])).toBe(true) + + await first.fiber.dispose() + contexts.splice(contexts.indexOf(first), 1) + + const second = await loadComposition(configPath) + await expect(second.messageFeedback.list({ sessionId: session.id })).resolves.toEqual({ + ok: true, + value: { items: [put.value] }, + }) + expect(second.sessions.get(session.id)).toBeUndefined() + }) +}) diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts new file mode 100644 index 0000000000..f4478ec202 --- /dev/null +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -0,0 +1,655 @@ +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import MessageFeedbackService, { messageFeedbackRowSchema } from '../src/index.ts' +import type { + MessageFeedbackItem, + MessageFeedbackVersion, +} from '../src/index.ts' +import { + appendMessageFixture, + messageFixture, + setupHarness, + type TestHarness, +} from './helpers.ts' + +const harnesses: TestHarness[] = [] + +async function harness(maxNoteBytes = 64): Promise { + const value = await setupHarness(maxNoteBytes) + harnesses.push(value) + return value +} + +afterEach(async () => { + vi.useRealTimers() + await Promise.all(harnesses.splice(0).map(value => value.dispose())) +}) + +function staleVersion(): MessageFeedbackVersion { + return randomUUID() as MessageFeedbackVersion +} + +function expectItem( + result: Awaited>, +): MessageFeedbackItem { + if (!result.ok) throw new Error(`expected feedback item, got ${result.error.code}`) + return result.value +} + +describe('MessageFeedbackService public contract', () => { + it('publishes the exact Gateway namespace and Remote method names', async () => { + const { ctx } = await harness() + const binding = ctx.messageFeedback.typertGateway + expect(binding.serviceKey).toBe('messageFeedback') + expect(binding.namespace).toBe('messageFeedback') + expect(remoteMethods(ctx.messageFeedback)).toEqual([ + { method: 'list', invocation: { kind: 'direct' } }, + { method: 'put', invocation: { kind: 'direct' } }, + { method: 'delete', invocation: { kind: 'direct' } }, + ]) + }) + + it('returns session-not-found only for a definite persistence miss', async () => { + const { ctx, persistence } = await harness() + const missing = SessionId('missing-session') + await expect(ctx.messageFeedback.list({ sessionId: missing })).resolves.toEqual({ + ok: false, + error: { code: 'session-not-found', sessionId: missing }, + }) + + const fixture = messageFixture('corrupt-session') + persistence.setDurable({ meta: fixture.session.header, events: fixture.session.events }) + const corruption = new Error('stored log checksum mismatch') + persistence.inspectFailure = corruption + await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption) + }) + + it('rechecks live ownership before returning a cold catalog miss', async () => { + const { ctx, persistence } = await harness() + const sessionId = SessionId('catalog-live-race') + const listed = Promise.withResolvers() + const release = Promise.withResolvers() + persistence.onListSnapshots = async () => { + listed.resolve(undefined) + await release.promise + } + + const pending = ctx.messageFeedback.list({ sessionId }) + await listed.promise + ctx.sessions.create(sessionId, { meta: { createdAt: 1_700_000_000_001 } }) + release.resolve(undefined) + + await expect(pending).resolves.toEqual({ ok: true, value: { items: [] } }) + expect(persistence.inspectCalls).toBe(1) + }) + + it('returns session-not-found from mutations and conflicts on an observed version for an absent item', async () => { + const { ctx, persistence } = await harness() + const missing = SessionId('missing-mutations') + const missingMessage = 'missing-message' as MessageId + await expect(ctx.messageFeedback.put({ + sessionId: missing, + messageId: missingMessage, + rating: 'positive', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { code: 'session-not-found', sessionId: missing }, + }) + await expect(ctx.messageFeedback.delete({ + sessionId: missing, + messageId: missingMessage, + ifVersion: staleVersion(), + })).resolves.toEqual({ + ok: false, + error: { code: 'session-not-found', sessionId: missing }, + }) + + const fixture = messageFixture('absent-version-conflict') + persistence.persist(fixture.session) + const expected = staleVersion() + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: expected, + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current: null }, + }) + }) + + it('creates, updates, and retry-reads immutable items with monotonic Host times', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('timestamps') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + + vi.useFakeTimers() + vi.setSystemTime(1_700_000_001_000) + const created = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: ' exact prose ', + ifVersion: null, + })) + expect(created).toMatchObject({ + messageId, + rating: 'positive', + note: ' exact prose ', + createdAt: 1_700_000_001_000, + updatedAt: 1_700_000_001_000, + }) + expect(created.version).toMatch(/^[0-9a-f-]{36}$/u) + expect(Object.isFrozen(created)).toBe(true) + + vi.setSystemTime(1_700_000_000_000) + const updated = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: created.version, + })) + expect(updated).toMatchObject({ + messageId, + rating: 'negative', + createdAt: created.createdAt, + updatedAt: created.updatedAt, + }) + expect(updated.version).not.toBe(created.version) + + const retry = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: updated.version, + })) + expect(retry).toEqual(updated) + + const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id }) + if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`) + expect(listed.value.items).toEqual([updated]) + expect(listed.value.items[0]).not.toBe(updated) + expect(Object.isFrozen(listed.value)).toBe(true) + expect(Object.isFrozen(listed.value.items)).toBe(true) + expect(Object.isFrozen(listed.value.items[0])).toBe(true) + }) + + it('reports non-blank and complete UTF-8 byte limits without touching persistence', async () => { + const { ctx, persistence } = await harness(4) + const fixture = messageFixture('note-limits') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + const before = persistence.inspectCalls + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: ' \n\t ', + ifVersion: null, + })).resolves.toEqual({ ok: false, error: { code: 'note-blank' } }) + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: 'ééé', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { code: 'note-too-large', maxBytes: 4, actualBytes: 6 }, + }) + expect(persistence.inspectCalls).toBe(before) + + expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: '😀', + ifVersion: null, + })) + }) + + it('accepts only non-empty append-origin assistant projections as targets', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('targets') + persistence.persist(fixture.session) + const rejectedTargets: MessageId[] = [ + fixture.userMessageId, + fixture.emptyAssistantMessageId, + fixture.replacementAssistantMessageId, + ] + for (const messageId of rejectedTargets) { + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { + code: 'target-not-found', + sessionId: fixture.session.id, + messageId, + }, + }) + } + expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })) + }) + + it('fails invalid direct configuration and a read before domain initialization', async () => { + const invalidCtx = new Context() + expect(() => new MessageFeedbackService(invalidCtx, { maxNoteBytes: 0 })) + .toThrow(/positive safe integer/u) + await invalidCtx.fiber.dispose() + + const fixture = messageFixture('uninitialized-domain') + const rawCtx = new Context() + rawCtx.provide('sessions', { get: () => undefined } as never) + rawCtx.provide('sessionPersistence', { + listSnapshots: () => Promise.resolve([{ header: fixture.session.header, revision: 'test' }]), + inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.events }), + } as never) + const raw = new MessageFeedbackService(rawCtx, { maxNoteBytes: 1 }) + await expect(raw.list({ sessionId: fixture.session.id })) + .rejects.toThrow(/durable domain is not initialized/u) + await rawCtx.fiber.dispose() + }) + + it('rejects durable rows with duplicate message ids or reused item versions', () => { + const version = staleVersion() + const duplicate = messageFeedbackRowSchema.safeParse({ + session: { createdAt: 1 }, + items: [ + { + messageId: 'same-message', + rating: 'positive', + version, + createdAt: 1, + updatedAt: 1, + }, + { + messageId: 'same-message', + rating: 'negative', + version, + createdAt: 1, + updatedAt: 1, + }, + ], + }) + expect(duplicate.success).toBe(false) + if (duplicate.success) throw new Error('expected duplicate row rejection') + expect(duplicate.error.issues.map(issue => issue.path.join('.'))) + .toEqual(['items.1.messageId', 'items.1.version']) + }) +}) + +describe('MessageFeedbackService item concurrency', () => { + it('serializes whole-row writes while keeping versions independent per message', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('concurrent-items') + persistence.persist(fixture.session) + const [firstId, secondId] = fixture.assistantMessageIds + + const [firstResult, secondResult] = await Promise.all([ + ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: firstId, + rating: 'positive', + ifVersion: null, + }), + ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: secondId, + rating: 'negative', + ifVersion: null, + }), + ]) + const first = expectItem(firstResult) + const second = expectItem(secondResult) + const updated = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: firstId, + rating: 'negative', + note: 'changed', + ifVersion: first.version, + })) + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: firstId, + rating: 'positive', + note: 'stale change', + ifVersion: first.version, + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current: updated }, + }) + + const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id }) + if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`) + expect(listed.value.items).toEqual([updated, second]) + expect(listed.value.items[1]?.version).toBe(second.version) + }) + + it('rejects a stale put even when the current value has returned to the same state', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('put-aba') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + const first = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: null, + })) + const second = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: first.version, + })) + const current = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: second.version, + })) + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: first.version, + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current }, + }) + }) + + it('makes delete retries stable and prevents delete/recreate ABA', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('delete-aba') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + const created = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: null, + })) + + await expect(ctx.messageFeedback.delete({ + sessionId: fixture.session.id, + messageId, + ifVersion: staleVersion(), + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current: created }, + }) + const request = { + sessionId: fixture.session.id, + messageId, + ifVersion: created.version, + } + await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({ + ok: true, + value: { absent: true }, + }) + await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({ + ok: true, + value: { absent: true }, + }) + + const recreated = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: null, + })) + expect(recreated.version).not.toBe(created.version) + await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current: recreated }, + }) + }) + + it('fences a reused Session id and lets the new lifecycle start cleanly', async () => { + const { ctx, persistence } = await harness() + const old = messageFixture('reused-session', { createdAt: 10, cwd: '/old' }) + persistence.persist(old.session) + const oldItem = expectItem(await ctx.messageFeedback.put({ + sessionId: old.session.id, + messageId: old.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })) + + const replacement = Session.create( + old.session.id, + old.session.events, + { ...old.session.header, createdAt: 20, cwd: '/new' }, + ) + persistence.persist(replacement) + await expect(ctx.messageFeedback.list({ sessionId: replacement.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + await expect(ctx.messageFeedback.delete({ + sessionId: replacement.id, + messageId: old.assistantMessageIds[0], + ifVersion: oldItem.version, + })).resolves.toEqual({ ok: true, value: { absent: true } }) + + const newItem = expectItem(await ctx.messageFeedback.put({ + sessionId: replacement.id, + messageId: old.assistantMessageIds[0], + rating: 'negative', + ifVersion: null, + })) + expect(newItem.version).not.toBe(oldItem.version) + }) + + it('drains admitted mutations before domain close and rejects later admission', async () => { + const current = await harness() + const { ctx, persistence } = current + const fixture = messageFixture('dispose-quiescence') + persistence.persist(fixture.session) + const service = ctx.messageFeedback + const lifecycle = service as unknown as { readonly mutationAdmissionOpen: boolean } + const started = Promise.withResolvers() + const release = Promise.withResolvers() + let physicalReads = 0 + let committed = 0 + persistence.onReadFrom = async () => { + physicalReads += 1 + if (physicalReads !== 1) return + started.resolve(undefined) + await release.promise + } + ctx.on('domain/changed', (change) => { + if (change.domain === 'message_feedback') committed += 1 + }) + + const first = service.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + }) + await started.promise + const second = service.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[1], + rating: 'negative', + ifVersion: null, + }) + const disposal = current.disposeFeedback() + await vi.waitFor(() => { expect(lifecycle.mutationAdmissionOpen).toBe(false) }) + + await expect(service.delete({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + ifVersion: staleVersion(), + })).rejects.toThrow('message-feedback: service is disposing') + release.resolve(undefined) + + expectItem(await first) + expectItem(await second) + await disposal + expect(physicalReads).toBe(2) + expect(committed).toBe(2) + }) +}) + +describe('MessageFeedbackService durability ordering', () => { + it('rejects a logical target missing from the cold physical durable prefix', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('cold-prefix') + persistence.logical.set(fixture.session.id, { + meta: fixture.session.header, + events: fixture.session.events, + }) + persistence.setDurable({ meta: fixture.session.header, events: [] }) + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { + code: 'target-not-found', + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + }, + }) + expect(persistence.readFromCalls).toBe(1) + await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + }) + + it('commits and physically verifies a live target checkpoint before the sidecar write', async () => { + const { ctx, persistence } = await harness() + const session = ctx.sessions.create(SessionId('live-checkpoint'), { + meta: { createdAt: 30, cwd: '/live' }, + }) + const fixture = appendMessageFixture(session) + const order: string[] = [] + ctx.on('session/flush', (current) => { + order.push('session:durable') + persistence.persist(current) + }) + ctx.on('domain/changed', (change) => { + if (change.domain === 'message_feedback') order.push('sidecar:durable') + }) + persistence.onReadFrom = () => { order.push('session:verified') } + + expectItem(await ctx.messageFeedback.put({ + sessionId: session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })) + expect(order).toEqual(['session:durable', 'session:verified', 'sidecar:durable']) + expect(persistence.readFromCalls).toBe(1) + expect(persistence.durable.get(session.id)?.events).toContainEqual( + expect.objectContaining({ type: 'assistant/message' }), + ) + }) + + it('fails closed when a live checkpoint fails, has no participant, or is not physically durable', async () => { + const failed = await harness() + const failedSession = failed.ctx.sessions.create(SessionId('live-flush-failure')) + const failedFixture = appendMessageFixture(failedSession) + const diskFailure = new Error('disk unavailable') + failed.ctx.on('session/flush', () => { throw diskFailure }) + await expect(failed.ctx.messageFeedback.put({ + sessionId: failedSession.id, + messageId: failedFixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).rejects.toBe(diskFailure) + await expect(failed.ctx.messageFeedback.list({ sessionId: failedSession.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + + const absent = await harness() + const absentSession = absent.ctx.sessions.create(SessionId('live-no-flush')) + const absentFixture = appendMessageFixture(absentSession) + await expect(absent.ctx.messageFeedback.put({ + sessionId: absentSession.id, + messageId: absentFixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).rejects.toThrow(/no durability listener participated/u) + await expect(absent.ctx.messageFeedback.list({ sessionId: absentSession.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + + const noDurability = await harness() + const unpersistedSession = noDurability.ctx.sessions.create(SessionId('live-unpersisted')) + const unpersistedFixture = appendMessageFixture(unpersistedSession) + noDurability.ctx.on('session/flush', () => {}) + await expect(noDurability.ctx.messageFeedback.put({ + sessionId: unpersistedSession.id, + messageId: unpersistedFixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).rejects.toThrow(/not found/u) + expect(noDurability.persistence.durable.has(unpersistedSession.id)).toBe(false) + await expect(noDurability.ctx.messageFeedback.list({ sessionId: unpersistedSession.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + }) + + it('finishes the captured live checkpoint when the Session detaches mid-flush', async () => { + const { ctx, persistence } = await harness() + const session = ctx.sessions.prepare(SessionId('detach-during-flush'), { + meta: { createdAt: 40, cwd: '/detach' }, + }) + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + const fixture = appendMessageFixture(session) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('session/flush', async (current) => { + started.resolve(undefined) + await release.promise + persistence.persist(current) + }) + + const pending = ctx.messageFeedback.put({ + sessionId: session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + }) + await started.promise + detach() + expect(ctx.sessions.get(session.id)).toBeUndefined() + release.resolve(undefined) + expectItem(await pending) + expect(persistence.readFromCalls).toBe(1) + await expect(ctx.messageFeedback.list({ sessionId: session.id })).resolves.toMatchObject({ + ok: true, + value: { items: [{ messageId: fixture.assistantMessageIds[0] }] }, + }) + }) +}) diff --git a/packages/feedback/message-feedback/tsconfig.json b/packages/feedback/message-feedback/tsconfig.json new file mode 100644 index 0000000000..e406c4c00e --- /dev/null +++ b/packages/feedback/message-feedback/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session/session-persistence" + }, + { + "path": "../../storage/storage" + }, + { + "path": "../../storage/storage-domain" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 3052b6895d..732ab3fa00 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -586,6 +586,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'messageFeedback', + summary: 'Storage-domain sidecar service.', + methods: [ + { + signature: '@Remote(\'list\') async list(request: MessageFeedbackListRequest): Promise', + jsDoc: '/**\n * Read feedback belonging to the current persisted Session lifecycle.\n * A stale row from a reused Session id is invisible.\n * @param request - Session identity to inspect and list.\n * @returns current immutable items or `session-not-found`.\n */', + }, + { + signature: '@Remote(\'put\') put(request: MessageFeedbackPutRequest): Promise', + jsDoc: '/**\n * Create or replace feedback for one derived append-origin assistant\n * message. Every request must match the addressed item\'s current version;\n * a matching no-op returns the stored item without changing its revision.\n * @param request - target, desired value, and observed item version.\n * @returns the committed item or an explicit business failure.\n */', + }, + { + signature: '@Remote(\'delete\') delete(request: MessageFeedbackDeleteRequest): Promise', + jsDoc: '/**\n * Delete one feedback item. Absence is successful regardless of the\n * supplied version; an existing item requires an exact version match.\n * @param request - Session, message, and observed item version.\n * @returns the stable absent postcondition, or an explicit failure.\n */', + }, + ], + }, { key: 'permission', summary: 'Owns the deployment\'s permission presets and their write path.', @@ -2335,6 +2353,82 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Message', declaration: 'export interface Message {\n readonly id: MessageId;\n readonly role: \'system\' | \'user\' | \'assistant\';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n}', }, + { + name: 'MessageFeedbackDeleteRequest', + declaration: 'export interface MessageFeedbackDeleteRequest {\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly ifVersion: MessageFeedbackVersion;\n}', + }, + { + name: 'MessageFeedbackDeleteResult', + declaration: 'export type MessageFeedbackDeleteResult = MessageFeedbackSuccess | MessageFeedbackRejected;', + }, + { + name: 'MessageFeedbackDeleteValue', + declaration: 'export interface MessageFeedbackDeleteValue {\n readonly absent: true;\n}', + }, + { + name: 'MessageFeedbackFailure', + declaration: 'export type MessageFeedbackFailure = MessageFeedbackSessionNotFound | MessageFeedbackTargetNotFound | MessageFeedbackVersionConflict | MessageFeedbackNoteBlank | MessageFeedbackNoteTooLarge;', + }, + { + name: 'MessageFeedbackItem', + declaration: 'export interface MessageFeedbackItem {\n readonly messageId: MessageId;\n readonly rating: MessageFeedbackRating;\n readonly note?: string;\n readonly version: MessageFeedbackVersion;\n readonly createdAt: number;\n readonly updatedAt: number;\n}', + }, + { + name: 'MessageFeedbackListRequest', + declaration: 'export interface MessageFeedbackListRequest {\n readonly sessionId: SessionId;\n}', + }, + { + name: 'MessageFeedbackListResult', + declaration: 'export type MessageFeedbackListResult = MessageFeedbackSuccess | MessageFeedbackRejected;', + }, + { + name: 'MessageFeedbackListValue', + declaration: 'export interface MessageFeedbackListValue {\n readonly items: readonly MessageFeedbackItem[];\n}', + }, + { + name: 'MessageFeedbackNoteBlank', + declaration: 'export interface MessageFeedbackNoteBlank {\n readonly code: \'note-blank\';\n}', + }, + { + name: 'MessageFeedbackNoteTooLarge', + declaration: 'export interface MessageFeedbackNoteTooLarge {\n readonly code: \'note-too-large\';\n readonly maxBytes: number;\n readonly actualBytes: number;\n}', + }, + { + name: 'MessageFeedbackPutRequest', + declaration: 'export interface MessageFeedbackPutRequest {\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly rating: MessageFeedbackRating;\n readonly note?: string;\n readonly ifVersion: MessageFeedbackVersion | null;\n}', + }, + { + name: 'MessageFeedbackPutResult', + declaration: 'export type MessageFeedbackPutResult = MessageFeedbackSuccess | MessageFeedbackRejected;', + }, + { + name: 'MessageFeedbackRating', + declaration: 'export type MessageFeedbackRating = \'positive\' | \'negative\';', + }, + { + name: 'MessageFeedbackRejected', + declaration: 'export interface MessageFeedbackRejected {\n readonly ok: false;\n readonly error: E;\n}', + }, + { + name: 'MessageFeedbackSessionNotFound', + declaration: 'export interface MessageFeedbackSessionNotFound {\n readonly code: \'session-not-found\';\n readonly sessionId: SessionId;\n}', + }, + { + name: 'MessageFeedbackSuccess', + declaration: 'export interface MessageFeedbackSuccess {\n readonly ok: true;\n readonly value: T;\n}', + }, + { + name: 'MessageFeedbackTargetNotFound', + declaration: 'export interface MessageFeedbackTargetNotFound {\n readonly code: \'target-not-found\';\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n}', + }, + { + name: 'MessageFeedbackVersion', + declaration: 'export type MessageFeedbackVersion = Branded<\'MessageFeedbackVersion\'>;', + }, + { + name: 'MessageFeedbackVersionConflict', + declaration: 'export interface MessageFeedbackVersionConflict {\n readonly code: \'version-conflict\';\n readonly current: MessageFeedbackItem | null;\n}', + }, { name: 'MessageId', declaration: 'export type MessageId = Branded<\'MessageId\'>;', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 495beb9e8b..a787f4c68e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1608,6 +1608,9 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver + '@deepseek-ai/dsh-message-feedback': + specifier: workspace:^ + version: link:../../feedback/message-feedback '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session/session-projection-cache @@ -3829,6 +3832,55 @@ importers: specifier: workspace:^ version: link:../../session/user-id + packages/feedback/message-feedback: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:^ + version: link:../../storage/storage-json + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + packages/fs/fs: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b6a315d7ad..a32117bab8 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -63,6 +63,7 @@ export const SERVICE_PAGE: Record = { httpServer: 'http-server.md', invariants: 'invariants.md', llm: 'llm-streaming.md', + messageFeedback: 'feedback.md', permission: 'permission.md', planMode: 'plan.md', pty: 'pty.md', @@ -229,6 +230,25 @@ export const LINK_MAP: Readonly> = { ResolvedRetryPolicy: 'llm-streaming.md', Message: 'llm-streaming.md', MessageSource: 'llm-streaming.md', + MessageFeedbackDeleteRequest: 'feedback.md', + MessageFeedbackDeleteResult: 'feedback.md', + MessageFeedbackDeleteValue: 'feedback.md', + MessageFeedbackFailure: 'feedback.md', + MessageFeedbackItem: 'feedback.md', + MessageFeedbackListRequest: 'feedback.md', + MessageFeedbackListResult: 'feedback.md', + MessageFeedbackListValue: 'feedback.md', + MessageFeedbackNoteBlank: 'feedback.md', + MessageFeedbackNoteTooLarge: 'feedback.md', + MessageFeedbackPutRequest: 'feedback.md', + MessageFeedbackPutResult: 'feedback.md', + MessageFeedbackRating: 'feedback.md', + MessageFeedbackRejected: 'feedback.md', + MessageFeedbackSessionNotFound: 'feedback.md', + MessageFeedbackSuccess: 'feedback.md', + MessageFeedbackTargetNotFound: 'feedback.md', + MessageFeedbackVersion: 'feedback.md', + MessageFeedbackVersionConflict: 'feedback.md', UserMessage: 'session.md', PreStepDecision: 'core.md', PreStepContext: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 699bcae707..5b950782b1 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -135,7 +135,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session', title: 'In-memory session store', mode: 'core', - consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'], + consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants', 'message-feedback'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, { @@ -167,7 +167,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Durable session persistence seam', mode: 'seam', implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'], - consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'], + consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, { @@ -211,9 +211,16 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'storage-domain', title: 'Domain data facility', mode: 'core', - consumers: ['workspace'], + consumers: ['workspace', 'message-feedback'], note: 'Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state.', }, + { + key: 'messageFeedback', + pkg: 'message-feedback', + title: 'Lifecycle-bound message feedback', + mode: 'core', + note: 'Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry.', + }, { key: 'workspace', pkg: 'workspace', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index c1b1b2753a..3c6ef4e192 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -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" } ] } diff --git a/tsconfig.host.json b/tsconfig.host.json index 690656e150..2beed0b1c3 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -14,6 +14,7 @@ "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", "apps/web/tests/minimal-preset.snapshot.ts", + "apps/web/tests/message-feedback-protocol.snapshot.ts", "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", "apps/web/tests/approval-composer.e2e.ts", @@ -135,6 +136,7 @@ { "path": "./packages/storage/storage-json" }, { "path": "./packages/storage/storage-sqlite" }, { "path": "./packages/storage/storage-domain" }, + { "path": "./packages/feedback/message-feedback" }, { "path": "./packages/workspace/workspace" }, { "path": "./packages/session/session-title" }, { "path": "./packages/session/session-title-llm" },