Merge branch 'claude/web-llm-pi-ai-config-385e24' into claude/pi-ai-model-discovery

# Conflicts:
#	docs/core-data-structures/core.i18n.yaml
#	docs/event-producer-consumer.md
This commit is contained in:
Yichen Jiang
2026-08-06 13:05:15 +08:00
409 changed files with 10797 additions and 2409 deletions

View File

@@ -1,4 +1,4 @@
import { createMessage } from '@deepseek-ai/dsh-llm'
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'
@@ -10,6 +10,49 @@ 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 next-step human input as durable steering', () => {
const steering = createUserMessage({
content: [{ type: 'text', text: 'change course' }],
source: { kind: 'user' },
})
const events = [
at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }),
at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(2, { type: 'user/message', surfaceOp: 'append', data: steering }),
]
const projection = projectConversationHistory(events.map(event => ({ event })))
expect(projection.eventNodes).toMatchObject([{
kind: 'steering', messageId: steering.id, seq: 2,
}])
})
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
const baseSeq = 400_000
const events = [

View File

@@ -29,6 +29,7 @@ const C: FC<object> = () => null
*/
interface ErasedService {
register(options: object, component: unknown): () => void
inject(name: string, callback: () => (() => void) | Iterable<() => void>): () => void
install(renderer: object): void
renderSlot(key: string, owner: object): unknown
}
@@ -166,6 +167,266 @@ describe('load-time validation', () => {
})
})
describe('declaration injection', () => {
it('activates immediately and ignores ordinary entry mutations', async () => {
const bench = await boot()
bench.erased.register({
name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
}, C)
const setup = vi.fn(() => bench.erased.register({ name: 't.rows', id: 'injected' }, C))
const dispose = bench.erased.inject('t.rows', setup)
expect(setup).toHaveBeenCalledOnce()
bench.erased.register({ name: 't.rows', id: 'ordinary' }, C)
await Promise.resolve()
expect(setup).toHaveBeenCalledOnce()
dispose()
expect(bench.svc.entries('t.rows').map(entry => entry.options.id)).toEqual(['ordinary'])
})
it('waits for declaration, cleans up on collapse, and reruns after redeclaration', async () => {
const bench = await boot()
const cleanup = vi.fn()
const setup = vi.fn(() => {
const unregister = bench.erased.register({ name: 't.host' }, C)
return () => { unregister(); cleanup() }
})
bench.erased.inject('t.host', setup)
expect(setup).not.toHaveBeenCalled()
const disposeFrame = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
await Promise.resolve()
expect(setup).toHaveBeenCalledOnce()
expect(bench.svc.entries('t.host')).toHaveLength(1)
disposeFrame()
await Promise.resolve()
expect(cleanup).toHaveBeenCalledOnce()
expect(bench.svc.entries('t.host')).toHaveLength(0)
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
await Promise.resolve()
expect(setup).toHaveBeenCalledTimes(2)
expect(bench.svc.entries('t.host')).toHaveLength(1)
})
it('observes a same-tick collapse and redeclaration through the declaration epoch', async () => {
const bench = await boot()
const firstFrame = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
const cleanup = vi.fn()
const setup = vi.fn(() => {
const unregister = bench.erased.register({ name: 't.host' }, C)
return () => { unregister(); cleanup() }
})
bench.erased.inject('t.host', setup)
firstFrame()
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
await Promise.resolve()
expect(cleanup).toHaveBeenCalledOnce()
expect(setup).toHaveBeenCalledTimes(2)
expect(bench.svc.entries('t.host')).toHaveLength(1)
})
it('plugin disposal removes an active injection and prevents a waiting one from resurrecting', async () => {
const active = await boot()
active.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
const activeFiber = active.ctx.plugin({
name: 'active-injection',
inject: ['slots'],
apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, C)) },
})
await activeFiber.await()
expect(active.svc.entries('t.host')).toHaveLength(1)
await activeFiber.dispose()
expect(active.svc.entries('t.host')).toHaveLength(0)
const waiting = await boot()
const setup = vi.fn(() => waiting.erased.register({ name: 't.host' }, C))
const waitingFiber = waiting.ctx.plugin({
name: 'waiting-injection',
inject: ['slots'],
apply: (ctx: Context) => { ctx.slots.inject('t.host', setup) },
})
await waitingFiber.await()
await waitingFiber.dispose()
waiting.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
await Promise.resolve()
expect(setup).not.toHaveBeenCalled()
})
it('rolls back earlier yielded registrations when generator setup fails', async () => {
const bench = await boot()
bench.erased.register({
name: 'root',
children: {
't.host': { kind: 'single', scope: 'root' },
't.rows': { kind: 'list', scope: 'root' },
},
}, C)
bench.erased.register({ name: 't.host' }, C)
expect(() => bench.erased.inject('t.rows', function* () {
yield bench.erased.register({ name: 't.rows', id: 'rolled-back' }, C)
yield bench.erased.register({ name: 't.host' }, C)
})).toThrow(/already has a registration/)
expect(bench.svc.entries('t.rows')).toHaveLength(0)
})
it('contains and wraps a delayed setup failure so later slot listeners still run', async () => {
const bench = await boot()
const failures: unknown[] = []
const onLoud = (error: unknown): void => { failures.push(error) }
process.on('uncaughtException', onLoud)
try {
const setup = vi.fn(function* () {
yield bench.erased.register({ name: 't.host' }, C)
throw null
})
bench.erased.inject('t.host', setup)
const later = vi.fn(() => () => undefined)
bench.erased.inject('t.host', later)
const disposeFrame = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
await new Promise(resolve => setTimeout(resolve, 20))
expect(failures).toHaveLength(1)
expect(failures[0]).toBeInstanceOf(Error)
expect(String(failures[0])).toContain('null')
expect(later).toHaveBeenCalledOnce()
disposeFrame()
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
expect(setup).toHaveBeenCalledOnce()
} finally {
process.off('uncaughtException', onLoud)
}
})
it('skips a stopped controller retained by the current declaration snapshot', async () => {
const bench = await boot()
let stopLater = (): void => {}
const first = vi.fn(() => {
stopLater()
return () => undefined
})
const later = vi.fn(() => () => undefined)
bench.erased.inject('t.host', first)
stopLater = bench.erased.inject('t.host', later)
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
expect(first).toHaveBeenCalledOnce()
expect(later).not.toHaveBeenCalled()
})
it('keeps a nested redeclaration activation when the outer collapse resumes', async () => {
const bench = await boot()
const disposeFrame = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
let disposeReplacement = (): void => {}
let replaced = false
const first = vi.fn(() => () => {
if (replaced) return
replaced = true
disposeReplacement = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
})
const later = vi.fn(() => () => undefined)
bench.erased.inject('t.host', first)
bench.erased.inject('t.host', later)
disposeFrame()
expect(first).toHaveBeenCalledTimes(2)
expect(later).toHaveBeenCalledTimes(2)
expect(bench.svc.spec('t.host')).toBeDefined()
disposeReplacement()
})
it('cancels a waiting injection when its contributor is already unloading', async () => {
const bench = await boot()
const setup = vi.fn(() => bench.erased.register({ name: 't.host' }, C))
let release = (): void => {}
const blocked = new Promise<void>((resolve) => { release = resolve })
const pauseUnload = vi.fn(async () => { await blocked })
const contributor = bench.ctx.plugin({
name: 'unloading-injection',
inject: ['slots'],
apply: (ctx: Context) => {
ctx.slots.inject('t.host', setup)
ctx.effect(() => pauseUnload, 'pause contributor unload')
},
})
await contributor.await()
const disposing = contributor.dispose()
expect(() => bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)).not.toThrow()
expect(setup).not.toHaveBeenCalled()
await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() })
release()
await disposing
})
it('supports dynamic plugin replacement without retaining the old rendered entry', async () => {
const bench = await boot()
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
const componentA = (): null => null
const componentB = (): null => null
const mount = (name: string, component: FC<object>) => bench.ctx.plugin({
name,
inject: ['slots'],
apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, component)) },
})
const first = mount('replacement-a', componentA)
await first.await()
expect(bench.svc.entries('t.host')[0]?.component).toBe(componentA)
await first.dispose()
expect(bench.svc.entries('t.host')).toHaveLength(0)
const second = mount('replacement-b', componentB)
await second.await()
expect(bench.svc.entries('t.host')[0]?.component).toBe(componentB)
})
it('releases service-layer store state when the declaration collapses', async () => {
const bench = await boot()
let host: SlotRendererHost | undefined
bench.erased.install({ renderRoot: (value: SlotRendererHost) => { host = value; return null } })
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
const disposeFrame = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
bench.erased.renderSlot('root', {})
if (host === undefined) throw new Error('renderer never received the host')
const { handle } = fakeHandle()
bench.erased.inject('t.host', () => bench.erased.register({ name: 't.host', store: handle }, C))
const oldEntry = host.entriesOf('t.host')[0]
expect(host.storeOf(oldEntry as never, undefined)).toBeDefined()
disposeFrame()
expect(() => host?.storeOf(oldEntry as never, undefined)).toThrow(/not registered/)
bench.erased.register({
name: 'root', children: { 't.panel': { kind: 'single', scope: 'session' } },
}, C)
bench.erased.register({ name: 't.panel', store: handle }, C)
const panelEntry = host.entriesOf('t.panel')[0]
expect(host.storeOf(panelEntry as never, 's1')).toBeDefined()
expect(handle.create).toHaveBeenLastCalledWith('s1')
})
})
describe('renderer install seam', () => {
it('throws on renderSlot before install (boot-order guidance)', async () => {
const bench = await boot()

View File

@@ -85,22 +85,85 @@ describe('TranscriptAdapter', () => {
it('materializes every append-origin variant with field mapping', () => {
const adapter = new TranscriptAdapter()
const steering = createUserMessage({
content: [{ type: 'text', text: '插话' }],
source: { kind: 'user' },
})
adapter.reset([
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
at(2, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }),
at(3, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(4, { type: 'user/message', surfaceOp: 'append', data: steering }),
at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(3, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(4, 0, 'c1', '结果'),
ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(7, 0, 'c1', '结果'),
])
const nodes = adapter.nodes()
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'context', 'tool-result'])
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id })
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
})
})
it('identifies steering on the live append path', () => {
const adapter = new TranscriptAdapter()
const steering = createUserMessage({
content: [{ type: 'text', text: 'live steer' }],
source: { kind: 'user' },
})
adapter.reset([])
adapter.append(at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }))
adapter.append(at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }))
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering }))
expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }])
})
it('does not mark queued, canceled, or non-user next-step messages as steering', () => {
const adapter = new TranscriptAdapter()
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } })
const context = createUserMessage({
content: [{ type: 'text', text: 'context' }],
source: { kind: 'plugin', plugin: 'test' },
})
adapter.reset([
at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-turn', start: 0, inserted: [queued],
} }),
at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
} }),
at(2, { type: 'user/message', surfaceOp: 'append', data: queued }),
at(3, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [canceled],
} }),
at(4, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
} }),
at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }),
at(6, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [context],
} }),
at(7, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(8, { type: 'user/message', surfaceOp: 'append', data: context }),
])
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
})
it('skips events core does not call surface-eligible, marker or not', () => {
// The transcript is the append-origin surface, so log-only events (a chunk,
// a turn boundary, a compact/* provenance record) and a future type core
@@ -198,10 +261,15 @@ describe('TranscriptAdapter', () => {
adapter.reset([
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '注入的上下文' }],
source: { kind: 'plugin', plugin: 'compact' },
source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
}) }),
])
expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }])
expect(adapter.nodes()).toMatchObject([{
kind: 'context',
seq: 0,
provenance: { role: 'inject', label: 'compact' },
form: 'instructions',
}])
})
it('ignores a foreign plugin s replacement user/message', () => {
@@ -408,4 +476,48 @@ describe('TranscriptAdapter', () => {
expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } })
})
})
describe('assistant timing', () => {
const base = 1_700_000_000_000
it('derives step timing across a window rebuild (start + first token + completion)', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.turnStart(0, 0),
ev.user(1, '问'),
ev.stepStart(2, 0),
ev.chunkStart(3, 0),
ev.chunkText(4, 0, '答'),
ev.chunkText(5, 0, '案'),
ev.assistant(6, 0, '答案'),
ev.turnEnd(7, 0),
])
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
expect(assistant).toMatchObject({
timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 },
})
})
it('derives the same timing on the live append path, first token winning once', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.user(0, '问')])
adapter.append(ev.stepStart(1, 0))
adapter.append(ev.chunkText(2, 0, '首'))
adapter.append(ev.chunkText(3, 0, '次'))
adapter.append(ev.assistant(4, 0, '首次'))
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
expect(assistant).toMatchObject({
timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 },
})
})
it('soft-falls to null boundaries when the step opening fell outside the window', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.assistant(100, 0, '被切窗的答案')])
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
expect(assistant).toMatchObject({
timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 },
})
})
})
})