fix(remote): deliver stdin-after-start prompt to interpreter-wrapped agents (aider/vibe)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-10 05:48:10 -04:00
co-authored by Orca
parent 26224196f5
commit fc40ca2bdf
2 changed files with 113 additions and 2 deletions
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { sendFollowupPromptWhenAgentReady } from './agent-followup-delivery'
import {
inspectRuntimeTerminalProcess,
sendRuntimePtyInputVerified
} from '@/runtime/runtime-terminal-inspection'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
inspectRuntimeTerminalProcess: vi.fn(),
sendRuntimePtyInputVerified: vi.fn()
}))
// The interpreter-wrapped agents that deliver their prompt over stdin after the
// process starts. These are pip console-scripts, so the PTY foreground comm is
// python/python3 — never the agent's own name.
const INTERPRETER_WRAPPED_AGENTS = [
{ agent: 'aider', expectedProcess: TUI_AGENT_CONFIG.aider.expectedProcess },
{ agent: 'mistral-vibe', expectedProcess: TUI_AGENT_CONFIG['mistral-vibe'].expectedProcess }
] as const
describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('globalThis', globalThis)
// Deliver the prompt write eagerly so the test does not depend on retries.
vi.mocked(sendRuntimePtyInputVerified).mockResolvedValue(true)
})
it('sanity: config keeps aider/vibe as stdin-after-start with python-style expected process', () => {
expect(TUI_AGENT_CONFIG.aider.promptInjectionMode).toBe('stdin-after-start')
expect(TUI_AGENT_CONFIG['mistral-vibe'].promptInjectionMode).toBe('stdin-after-start')
})
for (const { agent, expectedProcess } of INTERPRETER_WRAPPED_AGENTS) {
it(`types the prompt once ${agent} is up behind a python3 wrapper with a live child`, async () => {
// The console-script agent is running: foreground comm is python3 and the
// PTY has a non-shell child. The exact agent name never appears.
vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({
foregroundProcess: 'python3',
hasChildProcesses: true
})
const delivered = await sendFollowupPromptWhenAgentReady({
ptyId: 'pty-1',
expectedProcess,
prompt: 'ship it',
settings: null
})
expect(delivered).toBe(true)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r')
})
it(`still refuses to type into a bare ${agent} shell foreground`, async () => {
// No agent yet: foreground is a plain shell with no non-shell child. The
// guard must NOT write user text into an arbitrary shell.
vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({
foregroundProcess: 'zsh',
hasChildProcesses: false
})
const delivered = await sendFollowupPromptWhenAgentReady({
ptyId: 'pty-1',
expectedProcess,
prompt: 'ship it',
settings: null
})
expect(delivered).toBe(false)
expect(sendRuntimePtyInputVerified).not.toHaveBeenCalled()
})
}
it('types immediately when the resolver already returns the agent name (local ps path)', async () => {
// On local desktop the ps-table resolver usually resolves python3 → aider
// before we poll; the exact-match path must keep working.
vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({
foregroundProcess: 'aider',
hasChildProcesses: true
})
const delivered = await sendFollowupPromptWhenAgentReady({
ptyId: 'pty-1',
expectedProcess: 'aider',
prompt: 'ship it',
settings: null
})
expect(delivered).toBe(true)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r')
})
})
@@ -2,7 +2,11 @@ import {
inspectRuntimeTerminalProcess,
sendRuntimePtyInputVerified
} from '@/runtime/runtime-terminal-inspection'
import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition'
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess
} from '../../../shared/agent-process-recognition'
import { isShellProcess } from '../../../shared/shell-process-detection'
import type { GlobalSettings } from '../../../shared/types'
type RuntimeOwnerSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
@@ -25,7 +29,7 @@ export async function sendFollowupPromptWhenAgentReady(args: {
}
// Why: delayed follow-ups must not type into an arbitrary shell. Require a
// positive expected-process match before writing user/task text to the PTY.
// positive readiness signal before writing user/task text to the PTY.
async function waitForAgentForeground(
ptyId: string,
expectedProcess: string,
@@ -41,6 +45,20 @@ async function waitForAgentForeground(
if (isExpectedAgentProcess(foreground, expectedProcess)) {
return true
}
// Why: interpreter-wrapped agents (aider, mistral-vibe are pip console
// scripts) surface a python/node foreground comm, so the exact-name check
// never matches — locally when the ps-table resolver can't pin the child,
// and over SSH when the relay falls back to the bare interpreter name. If
// the foreground is a known agent wrapper (not a shell) with a live
// non-shell child, the agent has taken over the PTY and can accept input.
if (
attempt >= 4 &&
isAgentForegroundWrapperProcess(foreground) &&
!isShellProcess(foreground) &&
process.hasChildProcesses
) {
return true
}
} catch {
// Ignore transient PTY inspection failures and keep polling.
}