From 70db5e8313b7cc6fa2543e1210e91332d47ab6db Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:37:26 -0700 Subject: [PATCH] fix(orchestration): verify large Codex prompt submission --- src/main/ipc/pty.ts | 11 + src/main/providers/pty-provider-contract.ts | 6 + .../ssh-pty-provider-output-state.ts | 4 + src/main/providers/ssh-pty-provider.test.ts | 67 ++++ src/main/providers/ssh-pty-provider.ts | 20 +- .../ssh-pty-visible-screen-snapshot.ts | 85 +++++ .../agent-prompt-submission-runtime.test.ts | 123 ++++++- src/main/runtime/orca-runtime.ts | 308 ++++++++++++++++-- .../orchestration-federated-worker-start.ts | 2 + .../orchestration-migration-behavior.test.ts | 54 ++- .../orchestration-prompt-submit-capability.ts | 15 + .../pty-handler-output-streaming.test.ts | 31 ++ src/relay/pty-handler-spawn-admission.test.ts | 1 + src/relay/pty-handler.ts | 70 +++- src/shared/agent-prompt-codex-ingest.test.ts | 87 +++++ src/shared/agent-prompt-codex-ingest.ts | 78 +++++ src/shared/protocol-version.ts | 3 + src/shared/pty-visible-screen-snapshot.ts | 1 + 18 files changed, 929 insertions(+), 37 deletions(-) create mode 100644 src/main/providers/ssh-pty-visible-screen-snapshot.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-prompt-submit-capability.ts create mode 100644 src/shared/agent-prompt-codex-ingest.test.ts create mode 100644 src/shared/agent-prompt-codex-ingest.ts create mode 100644 src/shared/pty-visible-screen-snapshot.ts diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index cac4538be37..d26fd4b6358 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5912,6 +5912,17 @@ export function registerPtyHandlers( return null } }, + supportsProviderBufferSnapshot: async (ptyId, signal) => { + try { + const provider = getProviderForPty(ptyId) + if (provider.probeAuthoritativeBufferSnapshotSupport) { + return await provider.probeAuthoritativeBufferSnapshotSupport({ signal }) + } + return provider.canProvideAuthoritativeBufferSnapshot?.(ptyId) === true + } catch { + return false + } + }, hasRendererSerializer: (ptyId) => { // Why: a synchronous probe lets the runtime decide whether to skip the daemon-snapshot seed (renderer will hydrate) or run it (no renderer authoritative). return rendererSerializerReadiness.has(ptyId) diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 333d6fcc62c..dee142f1cbc 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -29,6 +29,8 @@ export type PtyProviderBufferSnapshot = { cwd?: string | null lastTitle?: string seq: number + /** Process identity that owned the provider screen at this sequence. */ + incarnationId?: PtyIncarnationId source: 'headless' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean @@ -178,6 +180,10 @@ export type IPtyProvider = { ) => Promise /** Whether this exact PTY can return a sequence-safe provider snapshot. */ canProvideAuthoritativeBufferSnapshot?: (id: string) => boolean + /** Negotiated proof that provider snapshots are available on this execution host. */ + probeAuthoritativeBufferSnapshotSupport?: ( + options?: PtyProbeOptions + ) => boolean | Promise /** * The size the PTY has ACTUALLY applied, not the last size requested. * resize() is fire-and-forget for remote providers (daemon/SSH `notify`), diff --git a/src/main/providers/ssh-pty-provider-output-state.ts b/src/main/providers/ssh-pty-provider-output-state.ts index 4d98097b9c6..5f8c6132764 100644 --- a/src/main/providers/ssh-pty-provider-output-state.ts +++ b/src/main/providers/ssh-pty-provider-output-state.ts @@ -128,6 +128,10 @@ export class SshPtyProviderOutputState { } } + ptyIncarnation(relayPtyId: string): string | undefined { + return this.incarnationByRelayPtyId.get(relayPtyId) + } + private resolvePtyIncarnation(relayPtyId: string, incarnationId: unknown): string { this.rememberPtyIncarnation(relayPtyId, incarnationId) let resolved = this.incarnationByRelayPtyId.get(relayPtyId) diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 0fc8702750d..160b5458450 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshPtyProvider } from './ssh-pty-provider' import { AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority' +import { PTY_VISIBLE_SCREEN_SNAPSHOT_PROTOCOL_VERSION } from '../../shared/pty-visible-screen-snapshot' import { createMockMux, expectRequest, @@ -25,6 +26,72 @@ describe('SshPtyProvider', () => { expect(provider.canProvideAuthoritativeBufferSnapshot(scopedPty1)).toBe(false) }) + it('negotiates authoritative visible-screen snapshots', async () => { + mux.request.mockResolvedValue({ + visibleScreenSnapshotVersion: PTY_VISIBLE_SCREEN_SNAPSHOT_PROTOCOL_VERSION + }) + + await expect(provider.probeAuthoritativeBufferSnapshotSupport()).resolves.toBe(true) + expect(provider.canProvideAuthoritativeBufferSnapshot(scopedPty1)).toBe(true) + expectRequest(mux.request, 'pty.getCapabilities', undefined, { timeoutMs: 5_000 }) + }) + + it('keeps old SSH relays snapshot-ineligible', async () => { + mux.request.mockResolvedValue({}) + + await expect(provider.probeAuthoritativeBufferSnapshotSupport()).resolves.toBe(false) + expect(provider.canProvideAuthoritativeBufferSnapshot(scopedPty1)).toBe(false) + }) + + it('returns an authoritative snapshot for the current incarnation', async () => { + mux.request + .mockResolvedValueOnce({ + visibleScreenSnapshotVersion: PTY_VISIBLE_SCREEN_SNAPSHOT_PROTOCOL_VERSION + }) + .mockResolvedValueOnce({ replay: '', incarnationId: 'incarnation-1' }) + .mockResolvedValueOnce({ + data: '', + cols: 80, + rows: 24, + seq: 12, + incarnationId: 'incarnation-1', + source: 'headless' + }) + + await provider.probeAuthoritativeBufferSnapshotSupport() + await provider.attachForReconnect(scopedPty1) + + await expect(provider.getBufferSnapshot(scopedPty1)).resolves.toEqual({ + data: '', + cols: 80, + rows: 24, + seq: 12, + incarnationId: 'incarnation-1', + source: 'headless' + }) + }) + + it('rejects a visible-screen snapshot from another incarnation', async () => { + mux.request + .mockResolvedValueOnce({ + visibleScreenSnapshotVersion: PTY_VISIBLE_SCREEN_SNAPSHOT_PROTOCOL_VERSION + }) + .mockResolvedValueOnce({ replay: '', incarnationId: 'incarnation-1' }) + .mockResolvedValueOnce({ + data: 'wrong owner', + cols: 80, + rows: 24, + seq: 12, + incarnationId: 'incarnation-2', + source: 'headless' + }) + + await provider.probeAuthoritativeBufferSnapshotSupport() + await provider.attachForReconnect(scopedPty1) + + await expect(provider.getBufferSnapshot(scopedPty1)).resolves.toBeNull() + }) + it('keeps a shared claim probe alive when one waiter disconnects', async () => { let finishProbe!: (result: { agentSessionClaimVersion: number }) => void mux.request.mockReturnValueOnce( diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 99df854f4d0..befbfc23755 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -23,6 +23,7 @@ import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' import type { PtyProcessInspection } from './pty-process-inspection' import { writeToSshPty, writeToSshPtyWithSettlement } from './ssh-pty-write' +import { SshPtyVisibleScreenSnapshots } from './ssh-pty-visible-screen-snapshot' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { @@ -38,6 +39,7 @@ export class SshPtyProvider implements IPtyProvider { private readonly agentSessionCapabilities: SshAgentSessionCapabilities private spawnExitRaces = new SshPtySpawnExitRaceTracker() private readonly outputState: SshPtyProviderOutputState + private readonly visibleScreenSnapshots: SshPtyVisibleScreenSnapshots requestHostRpc: NonNullable = (method, params, options) => this.mux.request(method, params as Record, options) @@ -61,6 +63,11 @@ export class SshPtyProvider implements IPtyProvider { this.spawnExitRaces.recordExit(relayPtyId, incarnationId) } }) + this.visibleScreenSnapshots = new SshPtyVisibleScreenSnapshots( + mux, + (id) => this.toRelayPtyId(id), + (id) => this.outputState.ptyIncarnation(id) + ) } dispose(): void { @@ -70,7 +77,18 @@ export class SshPtyProvider implements IPtyProvider { getConnectionId = (): string => this.connectionId - canProvideAuthoritativeBufferSnapshot = (_id: string): boolean => false + canProvideAuthoritativeBufferSnapshot = (_id: string): boolean => + this.visibleScreenSnapshots.canProvide() + + async probeAuthoritativeBufferSnapshotSupport( + options: { signal?: AbortSignal } = {} + ): Promise { + return await this.visibleScreenSnapshots.probe(options) + } + + async getBufferSnapshot(id: string, opts: { scrollbackRows?: number } = {}) { + return await this.visibleScreenSnapshots.get(id, opts) + } private toRelayPtyId = (id: string): string => toRelaySshPtyId(this.connectionId, id) diff --git a/src/main/providers/ssh-pty-visible-screen-snapshot.ts b/src/main/providers/ssh-pty-visible-screen-snapshot.ts new file mode 100644 index 00000000000..7e835f8ad2a --- /dev/null +++ b/src/main/providers/ssh-pty-visible-screen-snapshot.ts @@ -0,0 +1,85 @@ +import { PTY_VISIBLE_SCREEN_SNAPSHOT_PROTOCOL_VERSION } from '../../shared/pty-visible-screen-snapshot' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { waitForSshCapabilityProbe } from './ssh-capability-probe-waiter' +import type { PtyProviderBufferSnapshot } from './types' + +export class SshPtyVisibleScreenSnapshots { + private capabilityProbe: Promise | null = null + private supported = false + + constructor( + private readonly mux: SshChannelMultiplexer, + private readonly toRelayPtyId: (id: string) => string, + private readonly readIncarnation: (relayPtyId: string) => string | undefined + ) {} + + canProvide(): boolean { + return this.supported + } + + async probe(options: { signal?: AbortSignal } = {}): Promise { + const probe = this.capabilityProbe ?? this.probeCapability() + this.capabilityProbe = probe + try { + const supported = await waitForSshCapabilityProbe(probe, options.signal) + this.supported = supported + if (!supported && this.capabilityProbe === probe) { + this.capabilityProbe = null + } + return supported + } catch { + return false + } + } + + async get( + id: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + if (!this.supported) { + return null + } + const relayPtyId = this.toRelayPtyId(id) + const expectedIncarnation = this.readIncarnation(relayPtyId) + if (!expectedIncarnation) { + return null + } + const result = await this.mux.request('pty.getBufferSnapshot', { + id: relayPtyId, + scrollbackRows: opts.scrollbackRows ?? 0 + }) + return isSshPtyBufferSnapshot(result, expectedIncarnation) ? result : null + } + + private async probeCapability(): Promise { + try { + const result = (await this.mux.request('pty.getCapabilities', undefined, { + timeoutMs: 5_000 + })) as { visibleScreenSnapshotVersion?: unknown } + return result.visibleScreenSnapshotVersion === PTY_VISIBLE_SCREEN_SNAPSHOT_PROTOCOL_VERSION + } catch { + return false + } + } +} + +function isSshPtyBufferSnapshot( + value: unknown, + expectedIncarnation: string +): value is PtyProviderBufferSnapshot { + if (!value || typeof value !== 'object') { + return false + } + const snapshot = value as Partial + return ( + snapshot.incarnationId === expectedIncarnation && + typeof snapshot.data === 'string' && + Number.isInteger(snapshot.cols) && + Number(snapshot.cols) > 0 && + Number.isInteger(snapshot.rows) && + Number(snapshot.rows) > 0 && + Number.isSafeInteger(snapshot.seq) && + Number(snapshot.seq) >= 0 && + snapshot.source === 'headless' + ) +} diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 711e887b5c5..e2babe145b4 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -5,6 +5,7 @@ import { } from '../../shared/agent-prompt-injection' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' +import type { TuiAgent } from '../../shared/tui-agent' const WORKTREE_PATH = '/tmp/worktree-a' @@ -30,7 +31,9 @@ vi.mock('../git/worktree', () => ({ })) async function createPromptRuntime( - onWrite: (runtime: OrcaRuntimeService, data: string, writeIndex: number) => void + onWrite: (runtime: OrcaRuntimeService, data: string, writeIndex: number) => void, + agent: TuiAgent = 'aider', + settleWrite?: (data: string) => Promise ): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> { const runtime = new OrcaRuntimeService(makeStore() as never) const writes: string[] = [] @@ -41,11 +44,20 @@ async function createPromptRuntime( onWrite(runtime, data, writes.length) return true }, + ...(settleWrite + ? { + writeWithSettlement: async (_ptyId: string, data: string) => { + writes.push(data) + onWrite(runtime, data, writes.length) + return await settleWrite(data) + } + } + : {}), kill: () => true, getForegroundProcess: async () => null }) const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { - launchAgent: 'aider' + launchAgent: agent }) return { runtime, handle: terminal.handle, writes } } @@ -68,6 +80,113 @@ describe('agent prompt submission runtime', () => { expect(writes.filter((data) => data === '\r')).toHaveLength(1) }) + it('waits for a large Codex paste marker before Enter despite cursor output', async () => { + vi.useFakeTimers() + const prompt = 'x'.repeat(1_001) + let markerVisible = false + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData('pty-prompt', '\x1b[?25h', Date.now()) + setTimeout(() => { + markerVisible = true + runtime.onPtyData('pty-prompt', '[Pasted Content 1001 chars]', Date.now()) + }, 500) + } else if (data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[Hworking', Date.now()) + } + }, 'codex') + runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› ', Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + await vi.advanceTimersByTimeAsync(499) + expect(writes).not.toContain('\r') + await vi.advanceTimersByTimeAsync(501) + await submission + + expect(markerVisible).toBe(true) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + it('fails a large Codex paste before Enter when no marker appears', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData('pty-prompt', '\x1b[?25h', Date.now()) + } + }, 'codex') + runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› ', Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(1_001)) + const rejected = expect(submission).rejects.toThrow('agent_prompt_ingest_unverified') + await vi.runAllTimersAsync() + + await rejected + expect(writes).not.toContain('\r') + }) + + it('fails a large Codex paste closed when no visible screen is available', async () => { + const { runtime, handle, writes } = await createPromptRuntime(() => undefined, 'codex') + + await expect(runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(1_001))).rejects.toThrow( + 'agent_prompt_unverifiable' + ) + expect(writes).toEqual([]) + }) + + it('maps unsettled large Codex paste delivery to unverifiable', async () => { + const { runtime, handle, writes } = await createPromptRuntime( + () => undefined, + 'codex', + async () => false + ) + runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› ', Date.now()) + + await expect(runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(1_001))).rejects.toThrow( + 'agent_prompt_unverifiable' + ) + expect(writes).toHaveLength(1) + expect(writes).not.toContain('\r') + }) + + it('retries large Codex submit only while its marker remains', async () => { + vi.useFakeTimers() + let enters = 0 + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData('pty-prompt', '[Pasted Content 1001 chars] #23', Date.now()) + } else if (data === '\r') { + enters += 1 + if (enters === 2) { + runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[Hworking', Date.now()) + } + } + }, 'codex') + runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› ', Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(1_001)) + await vi.runAllTimersAsync() + await expect(submission).resolves.toMatchObject({ accepted: true }) + + expect(writes.filter((data) => data === '\r')).toHaveLength(2) + }) + + it('bounds large Codex retries when its marker never leaves', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData('pty-prompt', '[Pasted Content 1001 chars]', Date.now()) + } + }, 'codex') + runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› ', Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(1_001)) + const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') + await vi.runAllTimersAsync() + + await rejected + expect(writes.filter((data) => data === '\r')).toHaveLength(3) + }) + it('accepts a working-to-idle cycle completed before the first poll', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aac73f5e7f4..94179c0cdab 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -101,6 +101,14 @@ import { AGENT_PROMPT_SUBMIT_DELAY_MS, buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection' +import { + CODEX_LARGE_PASTE_CHAR_THRESHOLD, + codexAgentPromptCharCount, + collectCodexPasteMarkers, + countCodexPasteMarker, + findCodexPasteMarkerDelta, + type CodexPasteMarkerMultiset +} from '../../shared/agent-prompt-codex-ingest' import { type AgentPromptActivity, verifyAgentPromptSubmission @@ -1794,6 +1802,18 @@ type RuntimeVisibleTerminalState = { generation: number } +type CodexPasteIngestBaseline = Readonly<{ + charCount: number + markers: CodexPasteMarkerMultiset + screen: RuntimeVisibleTerminalState +}> + +type CodexPasteIngestProof = Readonly<{ + marker: string + visibleCount: number + screen: RuntimeVisibleTerminalState +}> + type ProviderBufferAcquisition = { generation: number scrollbackRows: number @@ -1955,6 +1975,7 @@ type RuntimePtyController = { ptyId: string, opts?: { scrollbackRows?: number } ): Promise + supportsProviderBufferSnapshot?(ptyId: string, signal?: AbortSignal): Promise // Why: synchronous probe used by maybeHydrateHeadlessFromRenderer to skip // hydration when no renderer is authoritative for this PTY. See // docs/mobile-prefer-renderer-scrollback.md. @@ -2027,6 +2048,8 @@ const BRACKETED_PASTE_END = '\x1b[201~' const BRACKETED_PASTE_QUIET_MS = 1500 const AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000 const AGENT_PROMPT_RENDER_QUIET_MS = 1500 +const AGENT_PROMPT_SCREEN_POLL_MS = 50 +const AGENT_PROMPT_MAX_SUBMITS = 3 // Why: Claude and Codex emit show-cursor after accepting bracketed paste. const AGENT_PROMPT_RENDER_MARKER = '\x1b[?25h' @@ -18681,6 +18704,7 @@ export class OrcaRuntimeService { } = {} ): Promise { const payload = buildAgentPromptPasteBytes(prompt) + const codexCharCount = codexAgentPromptCharCount(prompt) const pty = this.getLivePtyForHandle(handle) if (pty) { if (!pty.pty.connected) { @@ -18699,6 +18723,7 @@ export class OrcaRuntimeService { pty.pty.ptyId, generation, payload, + codexCharCount, options ) } @@ -18721,7 +18746,14 @@ export class OrcaRuntimeService { const submits = await this.serializeAgentPromptSubmission(leaf.ptyId, generation, async () => { this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId!) this.assertAgentPromptGeneration(leaf.ptyId!, generation) - return await this.writeTerminalAgentPrompt(handle, leaf.ptyId!, generation, payload, options) + return await this.writeTerminalAgentPrompt( + handle, + leaf.ptyId!, + generation, + payload, + codexCharCount, + options + ) }) const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits return { handle, accepted: true, bytesWritten } @@ -19395,6 +19427,7 @@ export class OrcaRuntimeService { ptyId: string, generation: number, pastePayload: string, + codexCharCount: number, options: { beforeWrite?: (ptyId: string) => void | Promise suffixFailureError?: string @@ -19405,7 +19438,18 @@ export class OrcaRuntimeService { this.assertAgentPromptGeneration(ptyId, generation) const permissionBaseline = this.getAgentPromptActivity(handle, ptyId) this.assertAgentPromptPermissionSafe(permissionBaseline, permissionBaseline) - const renderGate = this.createAgentPromptRenderGate(ptyId) + const isLargeCodexPaste = + this.getAgentPromptTargetAgent(ptyId) === 'codex' && + codexCharCount > CODEX_LARGE_PASTE_CHAR_THRESHOLD + const codexIngestBaseline = isLargeCodexPaste + ? await this.captureCodexPasteIngestBaseline( + ptyId, + generation, + codexCharCount, + options.signal + ) + : null + const renderGate = isLargeCodexPaste ? null : this.createAgentPromptRenderGate(ptyId) let wrotePasteBytes = false let completedPaste = false try { @@ -19413,22 +19457,18 @@ export class OrcaRuntimeService { let chunk = chunks.next() while (!chunk.done) { const nextChunk = chunks.next() - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - await options.beforeWrite?.(ptyId) - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - this.assertAgentPromptPermissionSafe( - permissionBaseline, - this.getAgentPromptActivity(handle, ptyId) - ) if (nextChunk.done) { renderGate?.arm() } - const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false - if (!wrote) { - throw new Error('terminal_not_writable') - } + await this.writeAgentPromptBytes({ + handle, + ptyId, + generation, + data: chunk.value, + permissionBaseline, + ...(isLargeCodexPaste ? { writeFailureError: 'agent_prompt_unverifiable' } : {}), + options + }) wrotePasteBytes = true chunk = nextChunk if (!chunk.done) { @@ -19448,7 +19488,17 @@ export class OrcaRuntimeService { throw error } - if (renderGate) { + let codexIngestProof: CodexPasteIngestProof | null = null + if (codexIngestBaseline) { + codexIngestProof = await this.waitForCodexPasteIngest( + handle, + ptyId, + generation, + permissionBaseline, + codexIngestBaseline, + options.signal + ) + } else if (renderGate) { try { await waitForAgentPromptPromise(renderGate.wait(), options.signal) } finally { @@ -19457,24 +19507,26 @@ export class OrcaRuntimeService { } else { await waitForAgentPromptDelay(AGENT_PROMPT_SUBMIT_DELAY_MS, options.signal) } - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - try { - await options.beforeWrite?.(ptyId) - } catch (error) { - if (options.suffixFailureError) { - throw new Error(options.suffixFailureError) - } - throw error + if (codexIngestProof) { + return await this.submitVerifiedCodexPaste({ + handle, + ptyId, + generation, + permissionBaseline, + ingest: codexIngestProof, + options + }) } - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) const baseline = this.getAgentPromptActivity(handle, ptyId) - this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) - const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false - if (!suffixWrote) { - throw new Error(options.suffixFailureError ?? 'terminal_not_writable') - } + await this.writeAgentPromptBytes({ + handle, + ptyId, + generation, + data: AGENT_PROMPT_SUBMIT, + permissionBaseline, + failureError: options.suffixFailureError, + options + }) await verifyAgentPromptSubmission({ baseline, readActivity: () => this.getAgentPromptActivity(handle, ptyId), @@ -19483,6 +19535,198 @@ export class OrcaRuntimeService { return 1 } + private getAgentPromptTargetAgent(ptyId: string): TuiAgent | null | undefined { + const pty = this.ptysById.get(ptyId) + return pty?.launchAgent ?? pty?.foregroundAgent + } + + private async captureCodexPasteIngestBaseline( + ptyId: string, + generation: number, + charCount: number, + signal?: AbortSignal + ): Promise { + if (this.providerSnapshotPreferredPtys.has(ptyId)) { + const supported = await this.ptyController?.supportsProviderBufferSnapshot?.(ptyId, signal) + assertAgentPromptRequestActive(signal) + this.assertAgentPromptGeneration(ptyId, generation) + if (supported !== true) { + throw new Error('agent_prompt_unverifiable') + } + } + const screen = await this.readCodexPasteScreen(ptyId, generation) + if (!screen) { + throw new Error('agent_prompt_unverifiable') + } + return { + charCount, + markers: collectCodexPasteMarkers(screen.lines, charCount), + screen + } + } + + private async waitForCodexPasteIngest( + handle: string, + ptyId: string, + generation: number, + permissionBaseline: AgentPromptActivity, + baseline: CodexPasteIngestBaseline, + signal?: AbortSignal + ): Promise { + const deadline = Date.now() + AGENT_PROMPT_RENDER_TIMEOUT_MS + while (Date.now() < deadline) { + assertAgentPromptRequestActive(signal) + this.assertAgentPromptGeneration(ptyId, generation) + this.assertAgentPromptPermissionSafe( + permissionBaseline, + this.getAgentPromptActivity(handle, ptyId) + ) + const screen = await this.readCodexPasteScreen(ptyId, generation) + if (screen) { + if (screen.sequence < baseline.screen.sequence) { + throw new Error('agent_prompt_unverifiable') + } + const delta = findCodexPasteMarkerDelta( + baseline.markers, + collectCodexPasteMarkers(screen.lines, baseline.charCount) + ) + if (delta) { + return { ...delta, screen } + } + } + await waitForAgentPromptDelay(AGENT_PROMPT_SCREEN_POLL_MS, signal) + } + throw new Error('agent_prompt_ingest_unverified') + } + + private async submitVerifiedCodexPaste(args: { + handle: string + ptyId: string + generation: number + permissionBaseline: AgentPromptActivity + ingest: CodexPasteIngestProof + options: { + beforeWrite?: (ptyId: string) => void | Promise + signal?: AbortSignal + } + }): Promise { + let previousScreen = args.ingest.screen + for (let submits = 1; submits <= AGENT_PROMPT_MAX_SUBMITS; submits += 1) { + await this.writeAgentPromptBytes({ + handle: args.handle, + ptyId: args.ptyId, + generation: args.generation, + data: AGENT_PROMPT_SUBMIT, + permissionBaseline: args.permissionBaseline, + failureError: 'agent_prompt_unverifiable', + options: args.options + }) + await waitForAgentPromptDelay(AGENT_PROMPT_SUBMIT_DELAY_MS, args.options.signal) + this.assertAgentPromptPermissionSafe( + args.permissionBaseline, + this.getAgentPromptActivity(args.handle, args.ptyId) + ) + const screen = await this.readCodexPasteScreen(args.ptyId, args.generation) + if (!screen || screen.sequence < previousScreen.sequence) { + throw new Error('agent_prompt_unverifiable') + } + const visibleCount = countCodexPasteMarker(screen.lines, args.ingest.marker) + if (visibleCount < args.ingest.visibleCount) { + if (screen.sequence <= previousScreen.sequence) { + throw new Error('agent_prompt_unverifiable') + } + return submits + } + if (visibleCount > args.ingest.visibleCount) { + throw new Error('agent_prompt_unverifiable') + } + previousScreen = screen + } + throw new Error('agent_prompt_stalled') + } + + private async readCodexPasteScreen( + ptyId: string, + generation: number + ): Promise { + if (!this.providerSnapshotPreferredPtys.has(ptyId)) { + const screen = await this.readHeadlessVisibleTerminalState(ptyId) + return screen?.generation === generation ? screen : null + } + const snapshot = await this.captureProviderTerminalBuffer( + ptyId, + { scrollbackRows: 0 }, + generation + ) + if ( + !snapshot || + this.getPtyLifecycleGeneration(ptyId) !== generation || + this.getPtyOutputSequence(ptyId) > snapshot.seq + ) { + return null + } + return { + lines: await this.parseVisibleSnapshotLines(snapshot), + isAlternateScreen: snapshot.alternateScreen ?? false, + sequence: snapshot.seq, + generation + } + } + + private async writeAgentPromptBytes(args: { + handle: string + ptyId: string + generation: number + data: string + permissionBaseline: AgentPromptActivity + failureError?: string + writeFailureError?: string + options: { + beforeWrite?: (ptyId: string) => void | Promise + signal?: AbortSignal + } + }): Promise { + assertAgentPromptRequestActive(args.options.signal) + this.assertLiveTerminalHandleTargetsPty(args.handle, args.ptyId) + this.assertAgentPromptGeneration(args.ptyId, args.generation) + try { + await args.options.beforeWrite?.(args.ptyId) + } catch (error) { + if (args.failureError) { + throw new Error(args.failureError) + } + throw error + } + assertAgentPromptRequestActive(args.options.signal) + this.assertLiveTerminalHandleTargetsPty(args.handle, args.ptyId) + this.assertAgentPromptGeneration(args.ptyId, args.generation) + this.assertAgentPromptPermissionSafe( + args.permissionBaseline, + this.getAgentPromptActivity(args.handle, args.ptyId) + ) + this.assertAgentPromptWritable(args.handle, args.ptyId) + const wrote = this.ptyController?.writeWithSettlement + ? await this.ptyController.writeWithSettlement(args.ptyId, args.data) + : (this.ptyController?.write(args.ptyId, args.data) ?? false) + if (!wrote) { + throw new Error(args.writeFailureError ?? args.failureError ?? 'terminal_not_writable') + } + } + + private assertAgentPromptWritable(handle: string, ptyId: string): void { + const runtimePty = this.getLivePtyForHandle(handle) + if (runtimePty) { + if (runtimePty.pty.ptyId !== ptyId || !runtimePty.pty.connected) { + throw new Error('terminal_not_writable') + } + return + } + const { leaf } = this.getLiveLeafForHandle(handle) + if (leaf.ptyId !== ptyId || !leaf.connected || !leaf.writable) { + throw new Error('terminal_not_writable') + } + } + private async serializeAgentPromptSubmission( ptyId: string, generation: number, diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts index 561c35603f2..a8464ed18d6 100644 --- a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts @@ -20,6 +20,7 @@ import { resolveFederatedWorkerLaunchReceipt, type OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' +import { assertPromptSubmitVerificationSupported } from './orchestration-prompt-submit-capability' import { validateFederatedWorkerStartPlacement } from './orchestration-worker-start-validation' export async function startFederatedWorker(args: { @@ -78,6 +79,7 @@ export async function startFederatedWorker(args: { `Connected server ${server.name} does not support orchestration federation.` ) } + assertPromptSubmitVerificationSupported(status.capabilities, server.name) assertWorkerLaunchPreferencesRuntimeSupported({ model: params.model, effort: params.effort, diff --git a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts index a8528803bf1..9d5b28774bc 100644 --- a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, - ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY + ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY, + ORCHESTRATION_PROMPT_SUBMIT_VERIFICATION_RUNTIME_CAPABILITY, + RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version' import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' @@ -245,4 +247,54 @@ describe('orchestration migration behavior', () => { expect(db.getTask(task.id)?.status).toBe('ready') expect(db.getDispatchContext(task.id)).toBeUndefined() }) + + it('rejects a connected server missing prompt verification before Task mutation', async () => { + const { db, runtime } = createRuntime() + const run = db.createRun({ + objective: 'unverified remote worker', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'remote work', runId: run.id }) + vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({ + environmentId: 'environment_windows', + name: 'windows', + peerFingerprint: 'windows_peer' + }) + vi.spyOn(runtime, 'callOrchestrationWorkerServer').mockResolvedValue({ + capabilities: [ + ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, + ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY + ] + }) + + await expect( + startFederatedWorker({ + params: { + task: task.id, + from: 'term_coord', + on: 'windows', + worktree: 'new-top-level', + repo: 'id:windows-repo', + name: 'remote-work', + agent: 'codex' + }, + runtime, + db, + runId: run.id, + task, + orchestrationMutation: { + callerFingerprint: 'caller', + requestId: 'remote_start', + method: 'orchestration.workerStart', + payloadHash: 'payload' + } + }) + ).rejects.toMatchObject({ code: 'capability_unsupported' }) + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getDispatchContext(task.id)).toBeUndefined() + expect(RUNTIME_CAPABILITIES).toContain( + ORCHESTRATION_PROMPT_SUBMIT_VERIFICATION_RUNTIME_CAPABILITY + ) + }) }) diff --git a/src/main/runtime/rpc/methods/orchestration-prompt-submit-capability.ts b/src/main/runtime/rpc/methods/orchestration-prompt-submit-capability.ts new file mode 100644 index 00000000000..ed9db51faca --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-prompt-submit-capability.ts @@ -0,0 +1,15 @@ +import { ORCHESTRATION_PROMPT_SUBMIT_VERIFICATION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { OrchestrationError } from '../../orchestration/orchestration-error' + +export function assertPromptSubmitVerificationSupported( + capabilities: readonly string[] | undefined, + serverName: string +): void { + if (capabilities?.includes(ORCHESTRATION_PROMPT_SUBMIT_VERIFICATION_RUNTIME_CAPABILITY)) { + return + } + throw new OrchestrationError( + 'capability_unsupported', + `Connected server ${serverName} cannot verify worker prompt submission. No effects were applied.` + ) +} diff --git a/src/relay/pty-handler-output-streaming.test.ts b/src/relay/pty-handler-output-streaming.test.ts index 75bf570687d..e739c132a90 100644 --- a/src/relay/pty-handler-output-streaming.test.ts +++ b/src/relay/pty-handler-output-streaming.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress' +import { PTY_VISIBLE_SCREEN_SNAPSHOT_PROTOCOL_VERSION } from '../shared/pty-visible-screen-snapshot' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -128,6 +129,36 @@ describe('PtyHandler', () => { expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'hello world' }) }) + it('serves an ordered visible-screen snapshot for the current PTY incarnation', async () => { + let dataCallback: ((data: string) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((callback: (data: string) => void) => { + dataCallback = callback + }), + onExit: vi.fn() + }) + const spawned = (await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24 + })) as { id: string; incarnationId: string } + + dataCallback?.('[Pasted Content 1001 chars]') + const snapshot = (await dispatcher.callRequest('pty.getBufferSnapshot', { + id: spawned.id + })) as { data: string; seq: number; incarnationId: string } + const capabilities = (await dispatcher.callRequest('pty.getCapabilities')) as { + visibleScreenSnapshotVersion: number + } + + expect(snapshot.data).toContain('[Pasted Content 1001 chars]') + expect(snapshot.seq).toBe('[Pasted Content 1001 chars]'.length) + expect(snapshot.incarnationId).toBe(spawned.incarnationId) + expect(capabilities.visibleScreenSnapshotVersion).toBe( + PTY_VISIBLE_SCREEN_SNAPSHOT_PROTOCOL_VERSION + ) + }) + it('consumes capable startup queries before relay replay and fanout', async () => { let dataCallback: ((data: string) => void) | undefined const term = { diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index aae35a2e166..cadaa6e619d 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -69,6 +69,7 @@ describe('PtyHandler', () => { expect(methods).toContain('pty.sendSignal') expect(methods).toContain('pty.getCwd') expect(methods).toContain('pty.getInitialCwd') + expect(methods).toContain('pty.getBufferSnapshot') expect(methods).toContain('pty.clearBuffer') expect(methods).toContain('pty.hasChildProcesses') expect(methods).toContain('pty.getForegroundProcess') diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index a9fd21a4fe2..dc1755810af 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -89,6 +89,9 @@ import { injectRelayFishHistoryEnv, injectRelayHistoryEnv } from './terminal-history' +import { HeadlessEmulator } from '../main/daemon/headless-emulator' +import type { PtyProviderBufferSnapshot } from '../main/providers/pty-provider-contract' +import { PTY_VISIBLE_SCREEN_SNAPSHOT_PROTOCOL_VERSION } from '../shared/pty-visible-screen-snapshot' // Why: only Linux compiles node-pty (no prebuilt), so the build-tools remedy is a closable setup gap // there and wrong advice anywhere node-pty ships one. The relay only sees an unloadable binding, never @@ -149,6 +152,10 @@ type ManagedPty = { /** Why a chunk deque: rebuilding a rolling 100KB string per PTY chunk copied the * whole window on every write once saturated. Readers are attach/adopt/revive only. */ buffered: RecentPtyOutputBuffer + screen: HeadlessEmulator + screenWriteChain: Promise + screenSequence: number + screenAppliedSequence: number /** Timer for SIGKILL fallback after a graceful SIGTERM shutdown. */ killTimer?: ReturnType /** True once disposeManagedPty has run; blocks double-dispose and makes post-dispose calls fail "not found" not silently. */ @@ -232,11 +239,24 @@ function finishPtyCreationOperations(operations: readonly (() => void)[]): void } } +function createManagedPtyScreen( + cols: number, + rows: number +): Pick { + return { + screen: new HeadlessEmulator({ cols, rows, scrollback: 0 }), + screenWriteChain: Promise.resolve(), + screenSequence: 0, + screenAppliedSequence: 0 + } +} + function disposeManagedPty(managed: ManagedPty): void { if (managed.disposed) { return } managed.disposed = true + managed.screen.dispose() // Why: clear the SIGKILL fallback timer so it can't fire pty.kill on an already-disposed instance. if (managed.killTimer) { clearTimeout(managed.killTimer) @@ -823,6 +843,13 @@ export class PtyHandler { }) } managed.pty.onData((data: string) => { + const screenSequence = managed.screenSequence + data.length + managed.screenSequence = screenSequence + const screenWrite = managed.screenWriteChain.then(async () => { + await managed.screen.write(data) + managed.screenAppliedSequence = screenSequence + }) + managed.screenWriteChain = screenWrite.catch(() => {}) const startup = managed.startupCommand if (startup?.waitForShellReady && startup.outputScanState && !startup.delivered) { const scanned = scanShellStartupOutput(startup.outputScanState, data) @@ -913,6 +940,7 @@ export class PtyHandler { this.dispatcher.onRequest('pty.getCwd', (p) => this.getCwd(p)) this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p)) this.dispatcher.onRequest('pty.getSize', (p) => this.getSize(p)) + this.dispatcher.onRequest('pty.getBufferSnapshot', (p) => this.getBufferSnapshot(p)) this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p)) this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p)) this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p)) @@ -920,7 +948,8 @@ export class PtyHandler { this.dispatcher.onRequest('pty.getCapabilities', async () => ({ startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION, - agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION + agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION, + visibleScreenSnapshotVersion: PTY_VISIBLE_SCREEN_SNAPSHOT_PROTOCOL_VERSION })) this.dispatcher.onRequest('pty.listProcesses', () => this.listProcesses()) this.dispatcher.onRequest('pty.getDefaultShell', async () => resolveDefaultShell()) @@ -1694,6 +1723,7 @@ export class PtyHandler { preserveChunkBoundaries: false, limit: REPLAY_BUFFER_MAX }), + ...createManagedPtyScreen(cols, rows), paneKey, tabId, ...(attachIdentity.paneKey || attachIdentity.tabId ? { attachIdentity } : {}), @@ -1897,6 +1927,43 @@ export class PtyHandler { const managed = this.ptys.get(id) if (managed && !managed.disposed) { managed.pty.resize(cols, rows) + managed.screenWriteChain = managed.screenWriteChain + .then(() => managed.screen.resize(cols, rows)) + .catch(() => {}) + } + } + + private async getBufferSnapshot( + params: Record + ): Promise { + const managed = this.ptys.get(params.id as string) + if (!managed || managed.disposed) { + return null + } + const incarnationId = managed.incarnationId + await managed.screenWriteChain + if (this.ptys.get(managed.id) !== managed || managed.disposed) { + return null + } + const scrollbackRows = Math.max(0, Math.floor(Number(params.scrollbackRows) || 0)) + const snapshot = managed.screen.getSnapshot({ scrollbackRows }) + return { + data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, + frameRestoreAnsi: snapshot.frameRestoreAnsi, + scrollbackAnsi: snapshot.scrollbackAnsi, + cols: snapshot.cols, + rows: snapshot.rows, + cwd: snapshot.cwd, + lastTitle: snapshot.lastTitle, + seq: managed.screenAppliedSequence, + incarnationId, + source: 'headless', + oscLinks: snapshot.oscLinks, + alternateScreen: managed.screen.isAlternateScreen, + pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi, + ...(typeof snapshot.modes.kittyKeyboardFlags === 'number' + ? { kittyKeyboardFlags: snapshot.modes.kittyKeyboardFlags } + : {}) } } @@ -2285,6 +2352,7 @@ export class PtyHandler { preserveChunkBoundaries: false, limit: REPLAY_BUFFER_MAX }), + ...createManagedPtyScreen(entry.cols, entry.rows), paneKey: entry.paneKey, tabId: entry.tabId, attachIdentity: entry.attachIdentity, diff --git a/src/shared/agent-prompt-codex-ingest.test.ts b/src/shared/agent-prompt-codex-ingest.test.ts new file mode 100644 index 00000000000..ab13ed816bd --- /dev/null +++ b/src/shared/agent-prompt-codex-ingest.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + codexAgentPromptCharCount, + collectCodexPasteMarkers, + countCodexPasteMarker, + findCodexPasteMarkerDelta +} from './agent-prompt-codex-ingest' + +describe('Codex agent-prompt paste ingest', () => { + it.each([ + ['astral Unicode', '😀', 1], + ['CRLF and bare CR', 'a\r\nb\rc', 5], + ['ESC literalization', 'a\x1bb', 7], + ['C0 and C1 controls', 'a\x00b\x85c', 3], + ['LF and tab retention', 'a\nb\tc', 5] + ])('matches Codex normalization for %s', (_name, prompt, expected) => { + expect(codexAgentPromptCharCount(prompt)).toBe(expected) + }) + + it('finds one delivery-owned member without predicting its suffix', () => { + const baseline = collectCodexPasteMarkers(['[Pasted Content 5000 chars]'], 5_000) + const current = collectCodexPasteMarkers( + ['[Pasted Content 5000 chars] [Pasted Content 5000 chars] #7'], + 5_000 + ) + + expect(findCodexPasteMarkerDelta(baseline, current)).toEqual({ + marker: '[Pasted Content 5000 chars] #7', + visibleCount: 1 + }) + }) + + it('finds a marker split across rendered grid rows', () => { + const current = collectCodexPasteMarkers(['[Pasted Content ', '5000 chars] #23'], 5_000) + + expect(findCodexPasteMarkerDelta(new Map(), current)).toEqual({ + marker: '[Pasted Content 5000 chars] #23', + visibleCount: 1 + }) + }) + + it('finds an unsuffixed marker added beside a pre-existing identical marker', () => { + const baseline = collectCodexPasteMarkers(['[Pasted Content 5000 chars]'], 5_000) + const current = collectCodexPasteMarkers( + ['[Pasted Content 5000 chars] [Pasted Content 5000 chars]'], + 5_000 + ) + + expect(findCodexPasteMarkerDelta(baseline, current)).toEqual({ + marker: '[Pasted Content 5000 chars]', + visibleCount: 2 + }) + }) + + it('rejects redraws, removed baselines, and multiple additions', () => { + const baseline = collectCodexPasteMarkers(['[Pasted Content 5000 chars]'], 5_000) + expect(findCodexPasteMarkerDelta(baseline, baseline)).toBeNull() + expect(findCodexPasteMarkerDelta(baseline, new Map())).toBeNull() + expect( + findCodexPasteMarkerDelta( + baseline, + collectCodexPasteMarkers( + ['[Pasted Content 5000 chars] #2 [Pasted Content 5000 chars] #3'], + 5_000 + ) + ) + ).toBeNull() + }) + + it('counts an exact owned marker', () => { + expect( + countCodexPasteMarker( + ['[Pasted Content 5000 chars] #2', '[Pasted Content 5000 chars]'], + '[Pasted Content 5000 chars] #2' + ) + ).toBe(1) + }) + + it('counts an exact marker across rendered grid rows', () => { + expect( + countCodexPasteMarker( + ['[Pasted Content 5000', ' chars] #2'], + '[Pasted Content 5000 chars] #2' + ) + ).toBe(1) + }) +}) diff --git a/src/shared/agent-prompt-codex-ingest.ts b/src/shared/agent-prompt-codex-ingest.ts new file mode 100644 index 00000000000..5bc54ba745a --- /dev/null +++ b/src/shared/agent-prompt-codex-ingest.ts @@ -0,0 +1,78 @@ +import { sanitizeAgentPromptText } from './agent-prompt-injection' + +export const CODEX_LARGE_PASTE_CHAR_THRESHOLD = 1_000 + +export type CodexPasteMarkerMultiset = ReadonlyMap + +export type CodexPasteMarkerDelta = Readonly<{ + marker: string + visibleCount: number +}> + +export function codexAgentPromptCharCount(prompt: string): number { + const normalized = sanitizeAgentPromptText(prompt).replace(/\r\n|\r/g, '\n') + let count = 0 + for (const character of normalized) { + const codePoint = character.codePointAt(0)! + if (character !== '\n' && character !== '\t' && isUnicodeControl(codePoint)) { + continue + } + count += 1 + } + return count +} + +export function collectCodexPasteMarkers( + lines: readonly string[], + charCount: number +): Map { + const markers = new Map() + const pattern = new RegExp(`\\[Pasted Content ${charCount} chars\\](?: #[1-9]\\d*)?`, 'g') + for (const marker of lines.join('').matchAll(pattern)) { + const value = marker[0] + markers.set(value, (markers.get(value) ?? 0) + 1) + } + return markers +} + +export function findCodexPasteMarkerDelta( + baseline: CodexPasteMarkerMultiset, + current: CodexPasteMarkerMultiset +): CodexPasteMarkerDelta | null { + let delta: CodexPasteMarkerDelta | null = null + for (const [marker, baselineCount] of baseline) { + if ((current.get(marker) ?? 0) < baselineCount) { + return null + } + } + for (const [marker, currentCount] of current) { + const increment = currentCount - (baseline.get(marker) ?? 0) + if (increment === 0) { + continue + } + if (increment !== 1 || delta) { + return null + } + delta = { marker, visibleCount: currentCount } + } + return delta +} + +export function countCodexPasteMarker(lines: readonly string[], marker: string): number { + let count = 0 + const visibleText = lines.join('') + let cursor = 0 + while (cursor <= visibleText.length - marker.length) { + const match = visibleText.indexOf(marker, cursor) + if (match === -1) { + break + } + count += 1 + cursor = match + marker.length + } + return count +} + +function isUnicodeControl(codePoint: number): boolean { + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d9d356c676a..b84930395d5 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -49,6 +49,8 @@ export const ORCHESTRATION_WORKER_STOP_VERDICT_RUNTIME_CAPABILITY = 'orchestration.worker-stop-verdict.v1' as const export const ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY = 'orchestration.worker-launch-preferences.v1' as const +export const ORCHESTRATION_PROMPT_SUBMIT_VERIFICATION_RUNTIME_CAPABILITY = + 'orchestration.prompt-submit-verification.v1' as const export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION = 2 as const export const ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION = 3 as const export const ORCHESTRATION_CONTRACT_VERSION = 1 as const @@ -123,6 +125,7 @@ export const RUNTIME_CAPABILITIES = [ ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_RUNTIME_CAPABILITY, ORCHESTRATION_WORKER_STOP_VERDICT_RUNTIME_CAPABILITY, ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY, + ORCHESTRATION_PROMPT_SUBMIT_VERIFICATION_RUNTIME_CAPABILITY, ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, BROWSER_SCREENCAST_RUNTIME_CAPABILITY, BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY, diff --git a/src/shared/pty-visible-screen-snapshot.ts b/src/shared/pty-visible-screen-snapshot.ts new file mode 100644 index 00000000000..ff6de209713 --- /dev/null +++ b/src/shared/pty-visible-screen-snapshot.ts @@ -0,0 +1 @@ +export const PTY_VISIBLE_SCREEN_SNAPSHOT_PROTOCOL_VERSION = 1 as const