import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' import { ActivityController } from '../src/index.ts' import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts' async function collect( iterable: AsyncIterable, count: number, abort: AbortController, ): Promise { const frames: Frame[] = [] const iterator = iterable[Symbol.asyncIterator]() try { while (frames.length < count) { const step = await iterator.next() if (step.done) return frames frames.push(step.value) } } finally { abort.abort() await iterator.next().catch(() => undefined) } return frames } describe('ActivityController', () => { it('closes live roster followers when the owning fiber unwinds', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(LocalActivityRegistry) const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) const abort = new AbortController() const iterator = controller.control(abort.signal)[Symbol.asyncIterator]() expect((await iterator.next()).value).toMatchObject({ type: 'baseline' }) const pending = iterator.next() await ctx.fiber.dispose() // The feed effect closed the follower, ending the generation cleanly. expect((await pending).done).toBe(true) }) it('coalesces bursts that land inside the flush window and carries the anchor detail', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(LocalActivityRegistry) const controller = new ActivityController(ctx, { flushMs: 30, maxFrameBytes: 1024 }) const handle = ctx.activities.open({ kind: 'bash', label: 'burst' }) handle.updateDetail('warming up') const abort = new AbortController() const frames: ActivityObserveFrame[] = [] const done = (async () => { for await (const frame of controller.observe({ activityId: handle.id }, abort.signal)) frames.push(frame) })() await new Promise(resolve => setTimeout(resolve, 10)) handle.append('a') await new Promise(resolve => setTimeout(resolve, 5)) // Lands inside the flush sleep, so the waiter is already dirty at the next wait. handle.append('b') await new Promise(resolve => setTimeout(resolve, 80)) handle.end({ status: 'completed' }) await done expect(frames[0]).toMatchObject({ type: 'opened', detail: 'warming up' }) const text = frames.filter(frame => frame.type === 'output') .flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') expect(text).toBe('ab') }) it('serves an empty baseline and refuses observation without the registry', async () => { const ctx = new Context() const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) const abort = new AbortController() const frames = await collect(controller.control(abort.signal), 1, abort) expect(frames).toEqual([{ type: 'baseline', activities: [] }]) expect(() => controller.observe({ activityId: 'bash-1' as never }, new AbortController().signal)) .toThrow(/activity observation unavailable/) }) it('streams the roster and one activity end to end over a live registry', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(LocalActivityRegistry) const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) handle.append('hi') handle.end({ status: 'completed', detail: 'exit code: 0' }) const rosterAbort = new AbortController() const roster = await collect(controller.control(rosterAbort.signal), 1, rosterAbort) expect(roster[0]).toMatchObject({ type: 'baseline' }) expect((roster[0] as Extract).activities[0]?.label).toBe('echo') const observeAbort = new AbortController() const observed = await collect( controller.observe({ activityId: handle.id }, observeAbort.signal), 3, observeAbort, ) expect(observed.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) }) })