From f8df313f218734a105fa547ef8df367047e299ca Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:24:33 +0800 Subject: [PATCH] fix(pty): preserve persistent bash exit status --- .../pty/tool-bash-persistent/src/index.ts | 26 ++++++++++++++++-- .../tool-bash-persistent/tests/tools.spec.ts | 27 +++++++++++++++---- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 136dcbaabd..6cadf75708 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -43,6 +43,7 @@ interface RetainedOutput { interface CapturedOutput { text: string incomplete: boolean + exitCode?: number } interface PersistentShells { @@ -94,11 +95,13 @@ function commandOutput( ): CapturedOutput { const text = snapshot.text const end = text.lastIndexOf(marker.end) + const exitCode = Number.parseInt(text.slice(end + marker.end.length), 10) 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, } } @@ -165,9 +168,24 @@ function retainedScrollback( function renderCaptured(output: CapturedOutput, maxOutputChars: number): string { const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete) - return output.incomplete && output.text.length > 0 + const withPrefix = output.incomplete && output.text.length > 0 ? LOST_PREFIX_MESSAGE + rendered : rendered + return renderExitStatus(withPrefix, output.exitCode ?? 0, null) +} + +function renderExitStatus( + content: string, + exitCode: number | null, + signal: NodeJS.Signals | null, +): string { + const marker = signal !== null + ? `[killed by signal: ${signal}]` + : exitCode !== null && exitCode !== 0 + ? `[exit code: ${exitCode}]` + : undefined + if (marker === undefined) return content + return content.length === 0 ? marker : `${content}\n${marker}` } function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { @@ -299,7 +317,11 @@ async function executeCommand( const snapshot = retainedScrollback(ctx, owner, id, latest) await shells.reset(owner, 'persistent bash shell exited') return [ - renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), + renderExitStatus( + renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), + result.sessionStatus.exitCode, + result.sessionStatus.signal, + ), SHELL_RESET_MESSAGE, ].filter(part => part.length > 0).join('\n') } diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 811d733f2f..990004c1c9 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -78,9 +78,11 @@ type StubMode = | 'empty-read' | 'stalled-read' | 'exit' + | 'signal-exit' | 'wait-for-abort' | 'idle-then-normal' | 'large' + | 'nonzero' | 'end-only' | 'init-exit' | 'init-timeout' @@ -157,13 +159,18 @@ class StubPtySession implements PtyBackendSession { this.scrollback += output return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } - const commandOutput = this.mode === 'large' ? 'x'.repeat(100) : 'hello from stub' - const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}0\n${this.motd}` + const commandOutput = this.mode === 'large' + ? 'x'.repeat(100) + : this.mode === 'nonzero' ? '' : 'hello from stub' + const exitCode = this.mode === 'nonzero' ? 7 : 0 + const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` this.scrollback += output - if (this.mode === 'exit') { + if (this.mode === 'exit' || this.mode === 'signal-exit') { const exitedOutput = `${start ?? ''}\nhello from stub\n` this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput - this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + this.statusValue = this.mode === 'signal-exit' + ? { kind: 'exited', exitCode: null, signal: 'SIGTERM' } + : { kind: 'exited', exitCode: 9, signal: null } return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) } return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) @@ -303,19 +310,29 @@ describe('tool-bash-persistent', () => { session.mode = 'large' expect(text(await call(ctx, owner, 'large'))).toContain('') + session.mode = 'nonzero' + expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]') + session.mode = 'exit' const exited = text(await call(ctx, owner, 'exit')) expect(exited).toContain('hello from') + expect(exited).toContain('[exit code: 9]') expect(exited).toContain('next bash call starts from the workspace') expect(session.closed).toContain('persistent bash shell exited') await call(ctx, owner, 'new shell') expect(stub.sessions).toHaveLength(2) + const replacement = stub.sessions[1]! + replacement.mode = 'signal-exit' + expect(text(await call(ctx, owner, 'kill shell'))).toContain('[killed by signal: SIGTERM]') + + await call(ctx, owner, 'another shell') + expect(stub.sessions).toHaveLength(3) const externallyClosed = ctx.pty.list(owner)[0]?.sessionId expect(externallyClosed).toBeDefined() await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') await fiber.dispose() - expect(stub.sessions[1]?.closed).toEqual(['external cleanup']) + expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => {