Files
deepseek-harness/packages/shell/tool-bash/tests/background.spec.ts
T
Yichen Jiang 053d302210 refactor(shell tools): hand the job registry pull sources and terminal outcomes
tool-bash and tool-pwsh no longer render their own background reads.
processSources() wraps the process's observed offset readers as registry
pull sources, pumped at dsh-jobs-local's pumpPollMs, and processOutcome()
folds the settled sandbox facts into the terminal detail. observe.ts and
the recordPollMs config field are gone; READMEs and pairings follow.
2026-09-03 16:30:57 +08:00

175 lines
7.2 KiB
TypeScript

import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import type { JobId } from '@deepseek-ai/dsh-jobs'
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { processSources } from '../src/background.ts'
import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
const testToolSignal = new AbortController().signal
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-background-spec-'))
/** Job harness with a fast registry pump for tests. */
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry, { pumpPollMs: 25 })
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalSubprocessRuntime)
;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
await ctx.plugin(ToolBash)
return ctx
}
let callCounter = 0
function call(ctx: Context, args: Record<string, unknown>) {
return ctx.tools.execute({
signal: testToolSignal,
callId: ToolCallId(`background-call-${++callCounter}`),
name: 'bash',
arguments: args,
})
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
async function until<T>(read: () => T | undefined, timeoutMs = 5_000): Promise<T> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const value = read()
if (value !== undefined) return value
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error('condition not reached before timeout')
}
function retainedText(ctx: Context, id: JobId, caller?: Agent): string {
return ctx.jobs.visibleTo(caller?.id).readAt(id, 0).chunks.map(chunk => chunk.text).join('')
}
describe('background bash output', () => {
it('streams a background run into the job ring with live output and settlement', async () => {
const ctx = await setup()
const ack = await call(ctx, {
command: 'printf "line-1\\n"; sleep 0.4; printf "line-2\\n"',
description: 'test command',
run_in_background: true,
})
expect(text(ack)).toContain('started background job')
const jobs = ctx.jobs.visibleTo()
const job = jobs.list()[0]
expect(job).toBeDefined()
// Live output appears in the ring while the command is still running.
await until(() => retainedText(ctx, job!.id).includes('line-1') ? true : undefined)
expect(jobs.get(job!.id).status).toBe('running')
// Settlement ends the ring with the job; the trailing bytes are drained first.
await until(() => jobs.get(job!.id).status === 'completed' ? true : undefined)
expect(jobs.get(job!.id).detail).toBe('exit code: 0')
expect(retainedText(ctx, job!.id)).toContain('line-2')
// The model-facing consuming cursor reads the same bytes: observation stole nothing.
const consumed = jobs.read(job!.id).chunks.map(chunk => chunk.text).join('')
expect(consumed).toContain('line-1')
expect(consumed).toContain('line-2')
})
it('a killed background job settles killed with the kill reason merged into its detail', async () => {
const ctx = await setup()
await call(ctx, { command: 'sleep 60', description: 'test command', run_in_background: true })
const jobs = ctx.jobs.visibleTo()
const job = jobs.list()[0]
jobs.kill(job!.id, { reason: 'test cleanup' })
await until(() => jobs.get(job!.id).status === 'killed' ? true : undefined)
expect(jobs.get(job!.id).detail).toMatch(/(signal|killed before exit).*; test cleanup$/)
})
it('labels stderr chunks with their channel', async () => {
const ctx = await setup()
await call(ctx, {
command: 'echo out-line; echo err-line 1>&2',
description: 'test command',
run_in_background: true,
})
const jobs = ctx.jobs.visibleTo()
const job = jobs.list()[0]
await until(() => jobs.get(job!.id).status === 'completed' ? true : undefined)
const chunks = jobs.readAt(job!.id, 0).chunks
expect(chunks.find(chunk => chunk.text.includes('out-line'))?.channel).toBe('stdout')
expect(chunks.find(chunk => chunk.text.includes('err-line'))?.channel).toBe('stderr')
})
})
describe('processSources', () => {
it('reads nothing while the process is not spawned yet and when the backend exposes no readers', () => {
const spawned: { proc?: ShellProcess } = {}
const [stdout, stderr] = processSources(() => spawned.proc)
expect(stdout!.channel).toBe('stdout')
expect(stderr!.channel).toBe('stderr')
expect(stdout!.read(0)).toEqual({ text: '', nextOffset: 0, lossy: false })
spawned.proc = { done: Promise.resolve() } as unknown as ShellProcess
expect(stderr!.read(3)).toEqual({ text: '', nextOffset: 3, lossy: false })
})
it('forwards each read to the matching stream reader once the process exists', () => {
const reads: number[] = []
const proc = {
observed: {
stdout: { readFrom: (from: number) => { reads.push(from); return { text: 'out', nextOffset: from + 3, lossy: false } } },
},
} as unknown as ShellProcess
const [stdout, stderr] = processSources(() => proc)
expect(stdout!.read(2)).toEqual({ text: 'out', nextOffset: 5, lossy: false })
expect(reads).toEqual([2])
// A missing stderr reader is an absent stream, not an error.
expect(stderr!.read(0)).toEqual({ text: '', nextOffset: 0, lossy: false })
})
})
describe('owned background output', () => {
it('fences an owned background run under the owning session', async () => {
const ctx = await setup()
const owner = {
id: SessionId('background-owner'),
session: { id: SessionId('background-owner'), header: { cwd: process.cwd() } },
status: 'idle',
ctx,
} as unknown as Agent
ctx.agents.register(owner)
await ctx.tools.execute({
signal: testToolSignal,
callId: ToolCallId('background-owned-1'),
name: 'bash',
arguments: { command: 'echo owned', description: 'test command', run_in_background: true },
agent: owner,
})
const owned = ctx.jobs.visibleTo(owner.id)
const job = owned.list()[0]
expect(job).toBeDefined()
expect(job!.owner).toBe(owner.id)
await until(() => owned.get(job!.id).status === 'completed' ? true : undefined)
expect(() => retainedText(ctx, job!.id)).toThrow(/belongs to another session/)
await until(() => retainedText(ctx, job!.id, owner).includes('owned') ? true : undefined)
})
})