/** * Canonical Session-log feedback for finalized assistant messages. * @module @deepseek-ai/dsh-message-feedback */ import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' import { isDeepStrictEqual } from 'node:util' import { Context, Service } from '@deepseek-ai/cordis' import s from '@deepseek-ai/schemastery' import { z } from 'zod' import { FEEDBACK_CATEGORIES } from '@deepseek-ai/dsh-command-feedback' import { SessionSeq } from '@deepseek-ai/dsh-session/types' import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import type { MessageFeedbackDeleteRequest, MessageFeedbackDeleteResult, MessageFeedbackFailure, MessageFeedbackItem, MessageFeedbackListRequest, MessageFeedbackListResult, MessageFeedbackNoteBlank, MessageFeedbackNoteTooLarge, MessageFeedbackPutRequest, MessageFeedbackPutResult, MessageFeedbackRejected, MessageFeedbackSessionNotFound, MessageFeedbackSuccess, MessageFeedbackVersion, MessageFeedbackVersionConflict, } from './types.ts' export type * from './types.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 } interface Events { /** * Observe a durable cold feedback mutation without publishing a live Session. * Observers run before write ownership is released and must not await * another message-feedback operation for this Session. The payload is borrowed * read-only; deep-clone it before transferring ownership (for example, to Session.fromRestore). * @param inspection - committed canonical prefix, including the feedback as its last event. * @mode parallel */ 'feedback/committed'(inspection: SessionInspection): void } } const timestamp = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) const itemSchema = z.object({ messageId: z.string().min(1), rating: z.enum(['positive', 'negative']), note: z.string().refine(note => note.trim().length > 0).optional(), category: z.enum(FEEDBACK_CATEGORIES).optional(), version: z.uuid(), createdAt: timestamp, updatedAt: timestamp, }).refine(item => item.updatedAt >= item.createdAt) const putSchema = z.object({ sessionId: z.string().min(1), item: itemSchema }) const deleteSchema = z.object({ sessionId: z.string().min(1), messageId: z.string().min(1) }) type FeedbackEvent = SessionEvent<'feedback/message-put' | 'feedback/message-delete'> type Mutation = Pick, 'type' | 'data'> | Pick, 'type' | 'data'> type Append = (event?: Mutation) => Promise type MissingSession = MessageFeedbackRejected type ResolvedNote = MessageFeedbackSuccess | MessageFeedbackRejected /** Return a caller-owned immutable value, detached from the log. */ function snapshotItem(item: MessageFeedbackItem): MessageFeedbackItem { return Object.freeze({ ...item }) } function success(value: T): MessageFeedbackSuccess { return Object.freeze({ ok: true, value }) } function rejected(error: E): MessageFeedbackRejected { return Object.freeze({ ok: false, error: Object.freeze(error) }) } /** Validate persisted payloads before deriving current, Session-owned feedback. */ function currentItems(sessionId: SessionId, events: readonly SessionEvent[]): MessageFeedbackItem[] { const items = new Map() for (const event of events) { switch (event.type) { case 'feedback/message-put': putSchema.parse(event.data) if (event.data.sessionId === sessionId) items.set(event.data.item.messageId, event.data.item) break case 'feedback/message-delete': deleteSchema.parse(event.data) if (event.data.sessionId === sessionId) items.delete(event.data.messageId) break default: // Other plugins' events do not change message feedback. break } } return [...items.values()] } /** Session-log service; cold operations never construct a Session or Agent. */ export class MessageFeedbackService extends TypertRemoteService { static inject = ['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 readonly operationTails = new Map>() private mutationAdmissionOpen = true /** * @param ctx - Host context carrying Session persistence and live owners. * @param config - Required note-size policy. */ constructor(ctx: Context, config: Config) { super(ctx, 'messageFeedback') if (!Number.isSafeInteger(config.maxNoteBytes) || config.maxNoteBytes < 1) { throw new TypeError('message-feedback: maxNoteBytes must be a positive safe integer') } this.maxNoteBytes = config.maxNoteBytes } protected [Service.init](): void { this.ctx.effect(() => async () => { this.mutationAdmissionOpen = false await Promise.all(this.operationTails.values()) }, 'message-feedback.drain') } /** * Read current feedback from the canonical log. * @param request - Session to inspect. * @returns immutable items or a definite persistence miss. */ @Remote('list') list(request: MessageFeedbackListRequest): Promise { return this.enqueue(request.sessionId, () => this.withSession(request.sessionId, false, events => success(Object.freeze({ items: Object.freeze(currentItems(request.sessionId, events).map(snapshotItem)) })))) } /** * Create or replace feedback after checking its current version. * Matching no-ops retain the version and append no event. * @param request - Target, desired value, and observed item version. * @returns the durable 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, () => this.withSession(request.sessionId, true, async (events, append) => { const items = currentItems(request.sessionId, events) if (!events.some(event => event.type === 'assistant/message' && isAppendSurfaceEvent(event) && deriveEventMessage(event)?.id === request.messageId)) { return rejected({ code: 'target-not-found', sessionId: request.sessionId, messageId: request.messageId }) } const existing = items.find(item => item.messageId === request.messageId) if (request.ifVersion !== (existing?.version ?? null)) { return rejected(this.versionConflict(existing ?? null)) } if (existing !== undefined && existing.rating === request.rating && existing.note === note.value && existing.category === request.category) { await append() return success(snapshotItem(existing)) } const now = Date.now() const item: MessageFeedbackItem = { messageId: request.messageId, rating: request.rating, ...(note.value === undefined ? {} : { note: note.value }), ...(request.category === undefined ? {} : { category: request.category }), version: randomUUID() as MessageFeedbackVersion, createdAt: existing?.createdAt ?? now, updatedAt: existing === undefined ? now : Math.max(now, existing.updatedAt), } await append({ type: 'feedback/message-put', data: { sessionId: request.sessionId, item } }) return success(snapshotItem(item)) })) } /** * Delete one item after checking its version; absence succeeds without an event. * @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, () => this.withSession(request.sessionId, true, async (events, append) => { const existing = currentItems(request.sessionId, events).find(item => item.messageId === request.messageId) if (existing !== undefined) { if (request.ifVersion !== existing.version) return rejected(this.versionConflict(existing)) await append({ type: 'feedback/message-delete', data: { sessionId: request.sessionId, messageId: request.messageId } }) } else { await append() } return success(Object.freeze({ absent: true as const })) })) } /** Hold cold write ownership across read/compare/append; use live owners directly. */ private async withSession( sessionId: SessionId, write: boolean, operation: (events: readonly SessionEvent[], append: Append) => T | Promise, ): Promise { if (this.ctx.sessions.get(sessionId) === undefined && await this.ctx.sessionPersistence.stat(sessionId) === undefined && this.ctx.sessions.get(sessionId) === undefined) { return rejected({ code: 'session-not-found', sessionId }) } const live = this.ctx.sessions.get(sessionId) if (live !== undefined) { // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return operation(live.snapshotEvents(), async (event) => { if (event !== undefined) { live.append(event.type, event.data) } // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const last = live.snapshotEvents().at(-1) if (!(await this.ctx.sessions.flush(live))) { throw new Error( `message-feedback: no durability listener participated for live session '${sessionId}'`, ) } // Listener participation alone does not prove this Session has a persistence writer. const handle = await this.ctx.sessionPersistence.open(sessionId, 'read') try { const { events: stored } = await handle.read(last?.seq ?? 0, 1) if (!isDeepStrictEqual( [handle.header.id, handle.header.createdAt, handle.header.cwd], [live.header.id, live.header.createdAt, live.header.cwd], ) || (last !== undefined && !isDeepStrictEqual(stored[0], last))) { throw new Error(`message-feedback: feedback prefix is not durable for live session '${sessionId}'`) } } finally { await handle.close() } }) } const handle = await this.ctx.sessionPersistence.open(sessionId, write ? 'write' : 'read') try { const { events } = await handle.read() return await operation(events, async (event) => { const entry: FeedbackEvent | undefined = event === undefined ? undefined : { ...event, seq: SessionSeq(events.length), time: Date.now() } if (entry !== undefined) await handle.append([entry]) await handle.flush() if (entry !== undefined) { try { await this.ctx.parallel('feedback/committed', { meta: handle.header, inheritedEventCount: handle.inheritedEventCount, events: [...events, entry], }) } catch (error) { this.ctx.logger.warn('message-feedback: committed feedback observer failed', error) } } }) } finally { await handle.close() } } 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) } private versionConflict(current: MessageFeedbackItem | null): MessageFeedbackVersionConflict { return { code: 'version-conflict', current: current === null ? null : snapshotItem(current) } } /** Serialize complete operations and drain their handles before disposal. */ 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) }) } } export default MessageFeedbackService