- `catalogHistory` validated its durable read. `agent.session.events` is a JSONL/SQLite seed on resume or fork, and seed validation guarantees only a source object with a non-empty `kind`; a `skill-catalog` record with missing or wrongly shaped `entries` threw inside the step listener, failing every later turn of that session. It is now skipped as an unrecognizable record, the posture the replaced content digest had, with a regression test over six malformed shapes. - The headless keyless smoke still filtered catalogs by the old plugin source, so the `built-bin-smoke` gate would not have found the catalog message. - Entries record the published description unescaped. The pseudo-XML escaping belongs to the `<available_skills>` frame and is applied when rendering it, so a description containing `<` no longer reaches the card as `<`. `escapeText` is injective, so republish semantics and the model-facing text are unchanged. - Adjacent text blocks join with no separator, matching how provider adapters flatten them; the body no longer shows a line break the model never saw. - Provenance fields are bounded like the text: an unknown producer may record an arbitrarily large value. - Both readers are all-or-nothing, and the row's form marker reports what rendered rather than what was declared, so a partly unreadable record cannot present a confident but incomplete account. - The catalog body consumes `update` as a replacement notice; the digest canonicalizes per entry as JSON, since every separator character is itself legal in a description. - `core.md` documents the form axis with a `ContextForm` type-equiv block, and both projections assert the wiring they duplicate.
118 lines
4.1 KiB
TypeScript
118 lines
4.1 KiB
TypeScript
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
|
import { ev } from './event-script.ts'
|
|
|
|
const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
|
|
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
|
|
|
|
describe('projectConversationHistory', () => {
|
|
it('names an injected context node from its durable source, like the live adapter', () => {
|
|
// The fold declares its own node mapping (jscpd:ignore in the source), so
|
|
// the provenance projection is pinned on both sides independently.
|
|
const injected = at(0, {
|
|
type: 'user/message',
|
|
surfaceOp: 'append',
|
|
data: createUserMessage({
|
|
content: [{ type: 'text', text: '<available_skills>…</available_skills>' }],
|
|
// A plugin source, because the client program does not see the host
|
|
// packages that merge richer source kinds; those arms are pinned in
|
|
// context-provenance.spec.ts.
|
|
source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
|
|
}),
|
|
})
|
|
const { contexts } = projectConversationHistory([{ event: injected }])
|
|
expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
|
|
kind: 'context',
|
|
seq: 0,
|
|
provenance: { role: 'inject', label: 'dsh-tool-skill' },
|
|
form: 'catalog',
|
|
}])
|
|
})
|
|
|
|
it('projects frozen surface generations without widening the core live surface', () => {
|
|
const events = [
|
|
ev.user(0, 'a'),
|
|
ev.user(1, 'b'),
|
|
at(2, {
|
|
type: 'assistant/message',
|
|
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
|
sourceEventSeqs: [0],
|
|
data: {
|
|
turn: 1,
|
|
step: 1,
|
|
message: createMessage({
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'summary' }],
|
|
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
|
}),
|
|
},
|
|
}),
|
|
at(3, {
|
|
type: 'assistant/message',
|
|
surfaceOp: { op: 'replace', start: 2, end: 1 },
|
|
sourceEventSeqs: [2, 1],
|
|
data: {
|
|
turn: 1,
|
|
step: 2,
|
|
message: createMessage({
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'summary 2' }],
|
|
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
|
}),
|
|
},
|
|
}),
|
|
]
|
|
|
|
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
|
|
id: context.id,
|
|
parentId: context.parentId,
|
|
originSeq: context.originSeq,
|
|
nodes: context.nodes.map(node => node.seq),
|
|
}))).toEqual([
|
|
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
|
|
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
|
|
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
|
|
])
|
|
})
|
|
|
|
it('projects assistant timing and the active request header from history', () => {
|
|
const projection = projectConversationHistory([
|
|
ev.stepStart(0, 1, 2),
|
|
at(1, { type: 'request/header', data: {
|
|
reason: 'initial',
|
|
header: {
|
|
config: { provider: 'fake', model: 'first' },
|
|
tools: [],
|
|
},
|
|
} }),
|
|
ev.chunkStart(2, 1, 2),
|
|
ev.chunkText(3, 1, 'token', 2),
|
|
ev.assistant(4, 1, 'done', 2),
|
|
ev.stepStart(5, 2, 1),
|
|
ev.chunkText(6, 2, 'next', 1),
|
|
ev.assistant(7, 2, 'next done', 1),
|
|
].map(event => ({ event })))
|
|
|
|
expect(projection.eventNodes[0]).toMatchObject({
|
|
kind: 'assistant',
|
|
timing: {
|
|
stepStartTime: 1_700_000_000_000,
|
|
firstTokenTime: 1_700_000_000_003,
|
|
completedTime: 1_700_000_000_004,
|
|
},
|
|
requestConfig: { provider: 'fake', model: 'first' },
|
|
})
|
|
|
|
expect(projection.eventNodes.at(-1)).toMatchObject({
|
|
timing: {
|
|
stepStartTime: 1_700_000_000_005,
|
|
firstTokenTime: 1_700_000_000_006,
|
|
completedTime: 1_700_000_000_007,
|
|
},
|
|
requestConfig: { provider: 'fake', model: 'first' },
|
|
})
|
|
})
|
|
})
|