fix(pty): keep the controlled prompt so persistent bash settles fast

tool-bash-persistent overwrote the backend's PS1, so terminal-bash prompt
readiness never matched and every send degraded to the 3.5s silence tier
(idleSilenceMs + handoffGraceMs) under production defaults.

The controlled PROMPT_COMMAND now re-asserts PS1 before every prompt, so an
in-shell override never survives to the next prompt. The tool initializes
with stty -echo alone and detects the no-end-marker fallback through the
seam's stdin_read wait reason instead of matching its own prompt text.

Tool calls drop from 7180/3560/3566 ms to 355/88/91 ms (spawn+init+echo,
echo, pwd; darwin, production defaults). The loader composition suite now
pins the fast path by pushing idleSilenceMs beyond the send bound, and a
real-PTY case proves PS1 self-healing.

Fixes #2585
This commit is contained in:
Yichen Jiang
2026-08-15 11:06:57 +08:00
parent 5bb600f9fb
commit a8dc6f9776
18 changed files with 145 additions and 40 deletions

View File

@@ -100,7 +100,7 @@ type StubMode =
| 'paged-scrollback'
class StubPtySession implements TerminalBackendSession {
readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
readonly motd = 'stub> '
readonly pid = 123
statusValue: TerminalSessionStatus = { kind: 'running' }
scrollback = this.motd
@@ -325,7 +325,7 @@ describe('tool-bash-persistent', () => {
expect(ctx.tools.get('bash')).toBeUndefined()
})
it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => {
it('handles inferred idle, stdin_read fallback, shell exit, clipping, and cleanup', async () => {
const { ctx, owner, stub, fiber } = await setup({
backendType: 'stub',
maxOutputChars: 10,
@@ -338,18 +338,16 @@ describe('tool-bash-persistent', () => {
session.mode = 'incremental-fallback'
session.scrollback = ''
expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment')
expect(text(await call(ctx, owner, 'incremental fallback'))).toContain('increment')
session.mode = 'prompt-only'
const promptFallback = text(await call(ctx, owner, 'bad {'))
expect(promptFallback).toContain('bash: synt')
expect(promptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
session.mode = 'prompt-crlf'
session.scrollback = ''
const crlfPromptFallback = text(await call(ctx, owner, 'bad {'))
expect(crlfPromptFallback).toContain('bash: synt')
expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
session.mode = 'end-only'
session.scrollback = ''
@@ -435,7 +433,7 @@ describe('tool-bash-persistent', () => {
expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub')
})
it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
it('returns a stdin_read fallback reached after multiple polling rounds', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
@@ -444,7 +442,8 @@ describe('tool-bash-persistent', () => {
const result = text(await call(ctx, owner, 'bad {'))
expect(result).toContain('partial syntax output')
expect(result).toContain('bash: syntax error')
expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
// The backend owns the prompt text, so the fallback retains it verbatim.
expect(result.endsWith('stub> ')).toBe(true)
expect(result).not.toContain('DSH_PERSISTENT_BASH_START')
})