diff --git a/.gitignore b/.gitignore index 2dcf9e39bf..b52f86cd61 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ coverage/ .humanize/ .vscode/ .DS_Store +.idea +mise.toml diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index f851e837d5..2a27f29731 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -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 { throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`) } +async function readUntil( + bash: LocalBashExecutor, + id: BashTaskId, + expected: string, + timeoutMs = 5_000, +): Promise { + 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 diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index ff1d92b519..b770c4a6c1 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -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 { throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`) } +async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise { + 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') }) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 68cf71eda6..9de079fd33 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -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>> { + const deadline = Date.now() + timeoutMs + let last: Awaited> | 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]') diff --git a/packages/support/ui-stdio/src/index.ts b/packages/support/ui-stdio/src/index.ts index 4d93ed3f86..070e842094 100644 --- a/packages/support/ui-stdio/src/index.ts +++ b/packages/support/ui-stdio/src/index.ts @@ -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 diff --git a/packages/support/ui-stdio/tests/readline.spec.ts b/packages/support/ui-stdio/tests/readline.spec.ts new file mode 100644 index 0000000000..c8b147ddab --- /dev/null +++ b/packages/support/ui-stdio/tests/readline.spec.ts @@ -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, + }) + }) +})