Files
deepseek-harness/packages/api/session-controller/tests/control-jobs.host.spec.ts
T
Tianyi Cui 7145acfe07 Merge master into session format migration
# 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
2026-09-02 13:34:40 +08:00

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)
})
})