Merge branch 'session-surface' into compact-interface

This commit is contained in:
Hypatia May
2026-06-25 10:07:37 +08:00
6 changed files with 109 additions and 12 deletions

2
.gitignore vendored
View File

@@ -13,3 +13,5 @@ coverage/
.humanize/
.vscode/
.DS_Store
.idea
mise.toml

View File

@@ -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

View File

@@ -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')
})

View File

@@ -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]')

View File

@@ -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

View 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,
})
})
})