Merge remote-tracking branch 'origin/master' into session-surface
This commit is contained in:
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashTaskRead } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
||||
|
||||
@@ -30,6 +31,22 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
|
||||
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
async function readUntil(
|
||||
bash: LocalBashExecutor,
|
||||
id: BashTaskId,
|
||||
expected: string,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<BashTaskRead> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let last: BashTaskRead | undefined
|
||||
while (Date.now() < deadline) {
|
||||
last = bash.readOutput(id)
|
||||
if (last.delta.includes(expected)) return last
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; last delta was ${JSON.stringify(last?.delta ?? '')}`)
|
||||
}
|
||||
|
||||
describe('LocalBashExecutor.run', () => {
|
||||
it('resolves with output and the effective timeout', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 5_000 })
|
||||
@@ -116,9 +133,8 @@ describe('LocalBashExecutor background tasks', () => {
|
||||
|
||||
it('readOutput returns increments without re-delivery', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo first; sleep 0.3; echo second' }))
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
const first = bash.readOutput(task.id)
|
||||
const task = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
|
||||
const first = await readUntil(bash, task.id, 'first\n')
|
||||
expect(first.delta).toBe('first\n')
|
||||
expect(first.lossy).toBe(false)
|
||||
await task.done
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { RunningBash } from '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
@@ -45,6 +46,15 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
|
||||
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (running.stdout.snapshot().text.includes(expected)) return
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
describe('runBash', () => {
|
||||
it('captures stdout on success', async () => {
|
||||
const result = await runBash(spec('echo hello')).done
|
||||
@@ -96,11 +106,10 @@ describe('runBash', () => {
|
||||
})
|
||||
|
||||
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
|
||||
const result = await runBash(
|
||||
spec('trap \'\' TERM; sleep 60', { timeoutMs: 100 }),
|
||||
{ graceMs: 200 },
|
||||
).done
|
||||
expect(result.timedOut).toBe(true)
|
||||
const running = runBash(spec('trap \'\' TERM; echo ready; sleep 60'), { graceMs: 200 })
|
||||
await waitForStdout(running, 'ready\n')
|
||||
running.kill()
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGKILL')
|
||||
})
|
||||
|
||||
|
||||
@@ -66,6 +66,23 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
async function callUntilText(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
args: unknown,
|
||||
expected: string,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<Awaited<ReturnType<typeof call>>> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let last: Awaited<ReturnType<typeof call>> | undefined
|
||||
while (Date.now() < deadline) {
|
||||
last = await call(ctx, name, args)
|
||||
if (text(last).includes(expected)) return last
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`${name} output did not include ${JSON.stringify(expected)}; last text was ${JSON.stringify(last !== undefined ? text(last) : '')}`)
|
||||
}
|
||||
|
||||
class LossyReadBashExecutor extends BashExecutor {
|
||||
private readonly task: BashTask = {
|
||||
id: BashTaskId('bash-lossy'),
|
||||
@@ -278,11 +295,10 @@ describe('background tools', () => {
|
||||
|
||||
it('bash_output polls incrementally and reports status', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'echo first; sleep 0.3; echo second', description: 'test command', run_in_background: true })
|
||||
const started = await call(ctx, 'bash', { command: 'echo first; sleep 1; echo second', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
const first = await call(ctx, 'bash_output', { task_id: id })
|
||||
const first = await callUntilText(ctx, 'bash_output', { task_id: id }, 'first')
|
||||
expect(text(first)).toContain('first')
|
||||
expect(text(first)).toContain('[status: running]')
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ export interface StdioRuntime {
|
||||
exit: (code: number) => void
|
||||
}
|
||||
|
||||
function isTTYPair(input: Readable, output: Writable): boolean {
|
||||
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin body, parameterized over its I/O runtime. `apply` is the thin
|
||||
* production wrapper that binds the real `process` streams; tests call this
|
||||
@@ -110,7 +114,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const reader = createInterface({ input })
|
||||
const reader = createInterface({ input, output, terminal: isTTYPair(input, output) })
|
||||
// Piped-input exit, once stdin reaches EOF:
|
||||
// - If no line ever submitted work (empty stdin, blank-only lines), exit
|
||||
// immediately — no turn will ever start, so there is nothing to wait
|
||||
|
||||
50
packages/support/ui-stdio/tests/readline.spec.ts
Normal file
50
packages/support/ui-stdio/tests/readline.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { StdioRuntime } from '../src/index.ts'
|
||||
|
||||
const createInterface = vi.hoisted(() => vi.fn(() => {
|
||||
const reader = new EventEmitter() as EventEmitter & { close(): void }
|
||||
reader.close = vi.fn()
|
||||
return reader
|
||||
}))
|
||||
|
||||
vi.mock('node:readline', () => ({ createInterface }))
|
||||
|
||||
function fakeContext(): Context {
|
||||
return {
|
||||
on: vi.fn(() => vi.fn()),
|
||||
effect: vi.fn((callback: () => () => void) => callback()),
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime {
|
||||
return {
|
||||
input: { isTTY: inputIsTTY } as Readable & { isTTY: boolean },
|
||||
output: { isTTY: outputIsTTY, write: vi.fn(() => true) } as unknown as Writable & { isTTY: boolean },
|
||||
exit: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('createStdioChat readline mode', () => {
|
||||
it('enables terminal editing only when both stdio streams are TTYs', async () => {
|
||||
const { createStdioChat } = await import('../src/index.ts')
|
||||
|
||||
const tty = fakeRuntime(true, true)
|
||||
createStdioChat(fakeContext(), {}, tty)
|
||||
expect(createInterface).toHaveBeenLastCalledWith({
|
||||
input: tty.input,
|
||||
output: tty.output,
|
||||
terminal: true,
|
||||
})
|
||||
|
||||
const piped = fakeRuntime(true, false)
|
||||
createStdioChat(fakeContext(), {}, piped)
|
||||
expect(createInterface).toHaveBeenLastCalledWith({
|
||||
input: piped.input,
|
||||
output: piped.output,
|
||||
terminal: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user