فهرست منبع

test(tools): close persistent tool coverage gaps

Yichen Jiang 2 ماه پیش
والد
کامیت
df58af92cd

+ 1 - 1
docs/config-catalog.md

@@ -1593,7 +1593,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/pty/tool-bash-persistent/src/index.ts:382`](../packages/pty/tool-bash-persistent/src/index.ts)
+Source: [`packages/pty/tool-bash-persistent/src/index.ts:373`](../packages/pty/tool-bash-persistent/src/index.ts)
 
 ## `@deepseek-ai/dsh-tool-cordis`
 

+ 1 - 1
packages/fs/tool-str-replace-editor/src/index.ts

@@ -281,7 +281,7 @@ async function createFile(
     outcome = await ctx.fs.writeText(
       target,
       content,
-      intent ?? { kind: 'createIfAbsent' },
+      intent,
       exec.signal,
       sandboxPolicy,
     )

+ 98 - 3
packages/fs/tool-str-replace-editor/tests/tools.spec.ts

@@ -129,6 +129,24 @@ describe('tool-str-replace-editor', () => {
       kind: 'edit',
       locations: [{ path: '/workspace/a.txt', line: 1 }],
     })
+    expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
+      command: 'create',
+      path: '/workspace/empty.txt',
+    })).toMatchObject({
+      diffs: [{ path: '/workspace/empty.txt', oldText: null, newText: '' }],
+    })
+    expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
+      command: 'str_replace',
+      path: '/workspace/a.txt',
+    })).toMatchObject({
+      diffs: [{ path: '/workspace/a.txt', oldText: null, newText: '' }],
+    })
+    expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
+      command: 'insert',
+      path: '/workspace/a.txt',
+    })).toMatchObject({
+      locations: [{ path: '/workspace/a.txt' }],
+    })
   })
 
   it('creates, views, replaces, and inserts with the canonical model-facing output', async () => {
@@ -192,7 +210,11 @@ describe('tool-str-replace-editor', () => {
     ctx.fs.listDir = async (target, signal) => {
       const entries = await listDir(target, signal)
       return target.displayPath === join(root, 'dir')
-        ? [...entries, { name: 'other', type: 'other', target: otherTarget }]
+        ? [
+          { name: 'same-target', type: 'other', target: otherTarget },
+          { name: 'other', type: 'other', target: otherTarget },
+          ...entries.toReversed(),
+        ]
         : entries
     }
 
@@ -235,6 +257,11 @@ describe('tool-str-replace-editor', () => {
       command: 'view',
       path: plain,
     }))).toContain('     1  one')
+    expect((await call(ctx, undefined, {
+      command: 'create',
+      path: join(root, 'ownerless.txt'),
+      file_text: 'ownerless',
+    })).isError).toBe(false)
 
     await call(ctx, owner, {
       command: 'insert',
@@ -382,6 +409,14 @@ describe('tool-str-replace-editor', () => {
     })).isError).toBe(false)
     expect(await readFile(existing, 'utf8')).toBe('after')
 
+    expect((await call(ctx, owner, {
+      command: 'insert',
+      path: existing,
+      insert_line: 1,
+      new_str: 'tail',
+    })).isError).toBe(false)
+    expect(await readFile(existing, 'utf8')).toBe('after\ntail')
+
     expect((await call(ctx, owner, {
       command: 'create',
       path: created,
@@ -400,19 +435,79 @@ describe('tool-str-replace-editor', () => {
     })
     expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
     expect(text(result)).toContain('[sandbox: file access denied under read-only mode]')
+
+    const ownerless = await call(ctx, undefined, {
+      command: 'create',
+      path: join(root, 'ownerless-blocked.txt'),
+      file_text: 'blocked',
+    })
+    expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
   })
 
   it('can preserve tabs outside the edited region', async () => {
     const { ctx, root, owner } = await setup({ expandTabsOnMutation: false })
     const path = join(root, 'Makefile')
-    await writeFile(path, 'target:\n\told\n')
+    await writeFile(path, 'target:\n\told\nremove\n')
+    await call(ctx, owner, {
+      command: 'str_replace',
+      path,
+      old_str: 'old',
+      new_str: 'new',
+    })
+    await call(ctx, owner, {
+      command: 'str_replace',
+      path,
+      old_str: 'remove\n',
+    })
     await call(ctx, owner, {
+      command: 'insert',
+      path,
+      insert_line: 1,
+      new_str: '\tkept',
+    })
+    expect(await readFile(path, 'utf8')).toBe('target:\n\tkept\n\tnew\n')
+  })
+
+  it('reports missing sandbox-policy composition during plugin startup', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-missing-policy-'))
+    roots.push(root)
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(LocalFileSystem, { cwd: root })
+    Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' })
+
+    await expect(ctx.plugin(ToolStrReplaceEditor))
+      .rejects.toThrow('the mounted filesystem confines but ctx.sandboxPolicy is missing')
+  })
+
+  it('maps unexpected backend write failures for replace and insert', async () => {
+    const { ctx, root, owner } = await setup()
+    const path = join(root, 'backend-error.txt')
+    await writeFile(path, 'old\n')
+    ctx.fs.writeText = async () => {
+      throw new Error('backend write failed')
+    }
+
+    const replace = await call(ctx, owner, {
       command: 'str_replace',
       path,
       old_str: 'old',
       new_str: 'new',
     })
-    expect(await readFile(path, 'utf8')).toBe('target:\n\tnew\n')
+    expect(replace.isError).toBe(true)
+    expect(text(replace)).toContain('backend write failed')
+
+    const insert = await call(ctx, owner, {
+      command: 'insert',
+      path,
+      insert_line: 1,
+      new_str: 'new',
+    })
+    expect(insert.isError).toBe(true)
+    expect(text(insert)).toContain('backend write failed')
   })
 
   it('rejects invalid plugin config', () => {

+ 3 - 12
packages/pty/tool-bash-persistent/src/index.ts

@@ -81,12 +81,7 @@ function wrapCommand(command: string, marker: CommandMarkers): string {
 }
 
 function stripPrompt(text: string): string {
-  let result = text
-  while (result.endsWith(`${SHELL_PROMPT}\r\n`) || result.endsWith(`${SHELL_PROMPT}\n`)) {
-    result = result.slice(0, result.endsWith('\r\n')
-      ? -SHELL_PROMPT.length - 2
-      : -SHELL_PROMPT.length - 1)
-  }
+  let result = text.replace(/\r?\n$/, '')
   while (result.endsWith(SHELL_PROMPT)) {
     result = result.slice(0, -SHELL_PROMPT.length)
   }
@@ -96,10 +91,9 @@ function stripPrompt(text: string): string {
 function commandOutput(
   snapshot: RetainedOutput,
   marker: CommandMarkers,
-): CapturedOutput | undefined {
+): CapturedOutput {
   const text = snapshot.text
   const end = text.lastIndexOf(marker.end)
-  if (end < 0) return undefined
   const startMarker = text.lastIndexOf(marker.start, end)
   const start = startMarker < 0 ? 0 : startMarker + marker.start.length
   return {
@@ -182,7 +176,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
   const creating = new Set<Promise<PtySessionId>>()
   const ownerCleanupInstalled = new WeakSet<Agent>()
   const lifecycle = new AbortController()
-  let disposed = false
 
   const close = async (owner: Agent, id: PtySessionId, reason: string): Promise<void> => {
     if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return
@@ -190,7 +183,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
   }
 
   ctx.effect(() => async () => {
-    disposed = true
     lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation'))
     await Promise.allSettled([...creating])
     const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') })
@@ -206,7 +198,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
   }
 
   const get = (owner: Agent, signal: AbortSignal): Promise<PtySessionId> => {
-    if (disposed) return Promise.reject(new Error('tool-bash-persistent is disposed'))
     const existing = pending.get(owner)
     if (existing !== undefined) return existing
     const combinedSignal = AbortSignal.any([signal, lifecycle.signal])
@@ -302,7 +293,7 @@ async function executeCommand(
     }
     if (latest.text.includes(marker.end)) {
       const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker)
-      if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
+      return renderCaptured(complete, config.maxOutputChars)
     }
     if (result.sessionStatus.kind === 'exited') {
       const snapshot = retainedScrollback(ctx, owner, id, latest)

+ 10 - 3
packages/pty/tool-bash-persistent/tests/tools.spec.ts

@@ -87,6 +87,7 @@ type StubMode =
   | 'spawn-error'
   | 'send-error'
   | 'prompt-after-idle'
+  | 'empty-page-after-latest'
 
 class StubPtySession implements PtyBackendSession {
   readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
@@ -168,19 +169,22 @@ class StubPtySession implements PtyBackendSession {
     return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
   }
 
-  read(_request: PtyReadRequest) {
+  read(request: PtyReadRequest) {
     if (this.mode === 'empty-read') {
       return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }
     }
     if (this.mode === 'stalled-read') {
       return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false }
     }
+    if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) {
+      return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false }
+    }
     const lines = this.scrollback.split('\n')
     return {
       text: this.scrollback,
-      totalLines: lines.length,
+      totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length,
       lineBegin: 0,
-      lineEnd: lines.length,
+      lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length,
       truncated: this.historyTruncated,
     }
   }
@@ -329,6 +333,9 @@ describe('tool-bash-persistent', () => {
 
     session.mode = 'stalled-read'
     expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub')
+
+    session.mode = 'empty-page-after-latest'
+    expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub')
   })
 
   it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {