feat(feedback): add durable message feedback backend
This commit is contained in:
206
packages/feedback/message-feedback/tests/helpers.ts
Normal file
206
packages/feedback/message-feedback/tests/helpers.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import SessionStore, {
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionId,
|
||||
type SessionEvent,
|
||||
type SessionHeader,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence, {
|
||||
SessionPersistenceRevision,
|
||||
type SessionInspection,
|
||||
type SessionLocation,
|
||||
type SessionPersistenceSnapshot,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
|
||||
import * as StorageJson from '@deepseek-ai/dsh-storage-json'
|
||||
import MessageFeedbackService from '../src/index.ts'
|
||||
|
||||
export interface MessageFixture {
|
||||
readonly session: Session
|
||||
readonly userMessageId: MessageId
|
||||
readonly assistantMessageIds: readonly [MessageId, MessageId]
|
||||
readonly emptyAssistantMessageId: MessageId
|
||||
readonly replacementAssistantMessageId: MessageId
|
||||
}
|
||||
|
||||
/** Append one deterministic transcript surface used by target-validation tests. */
|
||||
export function appendMessageFixture(session: Session): Omit<MessageFixture, 'session'> {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const user = createUserMessage({
|
||||
content: [{ type: 'text', text: 'Question' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
session.append('user/message', user, { surfaceOp: 'append' })
|
||||
|
||||
const first = createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'First answer' }],
|
||||
source: { provider: 'test', model: 'test' },
|
||||
})
|
||||
const firstEvent = session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: first,
|
||||
}, { surfaceOp: 'append' })
|
||||
const second = createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'Second answer' }],
|
||||
source: { provider: 'test', model: 'test' },
|
||||
})
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: second,
|
||||
}, { surfaceOp: 'append' })
|
||||
const empty = createAssistantMessage({
|
||||
content: [],
|
||||
source: { provider: 'test', model: 'test' },
|
||||
})
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: empty,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const replacement = createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'Model-only replacement' }],
|
||||
source: { provider: 'test', model: 'test' },
|
||||
})
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: replacement,
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: firstEvent.seq, end: firstEvent.seq },
|
||||
sourceEventSeqs: [firstEvent.seq],
|
||||
})
|
||||
|
||||
return {
|
||||
userMessageId: user.id,
|
||||
assistantMessageIds: [first.id, second.id],
|
||||
emptyAssistantMessageId: empty.id,
|
||||
replacementAssistantMessageId: replacement.id,
|
||||
}
|
||||
}
|
||||
|
||||
/** Construct one cold persistence fixture without publishing a live Session. */
|
||||
export function messageFixture(
|
||||
rawId: string,
|
||||
options: { readonly createdAt?: number; readonly cwd?: string } = {},
|
||||
): MessageFixture {
|
||||
const id = SessionId(rawId)
|
||||
const header: SessionHeader = {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id,
|
||||
createdAt: options.createdAt ?? 1_700_000_000_000,
|
||||
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
||||
}
|
||||
const session = Session.create(id, [], header)
|
||||
return { session, ...appendMessageFixture(session) }
|
||||
}
|
||||
|
||||
/** Minimal controllable persistence provider for service-level tests. */
|
||||
class TestPersistence extends SessionPersistence {
|
||||
static inject = ['sessions']
|
||||
|
||||
readonly durable = new Map<SessionId, SessionInspection>()
|
||||
readonly logical = new Map<SessionId, SessionInspection>()
|
||||
inspectFailure: Error | undefined
|
||||
inspectCalls = 0
|
||||
readFromCalls = 0
|
||||
onReadFrom: (() => void) | undefined
|
||||
|
||||
locate(_meta: SessionHeader): SessionLocation | undefined { return undefined }
|
||||
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
|
||||
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
|
||||
|
||||
load(id: SessionId): Promise<SessionInspection> {
|
||||
return this.readFrom(id, 0)
|
||||
}
|
||||
|
||||
inspect(id: SessionId): Promise<SessionInspection> {
|
||||
this.inspectCalls += 1
|
||||
if (this.inspectFailure !== undefined) return Promise.reject(this.inspectFailure)
|
||||
const explicit = this.logical.get(id)
|
||||
if (explicit !== undefined) return Promise.resolve(explicit)
|
||||
const live = this.ctx.sessions.get(id)
|
||||
if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.events })
|
||||
const stored = this.durable.get(id)
|
||||
return stored === undefined
|
||||
? Promise.reject(new Error(`test persistence: session '${id}' not found`))
|
||||
: Promise.resolve(stored)
|
||||
}
|
||||
|
||||
readFrom(
|
||||
id: SessionId,
|
||||
fromSeq: number,
|
||||
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
this.readFromCalls += 1
|
||||
this.onReadFrom?.()
|
||||
const stored = this.durable.get(id)
|
||||
return stored === undefined
|
||||
? Promise.reject(new Error(`test persistence: session '${id}' not found`))
|
||||
: Promise.resolve({ meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) })
|
||||
}
|
||||
|
||||
list(): Promise<SessionHeader[]> {
|
||||
return Promise.resolve([...this.durable.values()].map(value => value.meta))
|
||||
}
|
||||
|
||||
listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
|
||||
return Promise.resolve([...this.durable.values()].map((value, index) => ({
|
||||
header: value.meta,
|
||||
revision: SessionPersistenceRevision(`test:${index}:${value.events.length}`),
|
||||
})))
|
||||
}
|
||||
|
||||
persist(session: Session): void {
|
||||
this.durable.set(session.id, { meta: session.header, events: session.events })
|
||||
}
|
||||
|
||||
setDurable(inspection: SessionInspection): void {
|
||||
this.durable.set(inspection.meta.id, inspection)
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestHarness {
|
||||
readonly ctx: Context
|
||||
readonly persistence: TestPersistence
|
||||
readonly root: string
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/** Compose the service over the real storage hub/domain/JSON backend. */
|
||||
export async function setupHarness(maxNoteBytes = 64): Promise<TestHarness> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-test-'))
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(TestPersistence)
|
||||
await ctx.plugin(Storage)
|
||||
await ctx.plugin(StorageJson, { root })
|
||||
await ctx.plugin(StorageDomain, { backend: 'json' })
|
||||
await ctx.plugin(MessageFeedbackService, { maxNoteBytes })
|
||||
} catch (error) {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
ctx,
|
||||
persistence: ctx.sessionPersistence as unknown as TestPersistence,
|
||||
root,
|
||||
async dispose() {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
|
||||
import * as StorageJson from '@deepseek-ai/dsh-storage-json'
|
||||
import { remoteMethods } from '@deepseek-ai/dsh-type-meta'
|
||||
import MessageFeedbackService from '../src/index.ts'
|
||||
import { appendMessageFixture } from './helpers.ts'
|
||||
|
||||
let root: string | undefined
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
async function loadComposition(configPath: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
ctx.baseUrl = pathToFileURL(root as string).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-session', SessionStore],
|
||||
['@deepseek-ai/dsh-session-persistence-jsonl', SessionPersistenceJsonl],
|
||||
['@deepseek-ai/dsh-storage', Storage],
|
||||
['@deepseek-ai/dsh-storage-json', StorageJson],
|
||||
['@deepseek-ai/dsh-storage-domain', StorageDomain],
|
||||
['@deepseek-ai/dsh-message-feedback', MessageFeedbackService],
|
||||
])
|
||||
ctx.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof ctx.loader.internal>
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
const unloaded = [...ctx.loader.entries()]
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
.map(entry => entry.options.name)
|
||||
expect(unloaded).toEqual([])
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('message feedback through a real Loader composition', () => {
|
||||
it('persists a checkpointed target and its sidecar across a cold restart', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-session'",
|
||||
"- name: '@deepseek-ai/dsh-session-persistence-jsonl'",
|
||||
' config:',
|
||||
` root: ${JSON.stringify(join(root, 'sessions'))}`,
|
||||
' compression: none',
|
||||
' writeBatchMaxDelayMs: 1',
|
||||
"- name: '@deepseek-ai/dsh-storage'",
|
||||
"- name: '@deepseek-ai/dsh-storage-json'",
|
||||
' config:',
|
||||
` root: ${JSON.stringify(join(root, 'storage'))}`,
|
||||
"- name: '@deepseek-ai/dsh-storage-domain'",
|
||||
' config:',
|
||||
' backend: json',
|
||||
"- name: '@deepseek-ai/dsh-message-feedback'",
|
||||
' config:',
|
||||
' maxNoteBytes: 32',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const first = await loadComposition(configPath)
|
||||
expect(first.messageFeedback.typertGateway.namespace).toBe('messageFeedback')
|
||||
expect(remoteMethods(first.messageFeedback).map(marker => marker.method))
|
||||
.toEqual(['list', 'put', 'delete'])
|
||||
|
||||
const session = first.sessions.create(SessionId('loader-feedback'), {
|
||||
meta: { cwd: root },
|
||||
})
|
||||
const fixture = appendMessageFixture(session)
|
||||
const put = await first.messageFeedback.put({
|
||||
sessionId: session.id,
|
||||
messageId: fixture.assistantMessageIds[0],
|
||||
rating: 'positive',
|
||||
note: 'survives restart',
|
||||
ifVersion: null,
|
||||
})
|
||||
if (!put.ok) throw new Error(`expected put success, got ${put.error.code}`)
|
||||
const durable = await first.sessionPersistence.readFrom(session.id, 0)
|
||||
expect(durable.events.some(event =>
|
||||
event.type === 'assistant/message'
|
||||
&& event.data.message.id === fixture.assistantMessageIds[0])).toBe(true)
|
||||
|
||||
await first.fiber.dispose()
|
||||
contexts.splice(contexts.indexOf(first), 1)
|
||||
|
||||
const second = await loadComposition(configPath)
|
||||
await expect(second.messageFeedback.list({ sessionId: session.id })).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { items: [put.value] },
|
||||
})
|
||||
expect(second.sessions.get(session.id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,546 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { remoteMethods } from '@deepseek-ai/dsh-type-meta'
|
||||
import MessageFeedbackService, { messageFeedbackRowSchema } from '../src/index.ts'
|
||||
import type {
|
||||
MessageFeedbackItem,
|
||||
MessageFeedbackVersion,
|
||||
} from '../src/index.ts'
|
||||
import {
|
||||
appendMessageFixture,
|
||||
messageFixture,
|
||||
setupHarness,
|
||||
type TestHarness,
|
||||
} from './helpers.ts'
|
||||
|
||||
const harnesses: TestHarness[] = []
|
||||
|
||||
async function harness(maxNoteBytes = 64): Promise<TestHarness> {
|
||||
const value = await setupHarness(maxNoteBytes)
|
||||
harnesses.push(value)
|
||||
return value
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await Promise.all(harnesses.splice(0).map(value => value.dispose()))
|
||||
})
|
||||
|
||||
function staleVersion(): MessageFeedbackVersion {
|
||||
return randomUUID() as MessageFeedbackVersion
|
||||
}
|
||||
|
||||
function expectItem(
|
||||
result: Awaited<ReturnType<TestHarness['ctx']['messageFeedback']['put']>>,
|
||||
): MessageFeedbackItem {
|
||||
if (!result.ok) throw new Error(`expected feedback item, got ${result.error.code}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
describe('MessageFeedbackService public contract', () => {
|
||||
it('publishes the exact Gateway namespace and Remote method names', async () => {
|
||||
const { ctx } = await harness()
|
||||
const binding = ctx.messageFeedback.typertGateway
|
||||
expect(binding.serviceKey).toBe('messageFeedback')
|
||||
expect(binding.namespace).toBe('messageFeedback')
|
||||
expect(remoteMethods(ctx.messageFeedback)).toEqual([
|
||||
{ method: 'list', invocation: { kind: 'direct' } },
|
||||
{ method: 'put', invocation: { kind: 'direct' } },
|
||||
{ method: 'delete', invocation: { kind: 'direct' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns session-not-found only for a definite persistence miss', async () => {
|
||||
const { ctx, persistence } = await harness()
|
||||
const missing = SessionId('missing-session')
|
||||
await expect(ctx.messageFeedback.list({ sessionId: missing })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: { code: 'session-not-found', sessionId: missing },
|
||||
})
|
||||
|
||||
const fixture = messageFixture('corrupt-session')
|
||||
persistence.setDurable({ meta: fixture.session.header, events: fixture.session.events })
|
||||
const corruption = new Error('stored log checksum mismatch')
|
||||
persistence.inspectFailure = corruption
|
||||
await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption)
|
||||
})
|
||||
|
||||
it('returns session-not-found from mutations and conflicts on an observed version for an absent item', async () => {
|
||||
const { ctx, persistence } = await harness()
|
||||
const missing = SessionId('missing-mutations')
|
||||
const missingMessage = 'missing-message' as MessageId
|
||||
await expect(ctx.messageFeedback.put({
|
||||
sessionId: missing,
|
||||
messageId: missingMessage,
|
||||
rating: 'positive',
|
||||
ifVersion: null,
|
||||
})).resolves.toEqual({
|
||||
ok: false,
|
||||
error: { code: 'session-not-found', sessionId: missing },
|
||||
})
|
||||
await expect(ctx.messageFeedback.delete({
|
||||
sessionId: missing,
|
||||
messageId: missingMessage,
|
||||
ifVersion: staleVersion(),
|
||||
})).resolves.toEqual({
|
||||
ok: false,
|
||||
error: { code: 'session-not-found', sessionId: missing },
|
||||
})
|
||||
|
||||
const fixture = messageFixture('absent-version-conflict')
|
||||
persistence.persist(fixture.session)
|
||||
const expected = staleVersion()
|
||||
await expect(ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId: fixture.assistantMessageIds[0],
|
||||
rating: 'positive',
|
||||
ifVersion: expected,
|
||||
})).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'version-conflict',
|
||||
sessionId: fixture.session.id,
|
||||
messageId: fixture.assistantMessageIds[0],
|
||||
expected,
|
||||
actual: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('creates, updates, and retry-reads immutable items with monotonic Host times', async () => {
|
||||
const { ctx, persistence } = await harness()
|
||||
const fixture = messageFixture('timestamps')
|
||||
persistence.persist(fixture.session)
|
||||
const messageId = fixture.assistantMessageIds[0]
|
||||
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_700_000_001_000)
|
||||
const created = expectItem(await ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId,
|
||||
rating: 'positive',
|
||||
note: ' exact prose ',
|
||||
ifVersion: null,
|
||||
}))
|
||||
expect(created).toMatchObject({
|
||||
messageId,
|
||||
rating: 'positive',
|
||||
note: ' exact prose ',
|
||||
createdAt: 1_700_000_001_000,
|
||||
updatedAt: 1_700_000_001_000,
|
||||
})
|
||||
expect(created.version).toMatch(/^[0-9a-f-]{36}$/u)
|
||||
expect(Object.isFrozen(created)).toBe(true)
|
||||
|
||||
vi.setSystemTime(1_700_000_000_000)
|
||||
const updated = expectItem(await ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId,
|
||||
rating: 'negative',
|
||||
ifVersion: created.version,
|
||||
}))
|
||||
expect(updated).toMatchObject({
|
||||
messageId,
|
||||
rating: 'negative',
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
})
|
||||
expect(updated.version).not.toBe(created.version)
|
||||
|
||||
const retry = expectItem(await ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId,
|
||||
rating: 'negative',
|
||||
ifVersion: null,
|
||||
}))
|
||||
expect(retry).toEqual(updated)
|
||||
|
||||
const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id })
|
||||
if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`)
|
||||
expect(listed.value.items).toEqual([updated])
|
||||
expect(listed.value.items[0]).not.toBe(updated)
|
||||
expect(Object.isFrozen(listed.value)).toBe(true)
|
||||
expect(Object.isFrozen(listed.value.items)).toBe(true)
|
||||
expect(Object.isFrozen(listed.value.items[0])).toBe(true)
|
||||
})
|
||||
|
||||
it('reports non-blank and complete UTF-8 byte limits without touching persistence', async () => {
|
||||
const { ctx, persistence } = await harness(4)
|
||||
const fixture = messageFixture('note-limits')
|
||||
persistence.persist(fixture.session)
|
||||
const messageId = fixture.assistantMessageIds[0]
|
||||
const before = persistence.inspectCalls
|
||||
|
||||
await expect(ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId,
|
||||
rating: 'positive',
|
||||
note: ' \n\t ',
|
||||
ifVersion: null,
|
||||
})).resolves.toEqual({ ok: false, error: { code: 'note-blank' } })
|
||||
await expect(ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId,
|
||||
rating: 'positive',
|
||||
note: 'ééé',
|
||||
ifVersion: null,
|
||||
})).resolves.toEqual({
|
||||
ok: false,
|
||||
error: { code: 'note-too-large', maxBytes: 4, actualBytes: 6 },
|
||||
})
|
||||
expect(persistence.inspectCalls).toBe(before)
|
||||
|
||||
expectItem(await ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId,
|
||||
rating: 'positive',
|
||||
note: '😀',
|
||||
ifVersion: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('accepts only non-empty append-origin assistant projections as targets', async () => {
|
||||
const { ctx, persistence } = await harness()
|
||||
const fixture = messageFixture('targets')
|
||||
persistence.persist(fixture.session)
|
||||
const rejectedTargets: MessageId[] = [
|
||||
fixture.userMessageId,
|
||||
fixture.emptyAssistantMessageId,
|
||||
fixture.replacementAssistantMessageId,
|
||||
]
|
||||
for (const messageId of rejectedTargets) {
|
||||
await expect(ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId,
|
||||
rating: 'positive',
|
||||
ifVersion: null,
|
||||
})).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'target-not-found',
|
||||
sessionId: fixture.session.id,
|
||||
messageId,
|
||||
},
|
||||
})
|
||||
}
|
||||
expectItem(await ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId: fixture.assistantMessageIds[0],
|
||||
rating: 'positive',
|
||||
ifVersion: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('fails invalid direct configuration and a read before domain initialization', async () => {
|
||||
const invalidCtx = new Context()
|
||||
expect(() => new MessageFeedbackService(invalidCtx, { maxNoteBytes: 0 }))
|
||||
.toThrow(/positive safe integer/u)
|
||||
await invalidCtx.fiber.dispose()
|
||||
|
||||
const fixture = messageFixture('uninitialized-domain')
|
||||
const rawCtx = new Context()
|
||||
rawCtx.provide('sessions', { get: () => undefined } as never)
|
||||
rawCtx.provide('sessionPersistence', {
|
||||
listSnapshots: () => Promise.resolve([{ header: fixture.session.header, revision: 'test' }]),
|
||||
inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.events }),
|
||||
} as never)
|
||||
const raw = new MessageFeedbackService(rawCtx, { maxNoteBytes: 1 })
|
||||
await expect(raw.list({ sessionId: fixture.session.id }))
|
||||
.rejects.toThrow(/durable domain is not initialized/u)
|
||||
await rawCtx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects durable rows with duplicate message ids or reused item versions', () => {
|
||||
const version = staleVersion()
|
||||
const duplicate = messageFeedbackRowSchema.safeParse({
|
||||
session: { createdAt: 1 },
|
||||
items: [
|
||||
{
|
||||
messageId: 'same-message',
|
||||
rating: 'positive',
|
||||
version,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
{
|
||||
messageId: 'same-message',
|
||||
rating: 'negative',
|
||||
version,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(duplicate.success).toBe(false)
|
||||
if (duplicate.success) throw new Error('expected duplicate row rejection')
|
||||
expect(duplicate.error.issues.map(issue => issue.path.join('.')))
|
||||
.toEqual(['items.1.messageId', 'items.1.version'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageFeedbackService item concurrency', () => {
|
||||
it('serializes whole-row writes while keeping versions independent per message', async () => {
|
||||
const { ctx, persistence } = await harness()
|
||||
const fixture = messageFixture('concurrent-items')
|
||||
persistence.persist(fixture.session)
|
||||
const [firstId, secondId] = fixture.assistantMessageIds
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId: firstId,
|
||||
rating: 'positive',
|
||||
ifVersion: null,
|
||||
}),
|
||||
ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId: secondId,
|
||||
rating: 'negative',
|
||||
ifVersion: null,
|
||||
}),
|
||||
])
|
||||
const first = expectItem(firstResult)
|
||||
const second = expectItem(secondResult)
|
||||
const updated = expectItem(await ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId: firstId,
|
||||
rating: 'negative',
|
||||
note: 'changed',
|
||||
ifVersion: first.version,
|
||||
}))
|
||||
|
||||
await expect(ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId: firstId,
|
||||
rating: 'positive',
|
||||
note: 'stale change',
|
||||
ifVersion: first.version,
|
||||
})).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'version-conflict',
|
||||
sessionId: fixture.session.id,
|
||||
messageId: firstId,
|
||||
expected: first.version,
|
||||
actual: updated.version,
|
||||
},
|
||||
})
|
||||
|
||||
const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id })
|
||||
if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`)
|
||||
expect(listed.value.items).toEqual([updated, second])
|
||||
expect(listed.value.items[1]?.version).toBe(second.version)
|
||||
})
|
||||
|
||||
it('makes delete retries stable and prevents delete/recreate ABA', async () => {
|
||||
const { ctx, persistence } = await harness()
|
||||
const fixture = messageFixture('delete-aba')
|
||||
persistence.persist(fixture.session)
|
||||
const messageId = fixture.assistantMessageIds[0]
|
||||
const created = expectItem(await ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId,
|
||||
rating: 'positive',
|
||||
ifVersion: null,
|
||||
}))
|
||||
|
||||
await expect(ctx.messageFeedback.delete({
|
||||
sessionId: fixture.session.id,
|
||||
messageId,
|
||||
ifVersion: staleVersion(),
|
||||
})).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'version-conflict', actual: created.version },
|
||||
})
|
||||
const request = {
|
||||
sessionId: fixture.session.id,
|
||||
messageId,
|
||||
ifVersion: created.version,
|
||||
}
|
||||
await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { absent: true },
|
||||
})
|
||||
await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { absent: true },
|
||||
})
|
||||
|
||||
const recreated = expectItem(await ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId,
|
||||
rating: 'negative',
|
||||
ifVersion: null,
|
||||
}))
|
||||
expect(recreated.version).not.toBe(created.version)
|
||||
await expect(ctx.messageFeedback.delete(request)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'version-conflict', actual: recreated.version },
|
||||
})
|
||||
})
|
||||
|
||||
it('fences a reused Session id and lets the new lifecycle start cleanly', async () => {
|
||||
const { ctx, persistence } = await harness()
|
||||
const old = messageFixture('reused-session', { createdAt: 10, cwd: '/old' })
|
||||
persistence.persist(old.session)
|
||||
const oldItem = expectItem(await ctx.messageFeedback.put({
|
||||
sessionId: old.session.id,
|
||||
messageId: old.assistantMessageIds[0],
|
||||
rating: 'positive',
|
||||
ifVersion: null,
|
||||
}))
|
||||
|
||||
const replacement = Session.create(
|
||||
old.session.id,
|
||||
old.session.events,
|
||||
{ ...old.session.header, createdAt: 20, cwd: '/new' },
|
||||
)
|
||||
persistence.persist(replacement)
|
||||
await expect(ctx.messageFeedback.list({ sessionId: replacement.id })).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { items: [] },
|
||||
})
|
||||
await expect(ctx.messageFeedback.delete({
|
||||
sessionId: replacement.id,
|
||||
messageId: old.assistantMessageIds[0],
|
||||
ifVersion: oldItem.version,
|
||||
})).resolves.toEqual({ ok: true, value: { absent: true } })
|
||||
|
||||
const newItem = expectItem(await ctx.messageFeedback.put({
|
||||
sessionId: replacement.id,
|
||||
messageId: old.assistantMessageIds[0],
|
||||
rating: 'negative',
|
||||
ifVersion: null,
|
||||
}))
|
||||
expect(newItem.version).not.toBe(oldItem.version)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageFeedbackService durability ordering', () => {
|
||||
it('rejects a logical target missing from the cold physical durable prefix', async () => {
|
||||
const { ctx, persistence } = await harness()
|
||||
const fixture = messageFixture('cold-prefix')
|
||||
persistence.logical.set(fixture.session.id, {
|
||||
meta: fixture.session.header,
|
||||
events: fixture.session.events,
|
||||
})
|
||||
persistence.setDurable({ meta: fixture.session.header, events: [] })
|
||||
|
||||
await expect(ctx.messageFeedback.put({
|
||||
sessionId: fixture.session.id,
|
||||
messageId: fixture.assistantMessageIds[0],
|
||||
rating: 'positive',
|
||||
ifVersion: null,
|
||||
})).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'target-not-found',
|
||||
sessionId: fixture.session.id,
|
||||
messageId: fixture.assistantMessageIds[0],
|
||||
},
|
||||
})
|
||||
expect(persistence.readFromCalls).toBe(1)
|
||||
await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { items: [] },
|
||||
})
|
||||
})
|
||||
|
||||
it('commits a live target checkpoint before the sidecar write without a cold-log reread', async () => {
|
||||
const { ctx, persistence } = await harness()
|
||||
const session = ctx.sessions.create(SessionId('live-checkpoint'), {
|
||||
meta: { createdAt: 30, cwd: '/live' },
|
||||
})
|
||||
const fixture = appendMessageFixture(session)
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', (current) => {
|
||||
order.push('session:durable')
|
||||
persistence.persist(current)
|
||||
})
|
||||
ctx.on('domain/changed', (change) => {
|
||||
if (change.domain === 'message_feedback') order.push('sidecar:durable')
|
||||
})
|
||||
persistence.onReadFrom = () => { order.push('unexpected:cold-read') }
|
||||
|
||||
expectItem(await ctx.messageFeedback.put({
|
||||
sessionId: session.id,
|
||||
messageId: fixture.assistantMessageIds[0],
|
||||
rating: 'positive',
|
||||
ifVersion: null,
|
||||
}))
|
||||
expect(order).toEqual(['session:durable', 'sidecar:durable'])
|
||||
expect(persistence.readFromCalls).toBe(0)
|
||||
expect(persistence.durable.get(session.id)?.events).toContainEqual(
|
||||
expect.objectContaining({ type: 'assistant/message' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when a live checkpoint fails or has no participant', async () => {
|
||||
const failed = await harness()
|
||||
const failedSession = failed.ctx.sessions.create(SessionId('live-flush-failure'))
|
||||
const failedFixture = appendMessageFixture(failedSession)
|
||||
const diskFailure = new Error('disk unavailable')
|
||||
failed.ctx.on('session/flush', () => { throw diskFailure })
|
||||
await expect(failed.ctx.messageFeedback.put({
|
||||
sessionId: failedSession.id,
|
||||
messageId: failedFixture.assistantMessageIds[0],
|
||||
rating: 'positive',
|
||||
ifVersion: null,
|
||||
})).rejects.toBe(diskFailure)
|
||||
await expect(failed.ctx.messageFeedback.list({ sessionId: failedSession.id })).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { items: [] },
|
||||
})
|
||||
|
||||
const absent = await harness()
|
||||
const absentSession = absent.ctx.sessions.create(SessionId('live-no-flush'))
|
||||
const absentFixture = appendMessageFixture(absentSession)
|
||||
await expect(absent.ctx.messageFeedback.put({
|
||||
sessionId: absentSession.id,
|
||||
messageId: absentFixture.assistantMessageIds[0],
|
||||
rating: 'positive',
|
||||
ifVersion: null,
|
||||
})).rejects.toThrow(/no durability listener participated/u)
|
||||
await expect(absent.ctx.messageFeedback.list({ sessionId: absentSession.id })).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { items: [] },
|
||||
})
|
||||
})
|
||||
|
||||
it('finishes the captured live checkpoint when the Session detaches mid-flush', async () => {
|
||||
const { ctx, persistence } = await harness()
|
||||
const session = ctx.sessions.prepare(SessionId('detach-during-flush'), {
|
||||
meta: { createdAt: 40, cwd: '/detach' },
|
||||
})
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
const fixture = appendMessageFixture(session)
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.on('session/flush', async (current) => {
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
persistence.persist(current)
|
||||
})
|
||||
|
||||
const pending = ctx.messageFeedback.put({
|
||||
sessionId: session.id,
|
||||
messageId: fixture.assistantMessageIds[0],
|
||||
rating: 'positive',
|
||||
ifVersion: null,
|
||||
})
|
||||
await started.promise
|
||||
detach()
|
||||
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
||||
release.resolve(undefined)
|
||||
expectItem(await pending)
|
||||
expect(persistence.readFromCalls).toBe(0)
|
||||
await expect(ctx.messageFeedback.list({ sessionId: session.id })).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { items: [{ messageId: fixture.assistantMessageIds[0] }] },
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user