diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0e92e4e06e..2e6ffaf3d8 100644 --- a/docs/config-catalog.md +++ b/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` diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 5d9855c1da..7ff0773107 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/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, ) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 213161e147..fb1e0d4b58 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/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', }) - expect(await readFile(path, 'utf8')).toBe('target:\n\tnew\n') + 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(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', () => { diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 8ba51388cf..136dcbaabd 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/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>() const ownerCleanupInstalled = new WeakSet() const lifecycle = new AbortController() - let disposed = false const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { 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 => { - 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) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 3863ec29ce..811d733f2f 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/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 () => {