Merge branch 'split/agent-factory' into split/session-persistence-sqlite

This commit is contained in:
Tianyi Cui
2026-06-15 23:55:58 +08:00
17 changed files with 323 additions and 70 deletions

View File

@@ -120,13 +120,31 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
it('append rejects non-JSON-serializable event data, naming the event type', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('s5')
await persistence.create(m)
// A plugin-added event carrying a BigInt (not JSON-serializable).
const bad = [
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: 1n } },
] as unknown as SessionEvent[]
await expect(persistence.append(m.id, bad)).rejects.toThrow(/user\/message/)
// Every value `isJsonValue` rejects must be rejected by the backend, not
// just BigInt — otherwise a backend could pass this contract while still
// accepting values that corrupt the durable round-trip. Each is a
// plugin-added `extra` field on a single user/message (seq 0).
const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
cyclic['self'] = cyclic
const badValues: unknown[] = [
1n, // BigInt
undefined, // dropped by JSON.stringify
Infinity, // → null
() => 0, // function
Symbol('s'), // symbol
new Map(), // exotic object
cyclic, // circular ref
]
for (const [i, bad] of badValues.entries()) {
// A fresh session per value isolates each rejection (a rejected append
// must leave no state behind, but isolating keeps the assertion clean).
const mi = meta(`s5-${i}`)
await persistence.create(mi)
const events = [
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: bad } },
] as unknown as SessionEvent[]
await expect(persistence.append(mi.id, events)).rejects.toThrow(/user\/message/)
}
} finally {
await dispose()
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SessionId, isJsonValue } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session'
import { SessionPersistence } from '../src/index.ts'
import { runPersistenceContract, meta, oneTurnLog } from './contract.ts'
@@ -30,7 +30,7 @@ class MemoryPersistence extends SessionPersistence {
for (let i = 0; i < events.length; i++) {
const e = events[i]!
if (e.seq !== nextSeq + i) throw new Error(`non-contiguous seq in batch for "${id}" at index ${i}`)
if (containsNonSerializable(e.data)) {
if (!isJsonValue(e.data)) {
throw new Error(`event "${e.type}" carries non-JSON-serializable data`)
}
}
@@ -68,15 +68,6 @@ class MemoryPersistence extends SessionPersistence {
}
}
/** Detect BigInt (and other JSON-hostile values) in event data. */
function containsNonSerializable(value: unknown): boolean {
if (typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') return true
if (value && typeof value === 'object') {
return Object.values(value).some(containsNonSerializable)
}
return false
}
// Run the shared contract against the in-memory backend.
runPersistenceContract('memory', async () => {
const ctx = new Context()