fix(terminal): one intentional-stop register, and per-run spawn and input facts (#22989)

* fix(terminal): one intentional-stop register and per-run spawn and input facts

Main now keeps one register of PTY stops it made on purpose, with an owner
count and a kind: reversible (sleep, hibernation) or replaced (a restart
handing the pane to a new process). It replaces four separate markers, and
every exit path reads it, so a hibernated or restarted pane keeps its tab and
binding through the exit instead of being retired and grafted back.

Main also records two facts per process run, keyed by incarnation: whether the
run was a fresh spawn (from the spawn-commit origin, which now counts a cold
restore as a reattach) and when a client first sent it input a person
produced. Renderer keystroke, paste, drop and quick-command writes carry a
userInput flag; terminal.send, stream input and dispatched agent prompts are
recorded by the runtime, excluding terminal query replies.

* test(terminal): a stopped process's synthetic and late provider exits keep its pane

One process can reach the runtime's exit handler twice: main's synthetic exit
when the kill reply overtakes the stream, then the provider's own exit, which
certifies the death. Both must read the same stop, a later process on the
same id must not, and the stop is forgotten when the duplicate-exit window
closes.

* test(terminal): a runtime worktree sleep keeps its tabs and wake bindings

* fix(terminal): keep every overlapping stop kind on one exit

A sleep and a restart that stop the same process now each keep their
label: the exit carries both renderer flags, and a runtime kill during
the sleep still records its SSH stop as reversible. A later stop of an
already-stopped process joins its entry, so a failed repeat cannot erase
the stop that landed.

The cold-restore rule moves into the run facts, so the binding span keeps
labelling a cold restore as a spawn.

* test(terminal): IME and Hangul commits reach the PTY as user input

Drives a real xterm through the pane's input handling: a macOS
input-source substitution, an iPadOS Hangul syllable, and a composition
the route delivers after a pane switch each reach onData flagged as user
input, which is what tags the write main records.

* test(terminal): state why the IME provenance test's canvas stub is safe

* fix(terminal): a new process ends an unpinned stop, and focus reports are not input

- A landed stop that no exit pinned to an incarnation now ends when a new
  process commits on the same id, so that process's exit is not read as
  the stop. Both spawn-commit funnels report through one runtime method.
- A spawn commit with no incarnation starts its run facts clean, since it
  cannot be told from a new process.
- Stream input that is only focus reports no longer counts as user input;
  the desktop renderer already excludes them through xterm's own signal.
- The IME provenance test uses typed fakes: the pane input and composition
  route installers now take only the fields they read.

* chore: restore pnpm-lock.yaml to the base revision

* fix(terminal): typing in the dashboard preview is a run's user input

The dashboard popout's terminal preview writes to the PTY through its own
runtime path, which recorded no input, so a pane typed into only from the
preview read as untyped. It now records before the write, after the
mobile-driver check. One classifier, shared with terminal.send, decides
which provenance-free bytes nobody typed: a whole terminal reply or only
focus reports, which also covers the focus-in a desktop renderer sends
when it reattaches a remote pane.

* test(terminal): the IME provenance test removes the navigator stubs it adds

happy-dom serves navigator.userAgent from the prototype, so the test found no
own descriptor to restore and left the iPad user agent in place. The pane-switch
case then ran as a Mac pane only because it followed the Hangul case. Deleting
the own-property stub restores the default platform for every case.

* fix(terminal): every PTY write names its input kind, and one record point reads it

A run's first input was recorded by opposite defaults: the renderer's
pty:write recorded nothing unless a writer opted in, terminal.send recorded
everything that was not a reply, and main's own controller writes recorded
nothing. Each unclassified producer silently took its transport's default, so
the worktree-create draft counted from the renderer and not from main, and
mailbox pointers never counted.

Every host write entry point now takes a required kind: driving, launch or
query-reply. That covers the runtime controller's write and settled write,
the terminal writer, sendTerminal, sendTerminalAgentPrompt, pty:write and
pty:writeAccepted, the renderer transport and the runtime input helpers. The
fact is recorded once, just before the provider write, in the controller
funnel and the pty:write funnel: only driving bytes count, and a payload that
is only a reply or focus reports never does. The per-producer records are
gone.

Launch writes (create-time drafts and follow-ups, the agent launch prompt,
restored and cold-restore startup commands, the SSH background launch) do not
count. Mailbox pointers, dispatch, plugins and agent-team sends do. The
runtime mixins are unchecked by the compiler, and the pane session is an any
bag, so two source scans fail on any write there that leaves out its kind.

* fix(terminal): type the pane session's transport so the compiler checks every write's kind

The pane-connection session is an `any` bag, so a transport write there that
left out its input kind compiled, and only a text scan over the renderer
caught it. Declaring `transport: PtyTransport` on the session puts those writes
under the compiler, so the scan is gone. One hidden-delivery guard now narrows
a null PTY id itself instead of relying on an untyped predicate.

The main-process scan over `@ts-nocheck` runtime files missed five files whose
directive sits on the second line, below a lint directive, and missed a write
through a local alias of the PTY controller. It now finds both.

* fix(terminal): record a runtime spawn's run facts only after its binding save succeeds

The runtime spawn funnel reported the commit before its host-session binding save, so a spawn discarded for a failed save still recorded run facts and cleared a landed stop. Report it after the save, and separately on the adopted return, which makes no save. Also align tests and fixtures with main: the removed stop-owner map, required write input kinds, SQLite-backed stores and async binding saves.

* test(terminal): pin where the renderer spawn funnel records its commit

The merge moved the renderer funnel's spawn-commit report into the publish step, after the binding save, but no test covered it: deleting the call or moving it back before the save left every suite green. Cover both: a committed spawn records its run facts and supersedes an unpinned landed stop, and a spawn discarded for a failed binding save records nothing and leaves the stop.

* fix(terminal): record a runtime spawn's commit only once its registration succeeds

The runtime funnel reported each commit before registering the process, so a spawn that exited during start still recorded run facts and could end a landed stop, while the renderer funnel reports only after registration. Report after registration on both the ordinary and the adopted branch, so only a committed, registered run has facts and an unknown run keeps reading as not fresh.

* test(terminal): pin that a runtime adoption supersedes a landed stop no exit pinned

* fix(terminal): read an unconfirmed explicit stop's reversibility from the intentional-stop registry
This commit is contained in:
Brennan Benson
2026-09-27 23:13:24 -07:00
committed by GitHub
parent 67581dd090
commit c53ed030b2
173 changed files with 3139 additions and 876 deletions
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { openCodeClearPtyMock, piClearPtyMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops'
import {
SSH_PTY_IDENTITY_MISMATCH_ERROR,
SSH_SESSION_EXPIRED_ERROR
@@ -359,7 +360,8 @@ describe('registerPtyHandlers', () => {
const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
onPtyExit: vi.fn(),
intentionalPtyStops: new TerminalIntentionalStops()
}
setLocalPtyProvider({
spawn: vi.fn(),
@@ -393,15 +395,14 @@ describe('registerPtyHandlers', () => {
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
markReversibleStops: (ptyIds: readonly string[]) => () => void
stopAndWait: (ptyId: string) => Promise<boolean>
}
const release = controller.markReversibleStops(['local-pty'])
const settleStop = runtime.intentionalPtyStops.mark('local-pty', 'reversible', null)
const stopPromise = controller.stopAndWait('local-pty')
await vi.advanceTimersByTimeAsync(1_200)
await expect(stopPromise).resolves.toBe(true)
release()
settleStop(true)
expect(
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
@@ -3,6 +3,7 @@ import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harne
import { SessionNotFoundError } from '../daemon/daemon-errors'
import { makePaneKey } from '../../shared/stable-pane-id'
import { registerPtyHandlers, setLocalPtyProvider } from './pty'
import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -161,7 +162,8 @@ function installRestartHarness(
seedHeadlessTerminal: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
onPtyData: vi.fn(),
intentionalPtyStops: new TerminalIntentionalStops()
}
return { providerSpawn, shutdown, store, runtime, control }
}
+35 -10
View File
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops'
import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-errors'
import {
registerPtyHandlers,
@@ -79,7 +80,8 @@ function installController(handlers: Map<string, never>) {
setPtyController: vi.fn(),
markPtyStopRequested: vi.fn(),
markPtyLivenessUnverifiable: vi.fn(),
onPtyExit: vi.fn()
onPtyExit: vi.fn(),
intentionalPtyStops: new TerminalIntentionalStops()
}
handlers.clear()
return { runtime }
@@ -91,7 +93,6 @@ describe('undelivered SSH stops', () => {
function install(store: ReturnType<typeof createKillStore>): {
kill: (ptyId: string) => boolean
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
markReversibleStops: (ptyIds: readonly string[]) => () => void
recordUnconfirmedStop: (ptyId: string) => boolean
runtime: ReturnType<typeof installController>['runtime']
} {
@@ -108,13 +109,11 @@ describe('undelivered SSH stops', () => {
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
markReversibleStops: (ptyIds: readonly string[]) => () => void
recordUnconfirmedStop: (ptyId: string) => boolean
}
return {
kill: controller.kill,
stopAndWait: controller.stopAndWait,
markReversibleStops: controller.markReversibleStops,
recordUnconfirmedStop: controller.recordUnconfirmedStop,
runtime
}
@@ -302,15 +301,41 @@ describe('undelivered SSH stops', () => {
)
setPtyOwnership(SCOPED_PTY_ID, 'ssh-1')
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f')
const { kill, markReversibleStops } = install(store)
const release = markReversibleStops([SCOPED_PTY_ID])
const { kill, runtime } = install(store)
const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null)
try {
kill(SCOPED_PTY_ID)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
} finally {
release()
settleStop(false)
unregisterSshPtyProvider('ssh-1')
deletePtyOwnership(SCOPED_PTY_ID)
}
})
it('records nothing while a reversible stop owns the PTY and a restart stop joins it', async () => {
const store = createKillStore()
registerSshPtyProvider(
'ssh-1',
sshProviderStub(async () => {
throw new Error('socket closed')
})
)
setPtyOwnership(SCOPED_PTY_ID, 'ssh-1')
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f')
const { kill, runtime } = install(store)
const settleSleep = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null)
const settleRestart = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'replaced', null)
try {
kill(SCOPED_PTY_ID)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
} finally {
settleRestart(false)
settleSleep(false)
unregisterSshPtyProvider('ssh-1')
deletePtyOwnership(SCOPED_PTY_ID)
}
@@ -379,15 +404,15 @@ describe('undelivered SSH stops', () => {
restorePtyIncarnation('local-pty', 'inc-h')
setPtyOwnership(SCOPED_PTY_ID, 'ssh-1')
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-i')
const { recordUnconfirmedStop, markReversibleStops } = install(store)
const release = markReversibleStops([SCOPED_PTY_ID])
const { recordUnconfirmedStop, runtime } = install(store)
const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null)
try {
expect(recordUnconfirmedStop('local-pty')).toBe(false)
expect(recordUnconfirmedStop(SCOPED_PTY_ID)).toBe(false)
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
} finally {
release()
settleStop(false)
deletePtyOwnership('local-pty')
deletePtyOwnership(SCOPED_PTY_ID)
}
+4 -53
View File
@@ -7,57 +7,8 @@ import { allocatePtyLifecycleSequence } from '../host-env/types'
import { makePtyDataPayload, sendPtyDataToRenderer } from './payload'
import { getRendererInFlightCharsForPty } from './accounting'
import { clearFlushTimerIfIdle } from './flush'
import { ptyIncarnationById } from '../provider/ownership-state'
import type { PtyIpcSession } from '../session'
export type ReplacedPtyStop = {
incarnationId: string | undefined
expiryTimer?: NodeJS.Timeout
}
/** Labels the exit of a PTY that main stops so a new process can take its pane. Settle with
* whether the stop succeeded; a failed stop leaves no label behind. */
export function markReplacedPtyStop(
session: PtyIpcSession,
id: string
): (stopped: boolean) => void {
clearTimeout(session.replacedPtyStopsById.get(id)?.expiryTimer)
const mark: ReplacedPtyStop = { incarnationId: ptyIncarnationById.get(id) }
session.replacedPtyStopsById.set(id, mark)
return (stopped) => {
if (session.replacedPtyStopsById.get(id) !== mark) {
return
}
if (!stopped) {
session.replacedPtyStopsById.delete(id)
return
}
// Why a window: an SSH exit can reach the renderer after the stop settles; bound it like a synthetic kill.
mark.expiryTimer = setTimeout(() => {
if (session.replacedPtyStopsById.get(id) === mark) {
session.replacedPtyStopsById.delete(id)
}
}, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS)
mark.expiryTimer.unref?.()
}
}
function consumeReplacedPtyStop(
session: PtyIpcSession,
payload: { id: string; incarnationId?: string }
): boolean {
const mark = session.replacedPtyStopsById.get(payload.id)
if (
!mark ||
(mark.incarnationId && payload.incarnationId && mark.incarnationId !== payload.incarnationId)
) {
return false
}
clearTimeout(mark.expiryTimer)
session.replacedPtyStopsById.delete(payload.id)
return true
}
export function rememberSyntheticKillExit(
session: PtyIpcSession,
id: string,
@@ -202,12 +153,12 @@ export function finalizePtyExitForRenderer(
session.schedulePendingDataAfterCreditReport(true)
}
}
const intentionalStops =
session.runtime?.intentionalPtyStops?.claimExit(payload.id, payload.incarnationId) ?? []
session.mainWindow.webContents.send('pty:exit', {
...payload,
...(session.reversibleStopOwnersByPtyId.has(payload.id)
? { preserveRendererBinding: true }
: {}),
...(consumeReplacedPtyStop(session, payload) ? { replacedByRestart: true } : {})
...(intentionalStops.includes('reversible') ? { preserveRendererBinding: true } : {}),
...(intentionalStops.includes('replaced') ? { replacedByRestart: true } : {})
})
}
+39 -20
View File
@@ -1,10 +1,11 @@
import { getPtyIpc } from '../../pty-host-bindings'
import type { Store } from '../../../persistence'
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
import type { TerminalIntentionalStopKind } from '../../../runtime/terminal-intentional-stops'
import type { IPtyProvider } from '../../../providers/types'
import { parseAppSshPtyId } from '../../../providers/ssh-pty-id'
import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict'
import { ptyOwnership } from '../provider/ownership-state'
import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state'
import { getProviderForPty, sshProviders, tryGetProviderForPty } from '../provider/registry'
import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness'
import { recordUndeliveredSshPtyKill } from '../runtime/undelivered-ssh-kill'
@@ -31,27 +32,49 @@ export function installPtyKillIpcHandler(deps: PtyKillIpcDeps): void {
)
}
/** Stops a pane's PTY for the spawn replacing it. `markReplaced` labels the exit so the renderer
* reads it as a handoff, not the pane dying; a failed stop removes the label with nothing sent. */
export async function stopReplacedPanePty(
deps: PtyKillIpcDeps,
id: string,
markReplaced: (id: string) => (stopped: boolean) => void
): Promise<void> {
const settle = markReplaced(id)
try {
await stopRendererOwnedPty(deps, { id })
} catch (err) {
settle(false)
throw err
}
settle(true)
/** Stops a pane's PTY for the spawn replacing it. The stop register labels the exit so the
* renderer reads it as a handoff, not the pane dying; a failed stop leaves no label. */
export async function stopReplacedPanePty(deps: PtyKillIpcDeps, id: string): Promise<void> {
await stopRendererOwnedPtyAs(deps, { id }, 'replaced')
}
/** Stops a renderer-owned PTY and settles only once its shutdown has been observed or synthesized. */
export async function stopRendererOwnedPty(
deps: PtyKillIpcDeps,
args: { id: string; keepHistory?: boolean }
): Promise<void> {
// Why: only hibernation passes keepHistory, and its exit must keep the pane's wake binding.
await stopRendererOwnedPtyAs(deps, args, args?.keepHistory === true ? 'reversible' : null)
}
async function stopRendererOwnedPtyAs(
deps: PtyKillIpcDeps,
args: { id: string; keepHistory?: boolean },
intentionalStop: TerminalIntentionalStopKind | null
): Promise<void> {
if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) {
// Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider.
throw new Error('Invalid PTY provider id')
}
const settleStop = intentionalStop
? deps.runtime?.intentionalPtyStops?.mark(
args.id,
intentionalStop,
ptyIncarnationById.get(args.id) ?? null
)
: undefined
let stopped = false
try {
await stopRendererOwnedPtyProcess(deps, args)
stopped = true
} finally {
settleStop?.(stopped)
}
}
async function stopRendererOwnedPtyProcess(
deps: PtyKillIpcDeps,
args: { id: string; keepHistory?: boolean }
): Promise<void> {
const {
store,
@@ -61,10 +84,6 @@ export async function stopRendererOwnedPty(
rememberSyntheticKillExit,
sendPtyExitToRenderer
} = deps
if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) {
// Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider.
throw new Error('Invalid PTY provider id')
}
runtime?.markPtyStopRequested?.(args.id)
const ownedConnectionId = ptyOwnership.get(args.id)
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
@@ -83,6 +83,8 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<P
export function publishPtyIpcSpawnCommit(ctx: PtyIpcSpawnState, committedSize: PtyGrid): void {
const args = ctx.args
// Why here: every IPC spawn that survives its binding save publishes once through this point.
ctx.deps.runtime?.noteTerminalSpawnCommit?.(ctx.result)
ctx.spawnTiming.log(ctx.result.id, {
daemon: ctx.isDaemonHostSpawn,
reattach: ctx.result.isReattach ?? false
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeWithRuntimeId } from '../../../runtime/orca-runtime-runtime-id'
import { TerminalIntentionalStops } from '../../../runtime/terminal-intentional-stops'
import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts'
import { ptySizes } from '../delivery/visibility-state'
import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state'
import { commitPtyIpcSpawn } from './spawn-commit'
import { createPtyIpcSpawnState } from './spawn-state'
import type { PtySpawnIpcDeps } from './spawn-types'
const PTY_ID = 'orca-ipc-pty-run-facts'
const INCARNATION_ID = 'inc-ipc-run-facts'
async function commit(persistPtyBinding: ReturnType<typeof vi.fn>) {
const facts = new TerminalRunFactsRegister()
const stops = new TerminalIntentionalStops()
stops.mark(PTY_ID, 'reversible', null)(true)
const runtime = {
terminalRunFacts: facts,
intentionalPtyStops: stops,
// Why the real method: the case under test is what the runtime does with each commit.
noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit,
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
cancelPendingPtyRegistration: vi.fn(),
reflowHeadlessTerminalToPtyGrid: vi.fn(),
seedHeadlessTerminal: vi.fn(),
noteTerminalSpawnCommand: vi.fn()
}
const ports = {
runtime,
store: { persistPtyBinding },
options: {},
sendPtySpawnedToRenderer: vi.fn()
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reads only the ports above, and the store only for its binding save.
const deps = ports as unknown as PtySpawnIpcDeps
const ctx = createPtyIpcSpawnState(deps, {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'leaf-1',
cols: 120,
rows: 40
})
ctx.validatedLeafId = 'leaf-1'
ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) }
ctx.result = { id: PTY_ID, incarnationId: INCARNATION_ID }
const outcome = await commitPtyIpcSpawn(ctx).then(
() => 'committed',
() => 'discarded'
)
return { outcome, facts, stops }
}
describe('renderer spawn commit: run facts', () => {
afterEach(() => {
ptySizes.delete(PTY_ID)
ptyOwnership.delete(PTY_ID)
ptyIncarnationById.delete(PTY_ID)
})
it('records a committed spawn and lets it supersede a landed stop no exit pinned', async () => {
const { outcome, facts, stops } = await commit(vi.fn().mockResolvedValue(true))
expect(outcome).toBe('committed')
expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(true)
expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([])
})
it('records nothing for a spawn discarded because its binding save failed', async () => {
const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full'))
const { outcome, facts, stops } = await commit(persistPtyBinding)
expect(outcome).toBe('discarded')
expect(persistPtyBinding).toHaveBeenCalledOnce()
expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false)
expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible'])
})
})
@@ -68,7 +68,7 @@ describe('chunked pty write yield', () => {
}) as typeof setImmediate)
const outcome = await Promise.race([
createWriteInput()({ id: PTY_ID, data: THREE_CHUNK_INPUT }),
createWriteInput()({ inputKind: 'driving', id: PTY_ID, data: THREE_CHUNK_INPUT }),
afterImmediateTurns(50)
])
@@ -88,6 +88,7 @@ describe('chunked pty write yield', () => {
const immediate = vi.spyOn(globalThis, 'setImmediate')
const outcome = createWriteInput()({
inputKind: 'driving',
id: PTY_ID,
data: 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)
})
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts'
import { ptyOwnership } from '../provider/ownership-state'
import { createPtyWriteInput } from './write-input'
const PTY_ID = 'pty-user-input'
const { provider } = vi.hoisted(() => ({
provider: { write: vi.fn(), hasPty: vi.fn(() => true) }
}))
vi.mock('../provider/registry', () => ({
tryGetProviderForPty: (id: string) => (id === PTY_ID ? provider : undefined)
}))
function createWriteInput(facts: TerminalRunFactsRegister) {
const runtime = { getDriver: () => ({ kind: 'desktop' }), terminalRunFacts: facts }
const mainWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: write input reads only getDriver and terminalRunFacts from the runtime, and isDestroyed/webContents from the window.
return createPtyWriteInput({ mainWindow: mainWindow as never, runtime: runtime as never })
}
beforeEach(() => {
ptyOwnership.set(PTY_ID, null)
provider.write.mockReset()
})
afterEach(() => {
ptyOwnership.delete(PTY_ID)
})
describe('renderer PTY writes: input kind', () => {
it.each(['writePtyInput', 'writePtyInputAccepted'] as const)(
'%s records driving input before the provider write',
async (writer) => {
const facts = new TerminalRunFactsRegister()
facts.recordSpawnCommit({ id: PTY_ID, incarnationId: 'inc-1' })
const recordedAtWrite: (number | null)[] = []
provider.write.mockImplementation(() => {
recordedAtWrite.push(facts.read(PTY_ID, 'inc-1').firstUserInputAt)
})
await createWriteInput(facts)[writer]({ id: PTY_ID, data: 'exit\r', inputKind: 'driving' })
expect(recordedAtWrite).toEqual([expect.any(Number)])
}
)
it.each([
['a launch write', 'launch', 'echo startup\r'],
['a query reply', 'query-reply', '\x1b[3;4R'],
['a driving write that is only a reply', 'driving', '\x1b[3;4R'],
['a driving write that is only focus reports', 'driving', '\x1b[I\x1b[O']
] as const)('records nothing for %s', async (_label, inputKind, data) => {
const facts = new TerminalRunFactsRegister()
facts.recordSpawnCommit({ id: PTY_ID, incarnationId: 'inc-1' })
await createWriteInput(facts).writePtyInput({ id: PTY_ID, data, inputKind })
expect(provider.write).toHaveBeenCalledOnce()
expect(facts.read(PTY_ID, 'inc-1').firstUserInputAt).toBeNull()
})
})
+10 -7
View File
@@ -9,6 +9,7 @@ import {
} from '../../../../shared/terminal-input'
import { ptyOwnership } from '../provider/ownership-state'
import { tryGetProviderForPty } from '../provider/registry'
import type { TerminalInputKind } from '../../../../shared/terminal-input-kind'
import { interactiveOutputCharsByPty, lastInputAtByPty } from '../delivery/visibility-state'
export function isMainWindowPtyIpcEvent(
@@ -25,7 +26,7 @@ export function isMainWindowPtyIpcEvent(
)
}
export type PtyWritePayload = { id: string; data: string }
export type PtyWritePayload = { id: string; data: string; inputKind: TerminalInputKind }
export type PtyViewportClaimPayload = { id: string; cols: number; rows: number }
export function createPtyWriteInput(deps: {
@@ -148,6 +149,12 @@ export function createPtyWriteInput(deps: {
const isPtyWriteEventFromMainWindow = (event: IpcMainEvent | IpcMainInvokeEvent): boolean =>
isMainWindowPtyIpcEvent(event, mainWindow)
const noteRendererPtyInput = (args: PtyWritePayload): void => {
lastInputAtByPty.set(args.id, performance.now())
interactiveOutputCharsByPty.set(args.id, 0)
runtime?.terminalRunFacts?.recordInput(args.id, args.inputKind, args.data)
}
const writePtyInput = (args: PtyWritePayload): boolean | Promise<boolean> => {
// Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md.
if (runtime?.getDriver(args.id).kind === 'mobile') {
@@ -158,9 +165,7 @@ export function createPtyWriteInput(deps: {
return false
}
try {
const now = performance.now()
lastInputAtByPty.set(args.id, now)
interactiveOutputCharsByPty.set(args.id, 0)
noteRendererPtyInput(args)
return writePtyProviderInput(provider, args.id, args.data)
} catch {
return false
@@ -180,9 +185,7 @@ export function createPtyWriteInput(deps: {
return false
}
try {
const now = performance.now()
lastInputAtByPty.set(args.id, now)
interactiveOutputCharsByPty.set(args.id, 0)
noteRendererPtyInput(args)
return writePtyProviderInput(provider, args.id, args.data)
} catch {
return false
+1 -4
View File
@@ -17,7 +17,6 @@ import {
stopReplacedPanePty,
type PtyKillIpcDeps
} from './ipc/renderer-kill'
import { markReplacedPtyStop } from './delivery/exit'
import { installPtyWriteIpcHandlers } from './ipc/write'
import { installPtySpawnIpcHandler } from './ipc/spawn'
import { installPtyRuntimeController } from './runtime/controller'
@@ -237,7 +236,6 @@ export function registerPtyHandlers(
options,
trustedTerminalHandleEnv: session.trustedTerminalHandleEnv,
retiredRejectedPtyIds: session.retiredRejectedPtyIds,
reversibleStopOwnersByPtyId: session.reversibleStopOwnersByPtyId,
mainWindow,
transitionSpawnHiddenRendererPtyDeliveryState:
session.transitionSpawnHiddenRendererPtyDeliveryState,
@@ -275,8 +273,7 @@ export function registerPtyHandlers(
trustedTerminalHandleEnv: session.trustedTerminalHandleEnv,
sendPtySpawnedToRenderer: session.sendPtySpawnedToRenderer,
syncPtyBackgroundedDelivery: session.syncPtyBackgroundedDelivery,
stopReplacedPty: (id) =>
stopReplacedPanePty(killDeps, id, (ptyId) => markReplacedPtyStop(session, ptyId))
stopReplacedPty: (id) => stopReplacedPanePty(killDeps, id)
})
installPtyWriteIpcHandlers({ mainWindow, runtime })
installPtyResizeVisibilityIpc(session)
@@ -128,8 +128,8 @@ describe('PTY registration without renderer delivery', () => {
throw new Error('missing runtime PTY controller')
}
expect(controller.write('daemon-pty', 'local input')).toBe(true)
expect(controller.write(remoteId, 'remote input')).toBe(true)
expect(controller.write('daemon-pty', 'local input', 'driving')).toBe(true)
expect(controller.write(remoteId, 'remote input', 'driving')).toBe(true)
await controller.clearBuffer?.('daemon-pty')
await controller.clearBuffer?.(remoteId)
await expect(controller.attach?.('daemon-pty')).resolves.toBe(true)
@@ -147,7 +147,7 @@ describe('PTY registration without renderer delivery', () => {
expect(attach).toHaveBeenCalledExactlyOnceWith('daemon-pty')
unregisterSshPtyProvider('ssh-a')
expect(controller.write(remoteId, 'disconnected input')).toBe(false)
expect(controller.write(remoteId, 'disconnected input', 'driving')).toBe(false)
await expect(controller.probePtyLiveness?.(remoteId)).resolves.toBeNull()
expect(local.write).toHaveBeenCalledTimes(1)
})
@@ -82,7 +82,6 @@ export type PtyRuntimeControllerDeps = {
}
trustedTerminalHandleEnv: Set<string>
retiredRejectedPtyIds: Map<string, NodeJS.Timeout>
reversibleStopOwnersByPtyId: Map<string, number>
mainWindow?: PtyRendererDelivery
transitionSpawnHiddenRendererPtyDeliveryState?: (id: string, hidden: boolean) => void
syncPtyBackgroundedDelivery?: (id: string, caller: string) => void
+4 -6
View File
@@ -4,7 +4,6 @@ import type { PtyRuntimeControllerDeps } from './controller-deps'
import { spawnPtyFromRuntimeController } from './spawn'
import {
killPtyFromRuntimeController,
markReversibleStopsFromRuntimeController,
retireRejectedPtyFromRuntimeController,
stopAndWaitPtyFromRuntimeController
} from './kill'
@@ -45,9 +44,9 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi
},
adoptStablePane,
spawn: async (args) => spawnPtyFromRuntimeController(deps, args),
write: (ptyId, data) => writePtyFromRuntimeController(ptyId, data),
writeWithSettlement: (ptyId, data) =>
writePtyFromRuntimeController(ptyId, data, { waitForSettlement: true }),
write: (ptyId, data, inputKind) => writePtyFromRuntimeController(deps, ptyId, data, inputKind),
writeWithSettlement: (ptyId, data, inputKind) =>
writePtyFromRuntimeController(deps, ptyId, data, inputKind, { waitForSettlement: true }),
probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId),
// Why: subscriber-driven ingestion for daemon sessions no renderer pane
// ever attached. Local daemon sessions only — SSH panes have their own
@@ -57,13 +56,12 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi
kill: (ptyId) => killPtyFromRuntimeController(deps, ptyId),
retireRejectedPty: (ptyId, stopConfirmed) =>
retireRejectedPtyFromRuntimeController(deps, ptyId, stopConfirmed),
markReversibleStops: (ptyIds) => markReversibleStopsFromRuntimeController(deps, ptyIds),
stopAndWait: (ptyId, opts) => stopAndWaitPtyFromRuntimeController(deps, ptyId, opts),
recordUnconfirmedStop: (ptyId) =>
recordUnconfirmedExplicitSshStop({
store: deps.store,
ptyId,
reversible: deps.reversibleStopOwnersByPtyId.has(ptyId)
reversible: runtime?.intentionalPtyStops?.isReversibleStopInFlight(ptyId) ?? false
}),
getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId),
inspectProcess: (ptyId, options) => inspectProcessFromRuntimeController(ptyId, options),
+2 -28
View File
@@ -19,8 +19,7 @@ export function killPtyFromRuntimeController(
rememberSyntheticKillExit,
sendPtyExitToRenderer,
finishPtyShutdown,
retiredRejectedPtyIds,
reversibleStopOwnersByPtyId
retiredRejectedPtyIds
} = deps
runtime?.markPtyStopRequested?.(ptyId)
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
@@ -31,7 +30,7 @@ export function killPtyFromRuntimeController(
store,
ptyId,
connectionId,
reversible: reversibleStopOwnersByPtyId.has(ptyId),
reversible: runtime?.intentionalPtyStops?.isReversibleStopInFlight(ptyId) ?? false,
incarnationId
})
}
@@ -184,31 +183,6 @@ export function retireRejectedPtyFromRuntimeController(
})
}
export function markReversibleStopsFromRuntimeController(
deps: PtyRuntimeControllerDeps,
ptyIds: readonly string[]
): () => void {
const { reversibleStopOwnersByPtyId } = deps
for (const ptyId of ptyIds) {
reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1)
}
let released = false
return () => {
if (released) {
return
}
released = true
for (const ptyId of ptyIds) {
const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1
if (owners > 0) {
reversibleStopOwnersByPtyId.set(ptyId, owners)
} else {
reversibleStopOwnersByPtyId.delete(ptyId)
}
}
}
}
/**
* Deliberately records no undelivered-stop intent, unlike `killPtyFromRuntimeController`.
*
+15 -1
View File
@@ -12,16 +12,28 @@ import {
writeUnverifiable,
type WriteSettlement
} from '../../../../shared/pty-write-settlement'
import type { TerminalInputKind } from '../../../../shared/terminal-input-kind'
type RuntimeWriteDeps = Pick<PtyRuntimeControllerDeps, 'runtime'>
export function writePtyFromRuntimeController(ptyId: string, data: string): boolean
export function writePtyFromRuntimeController(
deps: RuntimeWriteDeps,
ptyId: string,
data: string,
inputKind: TerminalInputKind
): boolean
export function writePtyFromRuntimeController(
deps: RuntimeWriteDeps,
ptyId: string,
data: string,
inputKind: TerminalInputKind,
options: { waitForSettlement: true }
): WriteSettlement | Promise<WriteSettlement>
export function writePtyFromRuntimeController(
deps: RuntimeWriteDeps,
ptyId: string,
data: string,
inputKind: TerminalInputKind,
options?: { waitForSettlement: true }
): boolean | WriteSettlement | Promise<WriteSettlement> {
let provider: IPtyProvider
@@ -36,6 +48,7 @@ export function writePtyFromRuntimeController(
if (!provider.writeWithSettlement) {
return writeRefused('provider_cannot_settle')
}
deps.runtime?.terminalRunFacts?.recordInput(ptyId, inputKind, data)
try {
return provider.writeWithSettlement(ptyId, data)
} catch {
@@ -43,6 +56,7 @@ export function writePtyFromRuntimeController(
return writeUnverifiable('provider_threw_after_handoff', true)
}
}
deps.runtime?.terminalRunFacts?.recordInput(ptyId, inputKind, data)
try {
return provider.write(ptyId, data) !== false
} catch {
@@ -0,0 +1,147 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeWithRuntimeId } from '../../../runtime/orca-runtime-runtime-id'
import { TerminalIntentionalStops } from '../../../runtime/terminal-intentional-stops'
import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts'
import { ptySizes } from '../delivery/visibility-state'
import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state'
import { commitRuntimePtySpawn } from './spawn-commit'
import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state'
import type { PtyRuntimeControllerDeps } from './controller-deps'
const PTY_ID = 'orca-pty-run-facts'
const INCARNATION_ID = 'inc-run-facts'
const ADOPTED = {
disposition: 'adopted',
owner: {
claim: { kind: 'terminal' },
generation: 'g1',
phase: 'live',
ptyId: PTY_ID,
surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1', terminalHandle: 'h1' }
}
}
async function commit(
result: Record<string, unknown>,
facts = new TerminalRunFactsRegister(),
intentionalPtyStops = new TerminalIntentionalStops(),
prepare?: (ctx: ReturnType<typeof createRuntimePtySpawnState>) => void
) {
const runtime = {
terminalRunFacts: facts,
intentionalPtyStops,
// Why the real method: the case under test is what the runtime does with each commit.
noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit,
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
cancelPendingPtyRegistration: vi.fn(),
reflowHeadlessTerminalToPtyGrid: vi.fn(),
seedHeadlessTerminal: vi.fn(),
noteTerminalSpawnCommand: vi.fn()
}
const ports = { runtime, store: undefined, options: {}, sendPtySpawnedToRenderer: vi.fn() }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reads only the ports above; store-less deps skip every persistence branch.
const deps = ports as unknown as PtyRuntimeControllerDeps
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a worktree-less spawn needs only its grid.
const args = { cols: 120, rows: 40 } as unknown as RuntimePtySpawnArgs
const ctx = createRuntimePtySpawnState(deps, args)
const spawned = { id: PTY_ID, incarnationId: INCARNATION_ID, ...result }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each case sets the spawn-result fields the commit reads.
ctx.result = spawned as unknown as typeof ctx.result
prepare?.(ctx)
await commitRuntimePtySpawn(ctx)
return facts.read(PTY_ID, INCARNATION_ID)
}
describe('runtime spawn commit: run facts', () => {
afterEach(() => {
ptySizes.delete(PTY_ID)
ptyOwnership.delete(PTY_ID)
ptyIncarnationById.delete(PTY_ID)
})
it('records a new process as a fresh spawn', async () => {
expect((await commit({})).freshSpawn).toBe(true)
})
it('never reads an SSH adoption that omits isReattach as fresh', async () => {
expect((await commit({ agentSessionEnsure: ADOPTED })).freshSpawn).toBe(false)
})
it('never reads a cold restore as fresh', async () => {
const coldRestore = { scrollback: 'prior output', cwd: '/tmp' }
expect((await commit({ coldRestore })).freshSpawn).toBe(false)
})
it('keeps a process run facts when the same incarnation commits again', async () => {
const facts = new TerminalRunFactsRegister()
await commit({}, facts)
facts.recordInput(PTY_ID, 'driving', 'ls\r', 100)
expect(await commit({ isReattach: true }, facts)).toEqual({
freshSpawn: true,
firstUserInputAt: 100
})
})
it.each([
{ spawn: 'new process', result: {} },
{ spawn: 'adoption', result: { agentSessionEnsure: ADOPTED } }
])('lets a committed $spawn supersede a landed stop that no exit pinned', async ({ result }) => {
const stops = new TerminalIntentionalStops()
stops.mark(PTY_ID, 'reversible', null)(true)
await commit(result, new TerminalRunFactsRegister(), stops)
expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([])
})
it('records nothing for a spawn discarded because its binding save failed', async () => {
const facts = new TerminalRunFactsRegister()
const stops = new TerminalIntentionalStops()
stops.mark(PTY_ID, 'reversible', null)(true)
const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full'))
await expect(
commit({}, facts, stops, (ctx) => {
ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) }
ctx.hostSessionBinding = {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit calls only persistPtyBinding on this store.
store: { persistPtyBinding } as unknown as NonNullable<
typeof ctx.hostSessionBinding
>['store'],
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'leaf-1'
}
})
).rejects.toThrow()
expect(persistPtyBinding).toHaveBeenCalledOnce()
expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false)
expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible'])
})
it.each([
{ spawn: 'new process', result: {} },
{ spawn: 'adoption', result: { agentSessionEnsure: ADOPTED } }
])('records nothing for a $spawn that exited during start', async ({ result }) => {
const facts = new TerminalRunFactsRegister()
const stops = new TerminalIntentionalStops()
stops.mark(PTY_ID, 'reversible', null)(true)
await expect(
commit(result, facts, stops, (ctx) => {
ctx.args.worktreeId = 'wt-1'
ctx.deps.runtime!.registerPty = vi.fn(() => {
throw new Error('agent_session_exited_during_start')
})
})
).rejects.toThrow('agent_session_exited_during_start')
expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false)
expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible'])
})
})
+11
View File
@@ -92,6 +92,11 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
if (rejectedRegistration) {
await rejectedRegistration
}
// Why here: an adoption returns before the commit site below.
ctx.deps.runtime?.noteTerminalSpawnCommit?.(
ctx.result,
ctx.hostSessionBinding?.expectedSourceBinding
)
ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null)
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle)
if (ctx.result.incarnationId) {
@@ -187,6 +192,12 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
// Why: non-worktree PTYs have no later surface-registration phase to clear admission intent.
ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId)
}
// Why after registration: a spawn discarded for a failed save or rejected for exiting during
// start must not record facts or end a stop.
ctx.deps.runtime?.noteTerminalSpawnCommit?.(
ctx.result,
ctx.hostSessionBinding?.expectedSourceBinding
)
if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) {
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle)
}
@@ -43,7 +43,6 @@ function makeDeps(): PtyRuntimeControllerDeps {
finishPtyShutdown,
trustedTerminalHandleEnv: new Set(),
retiredRejectedPtyIds: new Map(),
reversibleStopOwnersByPtyId: new Map(),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `operations.ts` (write/clearBuffer) reads `mainWindow`; the spawn preflight and option build never touch it, and a real BrowserWindow cannot exist in vitest.
mainWindow: {} as BrowserWindow
}
-5
View File
@@ -10,7 +10,6 @@ import type {
PtyRendererDeliveryStateReport
} from '../../../shared/pty-renderer-delivery-health'
import type { PtyRendererDeliveryDebugSnapshot } from './delivery/debug'
import type { ReplacedPtyStop } from './delivery/exit'
import { PtyProducerFlowController } from '../pty-producer-flow-control'
import { PtyPendingDataDrainQueue, type PendingPtyData } from '../pty-pending-data-drain-queue'
import type { SshPtyOutputIntake } from '../ssh-pty-output-intake'
@@ -108,8 +107,6 @@ export type PtyIpcSession = {
string,
{ cleanupTimer: NodeJS.Timeout; incarnationId: string | undefined }
>
reversibleStopOwnersByPtyId: Map<string, number>
replacedPtyStopsById: Map<string, ReplacedPtyStop>
retiredRejectedPtyIds: Map<string, NodeJS.Timeout>
pendingSerializeRequests: Map<
string,
@@ -238,8 +235,6 @@ export function createPtyIpcSession(args: {
sourceCreditPendingPtys: new Set(),
backgroundedDeliverySyncByPty: new Map(),
syntheticKillExitPtyIds: new Map(),
reversibleStopOwnersByPtyId: new Map(),
replacedPtyStopsById: new Map(),
retiredRejectedPtyIds: new Map(),
pendingSerializeRequests: new Map(),
canSendPtyDataToRenderer: unsetSessionFn,
@@ -10,11 +10,13 @@ export type PtyBindingSpanOutcome = 'fast_lane' | 'flushed' | 'refused' | 'threw
*/
export type PtyBindingOrigin = 'reattach' | 'spawn' | 'relay_reattach' | 'split' | 'unknown'
export type PtySpawnCommitOrigin = Extract<PtyBindingOrigin, 'reattach' | 'spawn' | 'split'>
/** The spawn-commit paths share one rule: a split outranks a reattach, a reattach outranks a spawn. */
export function spawnCommitBindingOrigin(
commit: { isReattach?: boolean; agentSessionEnsure?: { disposition: string } },
expectedSourceBinding?: unknown
): PtyBindingOrigin {
): PtySpawnCommitOrigin {
if (expectedSourceBinding !== undefined) {
return 'split'
}
@@ -37,7 +37,6 @@ export function createPtySpawnCommitDependencies(
sendPtyExitToRenderer: unexpectedPreflight,
finishPtyShutdown: unexpectedPreflight,
retiredRejectedPtyIds: new Map(),
reversibleStopOwnersByPtyId: new Map(),
get mainWindow() {
return unexpectedPreflight()
}
+8 -4
View File
@@ -194,10 +194,14 @@ describe('terminal.sendText explicit worktree routing', () => {
{ includeVisualLayouts: false }
)
expect(delegate.sendTerminal).toHaveBeenCalledTimes(1)
expect(delegate.sendTerminal).toHaveBeenCalledWith(terminalId, {
text: 'echo hi',
enter: true
})
expect(delegate.sendTerminal).toHaveBeenCalledWith(
terminalId,
{
text: 'echo hi',
enter: true
},
{ inputKind: 'driving' }
)
expect(vi.mocked(delegate.listTerminals).mock.invocationCallOrder[0]!).toBeLessThan(
vi.mocked(delegate.sendTerminal).mock.invocationCallOrder[0]!
)
@@ -3,6 +3,7 @@ import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-hos
import type { PluginHostServices } from './plugin-host-methods'
import { PluginSecretsStore } from './plugin-secrets-store'
import { PluginKvStore } from './plugin-storage-store'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
/** Structural subset of OrcaRuntimeService exposed to plugin facade bindings. */
export type PluginRuntimeDelegate = {
@@ -19,7 +20,8 @@ export type PluginRuntimeDelegate = {
): Promise<{ terminals: { handle: string; title: string | null }[] }>
sendTerminal(
handle: string,
action: { text?: string; enter?: boolean }
action: { text?: string; enter?: boolean },
options: { inputKind: TerminalInputKind }
): Promise<{ accepted: boolean }>
dispatchPluginNotification(input: {
pluginId: string
@@ -59,7 +61,7 @@ export function bindPluginHostServices(input: {
.map((terminal) => ({ id: terminal.handle }))
},
sendTerminalText: async (terminalId, action) => {
const result = await delegate.sendTerminal(terminalId, action)
const result = await delegate.sendTerminal(terminalId, action, { inputKind: 'driving' })
return { accepted: result.accepted }
},
dispatchPluginNotification: (notification) => delegate.dispatchPluginNotification(notification),
@@ -37,7 +37,7 @@ describe('agent prompt line-settle scheduling', () => {
() => undefined,
'antigravity'
)
const submission = runtime.sendTerminalAgentPrompt(handle, prompt)
const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' })
const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.advanceTimersByTimeAsync(submitDelayMs - 1)
@@ -34,6 +34,7 @@ describe('agent prompt receipt correlation', () => {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', {
inputKind: 'driving',
acceptQueued: true,
requestId: 'historical-first',
observationTimeoutMs: 0
@@ -41,6 +42,7 @@ describe('agent prompt receipt correlation', () => {
await vi.runAllTimersAsync()
const first = await firstPromise
const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', {
inputKind: 'driving',
acceptQueued: true,
requestId: 'historical-second',
observationTimeoutMs: 0
@@ -102,7 +102,9 @@ describe('agent prompt submission runtime hook and generation cases', () => {
getForegroundProcess: async () => null
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
@@ -130,6 +132,7 @@ describe('agent prompt submission runtime hook and generation cases', () => {
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving',
acceptQueued: true,
requestId: 'antigravity-pre-invocation',
observationTimeoutMs: 20_000
@@ -156,7 +159,9 @@ describe('agent prompt submission runtime hook and generation cases', () => {
stateStartedAt: 1_000
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.runAllTimersAsync()
@@ -171,6 +176,7 @@ describe('agent prompt submission runtime hook and generation cases', () => {
const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'codex')
const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', {
inputKind: 'driving',
acceptQueued: true,
requestId: 'hook-queued-first',
observationTimeoutMs: 0
@@ -193,6 +199,7 @@ describe('agent prompt submission runtime hook and generation cases', () => {
getForegroundProcess: async () => null
})
const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', {
inputKind: 'driving',
acceptQueued: true,
requestId: 'hook-queued-second',
observationTimeoutMs: 500
@@ -219,7 +226,9 @@ describe('agent prompt submission runtime hook and generation cases', () => {
it('does not write Enter after the PTY generation changes during settlement', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
const rejected = expect(submission).rejects.toThrow('terminal_handle_stale')
await vi.advanceTimersByTimeAsync(0)
@@ -256,6 +265,7 @@ describe('agent prompt submission runtime hook and generation cases', () => {
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving',
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
@@ -291,7 +301,9 @@ describe('agent prompt submission runtime hook and generation cases', () => {
sequenceAtSpawnStart
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
@@ -318,9 +330,9 @@ describe('agent prompt submission runtime hook and generation cases', () => {
sequenceAtSpawnStart
)
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
await expect(
runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' })
).rejects.toThrow('agent_prompt_blocked')
expect(writes).toEqual([])
})
@@ -331,7 +343,9 @@ describe('agent prompt submission runtime hook and generation cases', () => {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
await vi.runAllTimersAsync()
@@ -351,8 +365,10 @@ describe('agent prompt submission runtime hook and generation cases', () => {
}
})
const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt')
const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt')
const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { inputKind: 'driving' })
const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt', {
inputKind: 'driving'
})
await vi.runAllTimersAsync()
await Promise.all([first, second])
@@ -373,6 +389,7 @@ describe('agent prompt submission runtime hook and generation cases', () => {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', {
inputKind: 'driving',
acceptQueued: true,
requestId: 'queued-first',
observationTimeoutMs: 0
@@ -380,6 +397,7 @@ describe('agent prompt submission runtime hook and generation cases', () => {
await vi.runAllTimersAsync()
const first = await firstPromise
const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', {
inputKind: 'driving',
acceptQueued: true,
requestId: 'queued-second',
observationTimeoutMs: 0
@@ -418,6 +436,7 @@ describe('agent prompt submission runtime hook and generation cases', () => {
})
const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', {
inputKind: 'driving',
beforeWrite: async () => {
firstWriteReached()
await firstGate
@@ -430,7 +449,9 @@ describe('agent prompt submission runtime hook and generation cases', () => {
0
)
const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt')
const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt', {
inputKind: 'driving'
})
await vi.runAllTimersAsync()
await expect(replacement).resolves.toMatchObject({ accepted: true })
expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true)
@@ -444,6 +465,7 @@ describe('agent prompt submission runtime hook and generation cases', () => {
let writeChecks = 0
const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), {
inputKind: 'driving',
beforeWrite: () => {
writeChecks += 1
if (writeChecks === 2) {
@@ -466,6 +488,7 @@ describe('agent prompt submission runtime hook and generation cases', () => {
const controller = new AbortController()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving',
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
@@ -483,6 +506,7 @@ describe('agent prompt submission runtime hook and generation cases', () => {
const controller = new AbortController()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving',
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
@@ -43,7 +43,9 @@ describe('agent prompt submission runtime', () => {
}
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
@@ -59,7 +61,9 @@ describe('agent prompt submission runtime', () => {
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
@@ -75,7 +79,9 @@ describe('agent prompt submission runtime', () => {
runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now())
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.runAllTimersAsync()
@@ -93,7 +99,9 @@ describe('agent prompt submission runtime', () => {
}
})
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.runAllTimersAsync()
@@ -109,7 +117,9 @@ describe('agent prompt submission runtime', () => {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
await vi.runAllTimersAsync()
@@ -122,9 +132,9 @@ describe('agent prompt submission runtime', () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
await expect(
runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' })
).rejects.toThrow('agent_prompt_blocked')
expect(writes).toEqual([])
})
@@ -140,9 +150,9 @@ describe('agent prompt submission runtime', () => {
Date.now()
)
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
await expect(
runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' })
).rejects.toThrow('agent_prompt_blocked')
expect(writes).toEqual([])
})
@@ -155,9 +165,9 @@ describe('agent prompt submission runtime', () => {
Date.now()
)
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
await expect(
runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' })
).rejects.toThrow('agent_prompt_blocked')
expect(writes).toEqual([])
})
@@ -175,7 +185,9 @@ describe('agent prompt submission runtime', () => {
)
runtime.onPtyData('pty-prompt', '}\x07\x07', Date.now())
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
await vi.runAllTimersAsync()
@@ -197,9 +209,9 @@ describe('agent prompt submission runtime', () => {
Date.now()
)
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
await expect(
runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' })
).rejects.toThrow('agent_prompt_blocked')
expect(writes).toEqual([])
})
@@ -214,7 +226,9 @@ describe('agent prompt submission runtime', () => {
text: 'Permission required\r\nAllow once\r\nAllow always\r\nReject\r\n'
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
@@ -235,7 +249,9 @@ describe('agent prompt submission runtime', () => {
}
})
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
await vi.runAllTimersAsync()
@@ -257,7 +273,9 @@ describe('agent prompt submission runtime', () => {
runtime.onPtyData('pty-prompt', output, Date.now())
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
await vi.runAllTimersAsync()
@@ -271,6 +289,7 @@ describe('agent prompt submission runtime', () => {
let writeChecks = 0
const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), {
inputKind: 'driving',
beforeWrite: () => {
writeChecks += 1
if (writeChecks === 2) {
@@ -291,6 +310,7 @@ describe('agent prompt submission runtime', () => {
let writeChecks = 0
const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), {
inputKind: 'driving',
beforeWrite: () => {
writeChecks += 1
if (writeChecks === 2) {
@@ -319,9 +339,9 @@ describe('agent prompt submission runtime', () => {
)
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
await expect(
runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' })
).rejects.toThrow('agent_prompt_blocked')
expect(writes).toEqual([])
})
@@ -369,7 +389,9 @@ describe('agent prompt submission runtime', () => {
)
vi.setSystemTime(2_000)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
@@ -389,7 +411,9 @@ describe('agent prompt submission runtime', () => {
Date.now()
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
@@ -408,7 +432,9 @@ describe('agent prompt submission runtime', () => {
Date.now()
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.runAllTimersAsync()
@@ -432,7 +458,9 @@ describe('agent prompt submission runtime', () => {
Date.now()
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
@@ -453,6 +481,7 @@ describe('agent prompt submission runtime', () => {
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving',
acceptQueued: true,
requestId: 'queued-output-only',
observationTimeoutMs: 0
@@ -111,7 +111,9 @@ describe('agent prompt submit delay on a ConPTY host', () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime()
const delayMs = submitDelayFor('review this', 'win32')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.advanceTimersByTimeAsync(delayMs - 1)
@@ -131,7 +133,7 @@ describe('agent prompt submit delay on a ConPTY host', () => {
// Measured ConPTY ingest for 8 KB is 60-89 ms; the old constant charged 1_500 ms.
const delayMs = submitDelayFor(prompt, 'win32')
expect(delayMs).toBeLessThan(700)
const submission = runtime.sendTerminalAgentPrompt(handle, prompt)
const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' })
const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.advanceTimersByTimeAsync(delayMs)
@@ -146,7 +148,7 @@ describe('agent prompt submit delay on a ConPTY host', () => {
vi.useFakeTimers()
const { runtime, handle, writes, submitTimes } = await createPromptRuntime()
const prompt = 'y'.repeat(320_000)
const submission = runtime.sendTerminalAgentPrompt(handle, prompt)
const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' })
const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled')
// Every byte is already handed to node-pty here -- the hazard is that the *host* is
@@ -167,6 +169,7 @@ describe('agent prompt submit delay on a ConPTY host', () => {
const controller = new AbortController()
const { runtime, handle, writes } = await createPromptRuntime()
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving',
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
@@ -186,7 +189,9 @@ describe('agent prompt submit delay on a ConPTY host', () => {
const { runtime, handle, writes } = await createPromptRuntime()
const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin')
expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32'))
const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT)
const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, {
inputKind: 'driving'
})
const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.advanceTimersByTimeAsync(delayMs - 1)
@@ -215,7 +220,9 @@ describe('agent prompt submit delay follows the execution host', () => {
patchPtyRecord(runtime, { isWsl: true, wslDistro: 'Ubuntu' })
const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32')
expect(delayMs).toBeGreaterThan(submitDelayFor(HOST_PROBE_PROMPT, 'linux'))
const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT)
const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, {
inputKind: 'driving'
})
const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.advanceTimersByTimeAsync(delayMs - 1)
@@ -235,7 +242,9 @@ describe('agent prompt submit delay follows the execution host', () => {
registerSshRemotePlatform('win32')
const clientDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin')
const hostDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32')
const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT)
const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, {
inputKind: 'driving'
})
const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.advanceTimersByTimeAsync(clientDelayMs)
@@ -255,7 +264,9 @@ describe('agent prompt submit delay follows the execution host', () => {
registerSshRemotePlatform('linux')
const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'linux')
expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32'))
const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT)
const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, {
inputKind: 'driving'
})
const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.advanceTimersByTimeAsync(delayMs - 1)
@@ -277,7 +288,9 @@ describe('agent prompt submit delay follows the execution host', () => {
})
registerSshRemotePlatform(undefined)
const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32')
const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT)
const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, {
inputKind: 'driving'
})
const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.advanceTimersByTimeAsync(delayMs - 1)
@@ -341,7 +354,9 @@ describe('agent prompt render gate on a ConPTY host', () => {
useHostPlatform('win32')
vi.useFakeTimers()
const { runtime, handle, writes, submitTimes } = await createSettlementRuntime()
const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000))
const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000), {
inputKind: 'driving'
})
const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled')
// Marker at 100 ms + a 1_500 ms quiet window would have submitted at ~1_600 ms, while
@@ -370,7 +385,7 @@ describe('agent prompt render gate on a ConPTY host', () => {
markerDelayMs: ingestMs - 1_000,
noiseUntilMs: ingestMs + 20_000
})
const submission = runtime.sendTerminalAgentPrompt(handle, prompt)
const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' })
const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.advanceTimersByTimeAsync(ingestMs + 8_000 - 1)
@@ -387,7 +402,9 @@ describe('agent prompt render gate on a ConPTY host', () => {
useHostPlatform('win32')
vi.useFakeTimers()
const { runtime, handle, submitTimes } = await createSettlementRuntime()
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
inputKind: 'driving'
})
const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.runAllTimersAsync()
@@ -409,7 +426,11 @@ describe('plain terminal send suffix delay', () => {
useHostPlatform('win32')
vi.useFakeTimers()
const { runtime, handle, writes, submitTimes } = await createPromptRuntime()
const send = runtime.sendTerminal(handle, { text: 'z'.repeat(320_000), enter: true })
const send = runtime.sendTerminal(
handle,
{ text: 'z'.repeat(320_000), enter: true },
{ inputKind: 'driving' }
)
// Same hazard as the agent-prompt path: a flat 500 ms wrote Enter mid-paste here.
await vi.advanceTimersByTimeAsync(3_342)
@@ -429,7 +450,7 @@ describe('plain terminal send suffix delay', () => {
const send = runtime.sendTerminal(
handle,
{ text: 'z'.repeat(320_000), enter: true },
{ signal: controller.signal }
{ inputKind: 'driving', signal: controller.signal }
)
const rejected = expect(send).rejects.toThrow('request_aborted')
@@ -226,7 +226,7 @@ export class ClaudeAgentTeamsTmuxDispatcher {
const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane)
const text = tmuxSendKeysText(parsed.positional, parsed.flags.has('-l'))
if (text) {
await api.sendTerminal(pane.handle, { text })
await api.sendTerminal(pane.handle, { text }, { inputKind: 'driving' })
}
return ''
}
+3 -1
View File
@@ -6,6 +6,7 @@ import type {
RuntimeTerminalShow,
RuntimeTerminalSplit
} from '../../shared/runtime-types'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
export type AgentTeamsTmuxCompatRequest = {
teamId: string
@@ -43,7 +44,8 @@ export type AgentTeamsTerminalApi = {
readTerminal(handle: string, opts?: { limit?: number }): Promise<RuntimeTerminalRead>
sendTerminal(
handle: string,
action: { text?: string; enter?: boolean; interrupt?: boolean }
action: { text?: string; enter?: boolean; interrupt?: boolean },
options: { inputKind: TerminalInputKind }
): Promise<RuntimeTerminalSend>
focusTerminal(handle: string): Promise<RuntimeTerminalFocus>
closeTerminal(handle: string): Promise<RuntimeTerminalClose>
@@ -182,6 +182,7 @@ export class OrcaRuntimeWithAttachRemoteTerminalSourceRangeConsumer extends Orca
try {
await assertTerminalInputWithinLimitWithYield(data)
await this.writeTerminalInputChunks(ptyId, data, {
inputKind: 'driving',
// Why: a phone can claim the floor while a paste yields between chunks.
beforeWrite: () => {
if (this.getDriver(ptyId).kind === 'mobile') {
@@ -3,6 +3,7 @@ import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-termi
import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core'
import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts'
import type { RuntimeTerminalSend } from '../../shared/runtime-types'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
import type { RuntimeAgentPromptWriteOptions } from './runtime-terminal-contracts'
import {
assertTerminalInputWithinLimitWithYield,
@@ -103,7 +104,8 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
reserveWrite?: (ptyId: string) => void
afterWrite?: (ptyId: string) => void | Promise<void>
suffixFailureError?: string
} = {}
inputKind: TerminalInputKind
}
): Promise<RuntimeTerminalSend> {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
@@ -152,7 +154,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
async sendTerminalAgentPrompt(
handle: string,
prompt: string,
options: RuntimeAgentPromptWriteOptions = {}
options: RuntimeAgentPromptWriteOptions
): Promise<RuntimeTerminalSend> {
// Why the consuming agent: the foreground process reads the bytes; launchAgent covers startup.
const payloadFor = (ptyId: string): string => {
@@ -45,7 +45,7 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi
// pending and flushes at the ready marker or the 15s
// SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query
// reply is delayed at most that bound, not lost.
this.ptyController?.write(ptyId, reply)
this.ptyController?.write(ptyId, reply, 'query-reply')
}
}
})
@@ -137,7 +137,7 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv
let settlesInEnterCallback = false
try {
const payload = formatMessagePointer(unread.length, mailboxHandle)
const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false
const wrote = this.ptyController?.write(deliveryPtyId, payload, 'driving') ?? false
if (!wrote) {
return
}
@@ -171,7 +171,7 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv
if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) {
return
}
this.ptyController?.write(deliveryPtyId, '\r')
this.ptyController?.write(deliveryPtyId, '\r', 'driving')
} catch {
// Terminal may have closed during the delay; mail remains queued for check.
} finally {
@@ -36,7 +36,7 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt
this.ptyController!.hasChildProcesses?.(ptyId) ?? Promise.resolve(false),
subscribeToData: (ptyId, listener) => this.subscribeToTerminalData(ptyId, listener),
readRecentOutput: (ptyId) => this.recentPtyOutputById.get(ptyId)?.read(),
write: (ptyId, data) => this.ptyController?.write(ptyId, data)
write: (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind)
}
}
+4 -5
View File
@@ -117,10 +117,9 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte
exitIncarnationId ?? pendingIncarnation ?? pty?.incarnationId ?? null
)
}
const intentionalStopIncarnation = this.intentionalHandlelessPtyStops.get(ptyId)
const preservesIntentionalHandlelessSurface =
this.intentionalHandlelessPtyStops.has(ptyId) &&
(intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId)
// Why both kinds: a sleep keeps its wake hint, and a restart's replacement takes the pane.
const preservesIntentionallyStoppedSurface =
this.intentionalPtyStops.claimExit(ptyId, exitIncarnationId ?? pty?.incarnationId).length > 0
advertisedUrlWatcher.unbindPty(ptyId)
// Clean up new mobile state for this PTY
this.mobileSubscribers.delete(ptyId)
@@ -217,7 +216,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte
this.pruneDisconnectedPtyTranscript(pty)
}
let retirement: Promise<void> | undefined
if (preservesIntentionalHandlelessSurface || preservesAbnormalSshSurface) {
if (preservesIntentionallyStoppedSurface || preservesAbnormalSshSurface) {
// Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace.
this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true })
} else {
@@ -146,6 +146,7 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt
this.providerVisibleRetryAtByPtyId.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
this.terminalSpawnCommandsByPtyId.delete(ptyId)
this.terminalRunFacts.delete(ptyId)
this.disposePtyTitleTracker(ptyId)
this.invalidatePtyIncarnationHandle(ptyId)
this.oscTitleScanTailByPtyId.delete(ptyId)
@@ -61,7 +61,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O
ptyId: string,
action: { text?: string; enter?: boolean; interrupt?: boolean },
payload: string,
options: RuntimeTerminalWriteOptions = {}
options: RuntimeTerminalWriteOptions
): Promise<void> {
return this.terminalWriter.writeAction(ptyId, action, payload, options)
}
@@ -69,7 +69,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O
protected writeTerminalInputChunks(
ptyId: string,
text: string,
options: RuntimeTerminalWriteOptions = {}
options: RuntimeTerminalWriteOptions
): Promise<void> {
return this.terminalWriter.writeChunks(ptyId, text, options)
}
@@ -100,7 +100,7 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt
return await this.claudeAgentTeams.handleTmuxCompat(request, {
splitTerminal: (handle, opts) => this.splitTerminal(handle, opts),
readTerminal: (handle, opts) => this.readTerminal(handle, opts),
sendTerminal: (handle, action) => this.sendTerminal(handle, action),
sendTerminal: (handle, action, options) => this.sendTerminal(handle, action, options),
focusTerminal: (handle) => this.focusTerminal(handle),
closeTerminal: (handle) => this.closeTerminal(handle),
showTerminal: (handle) => this.showTerminal(handle)
@@ -123,9 +123,9 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals
if (!pty || this.terminalSpawnCommandsByPtyId.has(pty.ptyId)) {
return
}
if (this.ptyController?.write(pty.ptyId, command)) {
if (this.ptyController?.write(pty.ptyId, command, 'launch')) {
// Why: Enter rides its own write so a long command cannot swallow it.
this.ptyController.write(pty.ptyId, '\r')
this.ptyController.write(pty.ptyId, '\r', 'launch')
this.noteTerminalSpawnCommand(pty.ptyId, command)
}
}
+13 -4
View File
@@ -45,6 +45,8 @@ import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoin
import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry'
import { RuntimeTerminalWriter } from './runtime-terminal-writer'
import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls'
import { TerminalIntentionalStops } from './terminal-intentional-stops'
import { TerminalRunFactsRegister, type TerminalSpawnCommit } from './terminal-run-facts'
import type { TuiIdleEvidenceSource } from './tui-idle-evidence'
import {
TUI_IDLE_DEFAULT_TIMEOUT_MS,
@@ -233,9 +235,16 @@ export class OrcaRuntimeWithRuntimeId {
protected pendingPtyRegistrationIncarnations = new Map<string, PtyIncarnationId | null>()
// Why: exact-stop is the current sleep transaction boundary; its exit must
// leave the renderer's intentional sleeping surface available for wake.
protected intentionalHandlelessPtyStops = new Map<string, string | null>()
// Why public: the PTY IPC layer's stop paths write it and its exit delivery reads it.
readonly intentionalPtyStops = new TerminalIntentionalStops()
readonly terminalRunFacts = new TerminalRunFactsRegister()
/** Both spawn-commit funnels report each committed process here, once. */
noteTerminalSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void {
this.terminalRunFacts.recordSpawnCommit(commit, expectedSourceBinding)
this.intentionalPtyStops.noteSpawnCommit(commit.id)
}
// Why: coalesces title/status-driven session.tabs emits so spinner churn
// doesn't fan out (and per-client JSON.stringify) a snapshot several times a
@@ -322,7 +331,7 @@ export class OrcaRuntimeWithRuntimeId {
protected readonly terminalWaiters = new RuntimeTerminalWaiterRegistry()
protected readonly terminalWriter = new RuntimeTerminalWriter(
(ptyId, data) => this.ptyController?.write(ptyId, data) ?? false,
(ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) ?? false,
(ptyId) => this.getPtyWriteHostPlatform(ptyId),
(ptyId) => this.getPtyAgent(ptyId)
)
@@ -64,7 +64,7 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi
const pendingPtyIds = new Set<string>()
let generation = 0
let fullyCommitted = false
let releaseReversibleRendererStops = (): void => {}
const settleReversibleStops = new Map<string, (stopped: boolean) => void>()
try {
const resolvedWorktrees = includeTargetResolvedWorktree(
[...(await this.getResolvedWorktreeMap()).values()],
@@ -148,16 +148,25 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi
const stopAndWait = ptyController.stopAndWait.bind(ptyController)
const orderedLivePtyIds = [...livePtyIds].sort()
releaseReversibleRendererStops =
ptyController.markReversibleStops?.(orderedLivePtyIds) ?? (() => {})
const stopResults = await Promise.allSettled(
orderedLivePtyIds.map(async (ptyId) => ({
for (const ptyId of orderedLivePtyIds) {
settleReversibleStops.set(
ptyId,
stopped: await stopAndWait(ptyId, {
this.intentionalPtyStops.mark(
ptyId,
'reversible',
this.ptysById.get(ptyId)?.incarnationId ?? null
)
)
}
const stopResults = await Promise.allSettled(
orderedLivePtyIds.map(async (ptyId) => {
const stopped = await stopAndWait(ptyId, {
keepHistory: true,
deadlineMs: teardownRpcDeadline(sleepDeadline)
})
}))
settleReversibleStops.get(ptyId)?.(stopped)
return { ptyId, stopped }
})
)
const successfulStopPtyIds = orderedLivePtyIds.filter((_, index) => {
const result = stopResults[index]
@@ -251,7 +260,9 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi
postStopVerified: true
}
} finally {
releaseReversibleRendererStops()
for (const settleStop of settleReversibleStops.values()) {
settleStop(false)
}
if (!fullyCommitted && generation > 0) {
const cancelledPtyIds = [...pendingPtyIds].sort()
if (cancelledPtyIds.length > 0) {
@@ -47,18 +47,22 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit
const stoppedPtyIds: string[] = []
for (const ptyId of [...expected].sort()) {
if (opts.keepHistory) {
this.intentionalHandlelessPtyStops.set(
ptyId,
this.ptysById.get(ptyId)?.incarnationId ?? null
)
}
// Why: exact-stop is the sleep transaction boundary; its exit must leave the sleeping surface for wake.
const settleStop = opts.keepHistory
? this.intentionalPtyStops.mark(
ptyId,
'reversible',
this.ptysById.get(ptyId)?.incarnationId ?? null
)
: null
let stopped = false
try {
if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) {
throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId })
}
stopped = await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory })
} finally {
this.intentionalHandlelessPtyStops.delete(ptyId)
settleStop?.(stopped)
}
if (!stopped) {
throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId })
}
stoppedPtyIds.push(ptyId)
}
@@ -159,12 +159,16 @@ describe('runtime terminal handle incarnation fencing', () => {
})
expect(replacement?.handle).not.toBe(staleHandle)
await expect(runtime.readTerminal(staleHandle)).rejects.toThrow('terminal_handle_stale')
await expect(runtime.sendTerminal(staleHandle, { text: 'stale input' })).rejects.toThrow(
'terminal_handle_stale'
)
await expect(
runtime.sendTerminal(staleHandle, { text: 'stale input' }, { inputKind: 'driving' })
).rejects.toThrow('terminal_handle_stale')
await expect(
runtime.sendTerminal(replacement!.handle, { text: 'replacement input' })
runtime.sendTerminal(
replacement!.handle,
{ text: 'replacement input' },
{ inputKind: 'driving' }
)
).resolves.toMatchObject({
accepted: true,
handle: replacement!.handle
@@ -85,7 +85,7 @@ describe('OrcaRuntimeService', () => {
runtime.sendTerminal(
terminal.handle,
{ text: 'notes', enter: true },
{ beforeWrite, afterWrite }
{ inputKind: 'driving', beforeWrite, afterWrite }
)
).rejects.toThrow('terminal_not_writable')
expect(writes).toEqual(['notes', '\r'])
@@ -150,10 +150,14 @@ describe('OrcaRuntimeService', () => {
nextCursor: expect.any(String)
})
const send = await runtime.sendTerminal(terminal.handle, {
text: 'continue',
enter: true
})
const send = await runtime.sendTerminal(
terminal.handle,
{
text: 'continue',
enter: true
},
{ inputKind: 'driving' }
)
expect(send).toMatchObject({
handle: terminal.handle,
accepted: true
@@ -602,7 +602,7 @@ describe('OrcaRuntimeService', () => {
expect(write).toHaveBeenCalledTimes(2)
expect(write.mock.calls[0][0]).toBe('pty-bare')
expect(String(write.mock.calls[0][1])).toMatch(/codex/)
expect(write.mock.calls[1]).toEqual(['pty-bare', '\r'])
expect(write.mock.calls[1]).toEqual(['pty-bare', '\r', 'launch'])
} finally {
vi.useRealTimers()
}
@@ -467,7 +467,7 @@ describe('OrcaRuntimeService', () => {
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
await runtime.sendTerminal(handle, { text: 'continue', enter: true })
await runtime.sendTerminal(handle, { text: 'continue', enter: true }, { inputKind: 'driving' })
expect(writes).toEqual(['continue', '\r'])
})
@@ -490,7 +490,7 @@ describe('OrcaRuntimeService', () => {
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
const prompt = 'line one\nline two\x1b[201~'
const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt)
const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' })
await vi.runAllTimersAsync()
const result = await sendPromise
@@ -535,6 +535,7 @@ describe('OrcaRuntimeService', () => {
)
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'the brief', {
inputKind: 'driving',
leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE
})
await vi.runAllTimersAsync()
@@ -601,6 +602,7 @@ describe('OrcaRuntimeService', () => {
const assertAuthority = vi.fn()
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', {
inputKind: 'driving',
beforeWrite: assertAuthority
})
await vi.advanceTimersByTimeAsync(500)
@@ -659,7 +661,9 @@ describe('OrcaRuntimeService', () => {
'review this change',
agent
)
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', {
inputKind: 'driving'
})
if (agent === 'omp') {
await sendPromise
expect(writes).toEqual([`${buildAgentPromptPasteBytes('review this change')}\r`])
@@ -44,7 +44,9 @@ describe('OrcaRuntimeService', () => {
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
await expect(runtime.isTerminalRunningSettledPromptAgent(handle)).resolves.toBe(true)
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', {
inputKind: 'driving'
})
await vi.advanceTimersByTimeAsync(1_199)
expect(writes).not.toContain('\r')
await vi.advanceTimersByTimeAsync(1_500)
@@ -78,7 +80,9 @@ describe('OrcaRuntimeService', () => {
launchAgent: 'claude'
})
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', {
inputKind: 'driving'
})
await vi.advanceTimersByTimeAsync(renderGateCapMs('review this change') - 1)
expect(writes).not.toContain('\r')
@@ -117,7 +121,9 @@ describe('OrcaRuntimeService', () => {
launchAgent: 'codex'
})
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', {
inputKind: 'driving'
})
await vi.advanceTimersByTimeAsync(8_000)
expect(writes).not.toContain('\r')
await vi.advanceTimersByTimeAsync(1_599)
@@ -159,7 +165,9 @@ describe('OrcaRuntimeService', () => {
launchAgent: 'claude'
})
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', {
inputKind: 'driving'
})
// The marker at 100 ms re-arms the cap, but the ingest term is absolute: a prompt this
// small is already ingested by then, so the fallback is one flat render timeout later.
await vi.advanceTimersByTimeAsync(100 + 8_000 - 1)
@@ -193,7 +201,7 @@ describe('OrcaRuntimeService', () => {
})
const prompt = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\ntail`
const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt)
const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' })
await vi.runAllTimersAsync()
const result = await sendPromise
@@ -230,7 +238,7 @@ describe('OrcaRuntimeService', () => {
})
const prompt = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1)
const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt)
const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' })
const sendRejection = expect(sendPromise).rejects.toThrow('terminal_not_writable')
await vi.runAllTimersAsync()
@@ -258,7 +266,7 @@ describe('OrcaRuntimeService', () => {
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
const text = ['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'].join('')
const result = await runtime.sendTerminal(handle, { text })
const result = await runtime.sendTerminal(handle, { text }, { inputKind: 'driving' })
expect(result).toMatchObject({
handle,
@@ -285,7 +293,7 @@ describe('OrcaRuntimeService', () => {
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
const text = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\nline two\nline three`
await runtime.sendTerminal(handle, { text, enter: true })
await runtime.sendTerminal(handle, { text, enter: true }, { inputKind: 'driving' })
expect(writes.at(-1)).toBe('\r')
expect(writes.slice(0, -1).join('')).toBe(text)
@@ -312,7 +320,7 @@ describe('OrcaRuntimeService', () => {
vi.useFakeTimers()
try {
const sendPromise = runtime.sendTerminal(handle, { text })
const sendPromise = runtime.sendTerminal(handle, { text }, { inputKind: 'driving' })
expect(writes).toEqual([])
@@ -346,7 +354,11 @@ describe('OrcaRuntimeService', () => {
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
await expect(
runtime.sendTerminal(handle, { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) })
runtime.sendTerminal(
handle,
{ text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) },
{ inputKind: 'driving' }
)
).rejects.toThrow(TERMINAL_INPUT_TOO_LARGE_ERROR)
expect(writes).toEqual([])
})
@@ -230,7 +230,7 @@ describe('OrcaRuntimeService', () => {
expect(new Set(handles).size).toBe(handles.length)
await expect(
runtime.sendTerminal('term_victim', { text: 'for victim' })
runtime.sendTerminal('term_victim', { text: 'for victim' }, { inputKind: 'driving' })
).resolves.toMatchObject({ accepted: true })
expect(writesByPty.get('pty-victim')).toEqual(['for victim'])
expect(writesByPty.has('pty-imposter')).toBe(false)
@@ -260,7 +260,7 @@ describe('OrcaRuntimeService', () => {
const listed = await runtime.listTerminals()
expect(listed.terminals[0]?.handle).toBe('term_already_bound')
await expect(
runtime.sendTerminal('term_already_bound', { text: 'still routed' })
runtime.sendTerminal('term_already_bound', { text: 'still routed' }, { inputKind: 'driving' })
).resolves.toMatchObject({ accepted: true })
expect(writes).toEqual(['still routed'])
// the reported-but-not-adopted handle must not resolve to the live pty
@@ -347,7 +347,9 @@ describe('OrcaRuntimeService', () => {
handle,
tail: ['after unavailable']
})
await expect(runtime.sendTerminal(handle, { text: 'still writable' })).resolves.toMatchObject({
await expect(
runtime.sendTerminal(handle, { text: 'still writable' }, { inputKind: 'driving' })
).resolves.toMatchObject({
handle,
accepted: true
})
@@ -54,7 +54,7 @@ describe('OrcaRuntimeService', () => {
tail: ['after restart']
})
await expect(
runtime.sendTerminal('term_exported', { text: 'still writable' })
runtime.sendTerminal('term_exported', { text: 'still writable' }, { inputKind: 'driving' })
).resolves.toMatchObject({
handle: 'term_exported',
accepted: true
@@ -179,7 +179,7 @@ describe('OrcaRuntimeService', () => {
])
expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID]).toBe(1)
await runtime.sendTerminal('term_agent', { text: 'input' })
await runtime.sendTerminal('term_agent', { text: 'input' }, { inputKind: 'driving' })
await runtime.updateRemoteDesktopViewer('pty-agent', 'viewer', 'client', 132, 41)
expect(writes).toEqual([['pty-agent', 'input']])
expect(resize).toHaveBeenCalledWith('pty-agent', 132, 41)
@@ -65,7 +65,7 @@ describe('OrcaRuntimeService', () => {
const afterRestart = await restarted.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const listed = await restarted.listTerminals(`id:${TEST_WORKTREE_ID}`)
restarted.onPtyData('persisted-pty', 'after restart\n', 1)
await restarted.sendTerminal('term_current', { text: 'input' })
await restarted.sendTerminal('term_current', { text: 'input' }, { inputKind: 'driving' })
await restarted.updateRemoteDesktopViewer('persisted-pty', 'viewer', 'client', 132, 41)
expect(beforeRestart.tabs[0]).toMatchObject({
@@ -183,7 +183,9 @@ describe('OrcaRuntimeService', () => {
const shown = await runtime.showTerminal(entry!.handle)
expect(shown.writable).toBe(true)
await expect(runtime.sendTerminal(entry!.handle, { text: 'hi' })).resolves.toMatchObject({
await expect(
runtime.sendTerminal(entry!.handle, { text: 'hi' }, { inputKind: 'driving' })
).resolves.toMatchObject({
accepted: true
})
expect(writes).toEqual([['pty-orphan', 'hi']])
@@ -418,7 +418,11 @@ describe('OrcaRuntimeService', () => {
await Promise.resolve()
await Promise.resolve()
expect(write).toHaveBeenCalledWith('pty-startup-draft', `\x1b[200~${draftUrl}\x1b[201~`)
expect(write).toHaveBeenCalledWith(
'pty-startup-draft',
`\x1b[200~${draftUrl}\x1b[201~`,
'launch'
)
})
it('keeps the 8s main-runtime startup readiness budget for agents without an override', async () => {
@@ -554,7 +558,11 @@ describe('OrcaRuntimeService', () => {
await Promise.resolve()
await Promise.resolve()
expect(write).toHaveBeenCalledWith('pty-opencode-draft-budget', `\x1b[200~${draftUrl}\x1b[201~`)
expect(write).toHaveBeenCalledWith(
'pty-opencode-draft-budget',
`\x1b[200~${draftUrl}\x1b[201~`,
'launch'
)
})
it('rejects explicit startup commands for disabled selected agents', async () => {
@@ -83,7 +83,7 @@ describe('OrcaRuntimeService', () => {
})
)
await vi.waitFor(() => {
expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r')
expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r', 'launch')
})
})
@@ -526,7 +526,11 @@ describe('OrcaRuntimeService', () => {
runtime.onPtyData('pty-explicit-draft', '\x1b[?2004h›', Date.now())
await vi.waitFor(() => {
expect(write).toHaveBeenCalledWith('pty-explicit-draft', `\x1b[200~${draftUrl}\x1b[201~`)
expect(write).toHaveBeenCalledWith(
'pty-explicit-draft',
`\x1b[200~${draftUrl}\x1b[201~`,
'launch'
)
})
})
@@ -27,7 +27,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso
ptyId: string,
generation: number,
pastePayload: string,
options: RuntimeAgentPromptWriteOptions = {}
options: RuntimeAgentPromptWriteOptions
): Promise<{ submits: number; prompt?: RuntimeTerminalPromptDelivery }> {
assertAgentPromptRequestActive(options.signal)
this.assertAgentPromptGeneration(ptyId, generation)
@@ -62,7 +62,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso
// beginning when a large frame is split into independently processed chunks.
renderGate?.arm()
const initialWrite = submitWithPaste ? pastePayload + AGENT_PROMPT_SUBMIT : pastePayload
if (!this.ptyController?.write(ptyId, initialWrite)) {
if (!this.ptyController?.write(ptyId, initialWrite, options.inputKind)) {
throw new Error('terminal_not_writable')
}
} catch (error) {
@@ -103,7 +103,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso
const baseline = preSubmitBaseline ?? this.getAgentPromptActivity(handle, ptyId, waitTextCache)
this.assertAgentPromptPermissionSafe(permissionBaseline, baseline)
if (!submitWithPaste) {
if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT)) {
if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind)) {
throw new Error(options.suffixFailureError ?? 'terminal_not_writable')
}
}
@@ -11,7 +11,7 @@ export type CoordinatorRuntime = {
sendTerminalAgentPrompt(
handle: string,
prompt: string,
options?: DispatchPreambleSendOptions
options: DispatchPreambleSendOptions
): Promise<unknown>
listTerminals(
worktreeSelector?: string,
@@ -24,7 +24,8 @@ export function writeOrchestrationPointerWithSettlement(
return writeRefused('provider_cannot_settle')
}
try {
return settledWrite.call(args.controller, args.ptyId, args.data)
// Why driving: a pointer is input that tells a running agent to read its mail.
return settledWrite.call(args.controller, args.ptyId, args.data, 'driving')
} catch {
// A partial write that then threw cannot prove the transport took nothing.
return writeUnverifiable('provider_threw_after_handoff', true)
+2 -1
View File
@@ -147,12 +147,13 @@ ${params.taskSpec}`
export type DispatchPreambleSendOptions = Pick<
RuntimeAgentPromptWriteOptions,
'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId'
'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId' | 'inputKind'
>
export function dispatchPreambleSendOptions(requestId: string): DispatchPreambleSendOptions {
// Why: a delayed provider hook must not revoke an accepted Dispatch.
return {
inputKind: 'driving',
leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE,
acceptQueued: true,
observationTimeoutMs: 0,
@@ -66,6 +66,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: {
return false
}
const sent = await args.runtime.sendTerminalAgentPrompt(args.handle, args.text, {
inputKind: 'launch',
// Paired: together these take the queued path, which settles an unobserved turn start into
// an `input_accepted` receipt rather than raising it. Without the id the write is verified
// strictly and a slow first turn throws.
@@ -100,7 +100,7 @@ it.each(['unary', 'stream'])('mobile %s bytes do no orchestration database work'
method.handler(method.params!.parse(params) as never, { runtime } as never)
).resolves.toMatchObject({ send: { accepted: true } })
}
expect(write).toHaveBeenCalledWith('pty-worker', 'x')
expect(write).toHaveBeenCalledWith('pty-worker', 'x', 'driving')
expect(commit).toHaveBeenCalledTimes(1)
expect(dbAccess).not.toHaveBeenCalled()
expect(takeover).not.toHaveBeenCalled()
@@ -381,6 +381,7 @@ describe('orchestration worker-start prompt contract', () => {
const { runtime, handle } = await createAgentPromptSubmissionRuntime(() => undefined, 'codex')
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
const pending = runtime.sendTerminalAgentPrompt(handle, 'queued prompt', {
inputKind: 'driving',
acceptQueued: true,
requestId: 'busy-swallowed',
observationTimeoutMs: 0
@@ -66,10 +66,11 @@ export async function sendTerminalStreamInput(
const floorClaim: MobileInputFloorClaimHolder = { current: null }
try {
if (!clientId) {
const result = await runtime.sendTerminal(args.terminal, action)
const result = await runtime.sendTerminal(args.terminal, action, { inputKind: 'driving' })
return result.accepted ? 'delivered' : 'rejected'
}
const result = await runtime.sendTerminal(args.terminal, action, {
inputKind: 'driving',
reserveWrite: (writePtyId) => {
const claim = runtime.beginMobileInputFloor(writePtyId, clientId)
if (!claim) {
@@ -210,6 +210,7 @@ export const TERMINAL_SEND_METHODS = [
try {
result = useSettledAgentPrompt
? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, {
inputKind: 'driving',
beforeWrite,
signal,
...(orchestrationMutation
@@ -234,6 +235,8 @@ export const TERMINAL_SEND_METHODS = [
{
beforeWrite,
signal,
// Why: a wire write carries no provenance beyond a client's own query reply.
inputKind: params.inputKind === 'query-reply' ? 'query-reply' : 'driving',
...(reserveWrite ? { reserveWrite } : {}),
...(params.inputKind !== 'query-reply' && mobileFloorClientId
? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) }
@@ -45,6 +45,7 @@ describe('terminal agent prompt send RPC', () => {
expect(response.ok).toBe(true)
expect(runtime.isTerminalRunningSettledPromptAgent).toHaveBeenCalledWith('terminal-1')
expect(sendTerminalAgentPrompt).toHaveBeenCalledWith('terminal-1', 'review this change', {
inputKind: 'driving',
beforeWrite: undefined,
signal: undefined
})
@@ -81,7 +82,7 @@ describe('terminal agent prompt send RPC', () => {
expect(sendTerminal).toHaveBeenCalledWith(
'terminal-1',
{ text: 'echo x', enter: true, interrupt: false },
{ beforeWrite: undefined, signal: undefined }
{ inputKind: 'driving', beforeWrite: undefined, signal: undefined }
)
expect(sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
@@ -241,11 +241,15 @@ describe('terminal multiplex RPC', () => {
)!
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
text: 'still interactive\r',
enter: false,
interrupt: false
})
expect(runtime.sendTerminal).toHaveBeenCalledWith(
'terminal-1',
{
text: 'still interactive\r',
enter: false,
interrupt: false
},
{ inputKind: 'driving' }
)
)
handlers.get(16)?.(
@@ -434,11 +438,15 @@ describe('terminal multiplex RPC', () => {
)!
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-8', {
text: 'remote-still-interactive\r',
enter: false,
interrupt: false
})
expect(runtime.sendTerminal).toHaveBeenCalledWith(
'terminal-8',
{
text: 'remote-still-interactive\r',
enter: false,
interrupt: false
},
{ inputKind: 'driving' }
)
)
const frameCountBeforeAck = binaryFrames.length
@@ -413,11 +413,15 @@ describe('terminal multiplex RPC', () => {
)!
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
text: 'still interactive\r',
enter: false,
interrupt: false
})
expect(runtime.sendTerminal).toHaveBeenCalledWith(
'terminal-1',
{
text: 'still interactive\r',
enter: false,
interrupt: false
},
{ inputKind: 'driving' }
)
)
binaryFrames.splice(0)
@@ -206,11 +206,15 @@ describe('terminal multiplex RPC', () => {
)
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
text: 'ls\r',
enter: false,
interrupt: false
})
expect(runtime.sendTerminal).toHaveBeenCalledWith(
'terminal-1',
{
text: 'ls\r',
enter: false,
interrupt: false
},
{ inputKind: 'driving' }
)
)
const sentAfterSuccessfulClaim = vi.mocked(runtime.sendTerminal).mock.calls.length
vi.mocked(runtime.updateRemoteDesktopViewer).mockResolvedValueOnce(false)
@@ -248,11 +252,15 @@ describe('terminal multiplex RPC', () => {
)
}
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenLastCalledWith('terminal-1', {
text: 'retry',
enter: false,
interrupt: false
})
expect(runtime.sendTerminal).toHaveBeenLastCalledWith(
'terminal-1',
{
text: 'retry',
enter: false,
interrupt: false
},
{ inputKind: 'driving' }
)
)
dataListenerRef.current?.('a')
@@ -327,11 +327,15 @@ describe('terminal multiplex RPC', () => {
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
text: 'echo one\necho two\r\n',
enter: false,
interrupt: false
})
expect(runtime.sendTerminal).toHaveBeenCalledWith(
'terminal-1',
{
text: 'echo one\necho two\r\n',
enter: false,
interrupt: false
},
{ inputKind: 'driving' }
)
)
runtime.cleanupSubscription('terminal-multiplex:conn-byte-preserving')
@@ -403,11 +407,15 @@ describe('terminal multiplex RPC', () => {
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
text: 'printf a\nprintf b\r\n',
enter: false,
interrupt: false
})
expect(runtime.sendTerminal).toHaveBeenCalledWith(
'terminal-1',
{
text: 'printf a\nprintf b\r\n',
enter: false,
interrupt: false
},
{ inputKind: 'driving' }
)
)
runtime.cleanupSubscription('terminal-1:desktop-1')
@@ -309,7 +309,11 @@ describe('terminal multiplex RPC', () => {
expect(runtime.sendTerminal).toHaveBeenCalledWith(
'terminal-1',
{ text: 'x', enter: false, interrupt: false },
{ reserveWrite: expect.any(Function), afterWrite: expect.any(Function) }
{
inputKind: 'driving',
reserveWrite: expect.any(Function),
afterWrite: expect.any(Function)
}
)
)
expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan(
@@ -347,7 +347,11 @@ describe('terminal output batching', () => {
expect(runtime.sendTerminal).toHaveBeenCalledWith(
'terminal-1',
{ text: 'ls\r', enter: false, interrupt: false },
{ reserveWrite: expect.any(Function), afterWrite: expect.any(Function) }
{
inputKind: 'driving',
reserveWrite: expect.any(Function),
afterWrite: expect.any(Function)
}
)
)
expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1')
+4 -3
View File
@@ -232,6 +232,7 @@ describe('terminal send RPC', () => {
interrupt: false
},
{
inputKind: 'driving',
beforeWrite: undefined,
reserveWrite: expect.any(Function),
afterWrite: expect.any(Function)
@@ -370,7 +371,7 @@ describe('terminal send RPC', () => {
expect(runtime.sendTerminal).toHaveBeenCalledWith(
'terminal-1',
{ text: '\x1b[3;4R', enter: false, interrupt: false },
{ beforeWrite: undefined }
{ beforeWrite: undefined, inputKind: 'query-reply' }
)
expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
})
@@ -570,7 +571,7 @@ describe('terminal send RPC', () => {
enter: false,
interrupt: false
},
{ beforeWrite: undefined }
{ inputKind: 'driving', beforeWrite: undefined }
)
})
@@ -653,7 +654,7 @@ describe('terminal send RPC', () => {
enter: true,
interrupt: false
},
{ beforeWrite: expect.any(Function) }
{ inputKind: 'driving', beforeWrite: expect.any(Function) }
)
})
@@ -12,6 +12,7 @@ import type { ExecutionHostId } from '../../shared/execution-host'
import type { PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnResult } from '../providers/types'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import type { WriteSettlement } from '../../shared/pty-write-settlement'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
export type RuntimePtyController = {
claimStablePaneCreate?(args: {
@@ -93,9 +94,13 @@ export type RuntimePtyController = {
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
agentSessionEnsure?: AgentSessionClaimedSpawnResult
}>
write(ptyId: string, data: string): boolean
write(ptyId: string, data: string, inputKind: TerminalInputKind): boolean
/** Three-valued settlement; local providers settle synchronously. */
writeWithSettlement?(ptyId: string, data: string): WriteSettlement | Promise<WriteSettlement>
writeWithSettlement?(
ptyId: string,
data: string,
inputKind: TerminalInputKind
): WriteSettlement | Promise<WriteSettlement>
/** Attach-only adoption of a live local daemon session so its output streams
* to main without a renderer pane; never creates, resizes, or focuses.
* False on doubt (absent session, SSH-scoped id, non-daemon provider). */
@@ -106,7 +111,6 @@ export type RuntimePtyController = {
ptyId: string,
opts?: { keepHistory?: boolean; deadlineMs?: number }
): Promise<boolean>
markReversibleStops?(ptyIds: readonly string[]): () => void
/** Durably records a kill order for an explicit close's unconfirmed stop, replayed when its SSH
* host reconnects. True only when an order was written; local PTYs have no later host to ask. */
recordUnconfirmedStop?(ptyId: string): boolean
@@ -16,6 +16,7 @@ import type { TuiAgent } from '../../shared/tui-agent'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type { RuntimeTerminalSend } from '../../shared/runtime-terminal-contracts'
import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows'
import type { WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness'
@@ -203,7 +204,9 @@ export type RuntimeProviderSnapshotReadOptions = {
}
/** Agent-prompt writes add the correlation inputs a queued-acceptance receipt needs. */
export type RuntimeAgentPromptWriteOptions = RuntimeTerminalWriteOptions & {
export type RuntimeAgentPromptWriteOptions = Omit<RuntimeTerminalWriteOptions, 'inputKind'> & {
/** `launch` for the prompt an agent starts with; `driving` for any prompt sent to a running one. */
inputKind: Exclude<TerminalInputKind, 'query-reply'>
/** Raw prompt text for submit scheduling; not written, only used for line-aware delays. */
promptForSchedule?: string
/** See buildAgentPromptPasteBytes. */
+8 -6
View File
@@ -1,8 +1,10 @@
import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection'
import type { TuiAgent } from '../../shared/tui-agent'
import { iterateTerminalInputChunks } from '../../shared/terminal-input'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
export type RuntimeTerminalWriteOptions = {
inputKind: TerminalInputKind
signal?: AbortSignal
beforeWrite?: (ptyId: string) => void | Promise<void>
reserveWrite?: (ptyId: string) => void
@@ -12,7 +14,7 @@ export type RuntimeTerminalWriteOptions = {
export class RuntimeTerminalWriter {
constructor(
private readonly write: (ptyId: string, data: string) => boolean,
private readonly write: (ptyId: string, data: string, inputKind: TerminalInputKind) => boolean,
private readonly getWriteHostPlatform: (ptyId: string) => NodeJS.Platform = () =>
process.platform,
private readonly getAgent: (ptyId: string) => TuiAgent | null = () => null
@@ -22,7 +24,7 @@ export class RuntimeTerminalWriter {
ptyId: string,
action: { text?: string; enter?: boolean; interrupt?: boolean },
payload: string,
options: RuntimeTerminalWriteOptions = {}
options: RuntimeTerminalWriteOptions
): Promise<void> {
// Why: direct terminal.send can carry paste-sized text from RPC/mobile
// clients; chunk text before PTY/ConPTY while preserving suffix separation.
@@ -54,7 +56,7 @@ export class RuntimeTerminalWriter {
throw error
}
options.reserveWrite?.(ptyId)
if (!this.write(ptyId, suffix)) {
if (!this.write(ptyId, suffix, options.inputKind)) {
throw new Error(options.suffixFailureError ?? 'terminal_not_writable')
}
await options.afterWrite?.(ptyId)
@@ -65,7 +67,7 @@ export class RuntimeTerminalWriter {
}
await options.beforeWrite?.(ptyId)
options.reserveWrite?.(ptyId)
if (!this.write(ptyId, payload)) {
if (!this.write(ptyId, payload, options.inputKind)) {
throw new Error('terminal_not_writable')
}
await options.afterWrite?.(ptyId)
@@ -74,14 +76,14 @@ export class RuntimeTerminalWriter {
async writeChunks(
ptyId: string,
text: string,
options: RuntimeTerminalWriteOptions = {}
options: RuntimeTerminalWriteOptions
): Promise<void> {
const chunks = iterateTerminalInputChunks(text)
let chunk = chunks.next()
while (!chunk.done) {
await options.beforeWrite?.(ptyId)
options.reserveWrite?.(ptyId)
if (!this.write(ptyId, chunk.value)) {
if (!this.write(ptyId, chunk.value, options.inputKind)) {
throw new Error('terminal_not_writable')
}
await options.afterWrite?.(ptyId)
@@ -4,6 +4,7 @@ import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-sca
import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { TuiAgent } from '../../shared/tui-agent'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
import type {
WorktreeStartupDraftPaste,
WorktreeStartupFollowup
@@ -19,7 +20,7 @@ export type WorktreeStartupReadinessHost = {
hasChildProcesses?: (ptyId: string) => Promise<boolean>
subscribeToData: (ptyId: string, listener: (data: string) => void) => () => void
readRecentOutput: (ptyId: string) => string | undefined
write: (ptyId: string, data: string) => void
write: (ptyId: string, data: string, inputKind: TerminalInputKind) => void
}
export function pasteWorktreeStartupDraftWhenReady(
@@ -33,7 +34,7 @@ export function pasteWorktreeStartupDraftWhenReady(
console.warn('[worktree-create] agent did not become ready for draft paste')
return
}
host.write(ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`)
host.write(ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`, 'launch')
})
.catch((error) => console.warn('[worktree-create] failed to paste startup draft:', error))
}
@@ -49,7 +50,7 @@ export function sendWorktreeStartupFollowupWhenReady(
console.warn('[worktree-create] agent did not become ready for follow-up prompt')
return
}
host.write(ptyId, `${followup.prompt}\r`)
host.write(ptyId, `${followup.prompt}\r`, 'launch')
})
.catch((error) =>
console.warn('[worktree-create] failed to send startup follow-up prompt:', error)
@@ -37,8 +37,7 @@ export async function runObservedExitSocketScenario(scenario: ObservedExitSocket
rememberSyntheticKillExit: harness.session.rememberSyntheticKillExit,
sendPtyExitToRenderer: harness.session.sendPtyExitToRenderer,
finishPtyShutdown,
retiredRejectedPtyIds: new Map<string, NodeJS.Timeout>(),
reversibleStopOwnersByPtyId: new Map<string, number>()
retiredRejectedPtyIds: new Map<string, NodeJS.Timeout>()
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stop/kill read only these controller ports and optional store; spawn ports are unused.
const deps = ports as unknown as PtyRuntimeControllerDeps
@@ -0,0 +1,80 @@
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { scanSourceTree } from '../../shared/source-scan/source-tree-scan'
import {
findCallsMissingArgument,
type RequiredCallArgument
} from '../../shared/source-scan/call-argument-scan'
/**
* Every PTY write names its input kind, and the compiler enforces that everywhere except the
* runtime files split out with `@ts-nocheck`. There a missing kind would compile and silently
* record nothing, so this scan is the ratchet for them.
*/
const MAIN_ROOT = resolve(__dirname, '..')
const namesInputKind = (literal: string): boolean => /\binputKind\b|\.\.\./.test(literal)
const kindAt = (index: number, receiver?: RegExp): RequiredCallArgument => ({
index,
...(receiver ? { receiver } : {}),
acceptsObjectLiteral: namesInputKind
})
const CONTROLLER = /[Cc]ontroller\??\s*$/
const KIND_ARGUMENT_BY_METHOD: Record<string, RequiredCallArgument> = {
write: kindAt(2, CONTROLLER),
writeWithSettlement: kindAt(2, CONTROLLER),
sendTerminal: kindAt(2),
sendTerminalAgentPrompt: kindAt(2),
writeTerminalAction: kindAt(3),
writeTerminalInputChunks: kindAt(2),
writeTerminalAgentPrompt: kindAt(4),
writeAction: kindAt(3),
writeChunks: kindAt(2)
}
// Why multiline: some unchecked files open with a lint directive before `@ts-nocheck`.
const uncheckedSources = scanSourceTree(MAIN_ROOT).filter((file) =>
/^\/\/ @ts-nocheck\b/m.test(file.source)
)
describe('PTY write call sites the compiler cannot check', () => {
it('scans the unchecked runtime files that write to a PTY', () => {
expect(uncheckedSources.map((file) => file.relativePath)).toEqual(
expect.arrayContaining([
'runtime/orca-runtime-deliver-pending-messages.ts',
'runtime/orca-runtime-create-pty-headless-terminal-state.ts',
'runtime/orca-runtime-write-terminal-agent-prompt.ts',
'runtime/orca-runtime-sync-window-graph.ts'
])
)
})
it('finds a write, prompt or send that leaves out its kind', () => {
const planted = [
'',
'this.ptyController?.write(ptyId, reply)',
"this.ptyController.write(ptyId, '\\r', 'launch')",
"await this.sendTerminal(handle, { text: 'a, b' }, { beforeWrite })",
'await this.sendTerminalAgentPrompt(handle, prompt, { ...options })',
'other.write(ptyId, data)',
'const controller = this.ptyController; controller.write(ptyId, data)'
].join('\n')
expect(findCallsMissingArgument(planted, KIND_ARGUMENT_BY_METHOD)).toEqual([
'2: .write(ptyId, reply)',
"4: .sendTerminal(handle, { text: 'a, b' }, { beforeWrite })",
'7: .write(ptyId, data)'
])
})
it('passes an input kind at every write', () => {
const missing = uncheckedSources.flatMap((file) =>
findCallsMissingArgument(file.source, KIND_ARGUMENT_BY_METHOD).map(
(site) => `${file.relativePath}:${site}`
)
)
expect(missing).toEqual([])
})
})
@@ -0,0 +1,288 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { BrowserWindow } from 'electron'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import { Store } from '../persistence/loading-store/store'
import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority'
import { wirePtyIpcSession } from '../ipc/pty/delivery/wire-session'
import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state'
import { bindProviderListeners } from '../ipc/pty/provider/bind-listeners'
import {
stopRendererOwnedPty,
stopReplacedPanePty,
type PtyKillIpcDeps
} from '../ipc/pty/ipc/renderer-kill'
import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state'
import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry'
import { createPtyIpcSession } from '../ipc/pty/session'
import type { IPtyProvider } from '../providers/types'
import { OrcaRuntimeService } from './orca-runtime'
import {
INCARNATION_ID,
LEAF_ID,
PTY_ID,
REPO_ID,
TAB_ID,
WORKTREE_ID,
WORKTREE_PATH,
makeSession
} from './__fixtures__/orca-runtime-terminal-close-continuity-state-fixture'
import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority'
const REPLACEMENT_PTY_ID = 'pty-close-continuity-replacement'
const REPLACEMENT_INCARNATION_ID = '77777777-7777-4777-8777-777777777777'
const LATER_INCARNATION_ID = '88888888-8888-4888-8888-888888888888'
const directories: string[] = []
const stores: Store[] = []
const priorProvider = getLocalPtyProvider()
afterEach(() => {
vi.useRealTimers()
setLocalPtyProvider(priorProvider)
ptyOwnership.delete(PTY_ID)
ptyIncarnationById.delete(PTY_ID)
for (const store of stores.splice(0)) {
store.freezeWrites()
}
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
/** A real store and runtime with one bound pane, and main's exit delivery to a renderer stub.
* `lateProviderExit`: the kill's reply overtakes the exit, so main synthesizes one and the
* provider's own exit arrives later through its listener. */
function createHarness(opts: { lateProviderExit?: boolean; folder?: boolean } = {}) {
const directory = mkdtempSync(join(tmpdir(), 'orca-intentional-stop-'))
directories.push(directory)
const store = new Store({
dataFile: join(directory, 'orca-data.json'),
profileStateAuthority: new ProfileStateSqliteAuthority(
join(directory, 'profile-state.db'),
'intentional-stop'
)
})
stores.push(store)
store.addRepo({
id: REPO_ID,
path: WORKTREE_PATH,
displayName: 'Fixture',
badgeColor: 'gray',
addedAt: 1,
// Why: a folder workspace resolves without git, which the sleep transaction needs.
...(opts.folder ? { kind: 'folder' as const } : {})
})
store.setWorkspaceSession(advanceTerminalTopologyRevision(makeSession(), WORKTREE_ID))
store.flushOrThrow()
const runtime = new OrcaRuntimeService(store)
runtime.registerPty(PTY_ID, WORKTREE_ID, null, {
tabId: TAB_ID,
leafId: LEAF_ID,
incarnationId: INCARNATION_ID
})
ptyOwnership.set(PTY_ID, null)
ptyIncarnationById.set(PTY_ID, INCARNATION_ID)
let emitProviderExit:
| ((payload: { id: string; code: number; incarnationId?: string }) => void)
| undefined
const provider = {
onData: () => () => {},
onExit: (listener: typeof emitProviderExit) => {
emitProviderExit = listener
return () => {}
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the listeners bind only onData and onExit, and the renderer kill hands the provider to the shutdown port below.
setLocalPtyProvider(provider as unknown as IPtyProvider)
const rendererSend = vi.fn()
const window = { isDestroyed: () => false, webContents: { send: rendererSend } }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: exit delivery reads only isDestroyed and webContents.send.
const session = createPtyIpcSession({ mainWindow: window as unknown as BrowserWindow, runtime })
wirePtyIpcSession(session)
bindProviderListeners(session)
const deps: PtyKillIpcDeps = {
store,
runtime,
getLocalPtyProviderStartupPromise: () => undefined,
// The provider's own exit, delivered the way its listener delivers it.
shutdownProviderAndDetectExit: async (_provider, id) => {
if (opts.lateProviderExit) {
return false
}
runtime.onPtyExit(id, 0, INCARNATION_ID, { providerExitObserved: true })
session.sendPtyExitToRenderer({ id, code: 0, incarnationId: INCARNATION_ID })
return true
},
rememberSyntheticKillExit: session.rememberSyntheticKillExit,
sendPtyExitToRenderer: session.sendPtyExitToRenderer
}
return {
store,
runtime,
deps,
emitProviderExit: (incarnationId: string) =>
emitProviderExit?.({ id: PTY_ID, code: 0, incarnationId }),
boundPtyId: () =>
store.getWorkspaceSession().terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId?.[LEAF_ID] ?? null,
tabIds: () => (store.getWorkspaceSession().tabsByWorktree[WORKTREE_ID] ?? []).map((t) => t.id),
rendererExits: () =>
rendererSend.mock.calls.filter(([channel]) => channel === 'pty:exit').map(([, p]) => p)
}
}
describe('intentional stops keep the pane through the exit', () => {
it('retires the pane when an ordinary close ends the process', async () => {
const harness = createHarness()
await stopRendererOwnedPty(harness.deps, { id: PTY_ID })
expect(harness.boundPtyId()).toBeNull()
expect(harness.rendererExits()).toEqual([
{ id: PTY_ID, code: 0, incarnationId: INCARNATION_ID }
])
})
it('keeps the tab and its wake binding when the renderer hibernates the pane', async () => {
const harness = createHarness()
await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true })
expect(harness.tabIds()).toEqual([TAB_ID])
expect(harness.boundPtyId()).toBe(PTY_ID)
expect(harness.rendererExits()).toEqual([
{ id: PTY_ID, code: 0, incarnationId: INCARNATION_ID, preserveRendererBinding: true }
])
})
it('keeps a typed pane that a restart replaces, and binds the replacement', async () => {
const harness = createHarness()
harness.runtime.terminalRunFacts.recordSpawnCommit({
id: PTY_ID,
incarnationId: INCARNATION_ID
})
harness.runtime.terminalRunFacts.recordInput(PTY_ID, 'driving', 'ls\r')
await stopReplacedPanePty(harness.deps, PTY_ID)
expect(harness.boundPtyId()).toBe(PTY_ID)
await harness.store.persistPtyBinding({
worktreeId: WORKTREE_ID,
tabId: TAB_ID,
leafId: LEAF_ID,
ptyId: REPLACEMENT_PTY_ID,
incarnationId: REPLACEMENT_INCARNATION_ID,
origin: 'spawn'
})
expect(harness.tabIds()).toEqual([TAB_ID])
expect(harness.boundPtyId()).toBe(REPLACEMENT_PTY_ID)
expect(
harness.store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[
makePaneKey(TAB_ID, LEAF_ID)
]
).toBe(REPLACEMENT_INCARNATION_ID)
expect(harness.rendererExits()).toEqual([
{ id: PTY_ID, code: 0, incarnationId: INCARNATION_ID, replacedByRestart: true }
])
})
it('labels the exit for both a sleep and a restart that stop the same process', async () => {
const harness = createHarness()
const settleSleep = harness.runtime.intentionalPtyStops.mark(
PTY_ID,
'reversible',
INCARNATION_ID
)
await stopReplacedPanePty(harness.deps, PTY_ID)
settleSleep(true)
expect(harness.boundPtyId()).toBe(PTY_ID)
expect(harness.rendererExits()).toEqual([
{
id: PTY_ID,
code: 0,
incarnationId: INCARNATION_ID,
preserveRendererBinding: true,
replacedByRestart: true
}
])
})
it('keeps the pane through the synthetic exit and the provider exit that follows it', async () => {
const harness = createHarness({ lateProviderExit: true })
await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true })
harness.emitProviderExit(INCARNATION_ID)
expect(harness.tabIds()).toEqual([TAB_ID])
expect(harness.boundPtyId()).toBe(PTY_ID)
expect(harness.rendererExits()).toEqual([
{ id: PTY_ID, code: -1, incarnationId: INCARNATION_ID, preserveRendererBinding: true }
])
})
it('never reads the exit of a later process on the same id as the stop', async () => {
const harness = createHarness({ lateProviderExit: true })
await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true })
harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, {
tabId: TAB_ID,
leafId: LEAF_ID,
incarnationId: LATER_INCARNATION_ID
})
ptyIncarnationById.set(PTY_ID, LATER_INCARNATION_ID)
await harness.store.persistPtyBinding({
worktreeId: WORKTREE_ID,
tabId: TAB_ID,
leafId: LEAF_ID,
ptyId: PTY_ID,
incarnationId: LATER_INCARNATION_ID,
origin: 'reattach'
})
harness.emitProviderExit(LATER_INCARNATION_ID)
// Why wait: an unstopped exit retires the pane through an async durable save.
await vi.waitFor(() => expect(harness.boundPtyId()).toBeNull())
expect(harness.rendererExits().at(-1)).toEqual({
id: PTY_ID,
code: 0,
incarnationId: LATER_INCARNATION_ID
})
})
it('forgets the stop once the duplicate-exit window after it closes', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createHarness({ lateProviderExit: true })
await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true })
vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1)
expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([
'reversible'
])
vi.advanceTimersByTime(1)
expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([])
})
it('keeps the tab and its wake binding when the runtime puts the worktree to sleep', async () => {
const harness = createHarness({ folder: true })
const inventories = [[{ id: PTY_ID, worktreeId: WORKTREE_ID, cwd: WORKTREE_PATH, title: 'a' }]]
harness.runtime.setPtyController({
write: () => true,
kill: () => true,
stopAndWait: async (ptyId) => {
harness.runtime.onPtyExit(ptyId, -1, INCARNATION_ID, { providerExitObserved: true })
return true
},
getForegroundProcess: async () => null,
listProcesses: async () => inventories.shift() ?? []
})
await harness.runtime.sleepTerminalsForWorktree(`id:${WORKTREE_ID}`)
expect(harness.tabIds()).toEqual([TAB_ID])
expect(harness.boundPtyId()).toBe(PTY_ID)
})
})
@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state'
import { TerminalIntentionalStops } from './terminal-intentional-stops'
afterEach(() => {
vi.useRealTimers()
})
describe('terminal intentional stops', () => {
it('keeps the mark while a second overlapping owner still holds it', () => {
const stops = new TerminalIntentionalStops()
const settleFirst = stops.mark('pty-1', 'reversible', 'inc-1')
const settleSecond = stops.mark('pty-1', 'reversible', 'inc-1')
settleFirst(false)
expect(stops.isReversibleStopInFlight('pty-1')).toBe(true)
expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible'])
settleSecond(false)
expect(stops.claimExit('pty-1', 'inc-1')).toEqual([])
})
it('still reads an exit that lands after the stop settled, until the window closes', () => {
vi.useFakeTimers()
const stops = new TerminalIntentionalStops()
const settle = stops.mark('pty-ssh', 'reversible', 'inc-1')
settle(true)
vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1)
expect(stops.isReversibleStopInFlight('pty-ssh')).toBe(false)
expect(stops.claimExit('pty-ssh', 'inc-1')).toEqual(['reversible'])
vi.advanceTimersByTime(1)
expect(stops.claimExit('pty-ssh', 'inc-1')).toEqual([])
})
it('drops the mark at once when the stop fails', () => {
const stops = new TerminalIntentionalStops()
stops.mark('pty-1', 'replaced', 'inc-1')(false)
expect(stops.claimExit('pty-1', 'inc-1')).toEqual([])
})
it('reads the synthetic exit and the provider exit of the same process alike', () => {
const stops = new TerminalIntentionalStops()
const settle = stops.mark('pty-1', 'replaced', null)
expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['replaced'])
settle(true)
expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['replaced'])
expect(stops.claimExit('pty-1', 'inc-2')).toEqual([])
})
it('never marks the exit of another process that reuses the id', () => {
const stops = new TerminalIntentionalStops()
stops.mark('pty-1', 'reversible', 'inc-1')
expect(stops.claimExit('pty-1', 'inc-2')).toEqual([])
})
it('starts a new stop of the same id fresh once the prior one settled', () => {
const stops = new TerminalIntentionalStops()
stops.mark('pty-1', 'reversible', 'inc-1')(true)
const settle = stops.mark('pty-1', 'replaced', 'inc-2')
expect(stops.claimExit('pty-1', 'inc-1')).toEqual([])
expect(stops.claimExit('pty-1', 'inc-2')).toEqual(['replaced'])
settle(false)
expect(stops.claimExit('pty-1', 'inc-2')).toEqual([])
})
it('labels one exit with every kind of stop that overlapped on it', () => {
const stops = new TerminalIntentionalStops()
const settleSleep = stops.mark('pty-1', 'reversible', 'inc-1')
const settleRestart = stops.mark('pty-1', 'replaced', 'inc-1')
expect(stops.isReversibleStopInFlight('pty-1')).toBe(true)
expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible', 'replaced'])
settleSleep(true)
expect(stops.isReversibleStopInFlight('pty-1')).toBe(false)
settleRestart(false)
expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible'])
})
it('keeps a landed stop when a later stop of the same process fails', () => {
const stops = new TerminalIntentionalStops()
stops.mark('pty-1', 'reversible', 'inc-1')(true)
stops.mark('pty-1', 'reversible', 'inc-1')(false)
expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible'])
})
it('lets a new process on the id supersede a landed stop no exit pinned', () => {
const stops = new TerminalIntentionalStops()
stops.mark('pty-unpinned', 'reversible', null)(true)
stops.mark('pty-pinned', 'reversible', 'inc-1')(true)
stops.mark('pty-in-flight', 'replaced', null)
for (const ptyId of ['pty-unpinned', 'pty-pinned', 'pty-in-flight']) {
stops.noteSpawnCommit(ptyId)
}
expect(stops.claimExit('pty-unpinned', null)).toEqual([])
expect(stops.claimExit('pty-pinned', 'inc-1')).toEqual(['reversible'])
expect(stops.claimExit('pty-in-flight', null)).toEqual(['replaced'])
})
})
@@ -0,0 +1,122 @@
import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state'
/**
* Why main stopped a PTY on purpose. Both kinds keep the pane's binding through the exit:
* - `reversible`: sleep or hibernation; the binding is the wake hint.
* - `replaced`: a restart stops the old process so a new one can take the pane.
*/
export type TerminalIntentionalStopKind = 'reversible' | 'replaced'
type IntentionalStopOwners = { inFlight: number; stopped: boolean }
type IntentionalStop = {
/** Null until known; the first exit that claims the stop pins it to that process. */
incarnationId: string | null
/** Why per kind: overlapping stops of different kinds each hold their own label on the exit. */
ownersByKind: Map<TerminalIntentionalStopKind, IntentionalStopOwners>
expiryTimer?: ReturnType<typeof setTimeout>
}
const NO_INTENTIONAL_STOP: readonly TerminalIntentionalStopKind[] = []
function hasInFlightOwners(stop: IntentionalStop): boolean {
return [...stop.ownersByKind.values()].some((owners) => owners.inFlight > 0)
}
/** The one register of PTY stops main made on purpose, read by every exit path. */
export class TerminalIntentionalStops {
private readonly stopsByPtyId = new Map<string, IntentionalStop>()
/** Registers one owner's stop; call the result once with whether the stop landed. */
mark(
ptyId: string,
kind: TerminalIntentionalStopKind,
incarnationId: string | null
): (stopped: boolean) => void {
let stop = this.stopsByPtyId.get(ptyId)
if (stop && this.joins(stop, incarnationId)) {
clearTimeout(stop.expiryTimer)
stop.expiryTimer = undefined
stop.incarnationId ??= incarnationId
} else {
clearTimeout(stop?.expiryTimer)
stop = { incarnationId, ownersByKind: new Map() }
this.stopsByPtyId.set(ptyId, stop)
}
const owners = stop.ownersByKind.get(kind) ?? { inFlight: 0, stopped: false }
owners.inFlight += 1
stop.ownersByKind.set(kind, owners)
const owned = stop
let settled = false
return (stopped) => {
if (settled || this.stopsByPtyId.get(ptyId) !== owned) {
return
}
settled = true
owners.stopped ||= stopped
owners.inFlight -= 1
if (owners.inFlight === 0 && !owners.stopped) {
owned.ownersByKind.delete(kind)
}
this.settleIfIdle(ptyId, owned)
}
}
/** The kinds of stop this exit ends; empty when the process was not stopped on purpose. */
claimExit(
ptyId: string,
exitIncarnationId: string | null | undefined
): readonly TerminalIntentionalStopKind[] {
const stop = this.stopsByPtyId.get(ptyId)
if (!stop) {
return NO_INTENTIONAL_STOP
}
if (stop.incarnationId && exitIncarnationId && stop.incarnationId !== exitIncarnationId) {
return NO_INTENTIONAL_STOP
}
stop.incarnationId ??= exitIncarnationId ?? null
return [...stop.ownersByKind.keys()]
}
/** Whether a stop of this PTY that may still be undone is in flight. */
isReversibleStopInFlight(ptyId: string): boolean {
return (this.stopsByPtyId.get(ptyId)?.ownersByKind.get('reversible')?.inFlight ?? 0) > 0
}
/** A process committed on this id. A landed stop's process is dead, so an entry no exit ever
* pinned could otherwise claim the new process's exit as the stop. */
noteSpawnCommit(ptyId: string): void {
const stop = this.stopsByPtyId.get(ptyId)
if (stop && stop.incarnationId === null && !hasInFlightOwners(stop)) {
clearTimeout(stop.expiryTimer)
this.stopsByPtyId.delete(ptyId)
}
}
// Why: a settled entry joins only its own known process, so an id reused by a process whose
// incarnation is not yet known never inherits the old stop.
private joins(stop: IntentionalStop, incarnationId: string | null): boolean {
if (stop.incarnationId !== null && stop.incarnationId === incarnationId) {
return true
}
return hasInFlightOwners(stop) && (stop.incarnationId === null || incarnationId === null)
}
private settleIfIdle(ptyId: string, stop: IntentionalStop): void {
if (hasInFlightOwners(stop)) {
return
}
if (stop.ownersByKind.size === 0) {
this.stopsByPtyId.delete(ptyId)
return
}
// Why a window: an SSH exit can arrive after the stop settles, and a synthetic exit can be
// followed by the provider's own; both describe the same stopped process.
stop.expiryTimer = setTimeout(() => {
if (this.stopsByPtyId.get(ptyId) === stop) {
this.stopsByPtyId.delete(ptyId)
}
}, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS)
stop.expiryTimer.unref?.()
}
}
@@ -92,9 +92,9 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => {
await expect(
assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} })
).rejects.toThrow('terminal_guard_permission')
await expect(runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble')).rejects.toThrow(
'agent_prompt_blocked'
)
await expect(
runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble', { inputKind: 'driving' })
).rejects.toThrow('agent_prompt_blocked')
})
it('lets a dispatch preamble through once the same lane is working', async () => {
@@ -21,6 +21,7 @@ import {
setTerminalViewAttributes
} from './terminal-view-attribute-store'
import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
const settingsState = {
terminalMainSideEffectAuthority: true as boolean,
@@ -58,9 +59,11 @@ type RendererBufferStub = { data: string; cols: number; rows: number }
function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) {
const runtime = new OrcaRuntimeService(store)
const replies: { ptyId: string; data: string }[] = []
const inputKinds: TerminalInputKind[] = []
runtime.setPtyController({
write: (ptyId, data) => {
write: (ptyId, data, inputKind) => {
replies.push({ ptyId, data })
inputKinds.push(inputKind)
return true
},
kill: () => true,
@@ -74,7 +77,7 @@ function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } =
}
: {})
})
return { runtime, replies }
return { runtime, replies, inputKinds }
}
/** Awaits the per-PTY emulator writeChain so queued chunk links (and the
@@ -146,7 +149,7 @@ describe('reply parity for hidden-dropped chunks', () => {
['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']],
['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']]
])('%s', async (_label, chunk, expectedReplies) => {
const { runtime, replies } = createResponderRuntime()
const { runtime, replies, inputKinds } = createResponderRuntime()
markHiddenRendererPty('pty-q')
runtime.onPtyData('pty-q', chunk, Date.now())
@@ -154,6 +157,8 @@ describe('reply parity for hidden-dropped chunks', () => {
expect(replies.map((reply) => reply.data)).toEqual(expectedReplies)
expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true)
// Why: a reply written as driving input would read the untouched run as typed.
expect(inputKinds).toEqual(expectedReplies.map(() => 'query-reply'))
})
it.each([
@@ -0,0 +1,244 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
import { writePtyFromRuntimeController } from '../ipc/pty/runtime/operations'
import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry'
import type { IPtyProvider } from '../providers/types'
import { settledWriteStub } from '../providers/settled-pty-write-stub'
import { OrcaRuntimeService } from './orca-runtime'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
import { writeOrchestrationPointerWithSettlement } from './orchestration/mailbox-pointer-pty-write'
import { sendTerminalStreamInput } from './rpc/methods/terminal/terminal-input-delivery'
import { makeStore } from './runtime-rpc-worktree-store-fixtures'
import {
pasteWorktreeStartupDraftWhenReady,
sendWorktreeStartupFollowupWhenReady,
type WorktreeStartupReadinessHost
} from './runtime-worktree-startup-readiness'
vi.mock('../git/worktree', () => {
const worktrees = [
{
path: '/tmp/worktree-a',
head: 'abc',
branch: 'feature/run-facts',
isBare: false,
isMainWorktree: false
}
]
return {
listWorktrees: vi.fn().mockResolvedValue(worktrees),
listWorktreesStrict: vi.fn().mockResolvedValue(worktrees)
}
})
const PTY_ID = 'pty-run-facts-input'
const priorProvider = getLocalPtyProvider()
type FreshRun = {
runtime: OrcaRuntimeService
controller: RuntimePtyController
handle: string
/** The kind each provider write was sent with, and whether input was recorded by then. */
writes: { data: string; inputRecorded: boolean }[]
kinds: TerminalInputKind[]
firstUserInputAt: () => number | null
}
/** A fresh shell whose controller writes go through main's real write funnel to a fake provider. */
async function createFreshRun(): Promise<FreshRun> {
const runtime = new OrcaRuntimeService(makeStore() as never)
const firstUserInputAt = (): number | null =>
runtime.terminalRunFacts.read(PTY_ID, null).firstUserInputAt
const writes: FreshRun['writes'] = []
const kinds: TerminalInputKind[] = []
const write = (_id: string, data: string): boolean => {
writes.push({ data, inputRecorded: firstUserInputAt() !== null })
if (data.endsWith('\r')) {
runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', Date.now())
}
return true
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the write funnel calls only write and writeWithSettlement on the provider.
setLocalPtyProvider({
write,
writeWithSettlement: settledWriteStub(write)
} as unknown as IPtyProvider)
const controller: RuntimePtyController = {
spawn: async () => ({ id: PTY_ID }),
write: (id, data, inputKind) => {
kinds.push(inputKind)
return writePtyFromRuntimeController({ runtime }, id, data, inputKind)
},
writeWithSettlement: (id, data, inputKind) => {
kinds.push(inputKind)
return writePtyFromRuntimeController({ runtime }, id, data, inputKind, {
waitForSettlement: true
})
},
kill: () => true,
getForegroundProcess: async () => null
}
runtime.setPtyController(controller)
const terminal = await runtime.createTerminal('path:/tmp/worktree-a', { launchAgent: 'aider' })
runtime.terminalRunFacts.recordSpawnCommit({ id: PTY_ID })
return { runtime, controller, handle: terminal.handle, writes, kinds, firstUserInputAt }
}
afterEach(() => {
vi.useRealTimers()
setLocalPtyProvider(priorProvider)
})
describe('run facts: the controller write funnel', () => {
it('records terminal.send input before the write that could end the process', async () => {
const run = await createFreshRun()
await run.runtime.sendTerminal(
run.handle,
{ text: 'exit', enter: true },
{ inputKind: 'driving' }
)
expect(run.firstUserInputAt()).not.toBeNull()
expect(run.writes.map((write) => write.inputRecorded)).toEqual([true, true])
})
it('records stream input from a client', async () => {
const run = await createFreshRun()
await sendTerminalStreamInput(run.runtime, {
terminal: run.handle,
text: 'l',
client: undefined,
isMobile: false
})
expect(run.firstUserInputAt()).not.toBeNull()
})
it('records a dispatched agent prompt before its first write', async () => {
vi.useFakeTimers()
const run = await createFreshRun()
const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'review this', {
inputKind: 'driving'
})
await vi.runAllTimersAsync()
await submission.catch(() => undefined)
expect(run.firstUserInputAt()).not.toBeNull()
expect(run.writes[0]?.inputRecorded).toBe(true)
})
it('reads a run launched with a prompt as untyped until someone drives it', async () => {
vi.useFakeTimers()
const run = await createFreshRun()
const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'start here', {
inputKind: 'launch'
})
await vi.runAllTimersAsync()
await submission.catch(() => undefined)
expect(run.writes.length).toBeGreaterThan(0)
expect(run.firstUserInputAt()).toBeNull()
await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'driving' })
expect(run.firstUserInputAt()).not.toBeNull()
})
it('records a mailbox pointer, which drives the running agent', async () => {
const run = await createFreshRun()
await writeOrchestrationPointerWithSettlement({
ptyId: PTY_ID,
data: 'You have 1 unread message.',
controller: run.controller
})
expect(run.kinds).toEqual(['driving'])
expect(run.firstUserInputAt()).not.toBeNull()
})
it('records nothing for a client query reply', async () => {
const run = await createFreshRun()
await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'query-reply' })
expect(run.firstUserInputAt()).toBeNull()
})
it.each([
['a terminal reply', '\x1b[3;4R'],
['focus reports', '\x1b[I\x1b[O'],
['the focus-in a desktop sends on reattaching a remote pane', '\x1b[I']
])('records nothing for stream input that is only %s', async (_label, text) => {
const run = await createFreshRun()
await sendTerminalStreamInput(run.runtime, {
terminal: run.handle,
text,
client: undefined,
isMobile: false
})
expect(run.writes).toHaveLength(1)
expect(run.firstUserInputAt()).toBeNull()
})
it('records a reply mixed with a keystroke', async () => {
const run = await createFreshRun()
await run.runtime.sendTerminal(run.handle, { text: '\x1b[3;4Rx' }, { inputKind: 'driving' })
expect(run.firstUserInputAt()).not.toBeNull()
})
it('records dashboard preview typing before the write that could end the process', async () => {
const run = await createFreshRun()
await expect(run.runtime.writeTerminalPreviewInput(PTY_ID, 'exit\r')).resolves.toBe(true)
expect(run.writes.map((write) => write.inputRecorded)).toEqual([true])
})
it('records nothing for dashboard preview bytes that are only a reply or focus reports', async () => {
const run = await createFreshRun()
await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[3;4R')
await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[O\x1b[I')
expect(run.writes).toHaveLength(2)
expect(run.firstUserInputAt()).toBeNull()
})
})
describe('run facts: a created worktree’s startup writes', () => {
function readinessHost(run: FreshRun): WorktreeStartupReadinessHost {
return {
getPtyId: () => PTY_ID,
getForegroundProcess: async () => 'aider',
subscribeToData: () => () => {},
// Why: bracketed paste enabled, then quiet, is the default draft-ready signal.
readRecentOutput: () => '\x1b[?2004h',
write: (ptyId, data, inputKind) => run.controller.write(ptyId, data, inputKind)
}
}
it('reads a run whose only input was its create-time draft and follow-up 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.firstUserInputAt()).toBeNull()
})
})
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { TerminalRunFactsRegister } from './terminal-run-facts'
describe('terminal run facts', () => {
it('reads a run main never saw committed as not fresh', () => {
expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({
freshSpawn: false,
firstUserInputAt: null
})
})
it('keeps the first user input across later input and a re-registration of the same process', () => {
const facts = new TerminalRunFactsRegister()
facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' })
facts.recordInput('pty-1', 'driving', 'ls\r', 100)
facts.recordInput('pty-1', 'driving', 'ls\r', 200)
facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true })
expect(facts.read('pty-1', 'inc-1')).toEqual({ freshSpawn: true, firstUserInputAt: 100 })
})
it('starts a new process clean', () => {
const facts = new TerminalRunFactsRegister()
facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' })
facts.recordInput('pty-1', 'driving', 'ls\r', 100)
facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-2' }, { tabId: 'source-tab' })
expect(facts.read('pty-1', 'inc-2')).toEqual({ freshSpawn: true, firstUserInputAt: null })
expect(facts.read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, firstUserInputAt: null })
})
it('never reads a reattached process as fresh', () => {
const facts = new TerminalRunFactsRegister()
facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true })
expect(facts.read('pty-1', 'inc-1').freshSpawn).toBe(false)
})
it('starts clean when a commit carries no incarnation to tell it from a new process', () => {
const facts = new TerminalRunFactsRegister()
facts.recordSpawnCommit({ id: 'pty-1' })
facts.recordInput('pty-1', 'driving', 'ls\r', 100)
facts.recordSpawnCommit({ id: 'pty-1', isReattach: true })
expect(facts.read('pty-1', null)).toEqual({ freshSpawn: false, firstUserInputAt: null })
})
it.each([
['a launch write', 'launch', 'echo startup\r'],
['a query reply', 'query-reply', '\x1b[1;1R'],
['driving bytes that are only a terminal reply', 'driving', '\x1b[1;1R'],
['driving bytes that are only focus reports', 'driving', '\x1b[I\x1b[O']
] as const)('records nothing for %s', (_label, inputKind, data) => {
const facts = new TerminalRunFactsRegister()
facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' })
facts.recordInput('pty-1', inputKind, data, 100)
expect(facts.read('pty-1', 'inc-1').firstUserInputAt).toBeNull()
})
})
+90
View File
@@ -0,0 +1,90 @@
import {
spawnCommitBindingOrigin,
type PtySpawnCommitOrigin
} from '../persistence/loading-store/pty-binding-span'
import { isTerminalQueryReply } from '../../shared/terminal-query-reply'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
export type TerminalRunFacts = {
/** This process was started for its pane, not reattached, adopted or cold-restored. */
freshSpawn: boolean
/** When input first drove this process, from any client or driver; null if none has. */
firstUserInputAt: number | null
}
// Why: a paired client's xterm answers focus changes (CSI I / CSI O) through input that carries no
// provenance; the desktop renderer already excludes them via xterm's user-input signal.
// oxlint-disable-next-line no-control-regex -- focus reports are ESC-framed sequences by definition.
const TERMINAL_FOCUS_REPORTS_ONLY_RE = new RegExp('^(?:\\u001b\\[[IO])+$')
/** Input with no provenance that no person typed: a whole terminal reply or only focus reports. */
function isUntypedTerminalInput(payload: string): boolean {
return isTerminalQueryReply(payload) || TERMINAL_FOCUS_REPORTS_ONLY_RE.test(payload)
}
export type TerminalSpawnCommit = Parameters<typeof spawnCommitBindingOrigin>[0] & {
id: string
incarnationId?: string
coldRestore?: object
}
/** A cold restore starts a new process for a pane that had one, so it is never fresh. */
type TerminalRunSpawnOrigin = PtySpawnCommitOrigin | 'cold-restore'
type TerminalRunRecord = {
incarnationId: string | null
spawnOrigin: TerminalRunSpawnOrigin
firstUserInputAt: number | null
}
/** Main's per-process facts about one PTY run, keyed by the incarnation they describe. */
export class TerminalRunFactsRegister {
private readonly runsByPtyId = new Map<string, TerminalRunRecord>()
/** Once per process: a re-registration of the same incarnation keeps its facts. Without an
* incarnation a commit cannot be told from a new process, so it starts clean. */
recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void {
const incarnationId = commit.incarnationId ?? null
if (
incarnationId !== null &&
this.runsByPtyId.get(commit.id)?.incarnationId === incarnationId
) {
return
}
const origin = spawnCommitBindingOrigin(commit, expectedSourceBinding)
this.runsByPtyId.set(commit.id, {
incarnationId,
spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin,
firstUserInputAt: null
})
}
/** The one record point both write funnels call just before the provider write, because input
* such as `exit` can end the process before the write returns. The payload check backs up a
* writer that labels a reply or focus report as driving. */
recordInput(ptyId: string, inputKind: TerminalInputKind, data: string, now = Date.now()): void {
if (inputKind !== 'driving') {
return
}
const run = this.runsByPtyId.get(ptyId)
if (run && run.firstUserInputAt === null && !isUntypedTerminalInput(data)) {
run.firstUserInputAt = now
}
}
/** A run main never saw committed reads as not fresh, which keeps today's close-on-exit. */
read(ptyId: string, incarnationId: string | null | undefined): TerminalRunFacts {
const run = this.runsByPtyId.get(ptyId)
if (!run || (run.incarnationId && incarnationId && run.incarnationId !== incarnationId)) {
return { freshSpawn: false, firstUserInputAt: null }
}
return {
freshSpawn: run.spawnOrigin === 'spawn' || run.spawnOrigin === 'split',
firstUserInputAt: run.firstUserInputAt
}
}
delete(ptyId: string): void {
this.runsByPtyId.delete(ptyId)
}
}
@@ -96,9 +96,9 @@ describe('sendTerminal absence gate for leaf-branch writes', () => {
const probe = vi.fn(async () => false)
const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe })
await expect(runtime.sendTerminal(handle, { text: 'ping' })).rejects.toThrow(
'terminal_not_writable'
)
await expect(
runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' })
).rejects.toThrow('terminal_not_writable')
expect(probe).toHaveBeenCalledWith(STALE_PTY_ID)
expect(write).not.toHaveBeenCalled()
@@ -108,9 +108,9 @@ describe('sendTerminal absence gate for leaf-branch writes', () => {
const probe = vi.fn(async () => false)
const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe })
await expect(runtime.sendTerminalAgentPrompt(handle, 'do the thing')).rejects.toThrow(
'terminal_not_writable'
)
await expect(
runtime.sendTerminalAgentPrompt(handle, 'do the thing', { inputKind: 'driving' })
).rejects.toThrow('terminal_not_writable')
expect(write).not.toHaveBeenCalled()
})
@@ -120,12 +120,14 @@ describe('sendTerminal absence gate for leaf-branch writes', () => {
probePtyLiveness: async () => null
})
await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({
await expect(
runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' })
).resolves.toMatchObject({
handle,
accepted: true
})
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping')
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving')
})
it('treats a throwing probe as unknown and proceeds', async () => {
@@ -135,11 +137,13 @@ describe('sendTerminal absence gate for leaf-branch writes', () => {
}
})
await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({
await expect(
runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' })
).resolves.toMatchObject({
accepted: true
})
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping')
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving')
})
it('proceeds when the probe answers live (restored session before its pane remounts)', async () => {
@@ -147,21 +151,25 @@ describe('sendTerminal absence gate for leaf-branch writes', () => {
probePtyLiveness: async () => true
})
await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({
await expect(
runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' })
).resolves.toMatchObject({
accepted: true
})
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping')
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving')
})
it('proceeds unchanged when the controller exposes no probe', async () => {
const { runtime, handle, write } = await makeRuntimeWithLeafHandle({})
await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({
await expect(
runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' })
).resolves.toMatchObject({
accepted: true
})
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping')
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving')
})
it('never probes when the provider synchronously knows the id (live pty)', async () => {
@@ -171,24 +179,26 @@ describe('sendTerminal absence gate for leaf-branch writes', () => {
hasPty: (ptyId) => ptyId === STALE_PTY_ID
})
await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({
await expect(
runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' })
).resolves.toMatchObject({
accepted: true
})
expect(probe).not.toHaveBeenCalled()
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping')
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving')
})
it('reuses a proven-absent verdict across repeated sends instead of re-probing', async () => {
const probe = vi.fn(async () => false)
const { runtime, handle } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe })
await expect(runtime.sendTerminal(handle, { text: 'a' })).rejects.toThrow(
'terminal_not_writable'
)
await expect(runtime.sendTerminal(handle, { text: 'b' })).rejects.toThrow(
'terminal_not_writable'
)
await expect(
runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' })
).rejects.toThrow('terminal_not_writable')
await expect(
runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' })
).rejects.toThrow('terminal_not_writable')
expect(probe).toHaveBeenCalledTimes(1)
})
@@ -201,17 +211,19 @@ describe('sendTerminal absence gate for leaf-branch writes', () => {
hasPty: (ptyId) => livePtyIds.has(ptyId)
})
await expect(runtime.sendTerminal(handle, { text: 'a' })).rejects.toThrow(
'terminal_not_writable'
)
await expect(
runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' })
).rejects.toThrow('terminal_not_writable')
// Same id recreated by a fresh spawn: provider knowledge must beat the verdict.
livePtyIds.add(STALE_PTY_ID)
await expect(runtime.sendTerminal(handle, { text: 'b' })).resolves.toMatchObject({
await expect(
runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' })
).resolves.toMatchObject({
accepted: true
})
expect(probe).toHaveBeenCalledTimes(1)
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'b')
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'b', 'driving')
})
})
+3 -2
View File
@@ -3,6 +3,7 @@ import type {
SleepingAgentLaunchConfig
} from '../../shared/agent-session-resume'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session'
import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics'
@@ -75,8 +76,8 @@ export type PtyApi = {
/** Host verdict on the shell-ready marker; absent when the execution host predates the field. */
shellReadyArmed?: boolean
}>
write: (id: string, data: string) => void
writeAccepted: (id: string, data: string) => Promise<boolean>
write: (id: string, data: string, inputKind: TerminalInputKind) => void
writeAccepted: (id: string, data: string, inputKind: TerminalInputKind) => Promise<boolean>
onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void
resize: (id: string, cols: number, rows: number) => void
claimViewport: (id: string, cols: number, rows: number) => void
@@ -1,6 +1,7 @@
import { ipcRenderer } from 'electron'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
@@ -71,11 +72,11 @@ export const ptySessionControlApi = {
/** Host verdict on the shell-ready marker; absent when the execution host predates the field. */
shellReadyArmed?: boolean
}> => ipcRenderer.invoke('pty:spawn', opts),
write: (id: string, data: string): void => {
ipcRenderer.send('pty:write', { id, data })
write: (id: string, data: string, inputKind: TerminalInputKind): void => {
ipcRenderer.send('pty:write', { id, data, inputKind })
},
writeAccepted: (id: string, data: string): Promise<boolean> =>
ipcRenderer.invoke('pty:writeAccepted', { id, data }),
writeAccepted: (id: string, data: string, inputKind: TerminalInputKind): Promise<boolean> =>
ipcRenderer.invoke('pty:writeAccepted', { id, data, inputKind }),
onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void =>
callback(payload)
@@ -147,7 +147,7 @@ export function createClaudeModelSwitchConfirmationObserver(args: {
// exact Claude warning defaults to “Yes” and needs only one Enter.
const accepted = args.submitConfirmation
? args.submitConfirmation() !== false
: sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT)
: sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT, 'driving')
if (!accepted) {
finish('unknown')
return
@@ -56,20 +56,20 @@ export function sendNativeChatMessageWithImageAttachments(
),
trimmedText.length > 0
)) {
sendRuntimePtyInput(settings, ptyId, payload)
sendRuntimePtyInput(settings, ptyId, payload, 'driving')
}
if (trimmedText.length > 0) {
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving')
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving')
markSubmitted()
})
})
return
}
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving')
markSubmitted()
})
})
@@ -138,7 +138,12 @@ describe('sendNativeChatMessage', () => {
])
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
NATIVE_CHAT_SUBMIT,
'driving'
)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
})
@@ -194,13 +199,19 @@ describe('sendNativeChatMessageVerified', () => {
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(
SETTINGS,
PTY,
buildNativeChatPasteBytes('/model sonnet')
buildNativeChatPasteBytes('/model sonnet'),
'driving'
)
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(await result).toBe(true)
expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
NATIVE_CHAT_SUBMIT,
'driving'
)
expect(
sendRuntimePtyInputVerified.mock.calls.some(
(call) => call[2] === NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT
@@ -234,7 +245,12 @@ describe('sendNativeChatMessageVerified', () => {
const submits = sendRuntimePtyInput.mock.calls.filter((call) => call[2] === NATIVE_CHAT_SUBMIT)
// Only the verified path's Enter — chat's delayed Enter was cancelled.
expect(submits).toHaveLength(0)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(
SETTINGS,
PTY,
NATIVE_CHAT_SUBMIT,
'driving'
)
})
it('returns false when the delayed Enter wait is aborted', async () => {
@@ -347,9 +363,14 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
'\x1b[200~@"C:\\Images\\screen shot.png"\x1b[201~ '
])
vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'describe')
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'describe', 'driving')
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
NATIVE_CHAT_SUBMIT,
'driving'
)
})
it('clears the line, then bracket-pastes image paths before prompt text', () => {
@@ -372,10 +393,20 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
])
vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'what do you see?')
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
'what do you see?',
'driving'
)
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
NATIVE_CHAT_SUBMIT,
'driving'
)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4)
})
@@ -396,7 +427,12 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
vi.advanceTimersByTime(1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
NATIVE_CHAT_SUBMIT,
'driving'
)
})
it('treats whitespace-only prompt input as attachment-only', () => {
@@ -450,7 +486,7 @@ describe('empty prompt submit', () => {
it('submits an empty prompt with a bare Enter', () => {
submitNativeChatPrompt(SETTINGS, PTY)
expect(sendRuntimePtyInput).toHaveBeenCalledOnce()
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT, 'driving')
})
})
@@ -483,16 +519,17 @@ describe('sendNativeChatAskAnswer', () => {
)
vi.advanceTimersByTime(0)
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1')
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1', 'driving')
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2')
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving')
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
buildNativeChatPasteBytes('custom answer')
buildNativeChatPasteBytes('custom answer'),
'driving'
)
})
@@ -533,7 +570,7 @@ describe('sendNativeChatAskAnswer', () => {
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }], onSettled)
await vi.advanceTimersByTimeAsync(handle.settleAfterMs)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2')
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving')
expect(onSettled).not.toHaveBeenCalled()
resolveAccepted(true)
@@ -72,7 +72,12 @@ export function clearUnsubmittedAgentInput(
ptyId: string,
options?: NativeChatSendOptions
): void {
sendRuntimePtyInput(settings, ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
sendRuntimePtyInput(
settings,
ptyId,
options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
'driving'
)
}
/**
@@ -104,7 +109,7 @@ export function clearThenWrite(
// An unreadable terminal is unconfirmed; the maximal clear remains safe.
}
if (!cleared) {
sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX)
sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving')
}
writeBody()
})
@@ -140,11 +145,11 @@ export function sendNativeChatMessage(
if (isCancelled()) {
return
}
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving')
// Schedule from the actual body write: an overdue clear-confirm callback
// must not collapse the required body-to-Enter gap after a renderer stall.
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving')
markSubmitted()
})
})
@@ -203,12 +208,13 @@ export async function sendNativeChatMessageVerified(
const bodyAccepted = await sendRuntimePtyInputVerified(
settings,
ptyId,
buildNativeChatPasteBytes(text)
buildNativeChatPasteBytes(text),
'driving'
)
if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) {
return false
}
return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT)
return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving')
}
/** Types a slash command as individual keys so Codex opens its command palette. */
@@ -224,7 +230,7 @@ export async function typeNativeChatCommand(
command,
signal,
write: async (key) =>
(await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected'
(await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving')) ? 'accepted' : 'rejected'
})
return outcome === 'accepted'
}
@@ -253,7 +259,9 @@ export function sendNativeChatTypedCommand(
if (isCancelled()) {
return 'rejected'
}
return (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected'
return (await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving'))
? 'accepted'
: 'rejected'
}
}).then(finish, () => finish('rejected'))
},
@@ -269,7 +277,7 @@ export function sendNativeChatTypedCommand(
/** Submit a TUI prompt with no body (Enter only) — e.g. a plain submit when the
* composer is empty. */
export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string): void {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving')
}
/**
@@ -297,10 +305,10 @@ export function sendNativeChatAskAnswer(
// Why: inference must use the remote host's acceptance result, not
// the fire-and-forget renderer dispatch result.
verifiedWrites.push(
sendRuntimePtyInputVerified(settings, ptyId, bytes).catch(() => false)
sendRuntimePtyInputVerified(settings, ptyId, bytes, 'driving').catch(() => false)
)
} else {
sendRuntimePtyInput(settings, ptyId, bytes)
sendRuntimePtyInput(settings, ptyId, bytes, 'driving')
}
}, index * NATIVE_CHAT_QUESTION_STEP_MS)
)
@@ -25,7 +25,7 @@ export function useNativeChatComposerInterrupt(args: {
}
const target = resolveTarget()
if (target) {
sendRuntimePtyInput(target.settings, target.ptyId, ESC)
sendRuntimePtyInput(target.settings, target.ptyId, ESC, 'driving')
}
}, [cancelPendingSends, isWorking, onStop, resolveTarget])
}
@@ -195,7 +195,8 @@ describe('useNativeChatInteractiveSend', () => {
expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith(
{ terminalTabId: 'tab-1' },
'pty-1',
'\x1b'
'\x1b',
'driving'
)
})
@@ -76,7 +76,12 @@ export function useNativeChatInteractiveSend(
if (!targetPtyId) {
return
}
sendRuntimePtyInput(getSettingsForAgentTabRuntimeOwner(terminalTabId), targetPtyId, raw)
sendRuntimePtyInput(
getSettingsForAgentTabRuntimeOwner(terminalTabId),
targetPtyId,
raw,
'driving'
)
},
[terminalTabId, targetPtyId]
)
@@ -30,39 +30,52 @@ describe('deferred split pane handoff', () => {
const cwdPromise = Promise.resolve('/source/cwd')
const initial = beginDeferredSplitPaneHandoff(key, cwdPromise)
appendDeferredSplitPaneInput(initial, { data: 'typed', kind: 'ordinary' })
appendDeferredSplitPaneInput(initial, { data: '\x1b[0n', kind: 'immediate' })
appendDeferredSplitPaneInput(initial, { data: '\x03', kind: 'accepted' })
appendDeferredSplitPaneInput(initial, { data: 'typed', kind: 'ordinary', inputKind: 'driving' })
appendDeferredSplitPaneInput(initial, {
data: '\x1b[0n',
kind: 'immediate',
inputKind: 'query-reply'
})
appendDeferredSplitPaneInput(initial, { data: '\x03', kind: 'accepted', inputKind: 'driving' })
const remounted = claimDeferredSplitPaneHandoff(key)
expect(remounted?.cwdPromise).toBe(cwdPromise)
expect(remounted?.preconnectInput).toEqual([
{ data: 'typed', kind: 'ordinary' },
{ data: '\x1b[0n', kind: 'immediate' },
{ data: '\x03', kind: 'accepted' }
{ data: 'typed', kind: 'ordinary', inputKind: 'driving' },
{ data: '\x1b[0n', kind: 'immediate', inputKind: 'query-reply' },
{ data: '\x03', kind: 'accepted', inputKind: 'driving' }
])
})
it('keeps input across repeated remounts and fences stale owners', () => {
const key = makePaneKey('tab-1', LEAF_1)
const initial = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd'))
appendDeferredSplitPaneInput(initial, { data: 'before-first-remount', kind: 'ordinary' })
appendDeferredSplitPaneInput(initial, {
data: 'before-first-remount',
kind: 'ordinary',
inputKind: 'driving'
})
const firstRemount = claimDeferredSplitPaneHandoff(key)
expect(firstRemount).not.toBeNull()
appendDeferredSplitPaneInput(initial, { data: 'stale-input', kind: 'ordinary' })
appendDeferredSplitPaneInput(initial, {
data: 'stale-input',
kind: 'ordinary',
inputKind: 'driving'
})
clearDeferredSplitPaneHandoff(initial)
clearDeferredSplitPaneHandoff(initial)
appendDeferredSplitPaneInput(firstRemount!.handle, {
data: 'before-second-remount',
kind: 'ordinary'
kind: 'ordinary',
inputKind: 'driving'
})
const secondRemount = claimDeferredSplitPaneHandoff(key)
expect(secondRemount?.preconnectInput).toEqual([
{ data: 'before-first-remount', kind: 'ordinary' },
{ data: 'before-second-remount', kind: 'ordinary' }
{ data: 'before-first-remount', kind: 'ordinary', inputKind: 'driving' },
{ data: 'before-second-remount', kind: 'ordinary', inputKind: 'driving' }
])
clearDeferredSplitPaneHandoff(firstRemount!.handle)
@@ -74,21 +87,33 @@ describe('deferred split pane handoff', () => {
it('releases an unmounted owner without dropping its pending handoff', () => {
const key = makePaneKey('tab-1', LEAF_1)
const initial = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd'))
appendDeferredSplitPaneInput(initial, { data: 'before-unmount', kind: 'ordinary' })
appendDeferredSplitPaneInput(initial, {
data: 'before-unmount',
kind: 'ordinary',
inputKind: 'driving'
})
releaseDeferredSplitPaneHandoff(initial)
appendDeferredSplitPaneInput(initial, { data: 'late-stale', kind: 'ordinary' })
appendDeferredSplitPaneInput(initial, {
data: 'late-stale',
kind: 'ordinary',
inputKind: 'driving'
})
clearDeferredSplitPaneHandoff(initial)
expect(claimDeferredSplitPaneHandoff(key)?.preconnectInput).toEqual([
{ data: 'before-unmount', kind: 'ordinary' }
{ data: 'before-unmount', kind: 'ordinary', inputKind: 'driving' }
])
})
it('lets a late close discard a released handoff by its stable pane key', () => {
const key = makePaneKey('tab-1', LEAF_1)
const owner = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd'))
appendDeferredSplitPaneInput(owner, { data: 'must-not-replay', kind: 'ordinary' })
appendDeferredSplitPaneInput(owner, {
data: 'must-not-replay',
kind: 'ordinary',
inputKind: 'driving'
})
// Whole-tab cleanup releases the mount-local handle before a stale close callback can run.
releaseDeferredSplitPaneHandoff(owner)
@@ -112,7 +137,11 @@ describe('deferred split pane handoff', () => {
it('drops a stale record when an authoritative restored PTY wins the key', () => {
const key = makePaneKey('tab-authoritative', LEAF_1)
const stale = beginDeferredSplitPaneHandoff(key, Promise.resolve('/stale'))
appendDeferredSplitPaneInput(stale, { data: 'must-not-replay', kind: 'ordinary' })
appendDeferredSplitPaneInput(stale, {
data: 'must-not-replay',
kind: 'ordinary',
inputKind: 'driving'
})
discardDeferredSplitPaneHandoffForKey(key)
@@ -123,26 +152,44 @@ describe('deferred split pane handoff', () => {
it('replaces an older handoff for the same stable pane key', () => {
const key = makePaneKey('tab-1', LEAF_1)
const stale = beginDeferredSplitPaneHandoff(key, Promise.resolve('/stale'))
appendDeferredSplitPaneInput(stale, { data: 'stale', kind: 'ordinary' })
appendDeferredSplitPaneInput(stale, { data: 'stale', kind: 'ordinary', inputKind: 'driving' })
const currentPromise = Promise.resolve('/current')
const current = beginDeferredSplitPaneHandoff(key, currentPromise)
appendDeferredSplitPaneInput(stale, { data: 'late-stale', kind: 'ordinary' })
appendDeferredSplitPaneInput(stale, {
data: 'late-stale',
kind: 'ordinary',
inputKind: 'driving'
})
clearDeferredSplitPaneHandoff(stale)
appendDeferredSplitPaneInput(current, { data: 'current', kind: 'ordinary' })
appendDeferredSplitPaneInput(current, {
data: 'current',
kind: 'ordinary',
inputKind: 'driving'
})
const claimed = claimDeferredSplitPaneHandoff(key)
expect(claimed?.cwdPromise).toBe(currentPromise)
expect(claimed?.preconnectInput).toEqual([{ data: 'current', kind: 'ordinary' }])
expect(claimed?.preconnectInput).toEqual([
{ data: 'current', kind: 'ordinary', inputKind: 'driving' }
])
})
it('retains input within the shared preconnect entry and code-unit caps', () => {
const entryKey = makePaneKey('tab-entries', LEAF_1)
const entryHandle = beginDeferredSplitPaneHandoff(entryKey, Promise.resolve('/entries'))
for (let index = 0; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) {
appendDeferredSplitPaneInput(entryHandle, { data: '', kind: 'ordinary' })
appendDeferredSplitPaneInput(entryHandle, {
data: '',
kind: 'ordinary',
inputKind: 'driving'
})
}
appendDeferredSplitPaneInput(entryHandle, { data: 'overflow', kind: 'ordinary' })
appendDeferredSplitPaneInput(entryHandle, {
data: 'overflow',
kind: 'ordinary',
inputKind: 'driving'
})
expect(claimDeferredSplitPaneHandoff(entryKey)?.preconnectInput).toHaveLength(
PTY_PRECONNECT_INPUT_MAX_ENTRIES
)
@@ -154,11 +201,20 @@ describe('deferred split pane handoff', () => {
)
appendDeferredSplitPaneInput(codeUnitHandle, {
data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS),
kind: 'ordinary'
kind: 'ordinary',
inputKind: 'driving'
})
appendDeferredSplitPaneInput(codeUnitHandle, {
data: 'overflow',
kind: 'ordinary',
inputKind: 'driving'
})
appendDeferredSplitPaneInput(codeUnitHandle, { data: 'overflow', kind: 'ordinary' })
expect(claimDeferredSplitPaneHandoff(codeUnitKey)?.preconnectInput).toEqual([
{ data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), kind: 'ordinary' }
{
data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS),
kind: 'ordinary',
inputKind: 'driving'
}
])
})
@@ -209,12 +265,12 @@ describe('deferred split pane handoff', () => {
it('does not expose the registry input array by reference', () => {
const key = makePaneKey('tab-1', LEAF_1)
const handle = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd'))
appendDeferredSplitPaneInput(handle, { data: 'kept', kind: 'ordinary' })
appendDeferredSplitPaneInput(handle, { data: 'kept', kind: 'ordinary', inputKind: 'driving' })
const firstClaim = claimDeferredSplitPaneHandoff(key)
firstClaim?.preconnectInput.splice(0)
expect(claimDeferredSplitPaneHandoff(key)?.preconnectInput).toEqual([
{ data: 'kept', kind: 'ordinary' }
{ data: 'kept', kind: 'ordinary', inputKind: 'driving' }
])
})
})
@@ -135,7 +135,7 @@ export function appendDeferredSplitPaneInput(
) {
return
}
owned.record.preconnectInput.push({ data: input.data, kind: input.kind })
owned.record.preconnectInput.push({ ...input })
owned.record.inputCodeUnits += input.data.length
}
@@ -328,7 +328,7 @@ describe('Windows IME Enter-keyup press-time evidence', () => {
vi.runAllTimers()
expect(harness.sendInput).toHaveBeenCalledTimes(1)
expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r')
expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r', 'driving')
hook.unmount()
harness.dispose()
})
@@ -351,7 +351,7 @@ describe('Windows IME Enter-keyup press-time evidence', () => {
vi.runAllTimers()
expect(harness.sendInput).toHaveBeenCalledTimes(1)
expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r')
expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r', 'driving')
hook.unmount()
harness.dispose()
})
@@ -374,7 +374,7 @@ describe('Windows IME Enter-keyup press-time evidence', () => {
expect(harness.setActivePane).toHaveBeenCalledWith(2, { focus: false })
expect(harness.sendInput).toHaveBeenCalledOnce()
expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r')
expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r', 'driving')
hook.unmount()
harness.dispose()
})
@@ -240,7 +240,7 @@ describe('connectPanePty', () => {
const forward = deferPtyInput.mock.calls[0]?.[2] as (data: string) => void
forward('a')
expect(transport.sendInput).toHaveBeenCalledWith('a')
expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply')
})
it('forwards terminal input directly when the host supplies no deferPtyInput', async () => {
@@ -253,7 +253,7 @@ describe('connectPanePty', () => {
await flushAsyncTicks()
sendTerminalInputThroughPane(pane, 'a')
expect(transport.sendInput).toHaveBeenCalledWith('a')
expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply')
})
it('keeps large ANSI redraws after captured shortcut input on the immediate path', async () => {

Some files were not shown because too many files have changed in this diff Show More