Kaynağa Gözat

Merge branch 'session-surface' into compact-interface

Hypatia May 3 ay önce
ebeveyn
işleme
4c1b7191f3

+ 2 - 0
.gitignore

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

+ 19 - 3
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<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

+ 14 - 5
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<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')
   })
 

+ 19 - 3
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<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]')
 

+ 5 - 1
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

+ 50 - 0
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,
+    })
+  })
+})