import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local'
import { ActivityId } from '@deepseek-ai/dsh-activity'
import { ActivityFeed } from '../src/feed.ts'
import { observeActivity } from '../src/observe.ts'
import type { ObserveOptions } from '../src/observe.ts'
import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts'
async function harness(withRegistry = true) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
if (withRegistry) await ctx.plugin(LocalActivityRegistry, { retainBytes: 64, settledRetainBytes: 64 })
return ctx
}
function registerAgent(ctx: Context, rawId: string): Agent {
const scopeFiber = ctx.plugin(() => {})
const id = SessionId(rawId)
const session = Session.create(id)
const agent = {
id,
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scopeFiber.ctx,
} as unknown as Agent
ctx.agents.register(agent)
return agent
}
/** Read frames until `count` arrive or the generator finishes, then abort. */
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
}
const OBSERVE: Omit = { flushMs: 5, maxFrameBytes: 1024 }
describe('ActivityFeed roster', () => {
it('serves an empty baseline without the registry', async () => {
const ctx = await harness(false)
const feed = new ActivityFeed(ctx)
expect(feed.baseline()).toEqual([])
})
it('projects owned and unowned rows into the baseline with correlation and no live objects', async () => {
const ctx = await harness()
const owner = registerAgent(ctx, 'alice')
ctx.activities.open({ kind: 'bash', label: 'unowned build' })
ctx.activities.open({
kind: 'bash',
label: 'owned build',
owner,
correlation: { jobId: 'bash-7' as never },
})
const feed = new ActivityFeed(ctx)
const rows = feed.baseline()
expect(rows).toHaveLength(2)
const owned = rows.find(row => row.sessionId !== undefined)
expect(owned?.sessionId).toBe('alice')
expect(owned?.correlation).toEqual({ jobId: 'bash-7' })
expect(owned?.status).toBe('running')
for (const row of rows) {
expect(Object.keys(row)).not.toContain('owner')
expect(row.outputTotal).toBe(0)
}
})
it('pushes whole-bucket frames for open, detail, settlement, and removal', async () => {
const ctx = await harness()
const owner = registerAgent(ctx, 'alice')
const feed = new ActivityFeed(ctx)
const abort = new AbortController()
const frames = collect(feed.follow(abort.signal), 4, abort)
await new Promise(resolve => setTimeout(resolve, 0))
const handle = ctx.activities.open({ kind: 'bash', label: 'work', owner })
handle.updateDetail('halfway')
handle.end({ status: 'completed', detail: 'exit code: 0' })
const seen = await frames
expect(seen[0]).toEqual({ type: 'baseline', activities: [] })
const rows = seen.slice(1) as Extract[]
expect(rows.every(frame => frame.sessionId === 'alice')).toBe(true)
expect(rows[0]?.activities[0]?.status).toBe('running')
expect(rows[1]?.activities[0]?.detail).toBe('halfway')
expect(rows[2]?.activities[0]?.status).toBe('completed')
expect(feed.ownerOf(String(rows[0]!.activities[0]!.id))).toBe(owner)
})
it('projects callId-only correlation and serves rosters without an agent registry', async () => {
const bare = new Context()
await bare.plugin(LocalActivityRegistry)
bare.activities.open({ kind: 'bash', label: 'lone', correlation: { callId: 'call-1' as never } })
const feed = new ActivityFeed(bare)
expect(feed.baseline()[0]?.correlation).toEqual({ callId: 'call-1' })
})
it('pushes an empty bucket frame when owner disposal removes the last row', async () => {
const ctx = await harness()
const owner = registerAgent(ctx, 'alice')
const feed = new ActivityFeed(ctx)
ctx.activities.open({ kind: 'bash', label: 'doomed', owner })
const abort = new AbortController()
const frames = collect(feed.follow(abort.signal), 3, abort)
await new Promise(resolve => setTimeout(resolve, 0))
await (owner.ctx as unknown as { fiber: { dispose(): Promise } }).fiber?.dispose?.()
// Owner-disposal removal reaches the feed as an emptied bucket.
await new Promise(resolve => setTimeout(resolve, 10))
abort.abort()
const seen = await frames
const last = seen.at(-1) as Extract | undefined
if (last !== undefined && last.type === 'rows') expect(Array.isArray(last.activities)).toBe(true)
})
it('resolves undefined for unowned and unknown activities', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const handle = ctx.activities.open({ kind: 'bash', label: 'shared' })
expect(feed.ownerOf(String(handle.id))).toBeUndefined()
expect(feed.ownerOf('bash-999')).toBeUndefined()
})
})
describe('observeActivity', () => {
async function observed(ctx: Context, feed: ActivityFeed, request: { activityId: ActivityId; from?: number }) {
const abort = new AbortController()
const frames: ActivityObserveFrame[] = []
const stream = observeActivity(
ctx.activities,
request,
{ ...OBSERVE, ownerOf: id => feed.ownerOf(id) },
abort.signal,
)
const done = (async () => {
for await (const frame of stream) frames.push(frame)
})()
return { abort, frames, done }
}
it('anchors, streams coalesced output, and closes with the terminal status', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const handle = ctx.activities.open({ kind: 'bash', label: 'echo' })
handle.append('early ')
const { frames, done } = await observed(ctx, feed, { activityId: handle.id })
await new Promise(resolve => setTimeout(resolve, 20))
handle.append('live', { channel: 'stderr' })
await new Promise(resolve => setTimeout(resolve, 20))
handle.end({ status: 'completed', detail: 'exit code: 0' })
await done
expect(frames[0]).toMatchObject({ type: 'opened', from: 0, earliest: 0, total: 6, status: 'running' })
const outputs = frames.filter(frame => frame.type === 'output')
const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('')
expect(text).toBe('early live')
expect(outputs.at(-1)?.next).toBe(10)
expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr')
expect(frames.at(-1)).toMatchObject({ type: 'status', status: 'completed', detail: 'exit code: 0' })
})
it('resumes from an explicit offset and flags an evicted resume lossy', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const handle = ctx.activities.open({ kind: 'bash', label: 'spam' })
handle.append('a'.repeat(60))
handle.append('b'.repeat(60))
handle.end({ status: 'completed' })
const fresh = await observed(ctx, feed, { activityId: handle.id, from: 0 })
await fresh.done
const output = fresh.frames.find(frame => frame.type === 'output')
expect(output?.lossy).toBe(true)
expect(output?.chunks.map(chunk => chunk.text).join('')).toBe('b'.repeat(60))
const resumed = await observed(ctx, feed, { activityId: handle.id, from: 120 })
await resumed.done
expect(resumed.frames.some(frame => frame.type === 'output')).toBe(false)
expect(resumed.frames.at(-1)?.type).toBe('status')
})
it('splits oversized reads along the frame budget', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const registry = ctx.activities
const handle = registry.open({ kind: 'bash', label: 'wide' })
const abort = new AbortController()
handle.append('x'.repeat(30))
handle.append('y'.repeat(30))
handle.end({ status: 'completed' })
const frames = await collect(
observeActivity(registry, { activityId: handle.id }, {
flushMs: 5,
maxFrameBytes: 30,
ownerOf: id => feed.ownerOf(id),
}, abort.signal),
5,
abort,
)
const outputs = frames.filter(frame => frame.type === 'output')
expect(outputs).toHaveLength(2)
expect(outputs[0]?.next).toBe(30)
expect(outputs[1]?.next).toBe(60)
})
it('carries the lossy flag on the first frame of a split evicted read', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const registry = ctx.activities
const handle = registry.open({ kind: 'bash', label: 'evicted split' })
handle.append('a'.repeat(40))
handle.append('b'.repeat(40))
handle.append('c'.repeat(40))
handle.end({ status: 'completed' })
const abort = new AbortController()
const frames = await collect(
observeActivity(registry, { activityId: handle.id, from: 0 }, {
flushMs: 5,
maxFrameBytes: 40,
ownerOf: id => feed.ownerOf(id),
}, abort.signal),
5,
abort,
)
const outputs = frames.filter(frame => frame.type === 'output')
expect(outputs[0]?.lossy).toBe(true)
expect(outputs[1]?.lossy).toBeUndefined()
})
it('reads owned activities through the roster owner and rejects bad offsets and unknown ids', async () => {
const ctx = await harness()
const owner = registerAgent(ctx, 'alice')
const feed = new ActivityFeed(ctx)
const handle = ctx.activities.open({ kind: 'bash', label: 'secret', owner })
handle.append('classified')
handle.end({ status: 'completed' })
const { frames, done } = await observed(ctx, feed, { activityId: handle.id })
await done
expect(frames.some(frame => frame.type === 'output'
&& frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true)
const abort = new AbortController()
await expect(collect(
observeActivity(ctx.activities, { activityId: handle.id, from: -1 }, {
...OBSERVE,
ownerOf: id => feed.ownerOf(id),
}, abort.signal),
1,
abort,
)).rejects.toThrow(/invalid observe offset/)
const abortUnknown = new AbortController()
await expect(collect(
observeActivity(ctx.activities, { activityId: ActivityId('bash-999') }, {
...OBSERVE,
ownerOf: id => feed.ownerOf(id),
}, abortUnknown.signal),
1,
abortUnknown,
)).rejects.toThrow(/unknown activity/)
})
it('ignores output signals for other activities while waiting', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const watched = ctx.activities.open({ kind: 'bash', label: 'watched' })
const noisy = ctx.activities.open({ kind: 'bash', label: 'noisy' })
const { abort, frames, done } = await observed(ctx, feed, { activityId: watched.id })
await new Promise(resolve => setTimeout(resolve, 15))
noisy.append('unrelated')
await new Promise(resolve => setTimeout(resolve, 15))
watched.end({ status: 'completed' })
await done
abort.abort()
expect(frames.filter(frame => frame.type === 'output')).toEqual([])
})
it('stops cleanly on abort while waiting for output', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const handle = ctx.activities.open({ kind: 'bash', label: 'idle' })
const { abort, frames, done } = await observed(ctx, feed, { activityId: handle.id })
await new Promise(resolve => setTimeout(resolve, 20))
abort.abort()
await done
expect(frames[0]?.type).toBe('opened')
expect(frames.at(-1)?.type).not.toBe('status')
})
})