Files
deepseek-harness/packages/shell/tool-bash/tests/background.spec.ts
T
Yichen Jiang 80bbdba617 fix(jobs): restore the dropped-output notice with its spill files for the model
The consuming shell read on master told the model where a truncated stream's complete text lived: [some output was dropped from memory; full output: <paths>]. The ring-backed read rendered a notice only for the ring's own eviction and ignored the gapBefore mark the pump puts on a chunk whose source lost bytes between two polls, so a fast producer's tail read as continuous output and no path survived. A lossy source read now carries the source's spillPath onto its gap chunk (JobSourceRead, JobAppendOptions, JobChunk), the pump forwards it, the ring retains it, and the model renderer emits master's notice for a producer-side gap or a ring eviction, listing the distinct spill files in offset order. The bash and pwsh sources already returned the subprocess reader's spillPath; the type now admits it.
2026-09-04 12:47:21 +08:00

185 lines
7.7 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 })
})
it("passes a lossy read's spill file through, so the model's notice can name it", () => {
const proc = {
observed: {
stderr: { readFrom: (from: number) => ({ text: 'tail', nextOffset: from + 4, lossy: true, spillPath: '/spill/err.log' }) },
},
} as unknown as ShellProcess
const [, stderr] = processSources(() => proc)
expect(stderr!.read(0)).toEqual({ text: 'tail', nextOffset: 4, lossy: true, spillPath: '/spill/err.log' })
})
})
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)
})
})