Merge branch 'master' into feat/session-completed-dot
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -408,4 +408,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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user