From 89ac8f2ac10db893b073abc340549e61b7ff2e79 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:39:36 -0700 Subject: [PATCH] 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> --- .../headless-automation-agent-launch.test.ts | 6 +- .../headless-automation-agent-launch.ts | 6 +- src/main/runtime/launched-agent-foreground.ts | 17 ++++ .../runtime/launched-agent-write-guard.ts | 14 ++- ...worktree-setup-and-startup-part-04.spec.ts | 5 +- .../rpc/methods/agent-launch-surfaces.ts | 16 +++- .../agent-launch-terminal-prompt.test.ts | 40 +++++++- .../methods/agent-launch-terminal-prompt.ts | 7 +- .../runtime/runtime-worktree-agent-startup.ts | 7 +- ...runtime-worktree-startup-readiness.test.ts | 96 ++++++++++++++++--- .../runtime-worktree-startup-readiness.ts | 59 ++++++++---- .../runtime/terminal-run-facts-input.test.ts | 11 +-- .../workspaces/workspace-activation.ts | 40 ++++---- .../src/lib/agent-followup-delivery.test.ts | 54 ++++++++--- .../src/lib/agent-followup-delivery.ts | 58 +++++++---- src/renderer/src/lib/agent-paste-draft.ts | 5 + src/renderer/src/lib/new-workspace.test.ts | 26 +++-- src/renderer/src/lib/new-workspace.ts | 1 + 18 files changed, 336 insertions(+), 132 deletions(-) diff --git a/src/main/automations/headless-automation-agent-launch.test.ts b/src/main/automations/headless-automation-agent-launch.test.ts index e8cb3c3881d..f4876f7689d 100644 --- a/src/main/automations/headless-automation-agent-launch.test.ts +++ b/src/main/automations/headless-automation-agent-launch.test.ts @@ -51,7 +51,7 @@ function harness(options: { startupTerminal?: Record | 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' }) }) diff --git a/src/main/automations/headless-automation-agent-launch.ts b/src/main/automations/headless-automation-agent-launch.ts index cab1941c51d..a3aab7c229a 100644 --- a/src/main/automations/headless-automation-agent-launch.ts +++ b/src/main/automations/headless-automation-agent-launch.ts @@ -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) diff --git a/src/main/runtime/launched-agent-foreground.ts b/src/main/runtime/launched-agent-foreground.ts index 9e9aa4dc235..14b31bc8515 100644 --- a/src/main/runtime/launched-agent-foreground.ts +++ b/src/main/runtime/launched-agent-foreground.ts @@ -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(/^-/, '')) } diff --git a/src/main/runtime/launched-agent-write-guard.ts b/src/main/runtime/launched-agent-write-guard.ts index 7d23445d6f0..7c65dec3e04 100644 --- a/src/main/runtime/launched-agent-write-guard.ts +++ b/src/main/runtime/launched-agent-write-guard.ts @@ -10,7 +10,12 @@ export type LaunchedAgentWriteGuardRuntime = Pick< OrcaRuntimeService, 'readLaunchedAgentForeground' | 'subscribeToTerminalData' > & - Partial> + Partial< + Pick + > + +/** 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 @@ -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') } diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts index e6b4b0c9a7a..3b32998b653 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts @@ -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 () => { diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index c8c9ba39817..5f647634240 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -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 } : {}) }) + } } } diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts index c5ac2e50ec7..d8470c032ab 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts @@ -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( 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[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() diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts index d6d708a333f..bfc107137fe 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts @@ -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 @@ -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 diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 91219e08d2c..1a835b86788 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -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 } } : {}) } diff --git a/src/main/runtime/runtime-worktree-startup-readiness.test.ts b/src/main/runtime/runtime-worktree-startup-readiness.test.ts index a3b0ca3319e..69252eb5f4a 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.test.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.test.ts @@ -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[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() }) }) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index 17f869f86d9..c9ec8c6a1c6 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -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 { + 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 { 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)) { diff --git a/src/main/runtime/terminal-run-facts-input.test.ts b/src/main/runtime/terminal-run-facts-input.test.ts index 2f031382043..21bdf9ba547 100644 --- a/src/main/runtime/terminal-run-facts-input.test.ts +++ b/src/main/runtime/terminal-run-facts-input.test.ts @@ -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() }) }) diff --git a/src/main/runtime/workspaces/workspace-activation.ts b/src/main/runtime/workspaces/workspace-activation.ts index f7b9edc1a62..0a50d6e6a85 100644 --- a/src/main/runtime/workspaces/workspace-activation.ts +++ b/src/main/runtime/workspaces/workspace-activation.ts @@ -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 { - 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 { - return deliverWorktreeStartupFollowup(this.getWorktreeStartupReadinessHost(), handle, followup) + deliverStartupFollowup( + handle: string, + followup: WorktreeStartupFollowup, + options?: { signal?: AbortSignal; agentLaunch?: boolean } + ): Promise { + const host = this.getWorktreeStartupReadinessHost() + return deliverStartupFollowupPrompt(this, host, handle, followup, options) } async provisionManagedWorktreeTerminals( diff --git a/src/renderer/src/lib/agent-followup-delivery.test.ts b/src/renderer/src/lib/agent-followup-delivery.test.ts index b94883b4c6c..4d92c20c101 100644 --- a/src/renderer/src/lib/agent-followup-delivery.test.ts +++ b/src/renderer/src/lib/agent-followup-delivery.test.ts @@ -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) }) }) diff --git a/src/renderer/src/lib/agent-followup-delivery.ts b/src/renderer/src/lib/agent-followup-delivery.ts index 86fc3c5fe1f..2af1ac89d62 100644 --- a/src/renderer/src/lib/agent-followup-delivery.ts +++ b/src/renderer/src/lib/agent-followup-delivery.ts @@ -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 { - 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 { + 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 + } +} diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 5f7b97ad9a2..d8e8a1516a3 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -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 }): Promise { 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, diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 627cdae3d8d..3e4d08546f2 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -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() }) }) diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index ffc0d1040ac..05621f1c6ae 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -231,6 +231,7 @@ async function deliverAgentStartupToTerminal( if (startup.followupPrompt) { const delivered = await sendFollowupPromptWhenAgentReady({ ptyId, + agent: startup.agent, expectedProcess: startup.expectedProcess, prompt: startup.followupPrompt })