Merge branch 'session-surface' into compact-interface
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,3 +13,5 @@ coverage/
|
|||||||
.humanize/
|
.humanize/
|
||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
mise.toml
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
|
|||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||||
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||||
|
import type { BashTaskRead } from '@deepseek-ai/dsh-bash'
|
||||||
|
|
||||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
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`)
|
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', () => {
|
describe('LocalBashExecutor.run', () => {
|
||||||
it('resolves with output and the effective timeout', async () => {
|
it('resolves with output and the effective timeout', async () => {
|
||||||
const { bash } = await setup({ timeoutMs: 5_000 })
|
const { bash } = await setup({ timeoutMs: 5_000 })
|
||||||
@@ -116,9 +133,8 @@ describe('LocalBashExecutor background tasks', () => {
|
|||||||
|
|
||||||
it('readOutput returns increments without re-delivery', async () => {
|
it('readOutput returns increments without re-delivery', async () => {
|
||||||
const { bash } = await setup()
|
const { bash } = await setup()
|
||||||
const task = bash.start(bash.resolve({ command: 'echo first; sleep 0.3; echo second' }))
|
const task = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
|
||||||
await new Promise(resolve => setTimeout(resolve, 150))
|
const first = await readUntil(bash, task.id, 'first\n')
|
||||||
const first = bash.readOutput(task.id)
|
|
||||||
expect(first.delta).toBe('first\n')
|
expect(first.delta).toBe('first\n')
|
||||||
expect(first.lossy).toBe(false)
|
expect(first.lossy).toBe(false)
|
||||||
await task.done
|
await task.done
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
|||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local'
|
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 } }))
|
const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
|
||||||
vi.mock('node:fs', async (importOriginal) => {
|
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`)
|
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', () => {
|
describe('runBash', () => {
|
||||||
it('captures stdout on success', async () => {
|
it('captures stdout on success', async () => {
|
||||||
const result = await runBash(spec('echo hello')).done
|
const result = await runBash(spec('echo hello')).done
|
||||||
@@ -96,11 +106,10 @@ describe('runBash', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
|
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
|
||||||
const result = await runBash(
|
const running = runBash(spec('trap \'\' TERM; echo ready; sleep 60'), { graceMs: 200 })
|
||||||
spec('trap \'\' TERM; sleep 60', { timeoutMs: 100 }),
|
await waitForStdout(running, 'ready\n')
|
||||||
{ graceMs: 200 },
|
running.kill()
|
||||||
).done
|
const result = await running.done
|
||||||
expect(result.timedOut).toBe(true)
|
|
||||||
expect(result.signal).toBe('SIGKILL')
|
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('')
|
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 {
|
class LossyReadBashExecutor extends BashExecutor {
|
||||||
private readonly task: BashTask = {
|
private readonly task: BashTask = {
|
||||||
id: BashTaskId('bash-lossy'),
|
id: BashTaskId('bash-lossy'),
|
||||||
@@ -278,11 +295,10 @@ describe('background tools', () => {
|
|||||||
|
|
||||||
it('bash_output polls incrementally and reports status', async () => {
|
it('bash_output polls incrementally and reports status', async () => {
|
||||||
const ctx = await setup()
|
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]!)
|
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 150))
|
const first = await callUntilText(ctx, 'bash_output', { task_id: id }, 'first')
|
||||||
const first = await call(ctx, 'bash_output', { task_id: id })
|
|
||||||
expect(text(first)).toContain('first')
|
expect(text(first)).toContain('first')
|
||||||
expect(text(first)).toContain('[status: running]')
|
expect(text(first)).toContain('[status: running]')
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ export interface StdioRuntime {
|
|||||||
exit: (code: number) => void
|
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
|
* The plugin body, parameterized over its I/O runtime. `apply` is the thin
|
||||||
* production wrapper that binds the real `process` streams; tests call this
|
* 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(() => {
|
ctx.effect(() => {
|
||||||
const reader = createInterface({ input })
|
const reader = createInterface({ input, output, terminal: isTTYPair(input, output) })
|
||||||
// Piped-input exit, once stdin reaches EOF:
|
// Piped-input exit, once stdin reaches EOF:
|
||||||
// - If no line ever submitted work (empty stdin, blank-only lines), exit
|
// - If no line ever submitted work (empty stdin, blank-only lines), exit
|
||||||
// immediately — no turn will ever start, so there is nothing to wait
|
// 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