mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-12 04:01:20 +00:00
# Conflicts: # .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml # .agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml # .agents/notes/implemented/architecture/2026-06-14-session-persistence.md # .agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md # .agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml # .agents/notes/implemented/architecture/2026-07-24-project-session-directories.md # .agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md # .agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml # .agents/notes/implemented/architecture/2026-08-05-session-preparation.md # .agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md # .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml # .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md # .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md # .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml # .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md # .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md # .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md # .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md # .agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.i18n.yaml # .agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml # .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md # .agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md # apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts # apps/web/tests/cold-blank-session.e2e.ts # apps/web/tests/message-actions.e2e.ts # apps/web/tests/scaffold.ts # apps/web/tests/workspace-management.e2e.ts # docs/config-catalog.i18n.yaml # docs/config-catalog.md # docs/config-catalog.zh.md # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # docs/persistence-catalog.i18n.yaml # docs/persistence-catalog.md # docs/subsystems/feedback.i18n.yaml # docs/subsystems/feedback.md # docs/subsystems/feedback.zh.md # docs/subsystems/persistence.i18n.yaml # docs/subsystems/persistence.md # docs/subsystems/persistence.zh.md # packages/acp/acp/src/index.ts # packages/acp/acp/tests/bridge.spec.ts # packages/api/session-controller/src/list.ts # packages/api/session-controller/tests/agent.host.spec.ts # packages/api/session-controller/tests/session-cold.host.spec.ts # packages/api/session-controller/tests/session-projections.host.spec.ts # packages/api/session-controller/tests/session-search.host.spec.ts # packages/api/session-controller/tests/test-remote.ts # packages/api/session-controller/tests/transport.host.spec.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/extensions/tool-cordis/src/api-catalog.ts # packages/feedback/message-feedback/src/index.ts # packages/feedback/message-feedback/tests/helpers.ts # packages/feedback/message-feedback/tests/message-feedback.spec.ts # packages/session-query/session-log-export/src/archive.ts # packages/session-query/session-log-export/tests/archive.host.spec.ts # packages/session-query/session-log-export/tests/route.host.spec.ts # packages/session-query/session-query-sqlite/README.i18n.yaml # packages/session-query/session-query-sqlite/README.md # packages/session-query/session-query-sqlite/README.zh.md # packages/session-query/session-query-sqlite/src/index.ts # packages/session-query/session-query-sqlite/tests/sqlite.spec.ts # packages/session-query/session-query/README.i18n.yaml # packages/session-query/session-query/README.md # packages/session-query/session-query/README.zh.md # packages/session-query/session-query/src/corpus.ts # packages/session-query/session-query/tests/observation.spec.ts # packages/session-query/session-query/tests/session-query.spec.ts # packages/session-query/session-query/tests/tracing.spec.ts # packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts # packages/session/session-persistence-jsonl/README.i18n.yaml # packages/session/session-persistence-jsonl/README.md # packages/session/session-persistence-jsonl/README.zh.md # packages/session/session-persistence-jsonl/src/index.ts # packages/session/session-persistence-jsonl/tests/jsonl.spec.ts # packages/session/session-persistence-jsonl/tests/zstd.spec.ts # packages/session/session-persistence/README.i18n.yaml # packages/session/session-persistence/README.md # packages/session/session-persistence/README.zh.md # packages/session/session-persistence/src/coordinator.ts # packages/session/session-persistence/src/index.ts # packages/session/session-persistence/tests/contract.ts # packages/session/session-persistence/tests/coordinator-contract.ts # packages/session/session-persistence/tests/persistence.spec.ts # packages/session/session-telemetry/package.json # packages/shell/tool-bash/tests/tools.spec.ts # packages/subagent/subagent/src/continuation.ts # packages/subagent/subagent/tests/list-children.spec.ts # packages/workspace/workspace/src/index.ts # packages/workspace/workspace/tests/workspace.spec.ts # scripts/gen-cordis-catalog.ts
229 lines
8.2 KiB
TypeScript
229 lines
8.2 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, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
|
import type { Session } from '@deepseek-ai/dsh-session'
|
|
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
|
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(withJobs: boolean): Promise<{
|
|
ctx: Context
|
|
session: Session
|
|
agent: Agent
|
|
control: SessionControlController
|
|
}> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(SessionProjectionRegistry)
|
|
if (withJobs) {
|
|
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: SESSION_FORMAT_VERSION, 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)
|
|
})
|
|
|
|
})
|