Files
deepseek-harness/packages/feedback/message-feedback/tests/helpers.ts
T
Tianyi Cui e58cc13de4 fix(session-export): distinguish unsupported raw artifacts
SessionPersistence.readRaw previously used undefined for two unrelated states: a supported backend could not find the requested session, or the backend had no per-session artifact concept at all. The export endpoint consequently reported an existing SQLite-backed session as HTTP 404, which falsely diagnosed storage capability as session absence.

Make raw-artifact support an explicit backend capability. Unsupported backends now fail their inherited readRaw path loudly and the host answers 501 before reading, while undefined retains the single meaning of an absent artifact on a supporting backend. First-party backends, test providers, generated API catalogs, bilingual persistence docs, and export error contracts now state that distinction; focused tests cover both the 501 and the inherited rejection.
2026-08-11 15:04:35 +08:00

216 lines
7.3 KiB
TypeScript

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 {
override readonly supportsRawArtifacts = false
static inject = ['sessions']
readonly durable = new Map<SessionId, SessionInspection>()
readonly logical = new Map<SessionId, SessionInspection>()
inspectFailure: Error | undefined
inspectCalls = 0
readFromCalls = 0
onReadFrom: (() => void | Promise<void>) | undefined
onListSnapshots: (() => void | Promise<void>) | undefined
locate(_meta: SessionHeader): SessionLocation | undefined { return undefined }
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
load(id: SessionId): Promise<SessionInspection> {
return this.readFrom(id, 0)
}
inspect(id: SessionId): Promise<SessionInspection> {
this.inspectCalls += 1
if (this.inspectFailure !== undefined) return Promise.reject(this.inspectFailure)
const explicit = this.logical.get(id)
if (explicit !== undefined) return Promise.resolve(explicit)
const live = this.ctx.sessions.get(id)
if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.events })
const stored = this.durable.get(id)
return stored === undefined
? Promise.reject(new Error(`test persistence: session '${id}' not found`))
: Promise.resolve(stored)
}
async readFrom(
id: SessionId,
fromSeq: number,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
this.readFromCalls += 1
await this.onReadFrom?.()
const stored = this.durable.get(id)
return stored === undefined
? Promise.reject(new Error(`test persistence: session '${id}' not found`))
: { meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) }
}
list(): Promise<SessionHeader[]> {
return Promise.resolve([...this.durable.values()].map(value => value.meta))
}
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
await this.onListSnapshots?.()
return [...this.durable.values()].map((value, index) => ({
header: value.meta,
revision: SessionPersistenceRevision(`test:${index}:${value.events.length}`),
}))
}
persist(session: Session): void {
this.durable.set(session.id, { meta: session.header, events: session.events })
}
setDurable(inspection: SessionInspection): void {
this.durable.set(inspection.meta.id, inspection)
}
}
export interface TestHarness {
readonly ctx: Context
readonly persistence: TestPersistence
readonly root: string
disposeFeedback(): Promise<void>
dispose(): Promise<void>
}
/** Compose the service over the real storage hub/domain/JSON backend. */
export async function setupHarness(maxNoteBytes = 64): Promise<TestHarness> {
const root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-test-'))
const ctx = new Context()
let disposeFeedback: (() => Promise<void>) | undefined
try {
await ctx.plugin(SessionStore)
await ctx.plugin(TestPersistence)
await ctx.plugin(Storage)
await ctx.plugin(StorageJson, { root })
await ctx.plugin(StorageDomain, { backend: 'json' })
const feedbackFiber = await ctx.plugin(MessageFeedbackService, { maxNoteBytes })
disposeFeedback = feedbackFiber.dispose
} catch (error) {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
throw error
}
if (disposeFeedback === undefined) throw new Error('message feedback test plugin did not load')
return {
ctx,
persistence: ctx.sessionPersistence as unknown as TestPersistence,
root,
disposeFeedback,
async dispose() {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
},
}
}