fix(kimi): deliver Kimi's first prompt on Windows and after it is ready (#33581)

Co-authored-by: innocarpe <2222333+innocarpe@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-25 19:39:36 -07:00
committed by GitHub
co-authored by innocarpe
parent ca064cc67e
commit 89ac8f2ac1
18 changed files with 336 additions and 132 deletions
@@ -51,7 +51,7 @@ function harness(options: { startupTerminal?: Record<string, unknown> | null } =
),
getTerminalPtyIdForHandle: vi.fn((_handle: string) => 'pty-prev'),
deliverStartupFollowup: vi.fn(
async (_handle: string, _followup: { expectedProcess: string; prompt: string }) => true
async (_handle: string, _followup: { agent: string; prompt: string }) => true
)
}
return runtime
@@ -172,7 +172,7 @@ describe('headless automation agent launch', () => {
)
expect(runtime.deliverStartupFollowup).toHaveBeenCalledTimes(1)
expect(runtime.deliverStartupFollowup).toHaveBeenCalledWith('term_existing', {
expectedProcess: 'aider',
agent: 'aider',
prompt: 'fix the flaky test'
})
finishDelivery(true)
@@ -221,7 +221,7 @@ describe('headless automation agent launch', () => {
expect(runtime.createManagedWorktree.mock.calls[0]![0]).not.toHaveProperty('startupPrompt')
expect(runtime.deliverStartupFollowup).toHaveBeenCalledTimes(1)
expect(runtime.deliverStartupFollowup).toHaveBeenCalledWith('term_new', {
expectedProcess: 'goose',
agent: 'goose',
prompt: 'fix the flaky test'
})
})
@@ -7,7 +7,6 @@ import type {
import { executeAgentLaunch } from '../agent-launch/agent-launch-executor'
import type { Automation, AutomationRun } from '../../shared/automations-types'
import { findReusableAutomationRun } from '../../shared/automation-session-reuse'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { deliverTerminalAgentLaunchPrompt } from '../runtime/rpc/methods/agent-launch-terminal-prompt'
import type { HeadlessAutomationDispatchLaunch } from './headless-dispatch'
@@ -116,10 +115,7 @@ export async function launchHeadlessAutomationAgent(
}
},
deliverTerminalPrompt: ({ handle, prompt }) =>
runtime.deliverStartupFollowup(handle, {
expectedProcess: TUI_AGENT_CONFIG[agent].expectedProcess,
prompt: prompt.text
})
runtime.deliverStartupFollowup(handle, { agent, prompt: prompt.text })
}
}
await launchUntilPublished(execution)
@@ -13,6 +13,23 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
/** agent permits writing; shell drops readiness; unknown proves neither. */
export type LaunchedAgentForeground = 'agent' | 'shell' | 'unknown'
export type LaunchedAgentHost = {
remote: boolean
windows: boolean
/** Only a local non-WSL Windows pane can prove its shell alone in its job; wsl.exe and SSH reads stay unknown. */
shellAloneProvable: boolean
}
export function describeLaunchedAgentHost(
pty: { connectionId: string | null; isWsl: boolean | null } | undefined,
remotePathFlavor: () => 'posix' | 'win32'
): LaunchedAgentHost {
const remote = !!pty?.connectionId
// A local WSL pane still runs on a Windows host, whose process reads cannot see into it.
const windows = remote ? remotePathFlavor() === 'win32' : process.platform === 'win32'
return { remote, windows, shellAloneProvable: windows && !remote && pty?.isWsl === false }
}
function isLaunchShell(processName: string): boolean {
return isShellProcess(processName.replace(/^-/, ''))
}
+10 -4
View File
@@ -10,7 +10,12 @@ export type LaunchedAgentWriteGuardRuntime = Pick<
OrcaRuntimeService,
'readLaunchedAgentForeground' | 'subscribeToTerminalData'
> &
Partial<Pick<OrcaRuntimeService, 'launchedAgentHostProvesAgent'>>
Partial<
Pick<OrcaRuntimeService, 'launchedAgentHostProvesAgent' | 'launchedAgentHostProvesShellAlone'>
>
/** refuse: write only on agent proof. write-unless-shell: permit unknown. write-if-shell-provable: permit unknown only where a lone shell is provable. */
export type UnprovableHostPolicy = 'refuse' | 'write-unless-shell' | 'write-if-shell-provable'
export type LaunchedAgentWriteGuard = {
beforeWrite: (ptyId: string) => Promise<void>
@@ -22,8 +27,7 @@ export type LaunchedAgentWriteGuard = {
export function createLaunchedAgentWriteGuard(
runtime: LaunchedAgentWriteGuardRuntime,
agent: TuiAgent,
/** Windows compatibility: write-unless-shell permits unknown; only proven shell refuses. */
{ unprovableHost = 'refuse' }: { unprovableHost?: 'refuse' | 'write-unless-shell' } = {}
{ unprovableHost = 'refuse' }: { unprovableHost?: UnprovableHostPolicy } = {}
): LaunchedAgentWriteGuard {
let cleared: { ptyId: string; shellMayHaveReturned: boolean; unsubscribe: () => void } | null =
null
@@ -61,7 +65,9 @@ export function createLaunchedAgentWriteGuard(
if (
foreground === 'shell' ||
unprovableHost === 'refuse' ||
runtime.launchedAgentHostProvesAgent?.(ptyId) !== false
runtime.launchedAgentHostProvesAgent?.(ptyId) !== false ||
(unprovableHost === 'write-if-shell-provable' &&
runtime.launchedAgentHostProvesShellAlone?.(ptyId) !== true)
) {
throw new Error('agent_not_in_foreground')
}
@@ -66,6 +66,8 @@ describe('OrcaRuntimeService', () => {
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
// Delivery waits for composer readiness and pastes; its behaviour is covered with the delivery itself.
const deliver = vi.spyOn(runtime, 'deliverStartupFollowup').mockResolvedValue(true)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-cli-aider-startup')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-cli-aider-startup')
@@ -94,8 +96,9 @@ describe('OrcaRuntimeService', () => {
})
)
await vi.waitFor(() => {
expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r', 'launch')
expect(deliver).toHaveBeenCalledWith(expect.any(String), { agent: 'aider', prompt: 'fix it' })
})
expect(write).not.toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r', 'launch')
})
it('does not send stdin-after-start prompts into a shell when the agent never starts', async () => {
@@ -189,15 +189,25 @@ export function agentLaunchSurfaceFactory(
...(promptRodeLaunchCommand ? { promptRodeLaunchCommand } : {})
}
},
deliverTerminalPrompt: async ({ handle, agent, freshLaunch, prompt, signal }) =>
deliverTerminalAgentLaunchPrompt({
runtime: context.runtime,
deliverTerminalPrompt: async ({ handle, agent, freshLaunch, prompt, signal }) => {
const { runtime } = context
// A stdin-after-start prompt cannot ride a Windows launch line, so it needs the startup path's Windows proof (#10336).
if (freshLaunch && typeof runtime.deliverStartupFollowup === 'function') {
return runtime.deliverStartupFollowup(
handle,
{ agent, prompt: prompt.text },
{ agentLaunch: true, ...(signal ? { signal } : {}) }
)
}
return deliverTerminalAgentLaunchPrompt({
runtime,
handle,
agent,
freshLaunch,
text: prompt.text,
...(signal ? { signal } : {})
})
}
}
}
@@ -23,6 +23,8 @@ function runtimeStub(overrides: {
foreground?: 'agent' | 'shell' | 'unknown'
/** False on a host that cannot find the agent in front (Windows); default: it can. */
hostProvesAgent?: boolean
/** True on a local non-WSL Windows pane, whose lone shell is provable; default: not. */
hostProvesShellAlone?: boolean
}) {
const queued = [...(overrides.waits ?? [])]
const waitForTerminal = vi.fn(
@@ -38,6 +40,7 @@ function runtimeStub(overrides: {
const readLaunchedAgentForeground = vi.fn(async () => overrides.foreground ?? 'agent')
const subscribeToTerminalData = vi.fn(() => () => {})
const launchedAgentHostProvesAgent = vi.fn(() => overrides.hostProvesAgent ?? true)
const launchedAgentHostProvesShellAlone = vi.fn(() => overrides.hostProvesShellAlone ?? false)
const sendTerminalAgentPrompt = vi.fn<SendFn>(
overrides.send ?? (async () => ({ handle: 'term_1', accepted: true, bytesWritten: 12 }))
)
@@ -53,7 +56,8 @@ function runtimeStub(overrides: {
sendTerminalAgentPrompt,
readLaunchedAgentForeground,
subscribeToTerminalData,
launchedAgentHostProvesAgent
launchedAgentHostProvesAgent,
launchedAgentHostProvesShellAlone
} as unknown as Parameters<typeof deliverTerminalAgentLaunchPrompt>[0]['runtime']
}
}
@@ -422,6 +426,40 @@ describe('writing a launch prompt into a terminal agent', () => {
})
})
// WSL's wsl.exe and SSH-to-Windows read as unknown even when a shell is alone, so only local Windows may write on unknown.
describe('write-if-shell-provable', () => {
it.each([
['writes on a local Windows pane whose lone shell is provable', 'unknown', true, true],
['refuses a shell proven alone', 'shell', true, false],
['refuses an unknown WSL or SSH-to-Windows pane', 'unknown', false, false]
] as const)('%s', async (_label, foreground, hostProvesShellAlone, expected) => {
const stub = runtimeStub({
composerSignal: true,
foreground,
hostProvesAgent: false,
hostProvesShellAlone
})
stub.sendTerminalAgentPrompt.mockImplementation(async (handle, _text, options) => {
const { beforeWrite } = options
if (typeof beforeWrite === 'function') {
await beforeWrite('pty-1')
}
return { handle, accepted: true, bytesWritten: 12 }
})
await expect(
deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'kimi',
freshLaunch: true,
text: 'fix the flaky test',
unprovableHost: 'write-if-shell-provable'
})
).resolves.toBe(expected)
})
})
describe('cancelling a launch prompt', () => {
it('pastes nothing when cancelled during the readiness wait, and forwards the signal', async () => {
const controller = new AbortController()
@@ -13,7 +13,8 @@ import type { OrcaRuntimeService } from '../../orca-runtime'
import { assertAgentPromptRequestActive } from '../../orca-runtime-core'
import {
createLaunchedAgentWriteGuard,
type LaunchedAgentWriteGuardRuntime
type LaunchedAgentWriteGuardRuntime,
type UnprovableHostPolicy
} from '../../launched-agent-write-guard'
/** The same budget orchestration gives a worker to reach its composer before dispatching to it. */
@@ -21,7 +22,7 @@ const AGENT_READY_TIMEOUT_MS = 60_000
/** How often a launch re-checks a blocking prompt the user may still dismiss. */
const BLOCKED_RECHECK_MS = 1_000
type TerminalPromptRuntime = LaunchedAgentReadinessRuntime &
export type TerminalPromptRuntime = LaunchedAgentReadinessRuntime &
LaunchedAgentWriteGuardRuntime &
Pick<OrcaRuntimeService, 'sendTerminalAgentPrompt'>
@@ -82,7 +83,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: {
freshLaunch: boolean
text: string
/** See `createLaunchedAgentWriteGuard`; default `refuse`. */
unprovableHost?: 'refuse' | 'write-unless-shell'
unprovableHost?: UnprovableHostPolicy
clock?: ReadinessClock
/** Aborting ends the readiness wait and refuses the paste; text already handed over stays so. */
signal?: AbortSignal
@@ -21,7 +21,7 @@ import type { RuntimeStore } from './runtime-store-contract'
import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types'
export type WorktreeStartupDraftPaste = { agent: TuiAgent; content: string }
export type WorktreeStartupFollowup = { expectedProcess: string; prompt: string }
export type WorktreeStartupFollowup = { agent: TuiAgent; prompt: string }
/** Require agent_started attribution on every host-built fresh launch. */
type AttributedWorktreeStartupLaunch = WorktreeStartupLaunch & {
@@ -210,10 +210,7 @@ export function buildWorktreeStartupForAgent(
launchRuntime,
...(startupPlan.followupPrompt
? {
followup: {
expectedProcess: startupPlan.expectedProcess,
prompt: startupPlan.followupPrompt
}
followup: { agent, prompt: startupPlan.followupPrompt }
}
: {})
}
@@ -3,15 +3,20 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner'
import {
deliverWorktreeStartupFollowup,
deliverStartupFollowupPrompt,
waitForWorktreeStartupDraft,
type WorktreeStartupReadinessHost
} from './runtime-worktree-startup-readiness'
describe('startup follow-up delivery', () => {
describe('startup follow-up prompt', () => {
afterEach(() => vi.useRealTimers())
function followupHost(foreground: string) {
function fixture(
foreground: string,
hostProvesAgent: boolean,
hostProvesShellAlone = !hostProvesAgent,
launchedForeground: 'agent' | 'unknown' = 'agent'
) {
const write = vi.fn()
const host: WorktreeStartupReadinessHost = {
getPtyId: () => 'pty-1',
@@ -21,35 +26,96 @@ describe('startup follow-up delivery', () => {
readRecentOutput: () => undefined,
write
}
return { host, write }
const sendTerminalAgentPrompt = vi.fn(async (handle: string) => ({
handle,
accepted: true,
bytesWritten: 12
}))
const waitForFreshWorkerComposer = vi.fn(async () => ({ satisfied: true, status: 'running' }))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: delivery reaches only these runtime methods; any other would throw.
const runtime = {
waitForFreshWorkerComposer,
waitForTerminal: vi.fn(async () => ({ satisfied: true, status: 'idle' })),
sendTerminalAgentPrompt,
readLaunchedAgentForeground: vi.fn(async () => launchedForeground),
subscribeToTerminalData: vi.fn(() => () => {}),
launchedAgentHostProvesAgent: vi.fn(() => hostProvesAgent),
launchedAgentHostProvesShellAlone: vi.fn(() => hostProvesShellAlone)
} as unknown as Parameters<typeof deliverStartupFollowupPrompt>[0]
return { host, write, runtime, sendTerminalAgentPrompt, waitForFreshWorkerComposer }
}
it('types the prompt once the agent is in front and reports it written', async () => {
const { host, write } = followupHost('aider')
// Kimi is in front before its TUI reads input; text typed then is echoed and lost (#10336).
it('pastes and submits through composer readiness, never typing raw input', async () => {
const f = fixture('kimi', true)
await expect(
deliverWorktreeStartupFollowup(host, 'term-1', {
expectedProcess: 'aider',
deliverStartupFollowupPrompt(f.runtime, f.host, 'term-1', {
agent: 'kimi',
prompt: 'fix the flaky test'
})
).resolves.toBe(true)
expect(write).toHaveBeenCalledOnce()
expect(write).toHaveBeenCalledWith('pty-1', 'fix the flaky test\r', 'launch')
expect(f.waitForFreshWorkerComposer).toHaveBeenCalled()
expect(f.sendTerminalAgentPrompt).toHaveBeenCalledWith(
'term-1',
'fix the flaky test',
expect.objectContaining({ inputKind: 'launch', composerReady: true })
)
expect(f.write).not.toHaveBeenCalled()
})
it('writes nothing and reports false when the agent never comes to the front', async () => {
// WSL and SSH-to-Windows reads cannot prove a lone shell, so they keep main's behaviour per caller.
it('types the follow-up on WSL or SSH-to-Windows once the agent is in front, as before', async () => {
const f = fixture('kimi', false, false, 'unknown')
await expect(
deliverStartupFollowupPrompt(f.runtime, f.host, 'term-1', { agent: 'kimi', prompt: 'go' })
).resolves.toBe(true)
expect(f.write).toHaveBeenCalledWith('pty-1', 'go\r', 'launch')
expect(f.sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
it('still refuses an agent.launch prompt on WSL or SSH-to-Windows, as before', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const f = fixture('kimi', false, false, 'unknown')
f.sendTerminalAgentPrompt.mockImplementation(async (handle: string, ...rest: unknown[]) => {
const options = rest[1]
if (options && typeof options === 'object' && 'beforeWrite' in options) {
const { beforeWrite } = options
if (typeof beforeWrite === 'function') {
await beforeWrite('pty-1')
}
}
return { handle, accepted: true, bytesWritten: 2 }
})
await expect(
deliverStartupFollowupPrompt(
f.runtime,
f.host,
'term-1',
{ agent: 'kimi', prompt: 'go' },
{ agentLaunch: true }
)
).resolves.toBe(false)
expect(f.write).not.toHaveBeenCalled()
warn.mockRestore()
})
it('writes nothing on a Windows host while the shell stays in front', async () => {
vi.useFakeTimers()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { host, write } = followupHost('zsh')
const f = fixture('pwsh', false)
const delivered = deliverWorktreeStartupFollowup(host, 'term-1', {
expectedProcess: 'aider',
const delivered = deliverStartupFollowupPrompt(f.runtime, f.host, 'term-1', {
agent: 'kimi',
prompt: 'fix the flaky test'
})
await vi.advanceTimersByTimeAsync(30 * 150)
await expect(delivered).resolves.toBe(false)
expect(write).not.toHaveBeenCalled()
expect(f.sendTerminalAgentPrompt).not.toHaveBeenCalled()
expect(f.write).not.toHaveBeenCalled()
warn.mockRestore()
})
})
@@ -12,6 +12,10 @@ import type {
WorktreeStartupDraftPaste,
WorktreeStartupFollowup
} from './runtime-worktree-agent-startup'
import {
deliverTerminalAgentLaunchPrompt,
type TerminalPromptRuntime
} from './rpc/methods/agent-launch-terminal-prompt'
const BRACKETED_PASTE_BEGIN = '\x1b[200~'
const BRACKETED_PASTE_END = '\x1b[201~'
@@ -46,30 +50,43 @@ export function pasteWorktreeStartupDraftWhenReady(
.catch((error) => console.warn('[worktree-create] failed to paste startup draft:', error))
}
export function sendWorktreeStartupFollowupWhenReady(
/** A post-start agent's first prompt: its process in front can still drop typed text (#10336). */
export async function deliverStartupFollowupPrompt(
runtime: TerminalPromptRuntime,
host: WorktreeStartupReadinessHost,
handle: string,
followup: WorktreeStartupFollowup
): void {
void deliverWorktreeStartupFollowup(host, handle, followup)
}
/** Types the follow-up once the agent process is in front; true when it was written. */
export async function deliverWorktreeStartupFollowup(
host: WorktreeStartupReadinessHost,
handle: string,
followup: WorktreeStartupFollowup
{ agent, prompt }: WorktreeStartupFollowup,
options: { signal?: AbortSignal; agentLaunch?: boolean } = {}
): Promise<boolean> {
const { signal } = options
try {
const ptyId = await waitForWorktreeStartupFollowup(host, handle, followup.expectedProcess)
if (!ptyId) {
console.warn('[worktree-create] agent did not become ready for follow-up prompt')
return false
const ptyId = host.getPtyId(handle)
// Where no read proves the agent in front (Windows), its process must be seen first: a lone shell's prompt also reads as ready.
if (ptyId && runtime.launchedAgentHostProvesAgent?.(ptyId) === false) {
const expected = TUI_AGENT_CONFIG[agent].expectedProcess
if (!(await waitForWorktreeStartupFollowup(host, handle, expected, signal))) {
console.warn(
'[agent-startup] agent did not come to the front; its prompt was not delivered'
)
return false
}
// WSL and SSH-to-Windows cannot prove a lone shell either: keep their typed follow-up; agent.launch refused there.
if (!options.agentLaunch && runtime.launchedAgentHostProvesShellAlone?.(ptyId) !== true) {
host.write(ptyId, `${prompt}\r`, 'launch')
return true
}
}
host.write(ptyId, `${followup.prompt}\r`, 'launch')
return true
return await deliverTerminalAgentLaunchPrompt({
runtime,
handle,
agent,
freshLaunch: true,
text: prompt,
unprovableHost: 'write-if-shell-provable',
...(signal ? { signal } : {})
})
} catch (error) {
console.warn('[worktree-create] failed to send startup follow-up prompt:', error)
console.warn('[agent-startup] failed to deliver the startup prompt:', error)
return false
}
}
@@ -77,7 +94,8 @@ export async function deliverWorktreeStartupFollowup(
export async function waitForWorktreeStartupFollowup(
host: WorktreeStartupReadinessHost,
handle: string,
expectedProcess: string
expectedProcess: string,
signal?: AbortSignal
): Promise<string | null> {
const ptyId = host.getPtyId(handle)
if (!ptyId) {
@@ -87,6 +105,9 @@ export async function waitForWorktreeStartupFollowup(
if (attempt > 0) {
await new Promise((resolve) => setTimeout(resolve, 150))
}
if (signal?.aborted) {
return null
}
try {
const foregroundProcess = await host.getForegroundProcess(ptyId)
if (isExpectedAgentProcess(foregroundProcess, expectedProcess)) {
@@ -11,7 +11,6 @@ import { sendTerminalStreamInput } from './rpc/methods/terminal/terminal-input-d
import { makeStore } from './runtime-rpc-worktree-store-fixtures'
import {
pasteWorktreeStartupDraftWhenReady,
sendWorktreeStartupFollowupWhenReady,
type WorktreeStartupReadinessHost
} from './runtime-worktree-startup-readiness'
@@ -235,20 +234,16 @@ describe('run facts: a created worktree’s startup writes', () => {
}
}
it('reads a run whose only input was its create-time draft and follow-up as untyped', async () => {
it('reads a run whose only input was its create-time draft as untyped', async () => {
vi.useFakeTimers()
const run = await createFreshRun()
const host = readinessHost(run)
pasteWorktreeStartupDraftWhenReady(host, run.handle, { agent: 'aider', content: 'plan it' })
sendWorktreeStartupFollowupWhenReady(host, run.handle, {
expectedProcess: 'aider',
prompt: 'and ship it'
})
await vi.runAllTimersAsync()
expect(run.kinds).toEqual(['launch', 'launch'])
expect(run.writes).toHaveLength(2)
expect(run.kinds).toEqual(['launch'])
expect(run.writes).toHaveLength(1)
expect(run.firstUserInputAt()).toBeNull()
})
})
@@ -30,9 +30,8 @@ import type {
} from '../../../shared/worktree/lineage-types'
import { recordCreatedWorktreeLineage as recordCreatedWorktreeLineageState } from '../runtime-worktree-lineage-recording'
import {
deliverWorktreeStartupFollowup,
deliverStartupFollowupPrompt,
pasteWorktreeStartupDraftWhenReady,
sendWorktreeStartupFollowupWhenReady,
waitForWorktreeStartupDraft,
type WorktreeStartupReadinessHost
} from '../runtime-worktree-startup-readiness'
@@ -44,8 +43,10 @@ import {
import { readFreshComposerHold } from '../launched-agent-composer-readiness'
import { buildTerminalWaitText } from '../terminal-wait-tail-state'
import {
describeLaunchedAgentHost,
readLaunchedAgentForeground,
type LaunchedAgentForeground
type LaunchedAgentForeground,
type LaunchedAgentHost
} from '../launched-agent-foreground'
import { worktreeRowTarget } from '../../persistence/host-qualified-worktree-meta'
@@ -271,12 +272,8 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime
/** What holds the terminal a launch started its agent in, read fresh from the execution host. */
readLaunchedAgentForeground(ptyId: string, agent: TuiAgent): Promise<LaunchedAgentForeground> {
return readLaunchedAgentForeground(
this.ptyController,
this.launchedAgentHost(ptyId),
ptyId,
agent
)
const host = this.launchedAgentHost(ptyId)
return readLaunchedAgentForeground(this.ptyController, host, ptyId, agent)
}
/** Whether the pane's execution host can find a launched agent in front: a Windows one cannot. */
@@ -284,23 +281,26 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime
return !this.launchedAgentHost(ptyId).windows
}
private launchedAgentHost(ptyId: string): { remote: boolean; windows: boolean } {
launchedAgentHostProvesShellAlone(ptyId: string): boolean {
return this.launchedAgentHost(ptyId).shellAloneProvable
}
private launchedAgentHost(ptyId: string): LaunchedAgentHost {
const pty = this.ptysById.get(ptyId)
const remote = !!pty?.connectionId
// A local WSL pane still runs on a Windows host, whose process reads cannot see into it.
return {
remote,
windows: remote ? this.pathFlavorForPty(pty) === 'win32' : process.platform === 'win32'
}
return describeLaunchedAgentHost(pty, () => this.pathFlavorForPty(pty))
}
protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void {
sendWorktreeStartupFollowupWhenReady(this.getWorktreeStartupReadinessHost(), handle, followup)
void this.deliverStartupFollowup(handle, followup)
}
/** The same follow-up write, awaited: true once the prompt was typed into the agent. */
deliverStartupFollowup(handle: string, followup: WorktreeStartupFollowup): Promise<boolean> {
return deliverWorktreeStartupFollowup(this.getWorktreeStartupReadinessHost(), handle, followup)
deliverStartupFollowup(
handle: string,
followup: WorktreeStartupFollowup,
options?: { signal?: AbortSignal; agentLaunch?: boolean }
): Promise<boolean> {
const host = this.getWorktreeStartupReadinessHost()
return deliverStartupFollowupPrompt(this, host, handle, followup, options)
}
async provisionManagedWorktreeTerminals(
@@ -1,14 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { sendFollowupPromptWhenAgentReady } from './agent-followup-delivery'
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input'
import { pasteDraftToAgentPtyWhenReady } from '@/lib/agent-paste-draft'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
inspectRuntimeTerminalProcess: vi.fn()
}))
vi.mock('@/runtime/runtime-terminal-verified-input', () => ({
sendRuntimePtyInputVerified: vi.fn()
vi.mock('@/lib/agent-paste-draft', () => ({
pasteDraftToAgentPtyWhenReady: vi.fn()
}))
// Pip console-script agents expose python/python3 foreground comm rather than their agent name.
@@ -16,13 +16,22 @@ const INTERPRETER_WRAPPED_AGENTS = [
{ agent: 'aider', expectedProcess: TUI_AGENT_CONFIG.aider.expectedProcess }
] as const
// The prompt is pasted and submitted through composer readiness, never typed with a raw CR.
const SUBMITTED_PASTE = {
ptyId: 'pty-1',
content: 'ship it',
agent: 'aider',
submit: true,
forcePaste: true
}
describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
vi.stubGlobal('globalThis', globalThis)
// Deliver the prompt write eagerly so the test does not depend on retries.
vi.mocked(sendRuntimePtyInputVerified).mockResolvedValue(true)
vi.mocked(pasteDraftToAgentPtyWhenReady).mockResolvedValue(true)
})
afterEach(() => {
@@ -40,6 +49,7 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () =
const delivery = sendFollowupPromptWhenAgentReady({
ptyId: 'pty-1',
agent,
expectedProcess,
prompt: 'ship it'
})
@@ -48,10 +58,8 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () =
const delivered = await delivery
expect(delivered).toBe(true)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledExactlyOnceWith(
'pty-1',
'ship it\r',
'launch'
expect(pasteDraftToAgentPtyWhenReady).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining(SUBMITTED_PASTE)
)
})
@@ -64,6 +72,7 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () =
const delivery = sendFollowupPromptWhenAgentReady({
ptyId: 'pty-1',
agent,
expectedProcess,
prompt: 'ship it'
})
@@ -72,7 +81,7 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () =
const delivered = await delivery
expect(delivered).toBe(false)
expect(sendRuntimePtyInputVerified).not.toHaveBeenCalled()
expect(pasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled()
})
it(`refuses to type into a ${agent} wrapper without a live child`, async () => {
@@ -83,6 +92,7 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () =
const delivery = sendFollowupPromptWhenAgentReady({
ptyId: 'pty-1',
agent,
expectedProcess,
prompt: 'ship it'
})
@@ -91,7 +101,7 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () =
const delivered = await delivery
expect(delivered).toBe(false)
expect(sendRuntimePtyInputVerified).not.toHaveBeenCalled()
expect(pasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled()
})
}
@@ -104,11 +114,33 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () =
const delivered = await sendFollowupPromptWhenAgentReady({
ptyId: 'pty-1',
agent: 'aider',
expectedProcess: 'aider',
prompt: 'ship it'
})
expect(delivered).toBe(true)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', 'ship it\r', 'launch')
expect(pasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith(
expect.objectContaining(SUBMITTED_PASTE)
)
})
it('pastes nothing when the agent left the terminal during the readiness wait', async () => {
vi.mocked(inspectRuntimeTerminalProcess)
.mockResolvedValueOnce({ foregroundProcess: 'aider', hasChildProcesses: true })
.mockResolvedValue({ foregroundProcess: 'zsh', hasChildProcesses: false })
// Run the real last check the paste would make once the composer reads ready.
vi.mocked(pasteDraftToAgentPtyWhenReady).mockImplementation(async ({ beforePaste }) =>
beforePaste ? await beforePaste() : true
)
const delivered = await sendFollowupPromptWhenAgentReady({
ptyId: 'pty-1',
agent: 'aider',
expectedProcess: 'aider',
prompt: 'ship it'
})
expect(delivered).toBe(false)
})
})
+38 -20
View File
@@ -1,5 +1,6 @@
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input'
import { pasteDraftToAgentPtyWhenReady } from '@/lib/agent-paste-draft'
import type { TuiAgent } from '../../../shared/tui-agent'
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess
@@ -8,15 +9,25 @@ import { isShellProcess } from '../../../shared/shell-process-detection'
export async function sendFollowupPromptWhenAgentReady(args: {
ptyId: string
agent: TuiAgent
expectedProcess: string
prompt: string
}): Promise<boolean> {
const { ptyId, expectedProcess, prompt } = args
const { ptyId, agent, expectedProcess, prompt } = args
if (!(await waitForAgentForeground(ptyId, expectedProcess))) {
return false
}
// Why: an agent in front may not read input yet; Kimi drops text typed before its composer is up (#10336).
try {
return await sendRuntimePtyInputVerified(ptyId, `${prompt}\r`, 'launch')
return await pasteDraftToAgentPtyWhenReady({
ptyId,
content: prompt,
agent,
submit: true,
forcePaste: true,
// An agent that exited during the readiness wait must not have its prompt pasted into the shell.
beforePaste: () => agentHoldsPty(ptyId, expectedProcess, true)
})
} catch {
return false
}
@@ -28,24 +39,31 @@ async function waitForAgentForeground(ptyId: string, expectedProcess: string): P
if (attempt > 0) {
await new Promise((resolve) => globalThis.setTimeout(resolve, 150))
}
try {
const process = await inspectRuntimeTerminalProcess(ptyId)
const foreground = process.foregroundProcess?.toLowerCase() ?? ''
if (isExpectedAgentProcess(foreground, expectedProcess)) {
return true
}
// Wrapped agents may expose only python/node comm locally or over SSH; require a known wrapper plus a live non-shell child.
if (
attempt >= 4 &&
isAgentForegroundWrapperProcess(foreground) &&
!isShellProcess(foreground) &&
process.hasChildProcesses
) {
return true
}
} catch {
// Ignore transient PTY inspection failures and keep polling.
if (await agentHoldsPty(ptyId, expectedProcess, attempt >= 4)) {
return true
}
}
return false
}
async function agentHoldsPty(
ptyId: string,
expectedProcess: string,
acceptWrapper: boolean
): Promise<boolean> {
try {
const process = await inspectRuntimeTerminalProcess(ptyId)
const foreground = process.foregroundProcess?.toLowerCase() ?? ''
// Wrapped agents may expose only python/node comm locally or over SSH; require a known wrapper plus a live non-shell child.
return (
isExpectedAgentProcess(foreground, expectedProcess) ||
(acceptWrapper &&
isAgentForegroundWrapperProcess(foreground) &&
!isShellProcess(foreground) &&
process.hasChildProcesses === true)
)
} catch {
// Transient PTY inspection failures prove nothing.
return false
}
}
@@ -109,6 +109,8 @@ export async function pasteDraftToAgentPtyWhenReady(args: {
timeoutMs?: number
onTimeout?: () => void
onUnconfirmedDelivery?: () => void
/** Last check after readiness; false drops the paste. */
beforePaste?: () => Promise<boolean>
}): Promise<boolean> {
const { ptyId, content, agent, submit, forcePaste, timeoutMs, onTimeout, onUnconfirmedDelivery } =
args
@@ -132,6 +134,9 @@ export async function pasteDraftToAgentPtyWhenReady(args: {
}
onUnconfirmedDelivery?.()
}
if (args.beforePaste && !(await args.beforePaste())) {
return false
}
return await sendBracketedPasteToAgent({
ptyId,
+12 -14
View File
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockInspectRuntimeTerminalProcess,
mockSendRuntimePtyInputVerified,
mockPasteDraftToAgentPtyWhenReady,
mockShowAutomationPromptNotSentToast,
mockTrack,
@@ -11,7 +10,6 @@ const {
startupLeafId
} = vi.hoisted(() => ({
mockInspectRuntimeTerminalProcess: vi.fn(),
mockSendRuntimePtyInputVerified: vi.fn(),
mockPasteDraftToAgentPtyWhenReady: vi.fn(),
mockShowAutomationPromptNotSentToast: vi.fn(),
mockTrack: vi.fn(),
@@ -75,9 +73,6 @@ vi.mock('@/store', () => ({
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
inspectRuntimeTerminalProcess: mockInspectRuntimeTerminalProcess
}))
vi.mock('@/runtime/runtime-terminal-verified-input', () => ({
sendRuntimePtyInputVerified: mockSendRuntimePtyInputVerified
}))
vi.mock('@/lib/agent-paste-draft', () => ({
pasteDraftToAgentPtyWhenReady: mockPasteDraftToAgentPtyWhenReady
@@ -277,7 +272,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
foregroundProcess: 'aider',
hasChildProcesses: true
})
mockSendRuntimePtyInputVerified.mockResolvedValue(true)
mockPasteDraftToAgentPtyWhenReady.mockResolvedValue(true)
})
@@ -298,16 +292,20 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
}
})
expect(mockSendRuntimePtyInputVerified).toHaveBeenCalledWith(
'pty-1',
'fix the spinner\r',
'launch'
expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith(
expect.objectContaining({
ptyId: 'pty-1',
content: 'fix the spinner',
agent: 'aider',
submit: true,
forcePaste: true
})
)
expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
it('does not track when follow-up prompt delivery is rejected by the terminal runtime', async () => {
mockSendRuntimePtyInputVerified.mockResolvedValue(false)
mockPasteDraftToAgentPtyWhenReady.mockResolvedValue(false)
await ensureAgentStartupInTerminal({
worktreeId: 'wt-1',
@@ -341,7 +339,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
}
})
expect(mockSendRuntimePtyInputVerified).not.toHaveBeenCalled()
expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled()
expect(mockShowAutomationPromptNotSentToast).toHaveBeenCalledWith('aider')
})
@@ -382,7 +380,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
})
it('does not track when follow-up prompt delivery rejects', async () => {
mockSendRuntimePtyInputVerified.mockRejectedValue(new Error('runtime timeout'))
mockPasteDraftToAgentPtyWhenReady.mockRejectedValue(new Error('runtime timeout'))
await expect(
ensureAgentStartupInTerminal({
@@ -806,7 +804,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
await vi.advanceTimersByTimeAsync(5_000)
await delivery
expect(mockSendRuntimePtyInputVerified).not.toHaveBeenCalled()
expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled()
})
})
+1
View File
@@ -231,6 +231,7 @@ async function deliverAgentStartupToTerminal(
if (startup.followupPrompt) {
const delivered = await sendFollowupPromptWhenAgentReady({
ptyId,
agent: startup.agent,
expectedProcess: startup.expectedProcess,
prompt: startup.followupPrompt
})