From 7f5aa2d053ff7c2068dd9b7b90a2d53a8d8713b3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:50:12 +0800 Subject: [PATCH] fix(persistent-bash): wait for complete status markers --- .../pty/tool-bash-persistent/src/index.ts | 10 +++++---- .../tool-bash-persistent/tests/tools.spec.ts | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 2ad2bd54b8..90cf1b4774 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -93,16 +93,18 @@ function stripPrompt(text: string): string { function commandOutput( snapshot: RetainedOutput, marker: CommandMarkers, -): CapturedOutput { +): CapturedOutput | undefined { const text = snapshot.text const end = text.lastIndexOf(marker.end) - const exitCode = Number.parseInt(text.slice(end + marker.end.length), 10) + if (end < 0) return undefined + const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1] + if (status === undefined) return undefined const startMarker = text.lastIndexOf(marker.start, end) const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), incomplete: startMarker < 0, - exitCode, + exitCode: Number(status), } } @@ -317,7 +319,7 @@ async function executeCommand( } if (latest.text.includes(marker.end)) { const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker) - return renderCaptured(complete, config.maxOutputChars) + if (complete !== undefined) 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 1a9d15c476..f3bd7bf40b 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -84,6 +84,8 @@ type StubMode = | 'idle-then-normal' | 'large' | 'nonzero' + | 'torn-status' + | 'finish-torn-status' | 'end-only' | 'init-exit' | 'init-timeout' @@ -160,6 +162,17 @@ class StubPtySession implements PtyBackendSession { this.pendingText = '' const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0] const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0] + if (this.mode === 'torn-status') { + const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` + this.scrollback += output + this.mode = 'finish-torn-status' + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + if (this.mode === 'finish-torn-status') { + const output = `7\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } if (this.mode === 'end-only') { const output = `recovered output\n${end ?? ''}0\n${this.motd}` this.scrollback += output @@ -345,6 +358,15 @@ describe('tool-bash-persistent', () => { expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) + it('waits for status digits after a torn completion marker', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'torn-status' + stub.sessions[0]!.scrollback = '' + + expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') + }) + it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) await call(ctx, owner, 'warm up')