mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
227 lines
8.1 KiB
TypeScript
227 lines
8.1 KiB
TypeScript
import { Context } from '@deepseek-ai/cordis'
|
|
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import type { JobOutcome } from '@deepseek-ai/dsh-jobs'
|
|
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
|
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
|
import type { Session } from '@deepseek-ai/dsh-session'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { SessionControlController } from '../src/control.ts'
|
|
import type { SessionControlFrame } from '../src/types.ts'
|
|
|
|
type BaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
|
|
type JobFrame = Extract<SessionControlFrame, { type: 'jobs' }>
|
|
|
|
function producer(label = 'sleep 60') {
|
|
let settle!: (outcome: JobOutcome) => void
|
|
const reads = { count: 0 }
|
|
const spec = {
|
|
kind: 'bash' as const,
|
|
label,
|
|
run: () => ({
|
|
cancel: () => {},
|
|
done: new Promise<JobOutcome>((resolve) => { settle = resolve }),
|
|
readOutput: () => { reads.count += 1; return 'stolen output' },
|
|
}),
|
|
}
|
|
return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } }
|
|
}
|
|
|
|
async function harness(withRegistry: boolean): Promise<{
|
|
ctx: Context
|
|
session: Session
|
|
agent: Agent
|
|
control: SessionControlController
|
|
}> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
if (withRegistry) {
|
|
await ctx.plugin(LocalJobRegistry)
|
|
ctx.jobs.attachController('session-controller-test')
|
|
}
|
|
const session = ctx.sessions.create()
|
|
const agent = {
|
|
id: session.id,
|
|
session,
|
|
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
|
status: 'idle',
|
|
ctx,
|
|
} as Agent
|
|
ctx.agents.register(agent)
|
|
const control = new SessionControlController(ctx)
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
return { ctx, session, agent, control }
|
|
}
|
|
|
|
async function baseline(control: SessionControlController): Promise<BaselineFrame> {
|
|
const abort = new AbortController()
|
|
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
|
|
const first = await iterator.next()
|
|
abort.abort()
|
|
await iterator.next()
|
|
if (first.done || first.value.type !== 'baseline') throw new Error('missing control baseline')
|
|
return first.value
|
|
}
|
|
|
|
async function collectJobs(
|
|
iterable: AsyncIterable<SessionControlFrame>,
|
|
count: number,
|
|
abort: AbortController,
|
|
): Promise<JobFrame[]> {
|
|
const jobs: JobFrame[] = []
|
|
for await (const frame of iterable) {
|
|
if (frame.type !== 'jobs') continue
|
|
jobs.push(frame)
|
|
if (jobs.length >= count) abort.abort()
|
|
}
|
|
return jobs
|
|
}
|
|
|
|
describe('Session control jobs baseline', () => {
|
|
it('represents an attached session with no jobs as an empty set', async () => {
|
|
const { session, control } = await harness(true)
|
|
const frame = await baseline(control)
|
|
expect(frame.value.jobs[session.id]).toEqual([])
|
|
})
|
|
|
|
it('carries the visible set when the stream opens', async () => {
|
|
const { ctx, session, agent, control } = await harness(true)
|
|
ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent })
|
|
const frame = await baseline(control)
|
|
const jobs = frame.value.jobs[session.id]
|
|
expect(jobs).toHaveLength(1)
|
|
const [job] = jobs ?? []
|
|
expect(job?.startedAt).toBeTypeOf('number')
|
|
expect({ ...job, startedAt: 0 }).toEqual({
|
|
id: 'bash-1',
|
|
kind: 'bash',
|
|
label: 'pnpm run build',
|
|
status: 'running',
|
|
startedAt: 0,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Session control jobs updates', () => {
|
|
it('publishes existing unowned jobs when a Session attaches after the stream opens', async () => {
|
|
const { ctx, control } = await harness(true)
|
|
const abort = new AbortController()
|
|
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
|
|
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } })
|
|
const task = producer('already running')
|
|
const id = ctx.jobs.start(task.spec)
|
|
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'jobs' } })
|
|
|
|
const created = ctx.sessions.create(SessionId('late-session'))
|
|
await expect(iterator.next()).resolves.toMatchObject({
|
|
value: {
|
|
type: 'jobs',
|
|
sessionId: created.id,
|
|
jobs: [expect.objectContaining({ id, label: 'already running' })],
|
|
},
|
|
})
|
|
|
|
task.settle({ status: 'completed' })
|
|
abort.abort()
|
|
await iterator.return?.()
|
|
})
|
|
|
|
it('pushes the owner whole set on registration, stopping, and settlement', async () => {
|
|
const { ctx, session, agent, control } = await harness(true)
|
|
const abort = new AbortController()
|
|
const collected = collectJobs(control.control(abort.signal), 3, abort)
|
|
|
|
const task = producer()
|
|
const id = ctx.jobs.start({ ...task.spec, owner: agent })
|
|
ctx.jobs.kill(id, agent, 'test')
|
|
task.settle({ status: 'killed', detail: 'signal: SIGTERM' })
|
|
|
|
const frames = await collected
|
|
expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id])
|
|
expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed'])
|
|
expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM')
|
|
expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number')
|
|
})
|
|
|
|
it('drops internal registry fields from the browser view', async () => {
|
|
const { ctx, agent, control } = await harness(true)
|
|
const abort = new AbortController()
|
|
const collected = collectJobs(control.control(abort.signal), 1, abort)
|
|
ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
|
|
|
|
const [frame] = await collected
|
|
expect(Object.keys(frame?.jobs[0] ?? {}).sort()).toEqual([
|
|
'id',
|
|
'kind',
|
|
'label',
|
|
'startedAt',
|
|
'status',
|
|
])
|
|
})
|
|
|
|
it('fans an unowned change out to every attached session', async () => {
|
|
const { ctx, control } = await harness(true)
|
|
const second = ctx.sessions.create()
|
|
const abort = new AbortController()
|
|
const collected = collectJobs(control.control(abort.signal), 2, abort)
|
|
|
|
ctx.jobs.start(producer('open to every caller').spec)
|
|
|
|
const frames = await collected
|
|
expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2)
|
|
expect(frames.some(frame => frame.sessionId === second.id)).toBe(true)
|
|
for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller')
|
|
})
|
|
|
|
it('does not resume persisted sessions while projecting an unowned change', async () => {
|
|
const { ctx, control } = await harness(true)
|
|
const coldId = SessionId('session-cold-tasks')
|
|
let loaded = false
|
|
ctx.provide('sessionPersistence', {
|
|
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
|
|
locate: () => undefined,
|
|
load: () => { loaded = true; throw new Error('job projection must not load a cold log') },
|
|
} as never)
|
|
const abort = new AbortController()
|
|
const collected = collectJobs(control.control(abort.signal), 1, abort)
|
|
|
|
ctx.jobs.start(producer().spec)
|
|
await collected
|
|
expect(loaded).toBe(false)
|
|
expect(ctx.agents.get(coldId)).toBeUndefined()
|
|
})
|
|
|
|
it('reports empty sets when no jobs registry is composed', async () => {
|
|
const { session, control } = await harness(false)
|
|
const frame = await baseline(control)
|
|
expect(frame.value.jobs[session.id]).toEqual([])
|
|
})
|
|
|
|
it('never consumes model output while projecting a lifecycle', async () => {
|
|
const { ctx, agent, control } = await harness(true)
|
|
const abort = new AbortController()
|
|
const collected = collectJobs(control.control(abort.signal), 3, abort)
|
|
|
|
const task = producer()
|
|
const id = ctx.jobs.start({ ...task.spec, owner: agent })
|
|
ctx.jobs.kill(id, agent, 'test')
|
|
task.settle({ status: 'killed', detail: 'signal: SIGTERM' })
|
|
await collected
|
|
|
|
expect(task.reads.count).toBe(0)
|
|
})
|
|
|
|
it('never consumes model output while producing a baseline', async () => {
|
|
const { ctx, agent, control } = await harness(true)
|
|
const task = producer()
|
|
ctx.jobs.start({ ...task.spec, owner: agent })
|
|
|
|
const frame = await baseline(control)
|
|
|
|
expect(frame.value.jobs[agent.id]).toHaveLength(1)
|
|
expect(task.reads.count).toBe(0)
|
|
})
|
|
|
|
})
|