diff --git a/docs/adr/ADR-20260921-orchestration-mcp-rides-agent-bridge.md b/docs/adr/ADR-20260921-orchestration-mcp-rides-agent-bridge.md index b3adc3e28..6327204dc 100644 --- a/docs/adr/ADR-20260921-orchestration-mcp-rides-agent-bridge.md +++ b/docs/adr/ADR-20260921-orchestration-mcp-rides-agent-bridge.md @@ -66,7 +66,12 @@ The orchestration MCP is a **second tool surface on the agent-bridge architectur sessions. No tool ever names an unrelated session. 3. Bootstrap capability injection happens at PTY launch exactly as the browser bridge does today (one-use, rotated to session-scoped, revoked at exit), but only for sessions whose - metadata role is `orchestrator`. + metadata role is `orchestrator`. A new CLI thread may start another MCP helper + with the same PTY environment: bootstrap authentication can join the same + launch while it has a healthy authenticated connection, even after the initial + bootstrap TTL. It cannot revive a disconnected, expired or revoked launch. + Helpers share a session-scoped reconnect capability so a new join does not + invalidate an earlier helper's reconnect path. PTY cleanup revokes them all. 4. Per-provider MCP config injection follows `ProviderLaunch.ts`'s existing adapters (CLI args for Claude/Codex/Qwen, inline config for OpenCode, owned temp entries for Kimi/Hermes), gated on the same role. diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index 0e8b6e3f4..476770647 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -114,6 +114,7 @@ The default private controller file is next to the connection descriptor. Use a - `status` returns the native session state plus the controller-submitted turn, if any. - Before its first prompt a ready provider can still report `unavailable`; inspect `screen`. Startup/authentication/trust menus require resolution before tasks. Task delivery never doubles as menu confirmation. - `send` accepts at most 16,000 characters of task text. It rejects slash commands, terminal control sequences and input while a submitted turn is active. It does not offer automatic permission approval. +- Automated Codex prompts wait for its empty task composer in the current launch, then use explicit bracketed paste followed by Enter. Startup menus are not task input; cancellation, timeout or restart drops the pending text. A screen from a previous launch cannot establish readiness, and paste-only sends do not submit the draft. - An accepted send means text was written to the PTY. It is not a completed task. - A result becomes fresh only after a corresponding working-to-idle lifecycle transition. Provider turn IDs, when present, reject stale completion signals. - `resultRevision` counts completed observations. A previous completed answer is kept separate from the active turn. `result --after N` returns `fresh: false` until a newer result exists. @@ -153,3 +154,15 @@ node scripts/smoke-agent-control.mjs --live --cwd /already/trusted/test-director The opt-in live smoke uses the actual CLI client, native PTY, installed Codex and authenticated lifecycle/result hooks. It asks Codex to create and edit one uniquely named proof file, independently verifies its bytes, and retains evidence. It never launches or controls the user's desktop window. It uses the existing Codex authentication and consumes provider usage. Platform claims require a run on that platform; Unix unit tests do not prove Windows behavior. The smoke prints its session ID and private connection/client paths at startup. If a startup menu blocks it, inspect that session with the CLI using those paths and resolve only reviewed, authorized choices. The smoke waits up to two minutes for a ready composer; it does not silently trust hooks or approve permissions. + + +Codex receives the enabled `canvastty_agents` tools directly through its supported +per-server `omit_tools_from = ["deferred"]` setting. Coordination does not depend +on ranked `tool_search` results. The launch allowlist, role checks, profile ceilings +and project boundary are unchanged; browser tools retain their existing exposure. + +Verify the Codex exposure contract without credentials or real providers with +`npm run smoke:codex-mcp-discovery -- /absolute/path/to/codex` +(pass the native `codex.exe` on Windows). This uses temporary profiles, a synthetic +model and loopback MCP/Responses servers. It checks both the deferred negative +control and direct exposure of the exact enabled coordination catalog. diff --git a/package.json b/package.json index 524b53037..1d4c02847 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "smoke:browser:providers": "node scripts/smoke-browser-providers.mjs --deterministic", "smoke:browser:provider-helper": "node scripts/smoke-browser-providers.mjs --direct", "smoke:browser:providers:live": "node scripts/smoke-browser-providers.mjs --all", + "smoke:codex-mcp-discovery": "node scripts/smoke-codex-mcp-discovery.mjs", "smoke:cli-resolution:mac": "node scripts/smoke-packaged-cli-resolution.mjs", "smoke:appimage": "node scripts/smoke-appimage.mjs", "preview": "electron-vite preview", diff --git a/scripts/smoke-codex-mcp-discovery.mjs b/scripts/smoke-codex-mcp-discovery.mjs new file mode 100644 index 000000000..8012524c8 --- /dev/null +++ b/scripts/smoke-codex-mcp-discovery.mjs @@ -0,0 +1,130 @@ +/** Real Codex with temporary profiles and synthetic loopback MCP/Responses only; no account or model API. */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { codexMcpArgs } from "../src/main/services/agent-browser/ProviderLaunch.ts"; +import { ORCHESTRATION_TOOL_DEFINITIONS, ORCHESTRATION_TOOL_NAMES } from "../src/agent-browser/orchestration-catalog.mjs"; + +const binary = process.argv[2]; +if (!binary || !isAbsolute(binary)) throw new Error("Pass the absolute native Codex executable path (codex.exe on Windows)."); +const root = await mkdtemp(join(tmpdir(), "canvas-mcp-exposure-")); +await mkdir(join(root, "work")); +const helper = join(root, "mcp.mjs"); +await writeFile(helper, ` +import readline from "node:readline"; +const tools = ${JSON.stringify(ORCHESTRATION_TOOL_DEFINITIONS)}; +for await (const line of readline.createInterface({ input: process.stdin })) { + const request = JSON.parse(line); + if (request.id === undefined) continue; + const result = request.method === "initialize" + ? { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } } + : request.method === "tools/list" ? { tools } : {}; + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\\n"); +} +`); + +let requests = []; +const server = createServer(async (req, res) => { + let raw = ""; + for await (const chunk of req) raw += chunk; + requests.push(JSON.parse(raw)); + const item = { + id: "msg_fixture", type: "message", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "FIXTURE_OK", annotations: [] }] + }; + const response = { + id: "resp_fixture", object: "response", created_at: 1, status: "completed", model: "fixture-model", + output: [item], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } + }; + const events = [ + { type: "response.created", response: { ...response, status: "in_progress", output: [] } }, + { type: "response.output_item.added", output_index: 0, item: { ...item, content: [] } }, + { type: "response.content_part.added", output_index: 0, content_index: 0, item_id: item.id, + part: { type: "output_text", text: "", annotations: [] } }, + { type: "response.output_text.delta", output_index: 0, content_index: 0, item_id: item.id, delta: "FIXTURE_OK" }, + { type: "response.output_text.done", output_index: 0, content_index: 0, item_id: item.id, text: "FIXTURE_OK" }, + { type: "response.output_item.done", output_index: 0, item }, + { type: "response.completed", response } + ]; + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.end(events.map((event, index) => + `event: ${event.type}\ndata: ${JSON.stringify({ ...event, sequence_number: index })}\n\n`).join("")); +}); +await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + +try { + const model = { + slug: "fixture-model", display_name: "Fixture", description: "Synthetic MCP exposure fixture", + default_reasoning_level: "low", supported_reasoning_levels: [{ effort: "low", description: "Fixture" }], + shell_type: "shell_command", visibility: "list", supported_in_api: true, priority: 0, + base_instructions: "Use the provided test tools only when requested.", supports_reasoning_summaries: false, + support_verbosity: false, truncation_policy: { mode: "tokens", limit: 10000 }, input_modalities: ["text"], + supports_search_tool: true, supports_parallel_tool_calls: true, experimental_supported_tools: [] + }; + const results = []; + for (const { direct, allowed } of [ + { direct: false, allowed: ORCHESTRATION_TOOL_NAMES }, + { direct: true, allowed: ORCHESTRATION_TOOL_NAMES }, + { direct: true, allowed: ["list_agents"] }, + { direct: true, allowed: [] } + ]) { + const home = join(root, `case-${results.length}`); + await mkdir(home, { mode: 0o700 }); + await writeFile(join(home, "models.json"), JSON.stringify({ models: [model] })); + await writeFile(join(home, "config.toml"), `model="fixture-model" +model_provider="fixture" +model_catalog_json=${JSON.stringify(join(home, "models.json"))} +[model_providers.fixture] +name="fixture" +base_url="http://127.0.0.1:${server.address().port}/v1" +wire_api="responses" +requires_openai_auth=false +env_key="FIXTURE_KEY" +`); + const args = codexMcpArgs(null, { command: process.execPath, args: [helper] }, allowed); + // Negative control: remove only the exposure override from the actual launch table. + if (!direct) args[1] = args[1].replace('omit_tools_from=["deferred"],', ""); + requests = []; + const environment = Object.fromEntries(Object.entries(process.env) + .filter(([key]) => ["PATH", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "TMP", "TEMP", "TMPDIR"].includes(key.toUpperCase())) + .map(([key, value]) => [key.toUpperCase(), value])); + Object.assign(environment, { + HOME: home, USERPROFILE: home, APPDATA: home, LOCALAPPDATA: home, + CODEX_HOME: home, CODEX_SQLITE_HOME: home, FIXTURE_KEY: "fixture" + }); + const child = spawn(binary, [ + "exec", "--ephemeral", "--json", "--skip-git-repo-check", "--sandbox", "read-only", + "-C", join(root, "work"), ...args, "Reply FIXTURE_OK." + ], { env: environment, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = "", stderr = ""; + child.stdout.on("data", chunk => { stdout = (stdout + chunk).slice(-16384); }); + child.stderr.on("data", chunk => { stderr = (stderr + chunk).slice(-16384); }); + const timer = setTimeout(() => child.kill("SIGKILL"), 45000); + let code; + try { + code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + } finally { + clearTimeout(timer); + } + if (code !== 0) throw new Error("Fixture client failed: " + stderr.slice(-1200)); + assert.match(stdout, /FIXTURE_OK/); + const tools = (requests[0]?.tools ?? []).flatMap(tool => tool.type === "namespace" + ? tool.tools.map(entry => ({ namespace: tool.name, ...entry })) : tool); + const exposed = tools.filter(tool => tool.namespace === "mcp__canvastty_agents" + || tool.name?.includes("canvastty_agents")).map(tool => tool.name); + assert.deepEqual(exposed.sort(), direct ? [...allowed].sort() : []); + results.push({ direct, allowed, clientExitCode: code, requestCount: requests.length, + orchestrationTools: exposed, searchAdvertised: tools.some(tool => tool.type === "tool_search") }); + } + console.log(JSON.stringify({ ok: true, liveProviderProof: false, cases: results })); +} finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + await rm(root, { recursive: true, force: true, maxRetries: 3 }); +} diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index 3b35fa78a..34a5fda11 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -1,3 +1,4 @@ +import { codexPromptInput } from "./codexInput.ts"; import type { AgentProviderId, CreateSessionRequest, @@ -191,7 +192,9 @@ export class AgentControlService { ...(request.effort !== undefined ? { effort: request.effort } : {}) }, { ...(RESULT_CAPTURE_PROVIDERS.has(request.provider) ? { captureResult: true } : {}), origin: "subagent" }); if (request.initialPrompt === undefined || request.initialPrompt.length === 0) return Promise.resolve(created); - return this.deliver(created.id, `${request.initialPrompt}\r`, "prompt", signal) + const input = request.provider === "codex" + ? codexPromptInput(request.initialPrompt) : `${request.initialPrompt}\r`; + return this.deliver(created.id, input, "prompt", signal) .then(() => this.terminals.getMetadata(created.id) ?? created, (error: unknown) => { // Cancelled while it started: nobody receives its id, so the card is closed instead of left running. if (!signal?.aborted) throw error; @@ -213,7 +216,8 @@ export class AgentControlService { if (!capabilities.send) throw new Error(`${session.provider} cannot receive prompts.`); if (typeof text !== "string" || text.length === 0) throw new Error("Prompt text is required."); if (session.exitCode !== null) throw new Error("Agent session has already exited."); - return this.deliver(sessionId, submit ? `${text}\r` : text, "text", signal); + const input = session.provider === "codex" ? codexPromptInput(text, submit) : submit ? `${text}\r` : text; + return this.deliver(sessionId, input, "text", signal); } status(sessionId: string): SessionMetadata { @@ -382,7 +386,7 @@ export class AgentControlService { /** Through the terminal manager's one delivery rule: exactly once, into the launch that is starting now. */ private async deliver(sessionId: string, data: string, what: "prompt" | "text", signal?: AbortSignal): Promise { - const delivery = await this.terminals.deliverInput(sessionId, data, undefined, signal); + const delivery = await this.terminals.deliverInput(sessionId, data, undefined, signal, this.requireSession(sessionId).provider === "codex"); if (!delivery.delivered) { throw new PromptNotDeliveredError(sessionId, `The ${what} for agent ${sessionId} was not delivered: ${delivery.reason}`); } diff --git a/src/main/services/PluginSessions.ts b/src/main/services/PluginSessions.ts index c7d18bba5..3193792ce 100644 --- a/src/main/services/PluginSessions.ts +++ b/src/main/services/PluginSessions.ts @@ -13,6 +13,7 @@ import type { TerminalDataEvent } from "../../shared/contracts.ts"; import { IPC } from "../../shared/contracts.ts"; +import { codexPromptInput } from "./codexInput.ts"; import type { PersistedEnvironmentRef } from "./TerminalSessionStore.ts"; /** A card as plugin services see it (EP-4): metadata and where it runs, never screen text. */ @@ -57,7 +58,7 @@ interface TerminalPort { } | null; setPluginOwner(id: string, pluginId: string): void; readBuffer(id: string): TerminalBufferSnapshot; - deliverInput(id: string, text: string): Promise<{ delivered: boolean }>; + deliverInput(id: string, text: string, waitMs?: number, signal?: AbortSignal, waitForCodexComposer?: boolean): Promise<{ delivered: boolean }>; dispose(id: string, options?: { keepEnvironmentData?: boolean }): void; redactSecrets(text: string): string; redactSecretsTail(text: string, maxChars: number): string; @@ -252,7 +253,9 @@ export class PluginSessions { throw new Error(`text must be 1 to ${MAX_SEND_CHARS} characters.`); } const submit = values.submit === undefined ? true : values.submit === true; - return this.deps.terminals.deliverInput(sessionId, submit ? `${values.text}\r` : values.text) + const codex = this.deps.terminals.pluginContext(sessionId)?.metadata.provider === "codex"; + const data = codex ? codexPromptInput(values.text, submit) : submit ? `${values.text}\r` : values.text; + return this.deps.terminals.deliverInput(sessionId, data, undefined, undefined, codex) .then((delivery) => ({ sessionId, sent: delivery.delivered })); } diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 2b70689de..aa3172c23 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -60,6 +60,8 @@ import { launchEffortProblem, launchModelProblem, type ReasoningEffort } from ". import { SecretRedactionRegistry } from "./safety/SecretRedaction.ts"; import type { DecisionSession } from "./DecisionHooks.ts"; import { tryPtyOperation } from "./ptySafety.ts"; +import { SessionScreen, terminalScreenText, withoutBraille } from "./materials/sessionScreen.ts"; +import { codexComposerReady, codexPlaceholderIsDim } from "./codexInput.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; import { canResumeThreadById, resolveTerminalLaunch } from "./terminalLaunch.ts"; import { isLaunchProfile, PROFILE_RANK, profileAvailable, profileCeiling, type LaunchProfile } from "../../shared/autoMode.ts"; @@ -141,6 +143,10 @@ interface ManagedSession { environmentReady: boolean; /** Bumped by every launch the person or the app asks for (create, restart, restore); input waits for one. */ launchEpoch: number; + /** An automation-owned paste-only draft may be extended in this launch; submit or person input clears it. */ + codexDraftEpoch?: number; + /** Output from an earlier launch or restored replay cannot prove this process is ready. */ + launchOutputOffset?: number; /** Input waiting for this launch to start (deliverInput): woken whenever the launch moves on. */ launchWaiters: Set<() => void>; /** Brought back from the saved sessions at startup (plugins see a "restored" event, not "created"). */ @@ -883,6 +889,8 @@ export class TerminalManager { } input(id: string, data: string): void { + const session = this.sessions.get(id); + if (session) delete session.codexDraftEpoch; this.inputChecked(id, data); } @@ -899,46 +907,73 @@ export class TerminalManager { * once, when that launch has started. A launch that is refused, fails, is cancelled or superseded (closed, * restarted), or does not start within LAUNCH_INPUT_WAIT_MS delivers nothing, says why, and drops the text: * it never reaches a later launch of the card. An aborted `signal` (the sender cancelled) delivers nothing either. + * Automated Codex prompts additionally wait for an empty task composer in this launch; a PTY alone is not ready. */ - async deliverInput(id: string, data: string, waitMs = LAUNCH_INPUT_WAIT_MS, signal?: AbortSignal): Promise { + async deliverInput(id: string, data: string, waitMs = LAUNCH_INPUT_WAIT_MS, signal?: AbortSignal, waitForCodexComposer = false): Promise { const canceled: InputDelivery = { delivered: false, reason: "The delivery was cancelled." }; if (signal?.aborted) return canceled; const session = this.sessions.get(id); if (!session) return { delivered: false, reason: "The session does not exist." }; const epoch = session.launchEpoch; const deadline = Date.now() + waitMs; - const waiting = (): boolean => this.sessions.get(id) === session && session.launchEpoch === epoch - && session.metadata.exitCode === null && !session.process; - while (waiting() && !signal?.aborted) { - const remaining = deadline - Date.now(); - if (remaining <= 0) { - return { delivered: false, reason: `The session did not start within ${Math.round(waitMs / 1000)} s.` }; - } - await new Promise((resolve) => { - const timer = setTimeout(wake, remaining); - function wake(): void { - clearTimeout(timer); - session!.launchWaiters.delete(wake); - signal?.removeEventListener("abort", wake); - resolve(); + const launchOutput = (): TerminalBufferSnapshot => { + const snapshot = this.readBuffer(id); + const start = snapshot.outputOffset - snapshot.buffer.length; + return { ...snapshot, buffer: snapshot.buffer.slice(Math.max(0, (session.launchOutputOffset ?? 0) - start)) }; + }; + // Build the screen only after this launch has a PTY: a pending restart + // still has its previous process's scrollback until bindProcess records the new offset. + let screen: SessionScreen | null = null; + let inputReady = !waitForCodexComposer || session.codexDraftEpoch === epoch; + try { + const waiting = (): boolean => this.sessions.get(id) === session && session.launchEpoch === epoch + && session.metadata.exitCode === null && (!session.process || !inputReady); + while (waiting() && !signal?.aborted) { + if (session.process && !inputReady) { + screen ??= new SessionScreen({ geometry: (id) => this.geometry(id), readBuffer: launchOutput }, id); + const snapshot = launchOutput(); + screen.feed({ id, data: snapshot.buffer, outputOffset: snapshot.outputOffset }); + if (await screen.inspect((terminal) => codexComposerReady( + withoutBraille(terminalScreenText(terminal, true)), codexPlaceholderIsDim(terminal)))) { + inputReady = true; + break; + } } - session.launchWaiters.add(wake); - signal?.addEventListener("abort", wake, { once: true }); - }); - } - if (signal?.aborted) return canceled; - if (this.sessions.get(id) !== session) return { delivered: false, reason: "The session was closed before it started." }; - if (session.launchEpoch !== epoch) return { delivered: false, reason: "The session was restarted before it started." }; - if (session.metadata.exitCode !== null) { - return { delivered: false, reason: session.metadata.failureDetails - ? `The session did not start: ${this.redactSecrets(session.metadata.failureDetails)}` - : "The session has already exited." }; + const remaining = deadline - Date.now(); + if (remaining <= 0) { + if (session.process && !inputReady) return { delivered: false, reason: `Codex did not become ready for input within ${Math.round(waitMs / 1000)} s.` }; + return { delivered: false, reason: `The session did not start within ${Math.round(waitMs / 1000)} s.` }; + } + await new Promise((resolve) => { + const timer = setTimeout(wake, session.process && !inputReady ? Math.min(100, remaining) : remaining); + function wake(): void { + clearTimeout(timer); + session!.launchWaiters.delete(wake); + signal?.removeEventListener("abort", wake); + resolve(); + } + session.launchWaiters.add(wake); + signal?.addEventListener("abort", wake, { once: true }); + }); + } + if (signal?.aborted) return canceled; + if (this.sessions.get(id) !== session) return { delivered: false, reason: "The session was closed before it started." }; + if (session.launchEpoch !== epoch) return { delivered: false, reason: "The session was restarted before it started." }; + if (session.metadata.exitCode !== null) { + return { delivered: false, reason: session.metadata.failureDetails + ? `The session did not start: ${this.redactSecrets(session.metadata.failureDetails)}` + : "The session has already exited." }; + } + // A submitted prompt: the agent's next turn is the one that answers it (turnProgress). + const mark = session.turnStarts ?? 0; + if (!this.inputChecked(id, data)) return { delivered: false, reason: "The terminal no longer accepts input." }; + if (waitForCodexComposer && !data.endsWith("\r")) session.codexDraftEpoch = epoch; + else delete session.codexDraftEpoch; + if (data.endsWith("\r")) session.promptTurnMark = mark; + return { delivered: true }; + } finally { + screen?.dispose(); } - // A submitted prompt: the agent's next turn is the one that answers it (turnProgress). - const mark = session.turnStarts ?? 0; - if (!this.inputChecked(id, data)) return { delivered: false, reason: "The terminal no longer accepts input." }; - if (data.endsWith("\r")) session.promptTurnMark = mark; - return { delivered: true }; } private wakeLaunchWaiters(session: ManagedSession): void { @@ -2100,6 +2135,7 @@ export class TerminalManager { } private bindProcess(id: string, session: ManagedSession, process: IPty): void { + session.launchOutputOffset = session.outputOffset; session.agentBrowser?.retainUntilExit?.(); process.onData((data) => { const current = this.sessions.get(id); diff --git a/src/main/services/agent-browser/OrchestrationGateway.ts b/src/main/services/agent-browser/OrchestrationGateway.ts index 5a8d60949..5b957fb73 100644 --- a/src/main/services/agent-browser/OrchestrationGateway.ts +++ b/src/main/services/agent-browser/OrchestrationGateway.ts @@ -347,7 +347,7 @@ export class OrchestrationGateway { this.recovering = false; } - /** Called at orchestrator PTY launch; the token is one-use with a short TTL. */ + /** Called at PTY launch; bootstrap may join an already-live launch, never revive a disconnected one. */ registerOrchestrator(input: { terminalSessionId: string }): OrchestrationCapability { if (!this.enabled || !this.running || this.socketEndpoint === null) { throw new Error("The orchestration bridge is not running."); @@ -467,6 +467,12 @@ export class OrchestrationGateway { if (!lease.used && this.now() <= lease.expiresAt && tokenMatches(message.capabilityToken, lease.tokenDigest)) { lease.used = true; accepted = true; + } else if (lease.used && tokenMatches(message.capabilityToken, lease.tokenDigest)) { + // New CLI threads can start another MCP helper with the same PTY environment. + // Match the browser bridge: bootstrap can join this live launch, but cannot + // revive it after every authenticated connection has gone away. + accepted = [...this.connections].some(active => active.lease === lease && active.authenticated + && !active.closed && active.lastHeartbeatAt >= this.now() - this.heartbeatExpiryMs); } else if (lease.reconnectToken !== null && tokenMatches(message.capabilityToken, lease.reconnectTokenDigest)) { accepted = true; } @@ -477,9 +483,13 @@ export class OrchestrationGateway { connection.authenticated = true; connection.lease = lease; connection.lastHeartbeatAt = this.now(); - const reconnectToken = randomBytes(32).toString("base64url"); - lease.reconnectToken = reconnectToken; - lease.reconnectTokenDigest = tokenDigest(reconnectToken); + // All helpers of a launch share its reconnect capability. Rotating it on + // every join would strand earlier helpers after their next socket drop. + if (lease.reconnectToken === null) { + lease.reconnectToken = randomBytes(32).toString("base64url"); + lease.reconnectTokenDigest = tokenDigest(lease.reconnectToken); + } + const reconnectToken = lease.reconnectToken; lease.resolveAuthenticated(); this.send(connection, { v: ORCHESTRATION_BRIDGE_PROTOCOL_VERSION, diff --git a/src/main/services/agent-browser/ProviderLaunch.ts b/src/main/services/agent-browser/ProviderLaunch.ts index f59cbe12c..ab2c45546 100644 --- a/src/main/services/agent-browser/ProviderLaunch.ts +++ b/src/main/services/agent-browser/ProviderLaunch.ts @@ -365,6 +365,8 @@ export function codexMcpArgs( "enabled=true", "required=false", 'default_tools_approval_mode="approve"', + // Coordination must be callable upfront; ranked discovery can omit an enabled tool even by exact name. + 'omit_tools_from=["deferred"]', `enabled_tools=${tomlStringArray([...orchestrationTools])}`, "disabled_tools=[]" ].join(","); diff --git a/src/main/services/agent-control/AgentControlGateway.ts b/src/main/services/agent-control/AgentControlGateway.ts index 659d5cb89..8fda9f20f 100644 --- a/src/main/services/agent-control/AgentControlGateway.ts +++ b/src/main/services/agent-control/AgentControlGateway.ts @@ -1,3 +1,6 @@ +import { terminalScreenText } from "../materials/sessionScreen.ts"; +import { codexComposerReady, codexPlaceholderIsDim } from "../codexInput.ts"; +export { codexComposerReady } from "../codexInput.ts"; import { createHash, randomBytes } from "node:crypto"; import { chmod, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; import { mkdirSync, rmSync, writeFileSync, chmodSync } from "node:fs"; @@ -605,6 +608,8 @@ export class AgentControlGateway { } const capabilities = controlCapabilities(fresh.provider); const screen = viewport(owned.terminal, (text) => this.redact(text)); + const composerReady = capabilities.menus && codexComposerReady( + this.redact(terminalScreenText(owned.terminal, true)), codexPlaceholderIsDim(owned.terminal)); if (request.method === "screen") return { sessionId: id, text: screen, revision: hash(screen), outputOffset: owned.outputOffset, interaction: capabilities.menus ? codexChoices(screen) : null }; if ((request.method === "choose" || request.method === "dismiss") && !capabilities.menus) { @@ -619,7 +624,7 @@ export class AgentControlGateway { try { if (request.method === "dismiss") { if (hash(screen) !== params.revision) throw new ControlError("STALE_MENU", "Screen changed; inspect it again."); - if (metadata.status === "working" || codexComposerReady(screen) || !/Press[^\n]*(?:esc|escape)/i.test(screen)) { + if (metadata.status === "working" || composerReady || !/Press[^\n]*(?:esc|escape)/i.test(screen)) { throw new ControlError("NOT_MENU", "No dismissible idle menu is visible."); } if (!this.options.terminals.inputChecked(id, "\x1b")) throw new ControlError("SESSION_EXITED", "The terminal no longer accepts input."); @@ -653,7 +658,7 @@ export class AgentControlGateway { // Only Codex's composer is recognised; for other providers the lifecycle // status is the sole readiness gate and the screen the only evidence. if (!["idle", "unavailable"].includes(fresh.status)) throw new ControlError("NOT_READY", "The session is not idle; inspect screen and wait for the current activity to finish."); - if (capabilities.menus && !codexComposerReady(screen)) throw new ControlError("NOT_READY", "Codex is not at an empty task composer; inspect screen and resolve startup or approvals without changing its sandbox."); + if (capabilities.menus && !composerReady) throw new ControlError("NOT_READY", "Codex is not at an empty task composer; inspect screen and resolve startup or approvals without changing its sandbox."); const previousTurn = owned.turn; owned.turn = { id: request.id, state: "queued", sawWorking: false, nativeTurnId: null, interruptRequested: false, result: null }; if (!this.options.terminals.inputChecked(id, `\x1b[200~${text}\x1b[201~\r`)) { @@ -772,11 +777,6 @@ export class AgentControlGateway { } } -export function codexComposerReady(screen: string): boolean { - if (/Do you trust the contents|Press enter to confirm|Update Model Permissions|model:\s+loading|MCP startup/i.test(screen)) return false; - return screen.split("\n").some((line) => /^\s*›\s*(?:Ask Codex to do anything)?\s*$/.test(line)); -} - function codexChoices(screen: string): { revision: string; selected: number; options: Array<{ number: number; label: string }> } | null { const matches = screen.split("\n").map((line) => line.match(/^\s*(›\s*)?(\d+)\.\s+(.+)$/)).filter((m) => m !== null); if (matches.length < 2 || matches.length > 12 || matches.filter((m) => m[1]).length !== 1) return null; diff --git a/src/main/services/codexInput.ts b/src/main/services/codexInput.ts new file mode 100644 index 000000000..76b8b9ad3 --- /dev/null +++ b/src/main/services/codexInput.ts @@ -0,0 +1,47 @@ +// Every nonempty suggestion needs native placeholder styling, including exact matches. +const EMPTY_COMPOSER_PLACEHOLDERS = new Set([ + "", "Ask Codex to do anything", "Ask a follow-up question", + "Find and fix a bug in @filename", "Implement {feature}", "Summarize recent commits", + "Explain this codebase", "Write tests for @filename", "Improve documentation in @filename", + "Use /skills to list available skills", "Run /review on my current changes", + "Check recently modified functions for compatibility", "How many files have been modified?", + "Will this algorithm scale well?", +]); + +/** The Codex task composer, shared by automated delivery and the native control API. */ +export function codexComposerReady(screen: string, dimPlaceholder = false): boolean { + // A completed transcript can quote any startup text; only the final composer and its live footer gate input. + const lines = screen.split("\n"); + let index = lines.length - 1; + while (index >= 0 && !/^\s*›/.test(lines[index])) index--; + if (index < 0) return false; + const prompt = lines[index].match(/^\s*›\s*(.*?)\s*$/)!; + if (/Do you trust the contents|Press enter to confirm|Update Model Permissions|model:\s+loading|(?:Starting|Booting)\s+MCP\b/i.test(lines.slice(index).join("\n"))) return false; + if (EMPTY_COMPOSER_PLACEHOLDERS.has(prompt[1])) return prompt[1].length === 0 || dimPlaceholder; + return dimPlaceholder && prompt[1].length > 0 + && [...EMPTY_COMPOSER_PLACEHOLDERS].some((placeholder) => placeholder.startsWith(prompt[1])); +} + +/** Explicit paste boundaries keep Codex's paste-burst detector from swallowing the submit key. */ +export function codexPromptInput(text: string, submit = true): string { + return `\x1b[200~${text}\x1b[201~${submit ? "\r" : ""}`; +} + +/** Codex draws placeholders dim; never accept a clipped human draft as a suggestion. */ +export function codexPlaceholderIsDim(terminal: import("@xterm/headless").Terminal): boolean { + const buffer = terminal.buffer.active; + for (let row = buffer.baseY + terminal.rows - 1; row >= buffer.baseY; row--) { + const line = buffer.getLine(row); + const match = line?.translateToString(true).match(/^\s*›\s*/); + if (!line || !match) continue; + let sawText = false; + for (let col = match[0].length; col < line.length; col++) { + const cell = line.getCell(col); + if (!cell?.getChars().replace(/[\u2800-\u28ff]/g, "").trim()) continue; + if (!cell.isDim()) return false; + sawText = true; + } + return sawText; + } + return false; +} diff --git a/src/main/services/materials/HandoffService.ts b/src/main/services/materials/HandoffService.ts index 85abce3fe..6f446939e 100644 --- a/src/main/services/materials/HandoffService.ts +++ b/src/main/services/materials/HandoffService.ts @@ -23,7 +23,7 @@ import type { import { IPC } from "../../../shared/contracts.ts"; import { extensionForMime, materialType, HANDOFF_NOTE_LIMIT, handoffBlockFor } from "../../../shared/materials.ts"; import { terminalFileQuotePath } from "../../../shared/terminalFileDrop.ts"; -import { codexComposerReady } from "../agent-control/AgentControlGateway.ts"; +import { codexComposerReady, codexPlaceholderIsDim } from "../codexInput.ts"; import { handoffPointerText, handoffText, @@ -37,7 +37,7 @@ import { import { anchorRect, cropRect, type PixelRect } from "./imageRegions.ts"; import { HANDOFF_REMARK_LIMIT, isId, MAX_SESSION_ID } from "./materialState.ts"; import type { MaterialService, MaterialVersionFile } from "./MaterialService.ts"; -import { imageMarkers, pasteMarkers, SessionScreen, withoutBraille, type ScreenPort } from "./sessionScreen.ts"; +import { imageMarkers, pasteMarkers, SessionScreen, terminalScreenText, withoutBraille, type ScreenPort } from "./sessionScreen.ts"; const HANDOFF_FOLDER_LIMIT = 50; const HANDOFF_PACKAGE_LIMIT = 512 * 1024 * 1024; @@ -300,7 +300,8 @@ export class HandoffService { private async composerBlock(plan: HandoffPlan, screen: SessionScreen | null): Promise { if (!screen) return null; - if (!composerReady(plan.session.provider, await screen.text())) return "composer-not-ready"; + if (!await screen.inspect((terminal) => composerReady(plan.session.provider, + terminalScreenText(terminal, true), codexPlaceholderIsDim(terminal)))) return "composer-not-ready"; if (plan.session.provider === "claude" && await screen.typedAfter("❯")) return "composer-not-ready"; return null; } @@ -687,8 +688,8 @@ function occurrences(text: string, part: string): number { return count; } -function composerReady(provider: SessionMetadata["provider"], screen: string): boolean { - if (provider === "codex") return codexComposerReady(withoutBraille(screen)); +function composerReady(provider: SessionMetadata["provider"], screen: string, dimPlaceholder: boolean): boolean { + if (provider === "codex") return codexComposerReady(withoutBraille(screen), dimPlaceholder); if (provider !== "claude") return true; const prompt = screen.split("\n").filter((line) => /^\s*❯/.test(line)).at(-1); return prompt !== undefined && !/\[(?:Pasted text|Image) #\d+/.test(prompt); diff --git a/src/main/services/materials/sessionScreen.ts b/src/main/services/materials/sessionScreen.ts index d4e96759d..e39b33f9f 100644 --- a/src/main/services/materials/sessionScreen.ts +++ b/src/main/services/materials/sessionScreen.ts @@ -45,15 +45,15 @@ export class SessionScreen { })); } + async inspect(read: (terminal: import("@xterm/headless").Terminal) => T): Promise { + await this.ready; + return this.disposed ? null : read(this.terminal); + } + async text(): Promise { await this.ready; if (this.disposed) return ""; - const buffer = this.terminal.buffer.active; - const lines: string[] = []; - for (let row = buffer.baseY; row < buffer.baseY + this.terminal.rows; row += 1) { - lines.push(buffer.getLine(row)?.translateToString(true) ?? ""); - } - return lines.join("\n"); + return terminalScreenText(this.terminal); } async typedAfter(marker: string): Promise { @@ -112,3 +112,16 @@ export function imageMarkers(screen: string): number { export function pasteMarkers(screen: string): number { return (screen.match(/\[Pasted (?:text #\d+[^\]]*|Content[^\]]*)\]/g) ?? []).length; } + +/** Visible screen rows, optionally reconstructing xterm's soft-wrapped logical lines. */ +export function terminalScreenText(terminal: import("@xterm/headless").Terminal, unwrap = false): string { + const buffer = terminal.buffer.active; + const lines: string[] = []; + for (let row = buffer.baseY; row < buffer.baseY + terminal.rows; row++) { + const line = buffer.getLine(row); + const text = line?.translateToString(!unwrap) ?? ""; + if (unwrap && line?.isWrapped && lines.length) lines[lines.length - 1] += text; + else lines.push(text); + } + return (unwrap ? lines.map((line) => line.trimEnd()) : lines).join("\n"); +} diff --git a/tests/agent-control.test.mjs b/tests/agent-control.test.mjs index 58ad664bc..7999be1ce 100644 --- a/tests/agent-control.test.mjs +++ b/tests/agent-control.test.mjs @@ -30,7 +30,7 @@ import { import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; const localSocket = { skip: process.platform === "win32" ? "Unix socket tests; native Windows pipe relay has its own suite." : false }; -const PROMPT = "\x1b[2J\x1b[H>_ OpenAI Codex\r\nmodel: test\r\npermissions: YOLO mode\r\n\r\n› Ask Codex to do anything"; +const PROMPT = "\x1b[2J\x1b[H>_ OpenAI Codex\r\nmodel: test\r\npermissions: YOLO mode\r\n\r\n› \x1b[2mAsk Codex to do anything\x1b[22m"; function registry() { return { get: (provider) => ({ state: "available", provider, executable: "/resolved/codex", launcher: "native", @@ -73,7 +73,7 @@ async function fixture(t, gatewayOptions = {}) { const ready = async (id, pty = calls.at(-1).pty) => { pty.data(PROMPT); for (let i = 0; i < 30; i++) { - if (codexComposerReady((await request("screen", { sessionId: id })).text)) return; + if (codexComposerReady((await request("screen", { sessionId: id })).text, true)) return; await delay(5); } assert.fail("fixture composer not ready"); @@ -866,3 +866,44 @@ test("the control CLI reads a connection's scope as the person's only when there await chmod(path, 0o600); } }); + +test("native control never sends into a draft after a historical suggestion", localSocket, async (t) => { + const f = await fixture(t); + const { session } = await f.create(); + f.calls.at(-1).pty.data(PROMPT + "\r\n• reply\r\n\r\n› unfinished personal draft"); + for (let i = 0; i < 30; i++) { + if ((await f.request("screen", { sessionId: session.id })).text.includes("unfinished personal draft")) break; + await delay(5); + } + await assert.rejects(f.request("send", { sessionId: session.id, text: "task" }), (e) => e.code === "NOT_READY"); + assert.deepEqual(f.calls.at(-1).pty.writes, []); +}); + +test("native control handles soft-wrapped and dim clipped placeholders", localSocket, async (t) => { + const f = await fixture(t); + for (const clipped of [false, true]) { + const { session } = await f.create(); + f.terminals.resize(session.id, 20, 24); + const suggestion = "Check recently modified functions for compatibility"; + f.calls.at(-1).pty.data(`\x1b[2J\x1b[H› ${"\x1b[2m" + (clipped ? suggestion.slice(0, 18) : suggestion)}\x1b[0m\r\n`); + for (let i = 0; i < 30; i++) { + if ((await f.request("screen", { sessionId: session.id })).text.includes("Check recently")) break; + await delay(5); + } + await f.request("send", { sessionId: session.id, text: "task" }); + assert.deepEqual(f.calls.at(-1).pty.writes, ["\x1b[200~task\x1b[201~\r"]); + } +}); + + +test("native control refuses an unstyled draft even when it equals a built-in suggestion",localSocket,async t=>{ + const f=await fixture(t),{session}=await f.create(); + await f.ready(session.id); + f.calls[0].pty.data("\x1b[2J\x1b[H› Explain this codebase\r\n"); + for(let i=0;i<30;i++) { + if((await f.request("screen",{sessionId:session.id})).text.includes("Explain this codebase"))break; + await delay(5); + } + await assert.rejects(f.request("send",{sessionId:session.id,text:"automated task"}),e=>e.code === "NOT_READY"); + assert.deepEqual(f.calls[0].pty.writes,[]); +}); diff --git a/tests/codex-prompt-delivery.test.mjs b/tests/codex-prompt-delivery.test.mjs new file mode 100644 index 000000000..34eb3c7cf --- /dev/null +++ b/tests/codex-prompt-delivery.test.mjs @@ -0,0 +1,255 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { codexComposerReady, codexPromptInput } from "../src/main/services/codexInput.ts"; +import { AgentControlService } from "../src/main/services/AgentControlService.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +const READY = "\x1b[2J\x1b[H>_ OpenAI Codex\r\n\r\n› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"; + +function fixture(t, autoReady = true) { + const writes = []; + const calls = []; + const spawn = fakeSpawner(calls, { onWrite: (text) => writes.push(text) }); + const terminals = new TerminalManager(() => {}, availableRegistry(), undefined, undefined, true, + (...args) => { + const process = spawn(...args); + if (autoReady) queueMicrotask(() => process.emitData(READY)); + return process; + }); + t.after(() => terminals.disposeAll()); + const parent = terminals.create({ provider: "codex", cwd: process.cwd(), profile: "normal", position: { x: 0, y: 0 } }); + return { writes, calls, terminals, parent, control: new AgentControlService(terminals) }; +} + +test("Codex spawn sends the initial multiline prompt as an explicit paste followed by Enter", async (t) => { + const f = fixture(t); + const prompt = "Прочитай структуру проекта.\nНичего не изменяй."; + await f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan", initialPrompt: prompt }); + assert.deepEqual(f.writes, [`\x1b[200~${prompt}\x1b[201~\r`]); +}); + +test("Codex follow-up submits after paste but submit=false only pastes", async (t) => { + const f = fixture(t); + const child = await f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan" }); + await f.control.send(child.id, "Первая строка\nВторая строка"); + await f.control.send(child.id, "Только вставить", false); + assert.deepEqual(f.writes, ["\x1b[200~Первая строка\nВторая строка\x1b[201~\r", "\x1b[200~Только вставить\x1b[201~"]); +}); + +test("other providers retain their existing delivery bytes", async (t) => { + const f = fixture(t); + const child = await f.control.spawn({ parentSessionId: f.parent.id, provider: "qwen", cwd: process.cwd(), profile: "normal", initialPrompt: "first" }); + await f.control.send(child.id, "second"); + await f.control.send(child.id, "draft", false); + assert.deepEqual(f.writes, ["first\r", "second\r", "draft"]); +}); + +test("a newly spawned Codex receives no prompt before its task composer is ready", async (t) => { + const f = fixture(t, false); + const result = f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan", initialPrompt: "DIAGNOSTIC_OK" }); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.deepEqual(f.writes, [], "a running PTY is not a ready Codex composer"); + f.calls.at(-1).process.emitData(READY); + await result; + assert.deepEqual(f.writes, ["\x1b[200~DIAGNOSTIC_OK\x1b[201~\r"]); +}); + +test("a restarted Codex cannot become ready from the previous launch's screen", async (t) => { + const f = fixture(t, false); + const child = await f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan" }); + f.calls.at(-1).process.emitData(READY); + await f.control.send(child.id, "first"); + f.calls.at(-1).process.emitExit(0); + f.terminals.restart(child.id); + f.writes.length = 0; + const sent = f.control.send(child.id, "second"); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.deepEqual(f.writes, [], "old scrollback is not readiness for the new process"); + f.calls.at(-1).process.emitData(READY); + await sent; + assert.deepEqual(f.writes, ["\x1b[200~second\x1b[201~\r"]); +}); + +test("startup readiness times out without writing any input", async (t) => { + const f = fixture(t, false); + const child = await f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan" }); + const delivery = await f.terminals.deliverInput(child.id, "must not reach a startup menu\r", 30, undefined, true); + assert.equal(delivery.delivered, false); + assert.match(delivery.reason, /did not become ready/); + assert.deepEqual(f.writes, []); +}); + +test("readiness is retained within a launch so submit=false drafts can still be extended", async (t) => { + const f = fixture(t); + const child = await f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan" }); + await f.control.send(child.id, "draft", false); + f.calls.at(-1).process.emitData("\x1b[2J\x1b[H› draft\r\n"); + await f.control.send(child.id, " continuation", false); + assert.deepEqual(f.writes, ["\x1b[200~draft\x1b[201~", "\x1b[200~ continuation\x1b[201~"]); +}); + + +test("startup menus are never treated as the task composer", async (t) => { + const f = fixture(t, false); + const child = await f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan" }); + f.calls.at(-1).process.emitData(`${READY}Press enter to confirm trust\r\n`); + const delivery = await f.terminals.deliverInput(child.id, "task\r", 30, undefined, true); + assert.equal(delivery.delivered, false); + assert.deepEqual(f.writes, []); +}); + +test("cancelled input stays discarded when the Codex composer appears later", async (t) => { + const f = fixture(t, false); + const child = await f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan" }); + const controller = new AbortController(); + const sent = f.control.send(child.id, "cancelled task", true, controller.signal); + controller.abort(); + await assert.rejects(sent, /cancelled/); + f.calls.at(-1).process.emitData(READY); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.deepEqual(f.writes, []); +}); + +test("input waiting for a Codex composer never reaches a restarted process", async (t) => { + const f = fixture(t, false); + const child = await f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan" }); + const sent = f.control.send(child.id, "obsolete task"); + const rejected = assert.rejects(sent, /restarted/); + f.calls.at(-1).process.emitExit(0); + f.terminals.restart(child.id); + f.calls.at(-1).process.emitData(READY); + await rejected; + assert.deepEqual(f.writes, []); +}); + +const EMPTY_PLACEHOLDERS = [ + "Ask Codex to do anything", "Ask a follow-up question", "Find and fix a bug in @filename", + "Implement {feature}", "Summarize recent commits", "Explain this codebase", + "Write tests for @filename", "Improve documentation in @filename", + "Use /skills to list available skills", "Run /review on my current changes", + "Check recently modified functions for compatibility", "How many files have been modified?", + "Will this algorithm scale well?", +]; + +test("all built-in empty composer placeholders release queued Codex input", async (t) => { + for (const placeholder of ["", ...EMPTY_PLACEHOLDERS]) { + await t.test(placeholder || "blank composer", async (t) => { + const f = fixture(t, false); + const child = await f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan" }); + f.calls.at(-1).process.emitData(READY.replace("Ask Codex to do anything", placeholder)); + const input = codexPromptInput("fixture task"); + const delivery = await f.terminals.deliverInput(child.id, input, 200, undefined, true); + assert.equal(delivery.delivered, true, delivery.reason); + assert.deepEqual(f.writes, [input]); + }); + } +}); + +test("placeholder prefixes do not make human drafts or startup gates ready", () => { + for (const text of ["Implement a custom task", "Find and fix something else", + "Write tests for my changes", "Summarize recent commits and then edit a file", + "Ask Codex to do anything extra", "1. Implement {feature}"]) { + assert.equal(codexComposerReady(`› ${text}`), false, text); + } + for (const placeholder of EMPTY_PLACEHOLDERS) { + assert.equal(codexComposerReady(`Press enter to confirm\n› ${placeholder}`), false); + assert.equal(codexComposerReady(`model: loading\n› ${placeholder}`), false); + } +}); + +test("active MCP startup holds queued input but a final incomplete warning does not", async (t) => { + for (const startup of ["Starting MCP servers (0/3)", "Booting MCP servers (1/2)"]) { + await t.test(startup, async (t) => { + const f = fixture(t, false); + const child = await f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan" }); + f.calls.at(-1).process.emitData(READY + `• ${startup}\r\n`); + const result = await f.terminals.deliverInput(child.id, "must wait\r", 100, undefined, true); + assert.equal(result.delivered, false); + assert.deepEqual(f.writes, []); + f.calls.at(-1).process.emitData(READY + "⚠ MCP startup incomplete (1 failed)\r\n"); + const resumed = await f.terminals.deliverInput(child.id, "task\r", 200, undefined, true); + assert.equal(resumed.delivered, true, resumed.reason); + assert.deepEqual(f.writes, ["task\r"]); + }); + } +}); + +test("transcript suggestions never make a person's current draft ready", async (t) => { + assert.equal(codexComposerReady("› Explain this codebase\n\n• reply\n\n› unfinished personal draft"), false); + const f = fixture(t, false); + const child = await f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan" }); + f.calls.at(-1).process.emitData(READY + "\r\n• reply\r\n\r\n› unfinished personal draft"); + const result = await f.terminals.deliverInput(child.id, "must not append\r", 100, undefined, true); + assert.equal(result.delivered, false); + assert.deepEqual(f.writes, []); +}); + +test("narrow terminals accept wrapped and dim clipped placeholders without accepting clipped drafts", async (t) => { + for (const mode of ["wrapped", "clipped", "draft"]) { + await t.test(mode, async (t) => { + const f = fixture(t, false); + const child = await f.control.spawn({ parentSessionId: f.parent.id, provider: "codex", cwd: process.cwd(), profile: "plan" }); + f.terminals.resize(child.id, 20, 24); + const placeholder = "Check recently modified functions for compatibility"; + const text = mode === "wrapped" ? placeholder : placeholder.slice(0, 18); + f.calls.at(-1).process.emitData(`\x1b[2J\x1b[H› ${mode !== "draft" ? "\x1b[2m" : ""}${text}\x1b[0m\r\n`); + const result = await f.terminals.deliverInput(child.id, "task\r", 150, undefined, true); + assert.equal(result.delivered, mode !== "draft", result.reason); + assert.deepEqual(f.writes, mode === "draft" ? [] : ["task\r"]); + }); + } +}); + +test("every submitted task rechecks the active composer; later drafts and menus receive no automated input", async (t) => { + for (const text of ["› unfinished personal draft", "Press enter to confirm trust"]) { + const f = fixture(t); + const child = await f.control.spawn({parentSessionId:f.parent.id,provider:"codex",cwd:process.cwd(),profile:"plan"}); + await f.control.send(child.id,"first"); + f.calls.at(-1).process.emitData("\x1b[2J\x1b[H"+text+"\r\n"); + const result = await f.terminals.deliverInput(child.id,"second\r",30,undefined,true); + assert.equal(result.delivered,false); + assert.equal(f.writes.length,1,"a completed earlier turn is no proof of current readiness"); + f.calls.at(-1).process.emitData(READY); + assert.equal((await f.terminals.deliverInput(child.id,"second\r",1000,undefined,true)).delivered,true); + } +}); + +test("person input revokes automation ownership of a paste-only draft", async (t) => { + const f = fixture(t); + const child = await f.control.spawn({parentSessionId:f.parent.id,provider:"codex",cwd:process.cwd(),profile:"plan"}); + await f.control.send(child.id,"draft",false); + f.terminals.input(child.id,"person edits"); + f.calls.at(-1).process.emitData("\x1b[2J\x1b[H› draft person edits\r\n"); + const count=f.writes.length; + assert.equal((await f.terminals.deliverInput(child.id,"must not append\r",30,undefined,true)).delivered,false); + assert.equal(f.writes.length,count); +}); + + +test("unstyled text equal to a built-in suggestion never releases automated input", async t=>{ + for(const placeholder of EMPTY_PLACEHOLDERS) { + assert.equal(codexComposerReady(`› ${placeholder}`,false),false,placeholder); + assert.equal(codexComposerReady(`› ${placeholder}`,true),true,placeholder); + } + assert.equal(codexComposerReady("›",false),true,"a genuinely blank composer needs no placeholder styling"); + const f=fixture(t,false); + const child=await f.control.spawn({parentSessionId:f.parent.id,provider:"codex",cwd:process.cwd(),profile:"plan"}); + f.calls.at(-1).process.emitData("\x1b[2J\x1b[H› Explain this codebase\r\n"); + const result=await f.terminals.deliverInput(child.id,"automated task\r",100,undefined,true); + assert.equal(result.delivered,false);assert.deepEqual(f.writes,[]); +}); + + +test("startup phrases in completed transcript do not block the final ready composer", async (t) => { + const phrases = ["Starting MCP servers", "Booting MCP servers", "Do you trust the contents", "Press enter to confirm", "Update Model Permissions", "model: loading"]; + for (const phrase of phrases) { + assert.equal(codexComposerReady(`› explain ${phrase}?\n• prior answer quotes ${phrase}.\n\n› Ask Codex to do anything`,true),true); + assert.equal(codexComposerReady(`› Ask Codex to do anything\n${phrase}`,true),false); + } + const f=fixture(t,false); + const child=await f.control.spawn({parentSessionId:f.parent.id,provider:"codex",cwd:process.cwd(),profile:"plan"}); + f.calls.at(-1).process.emitData("\x1b[2J\x1b[H› explain Starting MCP servers?\r\n• prior answer\r\n\r\n› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"); + const result=await f.terminals.deliverInput(child.id,"follow-up\r",300,undefined,true); + assert.equal(result.delivered,true,result.reason); +}); diff --git a/tests/launch-readiness.test.mjs b/tests/launch-readiness.test.mjs index b1aa64656..47768047e 100644 --- a/tests/launch-readiness.test.mjs +++ b/tests/launch-readiness.test.mjs @@ -31,14 +31,18 @@ function clis() { }; } -function spawner(calls) { +function spawner(calls, readiness) { return (command, args, options) => { const exits = []; const call = { command, args, options, written: [], exit: (code) => exits.forEach((listener) => listener({ exitCode: code })) }; calls.push(call); return { pid: 60_000 + calls.length, process: command, write(text) { call.written.push(text); }, resize() {}, kill() {}, pause() {}, resume() {}, - onData() { return { dispose() {} }; }, + onData(listener) { + call.data = listener; + if (command === "/resolved/codex" && readiness.auto) queueMicrotask(() => listener("\x1b[2J\x1b[H>_ OpenAI Codex\r\n\r\n› \x1b[2mAsk Codex to do anything\x1b[22m\r\n")); + return { dispose() {} }; + }, onExit(listener) { exits.push(listener); return { dispose() {} }; } }; }; @@ -64,8 +68,9 @@ async function fixture(t) { timeoutMs: 5_000 }); const calls = []; + const readiness = { auto: true }; let sessions; - const terminals = new TerminalManager((channel, payload) => sessions?.observe(channel, payload), clis(), undefined, undefined, true, spawner(calls)); + const terminals = new TerminalManager((channel, payload) => sessions?.observe(channel, payload), clis(), undefined, undefined, true, spawner(calls, readiness)); t.after(() => terminals.shutdown()); terminals.configureLaunchPipeline(pipeline); const control = new AgentControlService(terminals); @@ -81,7 +86,7 @@ async function fixture(t) { return next; }; return { - terminals, control, handler, calls, pending, orchestrator, execute, answerNext, + terminals, control, handler, calls, pending, orchestrator, execute, answerNext, readiness, attachSessions: (value) => { sessions = value; } }; } @@ -130,7 +135,7 @@ test("a launch policy alone (no launch options) also delays delivery; send_to_ag assert.equal(policy.params.chosen, false); await spawned; assert.deepEqual(await sent, { sessionId: childId, sent: true }); - assert.deepEqual(f.calls[1].written, ["Review the diff\r", "and the tests\r"]); + assert.deepEqual(f.calls[1].written, ["\x1b[200~Review the diff\x1b[201~\r", "\x1b[200~and the tests\x1b[201~\r"]); }); test("a refused launch is not a delivery: spawn_agent and send_to_agent fail with the reason and nothing is written later", async (t) => { @@ -209,3 +214,41 @@ test("the control CLI says a starting session is not ready instead of claiming t assert.equal(f.calls.length, 2); assert.deepEqual(f.calls[1].written, [], "the refused send was not queued"); }); + + +test("a prompt queued during a prepared restart cannot use the previous Codex composer's readiness", async (t) => { + const f = await fixture(t); + const spawned = f.execute("spawn_agent", { provider: "codex", cwd, prompt: "first task" }); + await f.answerNext(null); + const child = await spawned; + f.calls.at(-1).exit(0); + f.readiness.auto = false; + f.terminals.restart(child.sessionId); + const sent = f.execute("send_to_agent", { sessionId: child.sessionId, prompt: "next task" }); + await f.answerNext(null); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.deepEqual(f.calls.at(-1).written, [], "a ready screen from the old process cannot release new input"); + f.calls.at(-1).data("\x1b[2J\x1b[H>_ OpenAI Codex\r\n\r\n› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"); + await sent; + assert.deepEqual(f.calls.at(-1).written, ["\x1b[200~next task\x1b[201~\r"]); +}); + + +test("plugin Codex sends wait beyond PTY creation and bracket multiline tasks and drafts", async (t) => { + const f=await fixture(t);f.readiness.auto=false; + const sessions=new PluginSessions({terminals:f.terminals,notify:()=>true});f.attachSessions(sessions); + const permissions=["sessions:launch","sessions:control"]; + const {sessionId}=sessions.handle("p.owner","svc","sessions.create",{provider:"codex",cwd},permissions); + const text="First line\nSecond line"; + const sending=sessions.handle("p.owner","svc","sessions.send",{sessionId,text},permissions); + await f.answerNext(null); + for(let i=0;i<50&&f.calls.length<2;i++)await tick(); + const call=f.calls[1];assert.ok(call); + call.data("Press enter to confirm trust\r\n"); + assert.equal(await state(sending),"pending");assert.deepEqual(call.written,[]); + call.data("\x1b[2J\x1b[H› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"); + assert.deepEqual(await sending,{sessionId,sent:true}); + assert.deepEqual(call.written,[`\x1b[200~${text}\x1b[201~\r`]); + await sessions.handle("p.owner","svc","sessions.send",{sessionId,text:"draft",submit:false},permissions); + assert.equal(call.written.at(-1),"\x1b[200~draft\x1b[201~"); +}); diff --git a/tests/material-handoff-service.test.mjs b/tests/material-handoff-service.test.mjs index 141e452a4..ff58275db 100644 --- a/tests/material-handoff-service.test.mjs +++ b/tests/material-handoff-service.test.mjs @@ -230,7 +230,7 @@ test("Codex needs an empty prompt; then each image goes in its own paste before assert.equal(terminal.writes.length, 0); assert.equal(materials.snapshot().handoffs.length, 0); - terminal.emit("c1", "\x1b[2J\x1b[H› Ask Codex to do anything\r\n"); + terminal.emit("c1", "\x1b[2J\x1b[H› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"); const result = await handoffs.send(draft("c1")); assert.equal(result.ok, true); assert.deepEqual(terminal.writes.map((write) => write === "\r" ? "enter" : IMAGE_LINE.test(codexToken(write.slice(START.length, -END.length)) ?? "") ? "image" : "text"), @@ -246,7 +246,7 @@ test("dots Codex animates around its prompt do not hide whether it is empty", as terminal.tui = codexTui(terminal); terminal.emit("c1", "\x1b[2J\x1b[H›⠁half-typed words ⠈\r\n"); assert.deepEqual(await handoffs.send(draft("c1")), { ok: false, reason: "composer-not-ready" }); - terminal.emit("c1", "\x1b[2J\x1b[H ⠁ ⠐\r\n›⠁Ask Codex to do anything ⠈ ⠄\r\n"); + terminal.emit("c1", "\x1b[2J\x1b[H ⠁ ⠐\r\n›⠁\x1b[2mAsk Codex to do anything\x1b[22m ⠈ ⠄\r\n"); assert.equal((await handoffs.send(draft("c1"))).ok, true); }); }); @@ -256,7 +256,7 @@ test("a Codex prompt drawn before the kept output was trimmed still counts as em terminal.add({ id: "c1", provider: "codex", cwd: "/work" }); terminal.tui = codexTui(terminal); terminal.setStatus("c1", "unavailable"); - terminal.emit("c1", "\x1b[2J\x1b[H› Ask Codex to do anything\r\n"); + terminal.emit("c1", "\x1b[2J\x1b[H› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"); const drawn = terminal.readBuffer("c1").outputOffset; for (let frame = 0; frame < 400; frame += 1) terminal.emit("c1", `\x1b[${3 + (frame % 5)};${10 + (frame % 60)}H${frame % 2 ? "⠁" : " "}`); const kept = terminal.readBuffer("c1"); @@ -682,7 +682,7 @@ test("a status change after pasting blocks Enter", async () => { test("a status change between Codex images stops delivery", async () => { await withHandoffs(async ({ handoffs, terminal, draft }) => { terminal.add({ id: "c1", provider: "codex", cwd: "/work" }); - terminal.emit("c1", "\x1b[2J\x1b[H› Ask Codex to do anything\r\n"); + terminal.emit("c1", "\x1b[2J\x1b[H› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"); const render = codexTui(terminal); terminal.tui = (id, data) => { render(id, data); @@ -704,7 +704,7 @@ test("a session restarted in the middle of a delivery gets nothing more, and the setTimeout(() => terminal.emit(id, `[Image #${++images}] `), 5); if (images === 0) terminal.sessions[0].startedAt = 200; }; - terminal.emit("c1", "\x1b[2J\x1b[H› Ask Codex to do anything\r\n"); + terminal.emit("c1", "\x1b[2J\x1b[H› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"); const result = await handoffs.send(draft("c1")); assert.deepEqual({ state: result.handoff.delivery.state, error: result.handoff.delivery.error }, { state: "failed", error: "The session restarted or exited during delivery." }); @@ -722,7 +722,7 @@ test("Codex text counts as shown only when more of it appears than before the pa const inner = data.startsWith(START) ? data.slice(START.length, -END.length) : ""; if (IMAGE_LINE.test(codexToken(inner) ?? "")) setTimeout(() => terminal.emit(id, `[Image #${++images}] `), 5); }; - terminal.emit("c1", `\x1b[2J\x1b[H${preview.split("\n").at(-1)}\r\n› Ask Codex to do anything\r\n`); + terminal.emit("c1", `\x1b[2J\x1b[H${preview.split("\n").at(-1)}\r\n› \x1b[2mAsk Codex to do anything\x1b[22m\r\n`); const result = await handoffs.send(request); assert.deepEqual({ state: result.handoff.delivery.state, note: result.handoff.delivery.note }, { state: "pasted", note: "not-seen" }); assert.equal(terminal.writes.includes("\r"), false); @@ -775,7 +775,7 @@ test("sessions that were running before the service started are mirrored from wh await withHandoffs(async ({ materials, terminal, draft }) => { terminal.add({ id: "c1", provider: "codex", cwd: "/work" }); terminal.tui = codexTui(terminal); - terminal.emit("c1", "\x1b[2J\x1b[H› Ask Codex to do anything\r\n"); + terminal.emit("c1", "\x1b[2J\x1b[H› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"); const drawn = terminal.readBuffer("c1").outputOffset; const restored = new HandoffService({ materials, @@ -847,7 +847,7 @@ test("Codex text that never lands is not submitted even though its images did", const inner = data.startsWith(START) ? data.slice(START.length, -END.length) : ""; if (IMAGE_LINE.test(codexToken(inner) ?? "")) setTimeout(() => terminal.emit(id, `[Image #${++images}] `), 15); }; - terminal.emit("c1", "\x1b[2J\x1b[H› Ask Codex to do anything\r\n"); + terminal.emit("c1", "\x1b[2J\x1b[H› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"); const result = await handoffs.send(draft("c1")); assert.equal(result.handoff.delivery.state, "pasted"); assert.equal(terminal.writes.includes("\r"), false); @@ -883,7 +883,7 @@ test("Codex gets image paths it can read as one path even with spaces, and text const lines = inner.split("\n"); setTimeout(() => terminal.emit(id, `\x1b[2J\x1b[H› ${lines.slice(-3).join("\r\n ")}\r\n`), 15); }; - terminal.emit("c1", "\x1b[2J\x1b[H› Ask Codex to do anything\r\n"); + terminal.emit("c1", "\x1b[2J\x1b[H› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"); const result = await handoffs.send(draft("c1")); assert.ok(result.handoff.folder.includes(" ")); assert.deepEqual({ state: result.handoff.delivery.state, attached: result.handoff.delivery.imagesAttached }, { state: "submitted", attached: 2 }); @@ -979,3 +979,39 @@ test("a delivered handoff says when the outcome could not be saved", async () => await rm(blocked, { recursive: true }); }); }); + +test("material handoff preserves unstyled drafts that equal a Codex suggestion", async()=>{ + await withHandoffs(async ({handoffs,terminal,draft})=>{ + terminal.add({id:"c1",provider:"codex",cwd:"/work"}); + terminal.tui=codexTui(terminal); + for(const suggestion of ["Ask Codex to do anything","Explain this codebase"]) { + terminal.emit("c1",`\x1b[0m\x1b[2J\x1b[H› ${suggestion}\r\n`); + assert.deepEqual(await handoffs.send(draft("c1")),{ok:false,reason:"composer-not-ready"}); + } + assert.deepEqual(terminal.writes,[]); + }); +}); + + +for (const footer of ["Press enter to confirm trust", "Press enter to confirm permissions"]) { + test(`narrow Codex material handoffs refuse a wrapped live confirmation: ${footer}`, async () => { + await withHandoffs(async ({ handoffs, terminal, draft }) => { + terminal.geometry = () => ({ cols: 20, rows: 30 }); + terminal.add({ id: "c1", provider: "codex", cwd: "/work" }); + terminal.emit("c1", `› \x1b[2mAsk Codex to do anything\x1b[22m\r\n${footer}\r\n`); + assert.deepEqual(await handoffs.send(draft("c1")), { ok: false, reason: "composer-not-ready" }); + assert.deepEqual(terminal.writes, []); + }); + }); +} + +test("a narrow ordinary Codex composer still receives a material handoff", async () => { + await withHandoffs(async ({ handoffs, terminal, draft }) => { + terminal.geometry = () => ({ cols: 20, rows: 30 }); + terminal.add({ id: "c1", provider: "codex", cwd: "/work" }); + terminal.tui = codexTui(terminal); + terminal.emit("c1", "› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"); + assert.equal((await handoffs.send(draft("c1"))).ok, true); + assert.ok(terminal.writes.length > 0); + }); +}); diff --git a/tests/orchestration-gateway.test.mjs b/tests/orchestration-gateway.test.mjs index e2f0a524a..82d2c82c0 100644 --- a/tests/orchestration-gateway.test.mjs +++ b/tests/orchestration-gateway.test.mjs @@ -65,7 +65,7 @@ class TestClient { } -async function fixture(t) { +async function fixture(t, gatewayOptions = {}) { const directory = await mkdtemp(join(tmpdir(), "canvastty-orchestration-")); t.after(() => rm(directory, { recursive: true, force: true })); const calls = []; @@ -74,7 +74,8 @@ async function fixture(t) { const gateway = new OrchestrationGateway({ runtimeDirectory: join(directory, "runtime"), handler: new ScopedOrchestrationHandler(control), - windowsHostPath: WINDOWS_PIPE_HOST + windowsHostPath: WINDOWS_PIPE_HOST, + ...gatewayOptions }); await gateway.start(); t.after(() => gateway.stop()); @@ -263,6 +264,54 @@ test("unauthenticated commands and replayed bootstrap tokens are rejected", asyn terminals.disposeAll(); }); +test("additional helpers join only the same live launch and preserve its reconnect capability", async (t) => { + let now = Date.now(); + const { gateway } = await fixture(t, { now: () => now, capabilityTtlMs: 10 }); + const capability = gateway.registerOrchestrator({ terminalSessionId: "parent" }); + const first = await authenticatedClient(gateway, capability); + t.after(() => first.client.socket.destroy()); + now += 1_000; // Beyond bootstrap TTL, within the live connection's heartbeat window. + const second = await authenticatedClient(gateway, capability); + t.after(() => second.client.socket.destroy()); + assert.equal(second.reconnectToken, first.reconnectToken); + for (const [identity, token] of [ + [{ ...capability, terminalSessionId: "foreign-parent" }, capability.capabilityToken], + [{ ...capability, connectionId: "foreign-launch" }, capability.capabilityToken], + [capability, "invalid-capability"] + ]) { + const rejected = await TestClient.connectTo(gateway.address); + assert.equal((await authExpectingFailure(rejected, identity, token)).error.code, "AUTH_INVALID"); + rejected.socket.destroy(); + } + // An earlier helper still reconnects after a later helper joined. + const rejoined = await authenticatedClient(gateway, { ...capability, capabilityToken: first.reconnectToken }); + t.after(() => rejoined.client.socket.destroy()); + assert.equal(rejoined.reconnectToken, first.reconnectToken); + const closed = [first, second, rejoined].map(({ client }) => new Promise(resolve => client.socket.once("close", resolve))); + gateway.revokeTerminalSession(capability.terminalSessionId); + await Promise.all(closed); + for (const token of [capability.capabilityToken, first.reconnectToken]) { + const revoked = await TestClient.connectTo(gateway.address); + assert.equal((await authExpectingFailure(revoked, capability, token)).error.code, "AUTH_INVALID"); + revoked.socket.destroy(); + } +}); + +test("bootstrap cannot authenticate an expired launch or join a stale connection", async (t) => { + let now = Date.now(); + const { gateway } = await fixture(t, { now: () => now, heartbeatIntervalMs: 60_000 }); + const live = gateway.registerOrchestrator({ terminalSessionId: "live-parent" }); + const first = await authenticatedClient(gateway, live); + t.after(() => first.client.socket.destroy()); + const unused = gateway.registerOrchestrator({ terminalSessionId: "unused-parent" }); + now += 65_000; + for (const capability of [live, unused]) { + const rejected = await TestClient.connectTo(gateway.address); + assert.equal((await authExpectingFailure(rejected, capability, capability.capabilityToken)).error.code, "AUTH_INVALID"); + rejected.socket.destroy(); + } +}); + test("revoking the orchestrator session ends its connection and capability", async (t) => { const { terminals, gateway } = await fixture(t); const orchestrator = terminals.create({ diff --git a/tests/orchestration-helper-identity.test.mjs b/tests/orchestration-helper-identity.test.mjs index 755ac7197..2451f14f9 100644 --- a/tests/orchestration-helper-identity.test.mjs +++ b/tests/orchestration-helper-identity.test.mjs @@ -10,6 +10,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { AgentControlService } from "../src/main/services/AgentControlService.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { ScopedOrchestrationHandler } from "../src/main/services/agent-browser/OrchestrationTools.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; +import { IMPLEMENTATIONS, SKIP_NATIVE, baseEnvironment, startMcp, outputHasId } from "./native-helper-harness.mjs"; import { OrchestrationBridge } from "../src/main/services/agent-browser/OrchestrationBridge.ts"; import { OrchestrationGateway } from "../src/main/services/agent-browser/OrchestrationGateway.ts"; @@ -74,3 +79,63 @@ test("the orchestration helper authenticates with the card's own capability and assert.deepEqual(JSON.parse(listed.result.content[0].text), { agents: [] }); assert.deepEqual(calls, [{ sessionId: "orchestrator-1", tool: "list_agents" }]); }); + +// A CLI may start a fresh thread and a second MCP process without restarting its PTY. +for (const implementation of IMPLEMENTATIONS) { + test(`${implementation.name}: new threads in one live PTY discover tools and spawn scoped workers`, { + skip: implementation.name === "native" && SKIP_NATIVE, timeout: 15_000 + }, async (t) => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-orch-threads-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const writes = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, + fakeSpawner([], { onWrite: data => writes.push(data) })); + t.after(() => terminals.disposeAll()); + const parent = terminals.create({ provider: "codex", cwd: directory, profile: "yolo", + position: { x: 0, y: 0 }, role: "orchestrator" }); + let now = Date.now(); + const gateway = new OrchestrationGateway({ runtimeDirectory: join(directory, "runtime"), + handler: new ScopedOrchestrationHandler(new AgentControlService(terminals)), now: () => now, capabilityTtlMs: 10, + windowsHostPath: join(process.cwd(), "build", "windows-agent-pipe-host", "canvastty-windows-agent-pipe-host.exe") }); + await gateway.start(); + t.after(() => gateway.stop()); + const launch = new OrchestrationBridge(gateway).prepareLaunch({ terminalSessionId: parent.id }); + t.after(() => launch.cleanup()); + async function helper() { + const mcp = startMcp(implementation.orchestration, baseEnvironment(launch.environment)); + t.after(async () => { mcp.kill(); await mcp.exited; }); + return async (id, method, params) => { + mcp.send({ jsonrpc: "2.0", id, method, params }); + await mcp.until(outputHasId(id)); + return mcp.output.map(JSON.parse).find(item => item.id === id); + }; + } + const first = await helper(); + assert.equal((await first(1, "initialize", {})).error, undefined); + // The launch is already authenticated: its bootstrap TTL must not break a new thread. + now += 1_000; + const second = await helper(); + const initialized = await second(1, "initialize", {}); + assert.equal(initialized.error, undefined, JSON.stringify(initialized.error)); + const tools = await second(2, "tools/list", {}); + assert.ok(tools.result.tools.some(tool => tool.name === "spawn_agent")); + const children = []; + for (const [index, request] of [first, second].entries()) { + const prompt = `Complete independent task ${index}.`; + const reply = await request(3, "tools/call", { name: "spawn_agent", arguments: { + provider: "cursor", cwd: directory, prompt, profile: "normal" + } }); + assert.equal(reply.result.isError, false, reply.result.content?.[0]?.text); + const child = JSON.parse(reply.result.content[0].text); + children.push(child.sessionId); + assert.equal(terminals.list().find(session => session.id === child.sessionId).parentSessionId, parent.id); + assert.ok(writes.some(data => data.includes(prompt))); + } + assert.equal(new Set(children).size, 2); + launch.cleanup(); + for (const request of [first, second]) { + const reply = await request(4, "tools/call", { name: "list_agents", arguments: {} }); + assert.equal(reply.result.isError, true, "PTY cleanup must revoke every helper"); + } + }); +} diff --git a/tests/orchestration-launch.test.mjs b/tests/orchestration-launch.test.mjs index 22c5468c5..a37070984 100644 --- a/tests/orchestration-launch.test.mjs +++ b/tests/orchestration-launch.test.mjs @@ -83,3 +83,17 @@ test("the stdio helper advertises orchestration tools and forwards calls", async assert.equal(malformed.result.isError, true); assert.match(malformed.result.content[0].text, /INVALID_REQUEST.*Missing required argument: provider/u); }); + + +test("Codex orchestration is direct without broadening the allowlist or changing browser discovery", () => { + for (const tools of [["list_providers", "spawn_agent"], ["fixture-plugin__inspect"], []]) { + const args=codexMcpArgs(helper,orchestrationHelper,tools); + assert.match(args[3],/omit_tools_from=\["deferred"\]/u); + assert.ok(args[3].includes(`enabled_tools=${JSON.stringify(tools)}`)); + assert.match(args[3],/disabled_tools=\[\]/u); + assert.doesNotMatch(args[1],/omit_tools_from/u,"browser tools keep their existing exposure"); + } + const only=codexMcpArgs(null,orchestrationHelper); + assert.match(only[1],/omit_tools_from=\["deferred"\]/u); + assert.deepEqual(codexMcpArgs(null),[]); +});