From 2b6cce763aad1c4d178f58864d973717284484cd Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Mon, 29 Jun 2026 10:44:18 +0100 Subject: [PATCH 01/20] improve coverage of app server --- .github/workflows/agent-live-e2e.yml | 61 ++ packages/agent/e2e/README.md | 92 ++ packages/agent/e2e/config.ts | 103 ++ packages/agent/e2e/driver.ts | 302 ++++++ packages/agent/e2e/run-e2e.sh | 37 + .../agent/e2e/session-lifecycle.e2e.test.ts | 469 +++++++++ .../agent/e2e/structured-output.e2e.test.ts | 84 ++ packages/agent/package.json | 1 + packages/agent/parity/harness.ts | 157 +++ packages/agent/parity/run.ts | 235 +++++ .../agent/src/adapters/acp-connection.test.ts | 40 + packages/agent/src/adapters/acp-connection.ts | 34 +- .../app-server-client.test.ts | 26 +- .../codex-app-server/app-server-client.ts | 29 +- .../codex-app-server/approvals.test.ts | 257 +++++ .../adapters/codex-app-server/approvals.ts | 382 +++++++ .../codex-app-server-agent.test.ts | 905 ++++++++++++++++- .../codex-app-server-agent.ts | 933 +++++++++++++++++- .../ext-notifications.test.ts | 74 ++ .../codex-app-server/ext-notifications.ts | 119 +++ .../adapters/codex-app-server/input.test.ts | 112 +++ .../src/adapters/codex-app-server/input.ts | 113 +++ .../codex-app-server/local-tools-mcp.test.ts | 102 ++ .../codex-app-server/local-tools-mcp.ts | 119 +++ .../adapters/codex-app-server/mapping.test.ts | 555 ++++++++++- .../src/adapters/codex-app-server/mapping.ts | 527 +++++++++- .../codex-app-server/mcp-config.test.ts | 60 ++ .../adapters/codex-app-server/mcp-config.ts | 58 ++ .../src/adapters/codex-app-server/protocol.ts | 38 +- .../codex-app-server/session-config.test.ts | 89 ++ .../codex-app-server/session-config.ts | 111 +++ .../adapters/codex-app-server/spawn.test.ts | 8 +- .../src/adapters/codex-app-server/spawn.ts | 30 +- packages/agent/src/adapters/codex/spawn.ts | 8 + packages/agent/src/agent.ts | 1 + packages/agent/src/server/agent-server.ts | 5 + packages/agent/src/types.ts | 6 + packages/agent/vitest.e2e.config.ts | 24 + 38 files changed, 6208 insertions(+), 98 deletions(-) create mode 100644 .github/workflows/agent-live-e2e.yml create mode 100644 packages/agent/e2e/README.md create mode 100644 packages/agent/e2e/config.ts create mode 100644 packages/agent/e2e/driver.ts create mode 100755 packages/agent/e2e/run-e2e.sh create mode 100644 packages/agent/e2e/session-lifecycle.e2e.test.ts create mode 100644 packages/agent/e2e/structured-output.e2e.test.ts create mode 100644 packages/agent/parity/harness.ts create mode 100644 packages/agent/parity/run.ts create mode 100644 packages/agent/src/adapters/acp-connection.test.ts create mode 100644 packages/agent/src/adapters/codex-app-server/approvals.test.ts create mode 100644 packages/agent/src/adapters/codex-app-server/approvals.ts create mode 100644 packages/agent/src/adapters/codex-app-server/ext-notifications.test.ts create mode 100644 packages/agent/src/adapters/codex-app-server/ext-notifications.ts create mode 100644 packages/agent/src/adapters/codex-app-server/input.test.ts create mode 100644 packages/agent/src/adapters/codex-app-server/input.ts create mode 100644 packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts create mode 100644 packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts create mode 100644 packages/agent/src/adapters/codex-app-server/mcp-config.test.ts create mode 100644 packages/agent/src/adapters/codex-app-server/mcp-config.ts create mode 100644 packages/agent/src/adapters/codex-app-server/session-config.test.ts create mode 100644 packages/agent/src/adapters/codex-app-server/session-config.ts create mode 100644 packages/agent/vitest.e2e.config.ts diff --git a/.github/workflows/agent-live-e2e.yml b/.github/workflows/agent-live-e2e.yml new file mode 100644 index 0000000000..51f6985af1 --- /dev/null +++ b/.github/workflows/agent-live-e2e.yml @@ -0,0 +1,61 @@ +name: Agent live e2e + +# Opt-in, gated live e2e for the @posthog/agent adapters (claude + codex). It +# drives real model turns against a live llm-gateway on cheap models, so it is +# deliberately NOT run per-PR: only on manual dispatch and a nightly schedule, +# and only when the repo variable AGENT_E2E_ENABLED is "true". Without the +# E2E_GATEWAY_TOKEN secret the suite self-skips every arm, and without a +# reachable E2E_GATEWAY_URL the run fails loudly rather than reporting a false +# green. The codex arm additionally self-skips if the native codex binary is not +# present on the runner. Enabling this needs: AGENT_E2E_ENABLED=true, an +# E2E_GATEWAY_TOKEN secret (a gateway OAuth token with llm_gateway:read scope), +# and an E2E_GATEWAY_URL variable pointing at a gateway reachable from the runner. + +on: + workflow_dispatch: + schedule: + - cron: "0 7 * * *" # 07:00 UTC, once a day + +concurrency: + group: agent-live-e2e + cancel-in-progress: false + +jobs: + live-e2e: + name: Agent live e2e (claude + codex) + if: ${{ vars.AGENT_E2E_ENABLED == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + + - name: Set up Node 24 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build agent dependencies + run: | + pnpm --filter @posthog/shared run build + pnpm --filter @posthog/git run build + pnpm --filter @posthog/enricher run build + + - name: Run live e2e (both adapters) + run: pnpm --filter agent run test:e2e + env: + E2E_GATEWAY_TOKEN: ${{ secrets.E2E_GATEWAY_TOKEN }} + E2E_GATEWAY_URL: ${{ vars.E2E_GATEWAY_URL }} + E2E_CLAUDE_MODEL: ${{ vars.E2E_CLAUDE_MODEL }} + E2E_CODEX_MODEL: ${{ vars.E2E_CODEX_MODEL }} diff --git a/packages/agent/e2e/README.md b/packages/agent/e2e/README.md new file mode 100644 index 0000000000..dddb033b94 --- /dev/null +++ b/packages/agent/e2e/README.md @@ -0,0 +1,92 @@ +# Live agent e2e suite + +Drives representative sessions **end to end** through the real adapter, the real +binary (codex `app-server` / Claude Code CLI), and the real llm-gateway on a cheap +model — parametrized across `claude` and `codex`. The only thing mocked is the +host/UI client (a recording `sessionUpdate`, an auto-allow `requestPermission`, +and real file read/write against a throwaway git repo). Nothing in the +agent/model/tool path is stubbed. + +## What it covers + +Two suites, each a per-adapter loop with `describe.skipIf` over `["claude", +"codex"]` (titles carry a `(claude)` / `(codex)` marker so `-t "(codex)"` selects +one arm across both files): + +`session-lifecycle.e2e.test.ts` — one shared golden turn plus focused scenarios: +- **newSession config options** — model / effort selectors are offered. +- **working turn** — `initialize → newSession → prompt` (read a file, edit a + line, run a command): streamed assistant text, tool calls + a completed tool + call, the exact usage signal, `stopReason: end_turn`, the real on-disk file + edit, and (codex) the `_posthog/sdk_session` + `_posthog/turn_complete` + ext-notifications. +- **setSessionConfigOption** — switching a config option is accepted + acked. +- **interrupt** — `cancel` during an in-flight (unbounded) turn yields `cancelled`. +- **resumeSession** — reconnect returns config options. +- **loadSession** — a fresh connection reattaches and the transcript replays + (asserts the tool transcript replays, not just any update). + +Codex-only (advertised codex capabilities; registered as skipped on the claude +arm so the gap is visible): +- **mode switch** → `current_mode_update`. +- **steering** — a mid-turn prompt folds into the running turn via `turn/steer`. +- **list + fork** — `listSessions` finds the session; `forkSession` branches it. + +The command/file approval `{decision}` round-trip is **not** covered here: codex +spawns under a `danger-full-access` sandbox and auto-approves, so it never sends +an approval request to assert on. That envelope is covered by unit tests instead. + +`structured-output.e2e.test.ts` — `_meta.jsonSchema` + `onStructuredOutput` +delivers a parsed, schema-constrained object (the signals-pipeline contract). + +Assertions are structural lifecycle invariants + the deterministic file/JSON +side effects — never model prose — so they hold across adapters and cheap models. + +## Structure + +- `config.ts` — gateway/token/model resolution, per-adapter env wiring, skip logic. +- `driver.ts` — the in-process ACP host client (recording capture, auto-allow, + real FS), `openConnection` / `openSession` helpers, the throwaway-repo helpers, + and `waitFor`. +- `*.e2e.test.ts` — the scenarios. + +## Running + +These never run under `pnpm test` or per-PR CI (the default vitest config only +includes `src/**`). They are opt-in and cost a couple of short model turns. + +In CI they run **only** via the gated `agent-live-e2e` workflow (manual dispatch ++ nightly cron), and only when the repo variable `AGENT_E2E_ENABLED` is `true` +with an `E2E_GATEWAY_TOKEN` secret and an `E2E_GATEWAY_URL` variable pointing at a +gateway reachable from the runner. Off by default, so it costs nothing until +explicitly enabled; the codex arm self-skips if the native binary isn't on the +runner. + +```bash +# from packages/agent — mints a local-gateway OAuth token, runs both arms +bash e2e/run-e2e.sh + +# just one adapter (matches the (codex) / (claude) marker in every title) +bash e2e/run-e2e.sh -t "(codex)" +``` + +Prereqs: a local llm-gateway up (`./bin/start` in the posthog repo) and the +native codex binary present at `apps/code/resources/codex-acp/codex` (the codex +arm self-skips if it is missing). + +## Configuration (env) + +| Var | Default | Notes | +| --- | --- | --- | +| `E2E_GATEWAY_TOKEN` | — | Required. OAuth token the gateway accepts. Without it every arm skips. `run-e2e.sh` mints one. | +| `E2E_GATEWAY_URL` | `http://localhost:3308/posthog_code` | Gateway base (codex appends `/v1`). | +| `E2E_CLAUDE_MODEL` | `claude-haiku-4-5` | Override if the gateway serves a different cheap Claude id. | +| `E2E_CODEX_MODEL` | `gpt-5-mini` | Cheapest codex id the local gateway serves; override if needed. | +| `POSTHOG_REPO` | sibling `../posthog` | Where `run-e2e.sh` mints the token from. | +| `E2E_DEBUG` | — | `1` for verbose adapter logging. | + +If a default model isn't served by your gateway, the turn fails loudly (never a +false green) — set the matching `E2E_*_MODEL`. + +Each arm self-skips with a visible reason (missing token / missing binary) rather +than passing silently. diff --git a/packages/agent/e2e/config.ts b/packages/agent/e2e/config.ts new file mode 100644 index 0000000000..98098ca2f9 --- /dev/null +++ b/packages/agent/e2e/config.ts @@ -0,0 +1,103 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +export type Adapter = "claude" | "codex"; + +/** + * Live e2e configuration, resolved entirely from the environment so no secret is + * ever committed. A run needs a local llm-gateway (`./bin/start` in the posthog + * repo) and an OAuth token it accepts in `E2E_GATEWAY_TOKEN` — see `run-e2e.sh`, + * which mints one. Without the token every arm self-skips, so `pnpm test` and CI + * spend nothing. + */ +// `||` not `??`: CI sets unset `vars.*` to an empty string, which should fall +// back to the default rather than override it with "". +const GATEWAY_URL = + process.env.E2E_GATEWAY_URL || "http://localhost:3308/posthog_code"; +const TOKEN = process.env.E2E_GATEWAY_TOKEN ?? ""; + +// apps/code/resources/codex-acp/codex (the native app-server binary), relative +// to packages/agent/e2e. +const NATIVE_CODEX_BIN = join( + __dirname, + "..", + "..", + "..", + "apps", + "code", + "resources", + "codex-acp", + "codex", +); + +/** The gateway base with a trailing `/v1` (codex / OpenAI-format endpoint). */ +function openAiBase(): string { + return GATEWAY_URL.endsWith("/v1") ? GATEWAY_URL : `${GATEWAY_URL}/v1`; +} + +export const E2E = { + token: TOKEN, + hasToken: !!TOKEN, + gatewayUrl: GATEWAY_URL, + codexBin: NATIVE_CODEX_BIN, + + /** + * Cheap model per adapter (overridable). Defaults to a small/cheap model so a + * full run is a couple of short turns. If the gateway doesn't serve the + * default, override via `E2E_CLAUDE_MODEL` / `E2E_CODEX_MODEL` — the turn will + * fail loudly (never a false green) rather than silently skip. + */ + model(adapter: Adapter): string { + // `||` so an empty CI variable falls back to the default. + if (adapter === "claude") { + return process.env.E2E_CLAUDE_MODEL || "claude-haiku-4-5"; + } + // gpt-5-mini is the cheapest codex model the gateway serves. It's on the + // product block list, but that gate is only enforced in Agent.run — the + // e2e drives createAcpConnection directly, so the model is accepted. + return process.env.E2E_CODEX_MODEL || "gpt-5-mini"; + }, + + /** Null => runnable; a string => skip this arm with that reason (never silent). */ + skipReason(adapter: Adapter): string | null { + if (!TOKEN) return "E2E_GATEWAY_TOKEN not set"; + if (adapter === "codex" && !existsSync(NATIVE_CODEX_BIN)) { + return `native codex binary missing at ${NATIVE_CODEX_BIN}`; + } + return null; + }, + + /** + * Point the adapter at the gateway exactly as the host's `configureEnvironment` + * does: Claude reads `ANTHROPIC_*` from env; codex takes the gateway via + * `codexOptions` but we set `OPENAI_*` too for parity, and force the native + * app-server sub-adapter. + */ + configureEnv(adapter: Adapter): void { + if (adapter === "claude") { + process.env.ANTHROPIC_BASE_URL = GATEWAY_URL; + process.env.ANTHROPIC_AUTH_TOKEN = TOKEN; + return; + } + process.env.OPENAI_BASE_URL = openAiBase(); + process.env.OPENAI_API_KEY = TOKEN; + process.env.POSTHOG_CODEX_USE_APP_SERVER = "1"; + }, + + /** The codexOptions the codex arm passes through `createAcpConnection`. */ + codexOptions(cwd: string): { + cwd: string; + binaryPath: string; + apiBaseUrl: string; + apiKey: string; + model: string; + } { + return { + cwd, + binaryPath: NATIVE_CODEX_BIN, + apiBaseUrl: openAiBase(), + apiKey: TOKEN, + model: this.model("codex"), + }; + }, +}; diff --git a/packages/agent/e2e/driver.ts b/packages/agent/e2e/driver.ts new file mode 100644 index 0000000000..0908caa1e5 --- /dev/null +++ b/packages/agent/e2e/driver.ts @@ -0,0 +1,302 @@ +/** + * Adapter-agnostic ACP driver for the live e2e suite. + * + * Stands up the same in-process ACP transport the real host uses + * (`createAcpConnection` → `ClientSideConnection` over `ndJsonStream`) and drives + * a real adapter + real binary + real gateway. The ONLY thing mocked is the + * host/UI client: a recording `sessionUpdate`, an auto-allow `requestPermission`, + * and real `readTextFile`/`writeTextFile` against the on-disk test repo. Nothing + * in the agent/model/tool path is stubbed. + */ +import { execFileSync } from "node:child_process"; +import { + promises as fsp, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +// @ts-expect-error - runtime ESM export resolved by vitest +import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; +import { createAcpConnection } from "../src/adapters/acp-connection"; +import { Logger } from "../src/utils/logger"; +import type { Adapter } from "./config"; + +export type { Adapter } from "./config"; + +export interface CapturedEvent { + kind: "sessionUpdate" | "requestPermission" | "extNotification"; + sessionUpdate?: string; + method?: string; + data?: Record; +} + +export interface Capture { + events: CapturedEvent[]; + /** sessionUpdate events of a given type (e.g. "agent_message_chunk"). */ + updates(type: string): CapturedEvent[]; + /** server→client permission requests we auto-allowed. */ + approvals(): CapturedEvent[]; + /** distinct PostHog ext-notification methods seen (e.g. "_posthog/usage_update"). */ + extMethods(): string[]; +} + +export interface NewSessionResponse { + sessionId: string; + configOptions?: ConfigOption[]; + modes?: unknown; +} + +export interface ConfigOption { + id?: string; + category?: string; + currentValue?: unknown; + options?: Array<{ name?: string; value?: unknown }>; +} + +export interface AcpConn { + initialize: (p: unknown) => Promise; + newSession: (p: unknown) => Promise; + loadSession: (p: unknown) => Promise; + resumeSession: (p: unknown) => Promise; + listSessions: ( + p: unknown, + ) => Promise<{ sessions?: Array<{ sessionId?: string }> }>; + unstable_forkSession: (p: unknown) => Promise; + prompt: (p: unknown) => Promise<{ stopReason?: string; usage?: unknown }>; + setSessionConfigOption: (p: unknown) => Promise; + cancel: (p: unknown) => Promise; + /** Client→agent ext-method (the host drives _posthog/refresh_session). */ + extMethod: (method: string, params: unknown) => Promise; +} + +export interface E2EConnection { + conn: AcpConn; + capture: Capture; + cleanup: () => Promise; +} + +/** + * The ACP `initialize` params our host client sends. Matches the cloud host, + * which advertises NO clientCapabilities — so the adapter runs file/terminal + * tools in-process (codex via shell, claude via its own Read/Write) rather than + * proxying through the host's fs callbacks. The driver still implements + * readTextFile/writeTextFile as harmless insurance. + */ +export const INIT_PARAMS = { + protocolVersion: 1, + clientCapabilities: {}, +}; + +/** Open a live ACP connection to one adapter and start recording its stream. */ +export function openConnection(opts: { + adapter: Adapter; + cwd: string; + codexOptions?: Record; + onStructuredOutput?: (output: Record) => Promise; +}): E2EConnection { + const { adapter, cwd } = opts; + const events: CapturedEvent[] = []; + + // Mirror the real cloud host's client surface (sessionUpdate, requestPermission, + // fs read/write, extNotification). Deliberately NO extMethod: the real host + // doesn't implement it, so an adapter that ever calls it should fail e2e the + // same way it would fail in production. + const client = { + async sessionUpdate(p: any): Promise { + events.push({ + kind: "sessionUpdate", + sessionUpdate: p?.update?.sessionUpdate, + data: p?.update, + }); + }, + async requestPermission(p: any): Promise { + events.push({ + kind: "requestPermission", + data: { title: p?.toolCall?.title, kind: p?.toolCall?.kind }, + }); + const options = p?.options ?? []; + const allow = + options.find( + (o: any) => o?.kind === "allow_once" || o?.kind === "allow_always", + ) ?? options[0]; + return { + outcome: { outcome: "selected", optionId: allow?.optionId ?? "allow" }, + }; + }, + async readTextFile(p: any): Promise { + return { content: await fsp.readFile(resolve(cwd, p.path), "utf8") }; + }, + async writeTextFile(p: any): Promise { + await fsp.writeFile(resolve(cwd, p.path), p.content); + return {}; + }, + async extNotification(method: string, params: any): Promise { + events.push({ kind: "extNotification", method, data: params }); + }, + }; + + const logger = new Logger({ + debug: !!process.env.E2E_DEBUG, + prefix: "[e2e]", + }); + const acp = createAcpConnection({ + adapter, + codexOptions: opts.codexOptions as any, + onStructuredOutput: opts.onStructuredOutput, + logger, + }); + const stream = ndJsonStream( + acp.clientStreams.writable, + acp.clientStreams.readable, + ); + const conn = new ClientSideConnection( + () => client, + stream, + ) as unknown as AcpConn; + + const capture: Capture = { + events, + updates: (type) => + events.filter( + (e) => e.kind === "sessionUpdate" && e.sessionUpdate === type, + ), + approvals: () => events.filter((e) => e.kind === "requestPermission"), + extMethods: () => [ + ...new Set( + events + .filter((e) => e.kind === "extNotification" && e.method) + .map((e) => e.method as string), + ), + ], + }; + + return { + conn, + capture, + cleanup: async () => { + // Bounded: a wedged adapter cleanup must never hang the suite. + await Promise.race([ + acp.cleanup().catch(() => undefined), + new Promise((r) => setTimeout(r, 8000)), + ]); + }, + }; +} + +export interface OpenSession { + conn: AcpConn; + capture: Capture; + sessionId: string; + newSession: NewSessionResponse; + cleanup: () => Promise; +} + +/** + * openConnection + initialize + newSession — the common scenario setup. Returns + * the live connection, the recording capture, the session id, and the full + * newSession response (for configOptions assertions). + */ +export async function openSession(opts: { + adapter: Adapter; + cwd: string; + codexOptions?: Record; + onStructuredOutput?: (output: Record) => Promise; + meta: Record; +}): Promise { + const c = openConnection(opts); + await c.conn.initialize(INIT_PARAMS); + const newSession = await c.conn.newSession({ + cwd: opts.cwd, + mcpServers: [], + _meta: opts.meta, + }); + return { + conn: c.conn, + capture: c.capture, + sessionId: newSession.sessionId, + newSession, + cleanup: c.cleanup, + }; +} + +export const ORIGINAL_TARGET = "line1\nline2\nline3\n"; + +/** A throwaway git repo with a single editable file — the scenario's workspace. */ +export function setupRepo(): string { + // realpath so the cwd is canonical: on macOS os.tmpdir() is /var/... (a symlink + // to /private/var/...). The Claude SDK records the resolved path in its session + // store; a fresh connection must use the same path or loadSession's transcript + // replay (keyed by cwd) finds nothing. + const repo = realpathSync(mkdtempSync(join(tmpdir(), "agent-e2e-"))); + writeFileSync(join(repo, "target.txt"), ORIGINAL_TARGET); + execFileSync("git", ["init", "-q"], { cwd: repo }); + execFileSync("git", ["add", "-A"], { cwd: repo }); + // -c commit.gpgsign=false: ignore the user's global signing config (e.g. a + // 1Password SSH signer), which fails in this non-interactive context. + execFileSync( + "git", + [ + "-c", + "commit.gpgsign=false", + "-c", + "user.email=e2e@posthog.dev", + "-c", + "user.name=e2e", + "commit", + "-qm", + "init", + ], + { cwd: repo }, + ); + return repo; +} + +export function readTarget(repo: string): string { + return readFileSync(join(repo, "target.txt"), "utf8"); +} + +export function cleanupRepo(repo: string): void { + try { + rmSync(repo, { recursive: true, force: true }); + } catch { + /* best effort */ + } +} + +/** + * Poll `fn` until it returns a non-undefined value or the timeout elapses. + * Absorbs the small in-process transport / on-disk flush delay between an agent + * emitting a sessionUpdate and the recording client observing it. + */ +export async function waitFor( + fn: () => T | undefined, + timeoutMs = 5000, + intervalMs = 100, +): Promise { + const start = Date.now(); + for (;;) { + const value = fn(); + if (value !== undefined) return value; + if (Date.now() - start >= timeoutMs) return undefined; + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +/** + * codex spawns detached (own process group); a killed run can orphan it holding + * a flock under ~/.codex/tmp, wedging the next run. Kill stragglers first — + * process death releases the flock. + */ +export function killCodexStragglers(): void { + try { + execFileSync("pkill", ["-9", "-f", "resources/codex-acp"], { + stdio: "ignore", + }); + } catch { + /* none running */ + } +} diff --git a/packages/agent/e2e/run-e2e.sh b/packages/agent/e2e/run-e2e.sh new file mode 100755 index 0000000000..5be1616e72 --- /dev/null +++ b/packages/agent/e2e/run-e2e.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Run the live golden-path e2e for both adapters (claude + codex). +# +# Needs a local llm-gateway (run `./bin/start` in the posthog repo) and an OAuth +# token it accepts. If E2E_GATEWAY_TOKEN is unset, this mints a short-lived one +# from the posthog repo (override its path with POSTHOG_REPO). +# +# Usage: +# bash e2e/run-e2e.sh # both adapters, both suites +# bash e2e/run-e2e.sh -t "(codex)" # only the codex arm (vitest -t name filter) +# Env overrides: E2E_GATEWAY_URL, E2E_CLAUDE_MODEL, E2E_CODEX_MODEL, E2E_DEBUG=1 +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AGENT_DIR="$(cd "$HERE/.." && pwd)" +POSTHOG_REPO="${POSTHOG_REPO:-$(cd "$AGENT_DIR/../../.." && pwd)/posthog}" + +if [[ -z "${E2E_GATEWAY_TOKEN:-}" ]]; then + if [[ ! -d "$POSTHOG_REPO" ]]; then + echo "E2E_GATEWAY_TOKEN unset and posthog repo not found at $POSTHOG_REPO." >&2 + echo "Set E2E_GATEWAY_TOKEN, or POSTHOG_REPO to the posthog checkout." >&2 + exit 1 + fi + echo "Minting a local-gateway OAuth token from $POSTHOG_REPO ..." + MINT='from posthog.models import User; from products.tasks.backend.logic.services.code_usage_gate import create_oauth_access_token_for_user; print(create_oauth_access_token_for_user(User.objects.get(id=1), 1, scopes=["llm_gateway:read"], include_internal_scopes=False))' + E2E_GATEWAY_TOKEN="$(cd "$POSTHOG_REPO" && flox activate -- bash -c ".venv/bin/python manage.py shell -c '$MINT'" 2>/dev/null | grep -oE 'pha_[A-Za-z0-9]+' | head -1)" +fi + +if [[ -z "${E2E_GATEWAY_TOKEN:-}" ]]; then + echo "Failed to obtain an E2E_GATEWAY_TOKEN." >&2 + exit 1 +fi + +export E2E_GATEWAY_TOKEN +echo "token: ${E2E_GATEWAY_TOKEN:0:8}… gateway: ${E2E_GATEWAY_URL:-http://localhost:3308/posthog_code}" +cd "$AGENT_DIR" +pnpm test:e2e "$@" diff --git a/packages/agent/e2e/session-lifecycle.e2e.test.ts b/packages/agent/e2e/session-lifecycle.e2e.test.ts new file mode 100644 index 0000000000..34585e3ac3 --- /dev/null +++ b/packages/agent/e2e/session-lifecycle.e2e.test.ts @@ -0,0 +1,469 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { type Adapter, E2E } from "./config"; +import { + type Capture, + cleanupRepo, + INIT_PARAMS, + killCodexStragglers, + type NewSessionResponse, + ORIGINAL_TARGET, + openConnection, + openSession, + readTarget, + setupRepo, + waitFor, +} from "./driver"; + +/** + * Live session-lifecycle e2e: drives a representative session per adapter end to + * end against the real gateway + binary on a cheap model. One shared golden turn + * (in `beforeAll`) backs the turn / config / reattach assertions; the other + * scenarios use their own short sessions. Codex-specific capabilities (the + * `{decision}` approval round-trip, steering, mode synthesis, list/fork) run only + * on the codex arm. Assertions are structural lifecycle invariants + the on-disk + * side effect — never model prose (except the deterministic file edit) — so the + * suite holds across adapters and cheap models. Opt-in: each arm self-skips + * unless `E2E_GATEWAY_TOKEN` is set (and, for codex, the native binary exists). + * Run via `pnpm test:e2e`; filter one adapter with `-t "(codex)"`. + */ +const ADAPTERS: Adapter[] = ["claude", "codex"]; + +const EDIT_PROMPT = + "Do exactly these steps and nothing else: 1) Read the file target.txt. " + + "2) Edit it so the second line reads FOO instead of line2. " + + "3) Run the shell command `cat target.txt`. " + + "4) In one sentence confirm what you changed, then stop."; + +for (const adapter of ADAPTERS) { + const skip = E2E.skipReason(adapter); + const title = `session lifecycle (${adapter})${skip ? ` — SKIPPED (${skip})` : ""}`; + // Codex-only capabilities; registered as skipped on the claude arm so the gap + // is visible rather than silent. + const itCodex = adapter === "codex" ? it : it.skip; + + describe.skipIf(!!skip)(title, () => { + let repo: string; + const codexOptions = () => + adapter === "codex" ? E2E.codexOptions(repo) : undefined; + const meta = (extra: Record = {}) => ({ + systemPrompt: "You are a coding assistant in a tiny test repo.", + model: E2E.model(adapter), + permissionMode: "bypassPermissions", + // Drives the cloud ext-notifications (_posthog/sdk_session + turn_complete). + taskRunId: "e2e-run", + ...extra, + }); + + let sessionId: string; + let newSessionResponse: NewSessionResponse; + let turn: { stopReason?: string; capture: Capture; target: string }; + + beforeAll(async () => { + if (adapter === "codex") killCodexStragglers(); + E2E.configureEnv(adapter); + repo = setupRepo(); + const s = await openSession({ + adapter, + cwd: repo, + codexOptions: codexOptions(), + meta: meta(), + }); + sessionId = s.sessionId; + newSessionResponse = s.newSession; + try { + const res = await s.conn.prompt({ + sessionId, + prompt: [{ type: "text", text: EDIT_PROMPT }], + }); + turn = { + stopReason: res.stopReason, + capture: s.capture, + target: readTarget(repo), + }; + } finally { + await s.cleanup(); + } + }, 180_000); + + afterAll(() => { + cleanupRepo(repo); + }); + + it("newSession exposes selectable config options (model / effort)", () => { + const opts = newSessionResponse.configOptions ?? []; + expect(opts.length).toBeGreaterThan(0); + expect(opts.some((o) => (o.options?.length ?? 0) > 1)).toBe(true); + }); + + it("streams a working turn: assistant text, tool calls, usage, file edit", () => { + expect(turn.stopReason).toBe("end_turn"); + expect( + turn.capture.updates("agent_message_chunk").length, + ).toBeGreaterThan(0); + expect(turn.capture.updates("tool_call").length).toBeGreaterThan(0); + const anyToolCompleted = [ + ...turn.capture.updates("tool_call"), + ...turn.capture.updates("tool_call_update"), + ].some((e) => e.data?.status === "completed"); + expect(anyToolCompleted).toBe(true); + + // A concrete usage signal — the exact method, not a loose substring. + const hasUsage = + turn.capture.updates("usage_update").length > 0 || + turn.capture.extMethods().includes("_posthog/usage_update"); + expect(hasUsage).toBe(true); + + // Both adapters map the session to the host's taskRunId via sdk_session + // (the golden meta sets taskRunId, the cloud host always does). + expect(turn.capture.extMethods()).toContain("_posthog/sdk_session"); + + // The real on-disk side effect. + expect(turn.target).not.toBe(ORIGINAL_TARGET); + expect(turn.target).toContain("FOO"); + + // codex additionally emits _posthog/turn_complete (claude signals turn + // completion via the prompt response, not this ext-notification). + if (adapter === "codex") { + expect(turn.capture.extMethods()).toContain("_posthog/turn_complete"); + // turn_complete carries real, well-formed usage (totalTokens = sum). + const tc = turn.capture.events.find( + (e) => + e.kind === "extNotification" && + e.method === "_posthog/turn_complete", + ); + const usage = (tc?.data as { usage?: Record })?.usage; + expect(usage).toBeTruthy(); + expect(usage?.totalTokens ?? 0).toBeGreaterThan(0); + expect(usage?.totalTokens).toBe( + (usage?.inputTokens ?? 0) + + (usage?.outputTokens ?? 0) + + (usage?.cachedReadTokens ?? 0) + + (usage?.cachedWriteTokens ?? 0), + ); + } + }); + + it("switches a config option via setSessionConfigOption", async () => { + const s = await openSession({ + adapter, + cwd: repo, + codexOptions: codexOptions(), + meta: meta(), + }); + try { + const opt = (s.newSession.configOptions ?? []).find( + (o) => (o.options?.length ?? 0) > 1, + ); + expect( + opt, + "expected a config option with multiple values", + ).toBeTruthy(); + const alt = + opt?.options?.find((v) => v.value !== opt.currentValue) ?? + opt?.options?.[0]; + const res = await s.conn.setSessionConfigOption({ + sessionId: s.sessionId, + configId: opt?.id, + value: alt?.value, + }); + expect(res).toBeTruthy(); + if (adapter === "codex") { + // codex re-emits config_option_update as the side effect of a switch. + expect( + s.capture.updates("config_option_update").length, + ).toBeGreaterThan(0); + } else { + // claude acks via the returned configOptions and/or a re-emit. + const acknowledged = + s.capture.updates("config_option_update").length + + s.capture.updates("current_mode_update").length > + 0 || Array.isArray(res?.configOptions); + expect(acknowledged).toBe(true); + } + } finally { + await s.cleanup(); + } + }, 90_000); + + // The cloud host switches mode ONLY via setSessionConfigOption(configId:"mode") + // (never ACP setSessionMode) on BOTH adapters, so exercise both arms. + it("emits current_mode_update when the mode is switched via setSessionConfigOption", async () => { + if (adapter === "codex") killCodexStragglers(); + const s = await openSession({ + adapter, + cwd: repo, + codexOptions: codexOptions(), + meta: meta(), + }); + try { + // codex synthesizes modes (read-only); claude exposes a "mode" + // configOption — pick an alternate value from it. + let value = "read-only"; + if (adapter === "claude") { + const modeOpt = (s.newSession.configOptions ?? []).find( + (o) => o.id === "mode", + ); + value = + (modeOpt?.options?.find((v) => v.value !== modeOpt.currentValue) + ?.value as string) ?? "plan"; + } + await s.conn.setSessionConfigOption({ + sessionId: s.sessionId, + configId: "mode", + value, + }); + expect(s.capture.updates("current_mode_update").length).toBeGreaterThan( + 0, + ); + } finally { + await s.cleanup(); + } + }, 60_000); + + it("handles the host's refresh_session extMethod per adapter", async () => { + if (adapter === "codex") killCodexStragglers(); + const s = await openSession({ + adapter, + cwd: repo, + codexOptions: codexOptions(), + meta: meta(), + }); + try { + const call = s.conn.extMethod("_posthog/refresh_session", { + mcpServers: [], + }); + if (adapter === "claude") { + // claude IMPLEMENTS refresh_session; the cheap model (haiku) is on + // the MCP-injection exclude list, so it RECOGNIZES the method and + // rejects on the model gate — not method-not-found — proving the + // host's call reaches the handler. + await expect(call).rejects.toThrow(/MCP injection/i); + } else { + // codex doesn't implement extMethod — the host's refresh_session + // call rejects cleanly (the known adapter divergence). + await expect(call).rejects.toThrow(); + } + } finally { + await s.cleanup(); + } + }, 60_000); + + // NOTE: the command/file approval `{decision}` round-trip is NOT exercised + // here. codex spawns under a danger-full-access sandbox (spawn.ts), so it + // auto-approves and never sends item/*requestApproval — even in read-only + // mode — so an e2e approval assertion can't fire without changing production + // sandbox behavior. That envelope is covered by unit tests instead + // (codex-app-server-agent.test.ts: "maps allow to a decision envelope" et al). + // Likewise the server->client requestPermission policy (publish-blocking, + // Slack relay, plan approval, deny-on-shutdown) can't be triggered from a + // cheap model in this harness — it's covered by approvals.test.ts. + + it("incorporates a prompt's _meta.prContext without error", async () => { + if (adapter === "codex") killCodexStragglers(); + const s = await openSession({ + adapter, + cwd: repo, + codexOptions: codexOptions(), + meta: meta(), + }); + try { + // The host attaches prContext on PR-follow-up runs; both adapters + // prepend it to the forwarded prompt. + const res = await s.conn.prompt({ + sessionId: s.sessionId, + prompt: [ + { + type: "text", + text: "Acknowledge the linked pull request in one short sentence, then stop.", + }, + ], + _meta: { + prContext: + "Context: PR #4242 'Fix the thing' is open and under review.", + }, + }); + expect(res.stopReason).toBe("end_turn"); + expect(s.capture.updates("agent_message_chunk").length).toBeGreaterThan( + 0, + ); + } finally { + await s.cleanup(); + } + }, 120_000); + + itCodex( + "folds a mid-turn prompt into the running turn via steering", + async () => { + killCodexStragglers(); + const s = await openSession({ + adapter, + cwd: repo, + codexOptions: codexOptions(), + meta: meta(), + }); + try { + const p1 = s.conn.prompt({ + sessionId: s.sessionId, + prompt: [ + { + type: "text", + text: "Count up from 1, one number per line, and keep going.", + }, + ], + }); + await waitFor( + () => + s.capture.updates("agent_message_chunk").length > 0 + ? true + : undefined, + 20_000, + ); + // A second prompt while the turn is in flight folds in via turn/steer. + const p2 = s.conn.prompt({ + sessionId: s.sessionId, + prompt: [{ type: "text", text: "Now stop and say DONE." }], + }); + const [r1] = await Promise.all([p1, p2]); + expect(r1.stopReason).toBe("end_turn"); + // Both the original and the steered message echoed as user turns. + expect( + s.capture.updates("user_message_chunk").length, + ).toBeGreaterThanOrEqual(2); + } finally { + await s.cleanup(); + } + }, + 120_000, + ); + + itCodex( + "lists the session and forks it", + async () => { + killCodexStragglers(); + const b = openConnection({ + adapter, + cwd: repo, + codexOptions: codexOptions(), + }); + try { + await b.conn.initialize(INIT_PARAMS); + const listed = await b.conn.listSessions({ cwd: repo }); + const ids = (listed.sessions ?? []).map((x) => x.sessionId); + expect(ids).toContain(sessionId); + const forked = await b.conn.unstable_forkSession({ + sessionId, + cwd: repo, + mcpServers: [], + _meta: { model: E2E.model(adapter) }, + }); + expect(forked.sessionId).toBeTruthy(); + expect(forked.sessionId).not.toBe(sessionId); + } finally { + await b.cleanup(); + } + }, + 60_000, + ); + + // NOTE: the permission DENY path isn't exercised here. Neither arm reliably + // surfaces a deny-able approval to a cheap model: codex auto-approves under + // its danger-full-access sandbox, and claude routes file edits through ACP + // writeTextFile rather than a requestPermission round-trip. The deny/cancel + // paths are unit-covered instead (approvals.test.ts safe-default-on-reject; + // codex-app-server-agent.test.ts command-approval cancel/decline envelope). + + it("interrupts an in-flight turn", async () => { + if (adapter === "codex") killCodexStragglers(); + const s = await openSession({ + adapter, + cwd: repo, + codexOptions: codexOptions(), + meta: meta(), + }); + try { + const p = s.conn.prompt({ + sessionId: s.sessionId, + prompt: [ + { + type: "text", + text: "Count up from 1, one number per line, and never stop until told to.", + }, + ], + }); + // Cancel as soon as the turn is in flight (unbounded work, so no race + // with a fast finish). + await waitFor( + () => + s.capture.updates("agent_message_chunk").length > 0 || + s.capture.updates("tool_call").length > 0 + ? true + : undefined, + 20_000, + ); + await s.conn.cancel({ sessionId: s.sessionId }); + const res = await p; + expect(res.stopReason).toBe("cancelled"); + } finally { + await s.cleanup(); + } + }, 90_000); + + it("resumeSession reconnects and returns config options", async () => { + if (adapter === "codex") killCodexStragglers(); + const b = openConnection({ + adapter, + cwd: repo, + codexOptions: codexOptions(), + }); + try { + await b.conn.initialize(INIT_PARAMS); + const resumed = await b.conn.resumeSession({ + sessionId, + cwd: repo, + mcpServers: [], + _meta: { model: E2E.model(adapter) }, + }); + expect(resumed).toBeTruthy(); + expect(Array.isArray(resumed.configOptions)).toBe(true); + } finally { + await b.cleanup(); + } + }, 60_000); + + it("reattach (loadSession) restores the session and replays the transcript", async () => { + if (adapter === "codex") killCodexStragglers(); + const b = openConnection({ + adapter, + cwd: repo, + codexOptions: codexOptions(), + }); + try { + await b.conn.initialize(INIT_PARAMS); + const loaded = await b.conn.loadSession({ + sessionId, + cwd: repo, + mcpServers: [], + _meta: { model: E2E.model(adapter) }, + }); + expect(loaded).toBeTruthy(); + // loadSession runs no turn, so any transcript update here is replayed + // history. The replayed shape differs by adapter, so assert each arm's + // real replay path: codex replays user/agent message chunks (from + // thread.turns via mapHistoryItem); claude replays tool calls (from its + // SDK transcript via replaySessionHistory). + const replayed = await waitFor(() => { + const n = + adapter === "codex" + ? b.capture.updates("user_message_chunk").length + + b.capture.updates("agent_message_chunk").length + : b.capture.updates("tool_call").length + + b.capture.updates("tool_call_update").length; + return n > 0 ? n : undefined; + }, 8000); + expect(replayed ?? 0).toBeGreaterThan(0); + } finally { + await b.cleanup(); + } + }, 60_000); + }); +} diff --git a/packages/agent/e2e/structured-output.e2e.test.ts b/packages/agent/e2e/structured-output.e2e.test.ts new file mode 100644 index 0000000000..8f57edb69f --- /dev/null +++ b/packages/agent/e2e/structured-output.e2e.test.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { type Adapter, E2E } from "./config"; +import { + cleanupRepo, + killCodexStragglers, + openSession, + setupRepo, +} from "./driver"; + +/** + * Live structured-output e2e: both adapters constrain the final assistant message + * to a JSON schema (`_meta.jsonSchema`) and deliver the parsed object via the + * `onStructuredOutput` callback — the contract the signals pipeline relies on + * (codex: native `outputSchema`; claude: SDK `outputFormat: json_schema`). The + * answer is deterministic (capital of France) so a cheap model passes reliably. + * Opt-in (same gating as the lifecycle suite); run via `pnpm test:e2e`. + */ +const ADAPTERS: Adapter[] = ["claude", "codex"]; + +const SCHEMA = { + type: "object", + properties: { capital: { type: "string" } }, + required: ["capital"], + additionalProperties: false, +}; + +for (const adapter of ADAPTERS) { + const skip = E2E.skipReason(adapter); + const title = `structured output (${adapter})${skip ? ` — SKIPPED (${skip})` : ""}`; + + describe.skipIf(!!skip)(title, () => { + let repo: string; + + beforeAll(() => { + if (adapter === "codex") killCodexStragglers(); + E2E.configureEnv(adapter); + repo = setupRepo(); + }); + + afterAll(() => { + cleanupRepo(repo); + }); + + it("delivers schema-constrained structured output", async () => { + let captured: Record | undefined; + const s = await openSession({ + adapter, + cwd: repo, + codexOptions: adapter === "codex" ? E2E.codexOptions(repo) : undefined, + onStructuredOutput: async (o) => { + captured = o; + }, + meta: { + systemPrompt: "You answer strictly with JSON matching the schema.", + model: E2E.model(adapter), + permissionMode: "bypassPermissions", + jsonSchema: SCHEMA, + // Prod always sets taskRunId — exercise structured output and the + // session ext-notification together, the way the host actually drives it. + taskRunId: "e2e-structured", + }, + }); + try { + const res = await s.conn.prompt({ + sessionId: s.sessionId, + prompt: [ + { + type: "text", + text: "What is the capital of France? Answer using the required JSON schema.", + }, + ], + }); + expect(res.stopReason).toBe("end_turn"); + expect(captured, "onStructuredOutput should fire").toBeTruthy(); + expect(typeof captured?.capital).toBe("string"); + expect((captured?.capital as string).toLowerCase()).toContain("paris"); + // With taskRunId set, the session is mapped for the host too. + expect(s.capture.extMethods()).toContain("_posthog/sdk_session"); + } finally { + await s.cleanup(); + } + }, 120_000); + }); +} diff --git a/packages/agent/package.json b/packages/agent/package.json index 1ccc632d6e..bdc0b50f49 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -108,6 +108,7 @@ "dev": "tsup --watch", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "vitest run --config vitest.e2e.config.ts", "typecheck": "pnpm exec tsc --noEmit", "prepublishOnly": "pnpm run build", "clean": "node ../../scripts/rimraf.mjs dist .turbo" diff --git a/packages/agent/parity/harness.ts b/packages/agent/parity/harness.ts new file mode 100644 index 0000000000..d3edcf0595 --- /dev/null +++ b/packages/agent/parity/harness.ts @@ -0,0 +1,157 @@ +/** + * Differential parity harness for the two Codex adapters. + * + * Drives a scripted scenario (a stateful sequence of ACP client operations) + * through one codex adapter — selected by the POSTHOG_CODEX_USE_ACP env toggle — + * over the same in-process ACP transport the real host uses, and captures the + * full ACP stream (every sessionUpdate, every server→client requestPermission, + * and each call's response). Run the same scenario through both adapters and + * diff the captured streams to find parity gaps. No HTTP/JWT/Temporal. + */ +import { promises as fs } from "node:fs"; +import { resolve } from "node:path"; +// @ts-ignore - resolved by tsx at runtime +import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; +import { createAcpConnection } from "../src/adapters/acp-connection"; +import { Logger } from "../src/utils/logger"; + +export type AdapterMode = "acp" | "app-server"; + +export interface CapturedEvent { + t: number; + kind: "step" | "sessionUpdate" | "requestPermission" | "extNotification" | "extMethod"; + op?: string; + sessionUpdate?: string; + data?: any; +} + +export interface CapturedRun { + adapter: AdapterMode; + scenario: string; + events: CapturedEvent[]; + stepResults: Array<{ op: string; ok: boolean; result?: any; error?: string }>; + fatalError?: string; +} + +export interface ScenarioCtx { + cwd: string; + model?: string; + /** Run one ACP operation, record it as a step boundary + its (redacted) result. */ + step(op: string, fn: () => Promise): Promise; +} + +export interface Scenario { + name: string; + run: (conn: any, ctx: ScenarioCtx) => Promise; +} + +export interface HarnessConfig { + cwd: string; + codexOptions: { + cwd: string; + binaryPath?: string; + apiBaseUrl?: string; + apiKey?: string; + model?: string; + reasoningEffort?: string; + }; + /** Override flag plumbing once the migration adds useCodexAppServer. */ + selectAppServer?: boolean; + timeoutMs?: number; + logger?: Logger; +} + +/** Keep result shapes comparable: drop big/nondeterministic blobs, keep structure. */ +function redact(value: any): any { + if (!value || typeof value !== "object") return value; + const out: any = {}; + for (const [k, v] of Object.entries(value)) { + if (k === "sessionId") out[k] = ""; + else if (k === "configOptions" && Array.isArray(v)) { + out[k] = v.map((o: any) => ({ id: o?.id, category: o?.category, value: o?.value, options: (o?.options ?? []).map((x: any) => x?.id ?? x?.optionId) })); + } else if (k === "modes") { + out[k] = { currentModeId: (v as any)?.currentModeId, availableModes: ((v as any)?.availableModes ?? []).map((m: any) => m?.id) }; + } else if (k === "usage" && v && typeof v === "object") { + out[k] = Object.fromEntries(Object.entries(v).map(([uk, uv]) => [uk, typeof uv === "number" ? (uv > 0 ? ">0" : 0) : uv])); + } else if (typeof v === "string" && v.length > 120) out[k] = ``; + else out[k] = v; + } + return out; +} + +export async function runScenario(mode: AdapterMode, scenario: Scenario, cfg: HarnessConfig): Promise { + // Select the adapter. Until the migration adds a passed-in option, the env + // toggle is the only lever: set => codex-acp, unset => native app-server. + if (mode === "acp") process.env.POSTHOG_CODEX_USE_ACP = "1"; + else delete process.env.POSTHOG_CODEX_USE_ACP; + + const captured: CapturedRun = { adapter: mode, scenario: scenario.name, events: [], stepResults: [] }; + let ord = 0; + + const client = { + async sessionUpdate(p: any): Promise { + captured.events.push({ t: ord++, kind: "sessionUpdate", sessionUpdate: p?.update?.sessionUpdate, data: p?.update }); + }, + async requestPermission(p: any): Promise { + captured.events.push({ t: ord++, kind: "requestPermission", data: { title: p?.toolCall?.title, kind: p?.toolCall?.kind, options: (p?.options ?? []).map((o: any) => ({ id: o?.optionId, kind: o?.kind })) } }); + const allow = (p?.options ?? []).find((o: any) => o?.kind === "allow_once" || o?.kind === "allow_always") ?? p?.options?.[0]; + return { outcome: { outcome: "selected", optionId: allow?.optionId ?? "allow" } }; + }, + async readTextFile(p: any): Promise { + return { content: await fs.readFile(resolve(cfg.cwd, p.path), "utf8") }; + }, + async writeTextFile(p: any): Promise { + await fs.writeFile(resolve(cfg.cwd, p.path), p.content); + return {}; + }, + // PostHog ext-notifications (_posthog/usage_update, _posthog/turn_complete, + // _posthog/sdk_session, ...) are part of the parity surface and are sent + // outside sessionUpdate — capture them so the report covers them. + async extNotification(method: string, params: any): Promise { + captured.events.push({ t: ord++, kind: "extNotification", op: method, data: redact(params) }); + }, + async extMethod(method: string, params: any): Promise { + captured.events.push({ t: ord++, kind: "extMethod", op: method, data: redact(params) }); + return {}; + }, + }; + + const acp = createAcpConnection({ adapter: "codex", codexOptions: cfg.codexOptions as any, logger: cfg.logger }); + const stream = ndJsonStream(acp.clientStreams.writable, acp.clientStreams.readable); + const conn = new ClientSideConnection(() => client, stream); + + const ctx: ScenarioCtx = { + cwd: cfg.cwd, + model: cfg.codexOptions.model, + async step(op, fn) { + captured.events.push({ t: ord++, kind: "step", op }); + const started = Date.now(); + console.error(` [step] ${op} ...`); + try { + const result = await fn(); + console.error(` [step] ${op} ✓ (${Date.now() - started}ms)`); + captured.stepResults.push({ op, ok: true, result: redact(result) }); + return result; + } catch (e: any) { + console.error(` [step] ${op} ✗ (${Date.now() - started}ms): ${String(e?.message ?? e)}`); + captured.stepResults.push({ op, ok: false, error: String(e?.message ?? e) }); + throw e; + } + }, + }; + + const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error(`scenario timeout after ${cfg.timeoutMs ?? 180000}ms`)), cfg.timeoutMs ?? 180000)); + try { + await ctx.step("initialize", () => conn.initialize({ protocolVersion: 1, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } } })); + await Promise.race([scenario.run(conn, ctx), timeout]); + } catch (e: any) { + captured.fatalError = String(e?.message ?? e); + } finally { + // Bounded: a wedged adapter cleanup must never hang the loop. + await Promise.race([ + acp.cleanup().catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, 5000)), + ]); + } + return captured; +} diff --git a/packages/agent/parity/run.ts b/packages/agent/parity/run.ts new file mode 100644 index 0000000000..7beee0f094 --- /dev/null +++ b/packages/agent/parity/run.ts @@ -0,0 +1,235 @@ +/** + * Parity runner: drive scenarios through both codex adapters, extract a + * normalized feature report from each ACP stream, and diff app-server vs + * codex-acp. Writes raw captures + parity-report.json to parity/out/. + * + * Usage (from packages/agent): + * PARITY_API_KEY= pnpm exec tsx parity/run.ts [--only acp|app-server] [--scenario name] + * Env: + * PARITY_GATEWAY_URL default http://localhost:3308/posthog_code/v1 + * PARITY_API_KEY PostHog token the local llm-gateway accepts (required for a live run) + * PARITY_MODEL default gpt-5.5 + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { Logger } from "../src/utils/logger"; +import { type AdapterMode, type CapturedRun, type Scenario, runScenario } from "./harness"; + +const OUT_DIR = join(import.meta.dirname, "out"); +const RESOURCES = join(import.meta.dirname, "..", "..", "..", "apps", "code", "resources", "codex-acp"); +const CODEX_ACP_BIN = join(RESOURCES, "codex-acp"); +const NATIVE_CODEX_BIN = join(RESOURCES, "codex"); +const GATEWAY = process.env.PARITY_GATEWAY_URL ?? "http://localhost:3308/posthog_code/v1"; +const API_KEY = process.env.PARITY_API_KEY ?? ""; +const MODEL = process.env.PARITY_MODEL ?? "gpt-5.5"; +const REPO = "/tmp/codex-parity-repo"; + +const SCENARIOS: Scenario[] = [ + { + name: "basic-task", + async run(conn, ctx) { + const session = await ctx.step("newSession", () => + conn.newSession({ + cwd: ctx.cwd, + mcpServers: [], + _meta: { sessionId: "parity", systemPrompt: "You are a coding assistant in a tiny test repo.", model: ctx.model, permissionMode: "bypassPermissions" }, + }), + ); + const sessionId = session.sessionId; + await ctx.step("prompt", () => + conn.prompt({ + sessionId, + prompt: [ + { + type: "text", + text: "Do exactly these steps and nothing else: 1) Read the file target.txt. 2) Edit it so the second line reads FOO instead of line2. 3) Run the shell command `cat target.txt`. 4) In one sentence confirm what you changed, then stop.", + }, + ], + }), + ); + }, + }, + { + name: "modes-and-resume", + async run(conn, ctx) { + const session = await ctx.step("newSession", () => + conn.newSession({ cwd: ctx.cwd, mcpServers: [], _meta: { sessionId: "parity2", systemPrompt: "You are a coding assistant.", model: ctx.model, permissionMode: "auto" } }), + ); + const sessionId = session.sessionId; + // Mode switch — codex-acp supports it; app-server gap until migration. + await ctx.step("setSessionConfigOption(mode)", () => conn.setSessionConfigOption({ sessionId, configId: "mode", value: "read-only" }).catch((e: any) => { throw e; })); + await ctx.step("prompt", () => conn.prompt({ sessionId, prompt: [{ type: "text", text: "List the files in this repo with `ls`, then stop." }] })); + // Resume in the same connection (host calls resumeSession on reconnect). + await ctx.step("resumeSession", () => conn.resumeSession({ sessionId, cwd: ctx.cwd, mcpServers: [], _meta: { systemPrompt: "You are a coding assistant.", model: ctx.model } })); + }, + }, +]; + +function extractFeatures(run: CapturedRun): Record { + const updateTypes = new Set(); + const toolKinds = new Set(); + const toolStatuses = new Set(); + let hasDiff = false; + let hasToolContent = false; + const approvals: string[] = []; + let usageFields = new Set(); + let modeUpdate = false; + const extNotifs = new Set(); + + for (const e of run.events) { + if (e.kind === "requestPermission") approvals.push(e.data?.kind ?? "?"); + if (e.kind === "extNotification") extNotifs.add(e.op ?? "?"); + if (e.kind !== "sessionUpdate") continue; + const u = e.sessionUpdate ?? "?"; + updateTypes.add(u); + const d = e.data ?? {}; + if (u === "tool_call") { + if (d.kind) toolKinds.add(d.kind); + if (d.status) toolStatuses.add(d.status); + } + if (u === "tool_call_update") { + if (d.status) toolStatuses.add(d.status); + const content = d.content ?? []; + if (Array.isArray(content)) { + for (const c of content) { + if (c?.type === "diff") hasDiff = true; + if (c?.type === "content") hasToolContent = true; + } + } + if (d.rawInput?.diff || (typeof d.rawOutput === "string" && d.rawOutput.includes("diff"))) hasDiff = true; + } + if (u === "current_mode_update" || u === "config_option_update") modeUpdate = true; + if (u === "usage_update") usageFields = new Set([...usageFields, ...Object.keys(d.usage ?? d ?? {})]); + } + + // newSession response: configOptions / modes + const ns = run.stepResults.find((s) => s.op === "newSession")?.result ?? {}; + const configCategories = (ns.configOptions ?? []).map((o: any) => o.category).filter(Boolean); + const modes = ns.modes ?? null; + // prompt response usage / stopReason + const promptRes = run.stepResults.filter((s) => s.op === "prompt").map((s) => s.result ?? {}); + const stopReasons = promptRes.map((r) => r.stopReason).filter(Boolean); + const promptUsage = promptRes.some((r) => r.usage && Object.keys(r.usage).length > 0); + + return { + fatalError: run.fatalError ?? null, + updateTypes: [...updateTypes].sort(), + toolKinds: [...toolKinds].sort(), + toolStatuses: [...toolStatuses].sort(), + hasDiffContent: hasDiff, + hasToolContent: hasToolContent, + hasUsage: promptUsage || updateTypes.has("usage_update") || extNotifs.has("_posthog/usage_update"), + usageFields: [...usageFields].sort(), + configOptionCategories: [...new Set(configCategories)].sort(), + modesPresent: !!modes, + modeChangeEmitted: modeUpdate, + approvalsRequested: approvals.length, + extNotifications: [...extNotifs].sort(), + stopReasons, + steps: run.stepResults.map((s) => ({ op: s.op, ok: s.ok, error: s.error })), + }; +} + +// Adapter-level features must match for parity. tool-rendering features depend +// on which tools the model chose (native codex edits via shell `execute`; +// codex-acp exposes Edit/Read) — a tool-surface difference, not an adapter bug — +// so they're reported as behavioral, not counted as parity gaps. +const ADAPTER_KEYS = ["fatalError", "updateTypes", "hasUsage", "usageFields", "configOptionCategories", "modesPresent", "modeChangeEmitted", "extNotifications", "stopReasons"]; +const BEHAVIORAL_KEYS = ["toolKinds", "toolStatuses", "hasDiffContent", "hasToolContent"]; + +function diffFeatures(acp: Record, app: Record): Array<{ feature: string; acp: any; appServer: any; match: boolean; behavioral: boolean }> { + const j = (v: any) => JSON.stringify(v); + const mk = (k: string, behavioral: boolean) => ({ feature: k, acp: acp[k], appServer: app[k], match: j(acp[k]) === j(app[k]), behavioral }); + return [...ADAPTER_KEYS.map((k) => mk(k, false)), ...BEHAVIORAL_KEYS.map((k) => mk(k, true))]; +} + +function setupRepo(): void { + if (!existsSync(REPO)) mkdirSync(REPO, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: REPO }); + writeFileSync(join(REPO, "target.txt"), "line1\nline2\nline3\n"); + execFileSync("git", ["add", "-A"], { cwd: REPO }); + try { + // -c commit.gpgsign=false: ignore the user's global commit-signing config + // (e.g. 1Password SSH signer), which fails in this non-interactive context. + execFileSync("git", ["-c", "commit.gpgsign=false", "-c", "user.email=p@p.dev", "-c", "user.name=parity", "commit", "-qm", "init"], { cwd: REPO }); + } catch { + /* already committed */ + } +} + +async function main(): Promise { + const args = process.argv.slice(2); + const only = args.includes("--only") ? (args[args.indexOf("--only") + 1] as AdapterMode) : null; + const scenarioFilter = args.includes("--scenario") ? args[args.indexOf("--scenario") + 1] : null; + mkdirSync(OUT_DIR, { recursive: true }); + setupRepo(); + + const modes: AdapterMode[] = []; + if (!only || only === "acp") modes.push("acp"); + if ((!only || only === "app-server") && existsSync(NATIVE_CODEX_BIN)) modes.push("app-server"); + else if (only === "app-server") console.warn(`native codex binary missing at ${NATIVE_CODEX_BIN}; app-server arm skipped`); + + const scenarios = SCENARIOS.filter((s) => !scenarioFilter || s.name === scenarioFilter); + const logger = new Logger({ debug: !!process.env.PARITY_DEBUG, prefix: "[parity]" }); + const featuresByMode: Record> = {}; + + for (const scenario of scenarios) { + featuresByMode[scenario.name] = {}; + for (const mode of modes) { + console.log(`\n▶ ${scenario.name} via ${mode} ...`); + // codex spawns detached (own process group); a timed-out run orphans it + // holding a flock under ~/.codex/tmp, which wedges the next run. Kill any + // stragglers first — process death releases the flock. (Uses the default + // CODEX_HOME: an isolated empty home makes codex-acp crash at startup.) + try { + execFileSync("pkill", ["-9", "-f", "resources/codex-acp"], { stdio: "ignore" }); + } catch { + /* none running */ + } + const cfg = { + cwd: REPO, + codexOptions: { cwd: REPO, binaryPath: CODEX_ACP_BIN, apiBaseUrl: GATEWAY, apiKey: API_KEY, model: MODEL }, + timeoutMs: 240000, + logger, + }; + const run = await runScenario(mode, scenario, cfg); + writeFileSync(join(OUT_DIR, `${scenario.name}.${mode}.json`), JSON.stringify(run, null, 2)); + const feats = extractFeatures(run); + featuresByMode[scenario.name][mode] = feats; + writeFileSync(join(OUT_DIR, `${scenario.name}.${mode}.features.json`), JSON.stringify(feats, null, 2)); + console.log(` steps: ${feats.steps.map((s: any) => `${s.op}${s.ok ? "✓" : "✗"}`).join(" ")}`); + console.log(` updates: ${feats.updateTypes.join(",")} | tools: ${feats.toolKinds.join(",")} | usage:${feats.hasUsage} diff:${feats.hasDiffContent} stop:${feats.stopReasons.join(",")}`); + if (feats.fatalError) console.log(` ⚠ fatalError: ${feats.fatalError}`); + } + } + + // Diff report (only meaningful when both arms ran) + const report: any = { gateway: GATEWAY, model: MODEL, scenarios: {} }; + let totalGaps = 0; + for (const scenario of scenarios) { + const acp = featuresByMode[scenario.name].acp; + const app = featuresByMode[scenario.name]["app-server"]; + if (acp && app) { + const diff = diffFeatures(acp, app); + const gaps = diff.filter((d) => !d.match && !d.behavioral); + const behavioral = diff.filter((d) => !d.match && d.behavioral); + totalGaps += gaps.length; + report.scenarios[scenario.name] = { gaps, behavioral, allMatch: gaps.length === 0 }; + console.log(`\n=== parity diff: ${scenario.name} ===`); + if (!gaps.length) console.log(" ✅ adapter parity"); + for (const g of gaps) console.log(` ✗ ${g.feature}: acp=${JSON.stringify(g.acp)} app-server=${JSON.stringify(g.appServer)}`); + for (const b of behavioral) console.log(` · behavioral: ${b.feature} acp=${JSON.stringify(b.acp)} app-server=${JSON.stringify(b.appServer)}`); + } else { + report.scenarios[scenario.name] = { baselineOnly: acp ? "acp" : "app-server", features: acp ?? app }; + } + } + writeFileSync(join(OUT_DIR, "parity-report.json"), JSON.stringify(report, null, 2)); + console.log(`\nWrote ${join(OUT_DIR, "parity-report.json")} — ${totalGaps} parity gap(s).`); + process.exit(totalGaps > 0 ? 1 : 0); +} + +main().catch((e) => { + console.error("parity runner failed:", e); + process.exit(2); +}); diff --git a/packages/agent/src/adapters/acp-connection.test.ts b/packages/agent/src/adapters/acp-connection.test.ts new file mode 100644 index 0000000000..b9ec4c5c64 --- /dev/null +++ b/packages/agent/src/adapters/acp-connection.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { resolveUseCodexAppServer } from "./acp-connection"; + +describe("resolveUseCodexAppServer", () => { + const saved = { + app: process.env.POSTHOG_CODEX_USE_APP_SERVER, + acp: process.env.POSTHOG_CODEX_USE_ACP, + }; + afterEach(() => { + if (saved.app === undefined) delete process.env.POSTHOG_CODEX_USE_APP_SERVER; + else process.env.POSTHOG_CODEX_USE_APP_SERVER = saved.app; + if (saved.acp === undefined) delete process.env.POSTHOG_CODEX_USE_ACP; + else process.env.POSTHOG_CODEX_USE_ACP = saved.acp; + }); + + it("host flag wins over env and default", () => { + process.env.POSTHOG_CODEX_USE_ACP = "1"; + process.env.POSTHOG_CODEX_USE_APP_SERVER = "1"; + expect(resolveUseCodexAppServer({ useCodexAppServer: false })).toBe(false); + expect(resolveUseCodexAppServer({ useCodexAppServer: true })).toBe(true); + }); + + it("POSTHOG_CODEX_USE_APP_SERVER=1 forces app-server", () => { + delete process.env.POSTHOG_CODEX_USE_ACP; + process.env.POSTHOG_CODEX_USE_APP_SERVER = "1"; + expect(resolveUseCodexAppServer({})).toBe(true); + }); + + it("POSTHOG_CODEX_USE_ACP=1 forces codex-acp", () => { + delete process.env.POSTHOG_CODEX_USE_APP_SERVER; + process.env.POSTHOG_CODEX_USE_ACP = "1"; + expect(resolveUseCodexAppServer({})).toBe(false); + }); + + it("defaults to app-server when nothing is set", () => { + delete process.env.POSTHOG_CODEX_USE_APP_SERVER; + delete process.env.POSTHOG_CODEX_USE_ACP; + expect(resolveUseCodexAppServer({})).toBe(true); + }); +}); diff --git a/packages/agent/src/adapters/acp-connection.ts b/packages/agent/src/adapters/acp-connection.ts index 97251c8b7c..f083bad881 100644 --- a/packages/agent/src/adapters/acp-connection.ts +++ b/packages/agent/src/adapters/acp-connection.ts @@ -27,6 +27,14 @@ export type AcpConnectionConfig = { processCallbacks?: ProcessSpawnedCallback; codexOptions?: CodexProcessOptions; allowedModelIds?: Set; + /** + * Feature-flag lever for the codex sub-adapter, passed by the host from a + * PostHog flag (gradual rollout / kill-switch). `true` => native app-server, + * `false` => codex-acp. When undefined, falls back to env overrides then the + * bundled-binary default (app-server). Lets app-server run alongside codex-acp + * under controlled rollout without a code change. + */ + useCodexAppServer?: boolean; /** Callback invoked when the agent calls the create_output tool for structured output */ onStructuredOutput?: (output: Record) => Promise; /** PostHog API config; when set, enables file-read enrichment unless disabled. */ @@ -70,6 +78,24 @@ function resolveEnricherApiConfig( return enabled ? config.posthogApiConfig : undefined; } +/** + * Resolves which codex sub-adapter to use. Precedence: host flag + * (`config.useCodexAppServer`, from a PostHog flag) > env overrides + * (`POSTHOG_CODEX_USE_APP_SERVER=1` / `POSTHOG_CODEX_USE_ACP=1`) > bundled-binary + * default (app-server). The host flag is the rollout / kill-switch lever that + * lets app-server run alongside codex-acp without a code change. To run a gradual + * opt-in instead (default codex-acp), the host passes `useCodexAppServer: false` + * by default and `true` for the enabled cohort. + */ +export function resolveUseCodexAppServer(config: AcpConnectionConfig): boolean { + if (typeof config.useCodexAppServer === "boolean") { + return config.useCodexAppServer; + } + if (process.env.POSTHOG_CODEX_USE_APP_SERVER === "1") return true; + if (process.env.POSTHOG_CODEX_USE_ACP === "1") return false; + return true; +} + function createClaudeConnection(config: AcpConnectionConfig): AcpConnection { const logger = config.logger?.child("AcpConnection") ?? @@ -210,10 +236,9 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { const codexOptions = config.codexOptions ?? {}; const nativeBinary = nativeCodexBinaryPath(codexOptions.binaryPath); - // The native app-server is the default Codex harness. Fall back to the - // codex-acp (Zed) adapter only when the codex binary isn't bundled or when - // POSTHOG_CODEX_USE_ACP is set as an escape hatch. - if (nativeBinary && process.env.POSTHOG_CODEX_USE_ACP !== "1") { + // Use the native app-server when its binary is bundled AND the host (flag) + // / env selects it. See resolveUseCodexAppServer for precedence. + if (nativeBinary && resolveUseCodexAppServer(config)) { agent = new CodexAppServerAgent(client, { processOptions: { binaryPath: nativeBinary, @@ -225,6 +250,7 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { model: codexOptions.model, reasoningEffort: codexOptions.reasoningEffort, processCallbacks: config.processCallbacks, + onStructuredOutput: config.onStructuredOutput, logger: config.logger?.child("CodexAppServerAgent"), }); return agent; diff --git a/packages/agent/src/adapters/codex-app-server/app-server-client.test.ts b/packages/agent/src/adapters/codex-app-server/app-server-client.test.ts index db734950b0..d45276c6b1 100644 --- a/packages/agent/src/adapters/codex-app-server/app-server-client.test.ts +++ b/packages/agent/src/adapters/codex-app-server/app-server-client.test.ts @@ -7,7 +7,7 @@ import { import { AppServerClient } from "./app-server-client"; interface RpcMessage { - id?: number; + id?: number | string; method?: string; params?: unknown; result?: unknown; @@ -142,6 +142,30 @@ describe("AppServerClient", () => { await client.close(); }); + it("answers a server request with a STRING id (RequestId is string|number)", async () => { + const streams = createBidirectionalStreams(); + const onRequest = vi.fn(async () => ({ decision: "approved" })); + const client = new AppServerClient(streams.client, { + logger: silentLogger, + onRequest, + }); + const server = makeFakeServer(streams.agent); + + await server.send({ + id: "req-abc", + method: "item/commandExecution/requestApproval", + params: {}, + }); + + const response = await server.readMessage(); + // Routed to onRequest (not silently dropped as a notification) and the + // exact string id is echoed back, so the server can correlate the reply. + expect(onRequest).toHaveBeenCalledTimes(1); + expect(response.id).toBe("req-abc"); + expect(response.result).toEqual({ decision: "approved" }); + await client.close(); + }); + it("rejects in-flight requests when closed", async () => { const streams = createBidirectionalStreams(); const client = new AppServerClient(streams.client, { diff --git a/packages/agent/src/adapters/codex-app-server/app-server-client.ts b/packages/agent/src/adapters/codex-app-server/app-server-client.ts index 1fc5564ced..4915c2d624 100644 --- a/packages/agent/src/adapters/codex-app-server/app-server-client.ts +++ b/packages/agent/src/adapters/codex-app-server/app-server-client.ts @@ -1,6 +1,6 @@ import { Logger } from "../../utils/logger"; import type { StreamPair } from "../../utils/streams"; -import type { JsonRpcMessage, JsonRpcResponse } from "./protocol"; +import type { JsonRpcMessage, JsonRpcResponse, RequestId } from "./protocol"; export interface AppServerClientHandlers { /** Server-pushed notification (no id), e.g. `item/agentMessage/delta`. */ @@ -38,7 +38,7 @@ export interface AppServerRpc { export class AppServerClient implements AppServerRpc { private readonly writer: WritableStreamDefaultWriter; private readonly encoder = new TextEncoder(); - private readonly pending = new Map(); + private readonly pending = new Map(); private readonly handlers: AppServerClientHandlers; private readonly logger: Logger; private reader?: ReadableStreamDefaultReader; @@ -151,20 +151,25 @@ export class AppServerClient implements AppServerRpc { const id = (message as { id?: unknown }).id; const method = (message as { method?: unknown }).method; const params = (message as { params?: unknown }).params; - - if (typeof method !== "string") { - if (typeof id === "number") { - this.handleResponse(message as JsonRpcResponse); + // JSON-RPC framing: a request has both method and id; a notification has a + // method and no id; a response has an id and no method. Discriminate on + // presence, not `typeof id === "number"` — the schema's RequestId is + // `string | number`, so a string-id server request must still be answered + // (keying on number alone silently misroutes it and hangs the turn). + const hasId = id !== undefined && id !== null; + + if (typeof method === "string") { + if (hasId) { + void this.handleIncomingRequest(id as RequestId, method, params); + } else { + this.handlers.onNotification?.(method, params); } return; } - if (typeof id === "number") { - void this.handleIncomingRequest(id, method, params); - return; + if (hasId) { + this.handleResponse(message as JsonRpcResponse); } - - this.handlers.onNotification?.(method, params); } private handleResponse(message: JsonRpcResponse): void { @@ -182,7 +187,7 @@ export class AppServerClient implements AppServerRpc { } private async handleIncomingRequest( - id: number, + id: RequestId, method: string, params: unknown, ): Promise { diff --git a/packages/agent/src/adapters/codex-app-server/approvals.test.ts b/packages/agent/src/adapters/codex-app-server/approvals.test.ts new file mode 100644 index 0000000000..e1da8e6381 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/approvals.test.ts @@ -0,0 +1,257 @@ +import type { + RequestPermissionRequest, + RequestPermissionResponse, +} from "@agentclientprotocol/sdk"; +import { describe, expect, it, vi } from "vitest"; +import { handleServerRequest } from "./approvals"; +import { APP_SERVER_REQUESTS } from "./protocol"; + +// A fake ACP client whose requestPermission returns whatever the test queues, +// matched positionally to the order requestPermission is called. +function fakeClient(outcomes: RequestPermissionResponse["outcome"][]) { + const calls: RequestPermissionRequest[] = []; + let next = 0; + const requestPermission = vi.fn( + async ( + params: RequestPermissionRequest, + ): Promise => { + calls.push(params); + const outcome = outcomes[next++] ?? { outcome: "cancelled" as const }; + return { outcome }; + }, + ); + return { client: { requestPermission }, calls }; +} + +const opts = { sessionId: "sess-1" }; + +describe("handleServerRequest", () => { + it("maps a requestUserInput question's selected option back to an answer", async () => { + const { client, calls } = fakeClient([ + { outcome: "selected", optionId: "option_1" }, + ]); + + const params = { + threadId: "t", + turnId: "turn", + itemId: "item-9", + autoResolutionMs: null, + questions: [ + { + id: "q1", + header: "Pick one", + question: "Which environment?", + isOther: false, + isSecret: false, + options: [ + { label: "staging", description: "" }, + { label: "production", description: "danger" }, + ], + }, + ], + }; + + const result = await handleServerRequest( + APP_SERVER_REQUESTS.TOOL_USER_INPUT, + params, + client, + opts, + ); + + expect(result.handled).toBe(true); + expect(result.response).toEqual({ + answers: { q1: { answers: ["production"] } }, + }); + + // Prompt carried the question's options and the session id. + expect(calls).toHaveLength(1); + expect(calls[0].sessionId).toBe("sess-1"); + expect(calls[0].options.map((o) => o.name)).toEqual([ + "staging", + "production", + ]); + }); + + it("defaults a cancelled question to an empty answer", async () => { + const { client } = fakeClient([{ outcome: "cancelled" }]); + + const params = { + threadId: "t", + turnId: "turn", + itemId: "item-1", + autoResolutionMs: null, + questions: [ + { + id: "q1", + header: "h", + question: "q?", + isOther: false, + isSecret: false, + options: [{ label: "a", description: "" }], + }, + ], + }; + + const result = await handleServerRequest( + APP_SERVER_REQUESTS.TOOL_USER_INPUT, + params, + client, + opts, + ); + + expect(result.response).toEqual({ answers: { q1: { answers: [] } } }); + }); + + it("grants the requested permission profile for the turn on allow", async () => { + const { client } = fakeClient([{ outcome: "selected", optionId: "allow" }]); + + const params = { + threadId: "t", + turnId: "turn", + itemId: "perm-1", + environmentId: null, + startedAtMs: 0, + cwd: "/repo", + reason: "needs network", + permissions: { + network: { enabled: true }, + fileSystem: null, + }, + }; + + const result = await handleServerRequest( + APP_SERVER_REQUESTS.PERMISSIONS_APPROVAL, + params, + client, + opts, + ); + + expect(result.handled).toBe(true); + // "allow_once" click grants for the turn, not session-wide. + expect(result.response).toEqual({ + permissions: { network: { enabled: true } }, + scope: "turn", + }); + }); + + it("fails closed to the safe default when a payload is malformed", async () => { + // null params makes the handler throw on param access; it must deny, not raise. + const { client } = fakeClient([{ outcome: "selected", optionId: "allow" }]); + const result = await handleServerRequest( + APP_SERVER_REQUESTS.PERMISSIONS_APPROVAL, + null, + client, + opts, + ); + expect(result).toEqual({ + handled: true, + response: { permissions: {}, scope: "turn" }, + }); + }); + + it("denies a permission request with an empty profile on reject", async () => { + const { client } = fakeClient([ + { outcome: "selected", optionId: "reject" }, + ]); + + const params = { + threadId: "t", + turnId: "turn", + itemId: "perm-2", + environmentId: null, + startedAtMs: 0, + cwd: "/repo", + reason: null, + permissions: { network: { enabled: true }, fileSystem: null }, + }; + + const result = await handleServerRequest( + APP_SERVER_REQUESTS.PERMISSIONS_APPROVAL, + params, + client, + opts, + ); + + expect(result.response).toEqual({ permissions: {}, scope: "turn" }); + }); + + it("returns an accept elicitation response when the user accepts", async () => { + const { client } = fakeClient([ + { outcome: "selected", optionId: "accept" }, + ]); + + const params = { + threadId: "t", + turnId: "turn", + serverName: "posthog", + mode: "form", + message: "Confirm the export", + }; + + const result = await handleServerRequest( + APP_SERVER_REQUESTS.MCP_ELICITATION, + params, + client, + opts, + ); + + expect(result.handled).toBe(true); + expect(result.response).toEqual({ + action: "accept", + content: {}, + _meta: null, + }); + }); + + it("declines an elicitation when the user rejects", async () => { + const { client } = fakeClient([ + { outcome: "selected", optionId: "decline" }, + ]); + + const result = await handleServerRequest( + APP_SERVER_REQUESTS.MCP_ELICITATION, + { + threadId: "t", + turnId: null, + serverName: "x", + mode: "url", + message: "", + }, + client, + opts, + ); + + expect(result.response).toEqual({ + action: "decline", + content: null, + _meta: null, + }); + }); + + it("returns handled:false for the simple command approval (caller owns it)", async () => { + const { client, calls } = fakeClient([]); + + const result = await handleServerRequest( + APP_SERVER_REQUESTS.COMMAND_APPROVAL, + { itemId: "x", command: "ls" }, + client, + opts, + ); + + expect(result).toEqual({ handled: false, response: undefined }); + expect(calls).toHaveLength(0); + }); + + it("returns handled:false for an unknown method", async () => { + const { client } = fakeClient([]); + + const result = await handleServerRequest( + "some/unknown/method", + {}, + client, + opts, + ); + + expect(result).toEqual({ handled: false, response: undefined }); + }); +}); diff --git a/packages/agent/src/adapters/codex-app-server/approvals.ts b/packages/agent/src/adapters/codex-app-server/approvals.ts new file mode 100644 index 0000000000..b98e85af79 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/approvals.ts @@ -0,0 +1,382 @@ +/** + * Handlers for the richer Codex app-server server-requests that carry distinct + * response shapes rather than a yes/no approval decision string. + * + * The hub agent's `handleApproval` already covers the two simple approvals + * (`item/commandExecution/requestApproval`, `item/fileChange/requestApproval`) + * by returning "accept"/"decline"/"cancel". The three requests below each + * expect a *typed response object*, so they live here: + * + * - `item/tool/requestUserInput` — AskUserQuestion-style multi-question prompt; + * response is `{ answers: { [questionId]: { answers: string[] } } }`. + * - `item/permissions/requestApproval` — grant a permission profile for a + * turn/session; response is `{ permissions, scope }`. + * - `mcpServer/elicitation/request` — an MCP server asking the user for + * structured input; response is `{ action, content }`. + * + * We surface each to the ACP client through `requestPermission` (the only + * user-prompt primitive ACP gives an agent), mirroring how the Claude adapter + * maps AskUserQuestion options to permission options and the selected optionId + * back to an answer. On cancel/error we always default to the safe outcome + * (empty answers / no permissions granted / decline) so a dropped prompt never + * silently grants access. + */ + +import type { + AgentSideConnection, + PermissionOption, + RequestPermissionResponse, +} from "@agentclientprotocol/sdk"; +import type { Logger } from "../../utils/logger"; +// Shared with the Claude adapter so synthesized permission option ids round-trip +// the same way across adapters. +import { OPTION_PREFIX } from "../claude/questions/utils"; +import { APP_SERVER_REQUESTS } from "./protocol"; + +// Native app-server param/response shapes (subset of /tmp/codex-schema/v2). +// Re-declared locally so this module stays self-contained and does not depend +// on the generated schema package being present at build time. + +interface ToolRequestUserInputOption { + label: string; + description: string; +} + +interface ToolRequestUserInputQuestion { + id: string; + header: string; + question: string; + isOther: boolean; + isSecret: boolean; + options: ToolRequestUserInputOption[] | null; +} + +interface ToolRequestUserInputParams { + threadId: string; + turnId: string; + itemId: string; + questions: ToolRequestUserInputQuestion[]; + autoResolutionMs: number | null; +} + +interface ToolRequestUserInputResponse { + answers: { [questionId: string]: { answers: string[] } }; +} + +interface AdditionalNetworkPermissions { + enabled: boolean | null; +} + +interface AdditionalFileSystemPermissions { + read: string[] | null; + write: string[] | null; + globScanMaxDepth?: number; + entries?: unknown[]; +} + +interface RequestPermissionProfile { + network: AdditionalNetworkPermissions | null; + fileSystem: AdditionalFileSystemPermissions | null; +} + +interface PermissionsRequestApprovalParams { + threadId: string; + turnId: string; + itemId: string; + environmentId: string | null; + startedAtMs: number; + cwd: string; + reason: string | null; + permissions: RequestPermissionProfile; +} + +interface GrantedPermissionProfile { + network?: AdditionalNetworkPermissions; + fileSystem?: AdditionalFileSystemPermissions; +} + +type PermissionGrantScope = "turn" | "session"; + +interface PermissionsRequestApprovalResponse { + permissions: GrantedPermissionProfile; + scope: PermissionGrantScope; +} + +type McpServerElicitationAction = "accept" | "decline" | "cancel"; + +interface McpServerElicitationRequestParams { + threadId: string; + turnId: string | null; + serverName: string; + mode: "form" | "url"; + message: string; + // form mode carries requestedSchema; url mode carries url/elicitationId. + // We only need `message` to render the prompt, so the rest is untyped here. + [key: string]: unknown; +} + +interface McpServerElicitationRequestResponse { + action: McpServerElicitationAction; + content: unknown | null; + _meta?: unknown | null; +} + +export interface HandleServerRequestResult { + // false → not one of the richer requests; the caller should fall through to + // its own handling (e.g. the simple command/file approvals). + handled: boolean; + response: unknown; +} + +export interface HandleServerRequestOptions { + sessionId: string; + logger?: Logger; +} + +/** + * Routes a server-initiated request to the matching richer-response handler. + * Returns `{ handled: false }` for anything this module does not own (including + * the two simple approvals and unknown methods) so the caller keeps control. + */ +export async function handleServerRequest( + method: string, + params: unknown, + client: Pick, + opts: HandleServerRequestOptions, +): Promise { + try { + switch (method) { + case APP_SERVER_REQUESTS.TOOL_USER_INPUT: + return { + handled: true, + response: await handleToolUserInput( + params as ToolRequestUserInputParams, + client, + opts, + ), + }; + case APP_SERVER_REQUESTS.PERMISSIONS_APPROVAL: + return { + handled: true, + response: await handlePermissionsApproval( + params as PermissionsRequestApprovalParams, + client, + opts, + ), + }; + case APP_SERVER_REQUESTS.MCP_ELICITATION: + return { + handled: true, + response: await handleMcpElicitation( + params as McpServerElicitationRequestParams, + client, + opts, + ), + }; + default: + return { handled: false, response: undefined }; + } + } catch (err) { + // A malformed payload must fail CLOSED to the method's safe default — never + // throw (the hub would surface a JSON-RPC error the server may treat + // ambiguously) and never grant. + opts.logger?.warn("server-request handler threw; failing closed", { + method, + error: String(err), + }); + return { handled: true, response: safeDefaultFor(method) }; + } +} + +/** Fail-closed default response per richer-approval method. */ +function safeDefaultFor(method: string): unknown { + if (method === APP_SERVER_REQUESTS.PERMISSIONS_APPROVAL) { + return { permissions: {}, scope: "turn" }; + } + if (method === APP_SERVER_REQUESTS.MCP_ELICITATION) { + return { action: "decline", content: null, _meta: null }; + } + return { answers: {} }; +} + +function buildQuestionOptions( + question: ToolRequestUserInputQuestion, +): PermissionOption[] { + return (question.options ?? []).map((opt, idx) => ({ + kind: "allow_once" as const, + name: opt.label, + optionId: `${OPTION_PREFIX}${idx}`, + _meta: opt.description ? { description: opt.description } : undefined, + })); +} + +// Maps a selected permission optionId back to the chosen option's label. The id +// is `option_`, so we index back into the question's options. +function answerFromSelection( + question: ToolRequestUserInputQuestion, + optionId: string | undefined, +): string[] { + if (!optionId || !optionId.startsWith(OPTION_PREFIX)) { + return []; + } + const idx = Number(optionId.slice(OPTION_PREFIX.length)); + const opt = question.options?.[idx]; + return opt ? [opt.label] : []; +} + +async function handleToolUserInput( + params: ToolRequestUserInputParams, + client: Pick, + opts: HandleServerRequestOptions, +): Promise { + const answers: ToolRequestUserInputResponse["answers"] = {}; + + for (const question of params.questions ?? []) { + // Default each question to "no answer" so a cancel or failure leaves a + // well-formed, empty response rather than a missing key. + answers[question.id] = { answers: [] }; + + const options = buildQuestionOptions(question); + // Free-text ("other"/secret) questions have no selectable options; we can't + // collect typed input over requestPermission, so leave them empty. + if (options.length === 0) { + continue; + } + + let response: RequestPermissionResponse; + try { + response = await client.requestPermission({ + sessionId: opts.sessionId, + options, + toolCall: { + toolCallId: `${params.itemId}:${question.id}`, + title: question.question, + kind: "other", + _meta: { codeToolKind: "question", header: question.header }, + }, + }); + } catch (err) { + opts.logger?.warn("requestUserInput prompt failed; leaving empty", { + questionId: question.id, + error: String(err), + }); + continue; + } + + if (response.outcome.outcome !== "selected") { + // Cancelled → keep the safe empty default. + continue; + } + answers[question.id] = { + answers: answerFromSelection(question, response.outcome.optionId), + }; + } + + return { answers }; +} + +async function handlePermissionsApproval( + params: PermissionsRequestApprovalParams, + client: Pick, + opts: HandleServerRequestOptions, +): Promise { + const denied: PermissionsRequestApprovalResponse = { + permissions: {}, + scope: "turn", + }; + + let response: RequestPermissionResponse; + try { + response = await client.requestPermission({ + sessionId: opts.sessionId, + options: [ + { kind: "allow_once", name: "Allow", optionId: "allow" }, + { kind: "reject_once", name: "Reject", optionId: "reject" }, + ], + toolCall: { + toolCallId: params.itemId, + title: params.reason ?? "Grant additional permissions", + kind: "other", + }, + }); + } catch (err) { + opts.logger?.warn("permissions approval prompt failed; denying", { + itemId: params.itemId, + error: String(err), + }); + return denied; + } + + if ( + response.outcome.outcome === "selected" && + response.outcome.optionId === "allow" + ) { + // Grant exactly what was requested, scoped to this turn — the option is + // "allow_once", so a single click must not grant session-wide access. + return { + permissions: grantedFromRequested(params.permissions), + scope: "turn", + }; + } + return denied; +} + +function grantedFromRequested( + requested: RequestPermissionProfile, +): GrantedPermissionProfile { + const granted: GrantedPermissionProfile = {}; + if (requested.network) { + granted.network = requested.network; + } + if (requested.fileSystem) { + granted.fileSystem = requested.fileSystem; + } + return granted; +} + +async function handleMcpElicitation( + params: McpServerElicitationRequestParams, + client: Pick, + opts: HandleServerRequestOptions, +): Promise { + const declined: McpServerElicitationRequestResponse = { + action: "decline", + content: null, + _meta: null, + }; + + let response: RequestPermissionResponse; + try { + response = await client.requestPermission({ + sessionId: opts.sessionId, + options: [ + { kind: "allow_once", name: "Accept", optionId: "accept" }, + { kind: "reject_once", name: "Decline", optionId: "decline" }, + ], + toolCall: { + toolCallId: `${params.serverName}:elicitation`, + title: params.message || `${params.serverName} requests input`, + kind: "other", + }, + }); + } catch (err) { + opts.logger?.warn("elicitation prompt failed; declining", { + serverName: params.serverName, + error: String(err), + }); + return declined; + } + + if (response.outcome.outcome === "cancelled") { + return { action: "cancel", content: null, _meta: null }; + } + if ( + response.outcome.outcome === "selected" && + response.outcome.optionId === "accept" + ) { + // We have no structured form UI over requestPermission, so accept with no + // content. The MCP server treats this as an empty-but-accepted result. + return { action: "accept", content: {}, _meta: null }; + } + return declined; +} diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index 140c4abed1..ed3055e8bd 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -47,13 +47,17 @@ function makeFakeClient( outcome: unknown = { outcome: "selected", optionId: "allow" }, ) { const sessionUpdates: unknown[] = []; + const extNotifications: Array<{ method: string; params: unknown }> = []; const client = { sessionUpdate: async (notification: unknown) => { sessionUpdates.push(notification); }, requestPermission: async () => ({ outcome }), + extNotification: async (method: string, params: unknown) => { + extNotifications.push({ method, params }); + }, } as unknown as AgentSideConnection; - return { client, sessionUpdates }; + return { client, sessionUpdates, extNotifications }; } const init = { protocolVersion: 1 } as unknown as InitializeRequest; @@ -83,7 +87,7 @@ describe("CodexAppServerAgent", () => { prompt: [{ type: "text", text: "hello" }], } as unknown as PromptRequest); - stub.emit("item/agentMessage/delta", { itemId: "i1", text: "Hi there" }); + stub.emit("item/agentMessage/delta", { itemId: "i1", delta: "Hi there" }); stub.emit("turn/completed", { turn: { id: "turn_1", status: "completed" }, }); @@ -105,6 +109,320 @@ describe("CodexAppServerAgent", () => { }); }); + it("passes outputSchema to turn/start and fires onStructuredOutput", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient(); + const outputs: Array> = []; + const schema = { + type: "object", + properties: { repo: { type: "string" } }, + required: ["repo"], + }; + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + onStructuredOutput: async (o) => { + outputs.push(o); + }, + }); + + await agent.newSession({ + cwd: "/r", + _meta: { jsonSchema: schema }, + } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "pick a repo" }], + } as unknown as PromptRequest); + + // The schema-constrained final message is pure JSON. + stub.emit("item/completed", { + item: { + type: "agentMessage", + id: "a1", + text: '{"repo":"posthog/posthog"}', + }, + }); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await done; + + const turnStart = stub.requests.find((r) => r.method === "turn/start"); + expect(turnStart?.params).toMatchObject({ outputSchema: schema }); + expect(outputs).toEqual([{ repo: "posthog/posthog" }]); + }); + + it("injects task instructions and mcp_servers into thread/start", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { + binaryPath: "/x/codex", + developerInstructions: "Codex guidance.", + }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ + cwd: "/r", + _meta: { systemPrompt: "You are a repo selector." }, + mcpServers: [ + { + name: "posthog", + command: "node", + args: ["server.js"], + env: [{ name: "TOKEN", value: "abc" }], + }, + ], + } as unknown as NewSessionRequest); + + const threadStart = stub.requests.find((r) => r.method === "thread/start"); + expect(threadStart?.params).toMatchObject({ + developerInstructions: "Codex guidance.\n\nYou are a repo selector.", + config: { + mcp_servers: { + posthog: { + command: "node", + args: ["server.js"], + env: { TOKEN: "abc" }, + }, + }, + }, + }); + }); + + it("flattens the host's {append} systemPrompt and dedupes it against developerInstructions", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { + binaryPath: "/x/codex", + // The host pre-flattens the session prompt into developerInstructions + // for codex AND also sends the raw {append} form as _meta.systemPrompt. + developerInstructions: "Be a careful engineer.", + }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ + cwd: "/r", + _meta: { systemPrompt: { append: "Be a careful engineer." } }, + } as unknown as NewSessionRequest); + + const threadStart = stub.requests.find((r) => r.method === "thread/start"); + // {append} is flattened (NOT "[object Object]") and, being identical to the + // pre-flattened developerInstructions, deduped to a single copy. + expect( + (threadStart?.params as { developerInstructions?: string }) + .developerInstructions, + ).toBe("Be a careful engineer."); + }); + + it("appends a distinct {append} systemPrompt to developerInstructions", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { + binaryPath: "/x/codex", + developerInstructions: "Codex base guidance.", + }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ + cwd: "/r", + _meta: { systemPrompt: { append: "Task: fix the bug." } }, + } as unknown as NewSessionRequest); + + const threadStart = stub.requests.find((r) => r.method === "thread/start"); + expect( + (threadStart?.params as { developerInstructions?: string }) + .developerInstructions, + ).toBe("Codex base guidance.\n\nTask: fix the bug."); + }); + + it("honors the host's initial _meta.permissionMode (read-only) in turn/start", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "turn/start": { turn: { id: "turn_1" } }, + }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + await agent.newSession({ + cwd: "/r", + _meta: { permissionMode: "read-only" }, + } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await done; + + const turnStart = stub.requests.find((r) => r.method === "turn/start"); + // read-only maps to approvalPolicy "untrusted" (mirrors codex-acp). + expect( + (turnStart?.params as { approvalPolicy?: string }).approvalPolicy, + ).toBe("untrusted"); + }); + + it("falls back to auto for a non-codex initial permissionMode", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "turn/start": { turn: { id: "turn_1" } }, + }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + // "bypassPermissions" is a Claude mode, not a codex mode → default "auto". + await agent.newSession({ + cwd: "/r", + _meta: { permissionMode: "bypassPermissions" }, + } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await done; + + const turnStart = stub.requests.find((r) => r.method === "turn/start"); + expect( + (turnStart?.params as { approvalPolicy?: string }).approvalPolicy, + ).toBe("on-request"); + }); + + it("returns model + thought_level configOptions and emits config_option_update", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "model/list": { + data: [ + { + id: "gpt-5.5", + model: "gpt-5.5", + displayName: "GPT-5.5", + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: "low" }, + { reasoningEffort: "high" }, + ], + }, + ], + }, + }); + const { client, sessionUpdates } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + const session = await agent.newSession({ + cwd: "/r", + } as unknown as NewSessionRequest); + const opts = (session.configOptions ?? []) as any[]; + expect(opts.map((o) => o.category)).toEqual(["model", "thought_level"]); + expect( + opts + .find((o) => o.category === "thought_level") + .options.map((x: any) => x.value), + ).toEqual(["low", "high"]); + expect( + sessionUpdates.some( + (u: any) => u.update?.sessionUpdate === "config_option_update", + ), + ).toBe(true); + }); + + it("setSessionConfigOption switches the model and re-emits config", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const res = await agent.setSessionConfigOption({ + configId: "model", + value: "gpt-6", + sessionId: "t", + } as any); + const modelOpt = (res.configOptions as any[]).find( + (o) => o.category === "model", + ); + expect(modelOpt.currentValue).toBe("gpt-6"); + }); + + it("resumeSession resumes the existing thread and returns configOptions", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t1" } }, + "thread/resume": { thread: { id: "t1" } }, + }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const res = await agent.resumeSession({ + sessionId: "t1", + cwd: "/r", + mcpServers: [], + } as any); + const resumeReq = stub.requests.find((r) => r.method === "thread/resume"); + expect(resumeReq?.params).toMatchObject({ threadId: "t1" }); + expect((res.configOptions as any[]).length).toBeGreaterThan(0); + }); + + it("listSessions maps thread/list to ACP sessions", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "thread/list": { + data: [ + { id: "t1", cwd: "/r", name: "Task 1" }, + { id: "t2", cwd: "/r2" }, + ], + }, + }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const res = await agent.listSessions({ cwd: "/r" } as any); + expect(res.sessions).toEqual([ + { sessionId: "t1", cwd: "/r", title: "Task 1" }, + { sessionId: "t2", cwd: "/r2" }, + ]); + }); + + it("forkSession forks and returns a session id", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t1" } }, + "thread/fork": { thread: { id: "t2" } }, + }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const res = await agent.unstable_forkSession({ + sessionId: "t1", + cwd: "/r", + mcpServers: [], + } as any); + expect(res.sessionId).toBe("t2"); + }); + it("maps a failed turn to a refusal stop reason", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); const { client } = makeFakeClient(); @@ -116,14 +434,111 @@ describe("CodexAppServerAgent", () => { await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); const done = agent.prompt({ sessionId: "t", - prompt: [], + prompt: [{ type: "text", text: "go" }], } as unknown as PromptRequest); stub.emit("turn/completed", { turn: { status: "failed" } }); expect((await done).stopReason).toBe("refusal"); }); - it("routes command approvals to the host and maps allow to accept", async () => { + it("maps an interrupted turn to cancelled", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + stub.emit("turn/completed", { turn: { status: "interrupted" } }); + + expect((await done).stopReason).toBe("cancelled"); + }); + + it("finalizes the turn on a non-retried error notification", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + // willRetry:false must resolve the turn rather than hang until stream close. + stub.emit("error", { willRetry: false, error: { message: "boom" } }); + + expect((await done).stopReason).toBe("refusal"); + }); + + it("ends the turn without turn/start when no prompt block is usable", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const res = await agent.prompt({ + sessionId: "t", + prompt: [{ type: "audio", data: "AAAA", mimeType: "audio/wav" }], + } as unknown as PromptRequest); + + expect(res.stopReason).toBe("end_turn"); + expect(stub.requests.some((r) => r.method === "turn/start")).toBe(false); + }); + + it("finalizes a turn once when error and turn/completed both arrive", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const outputs: Array> = []; + const { client, extNotifications } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + onStructuredOutput: async (o) => { + outputs.push(o); + }, + }); + const schema = { + type: "object", + properties: { ok: { type: "boolean" } }, + required: ["ok"], + }; + + await agent.newSession({ + cwd: "/r", + _meta: { jsonSchema: schema, taskRunId: "run_x" }, + } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + + stub.emit("item/completed", { + item: { type: "agentMessage", id: "a1", text: '{"ok":true}' }, + }); + // A fatal error AND turn/completed for the same turn must not double-fire + // the structured-output callback or the _posthog/turn_complete notification. + stub.emit("error", { willRetry: false, error: { message: "boom" } }); + stub.emit("turn/completed", { turn: { status: "failed" } }); + await done; + + expect(outputs).toEqual([{ ok: true }]); + expect( + extNotifications.filter((n) => n.method === "_posthog/turn_complete") + .length, + ).toBe(1); + }); + + it("routes command approvals to the host and maps allow to a decision envelope", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); const { client } = makeFakeClient(); const agent = new CodexAppServerAgent(client, { @@ -137,7 +552,7 @@ describe("CodexAppServerAgent", () => { { itemId: "i", command: "ls -la" }, ); - expect(decision).toBe("accept"); + expect(decision).toEqual({ decision: "accept" }); }); it("rejects the pending turn when the app-server stream closes", async () => { @@ -170,7 +585,7 @@ describe("CodexAppServerAgent", () => { await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); const done = agent.prompt({ sessionId: "t", - prompt: [], + prompt: [{ type: "text", text: "go" }], } as unknown as PromptRequest); await agent.cancel({ sessionId: "t" }); @@ -179,6 +594,35 @@ describe("CodexAppServerAgent", () => { expect(stub.requests.some((r) => r.method === "turn/interrupt")).toBe(true); }); + it("emits _posthog/turn_complete with cancelled on interrupt (matches codex-acp)", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client, extNotifications } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ + cwd: "/r", + _meta: { taskRunId: "run_c" }, + } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + await agent.cancel({ sessionId: "t" }); + + expect((await done).stopReason).toBe("cancelled"); + // A cancelled turn still emits the cloud idle signal, exactly once. + const tcs = extNotifications.filter( + (n) => n.method === "_posthog/turn_complete", + ); + expect(tcs).toHaveLength(1); + expect((tcs[0].params as { stopReason?: string }).stopReason).toBe( + "cancelled", + ); + }); + it("rejects a concurrent prompt while a turn is in progress", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); const { client } = makeFakeClient(); @@ -190,11 +634,14 @@ describe("CodexAppServerAgent", () => { await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); const first = agent.prompt({ sessionId: "t", - prompt: [], + prompt: [{ type: "text", text: "go" }], } as unknown as PromptRequest); await expect( - agent.prompt({ sessionId: "t", prompt: [] } as unknown as PromptRequest), + agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "again" }], + } as unknown as PromptRequest), ).rejects.toThrow(/already in progress/); stub.emit("turn/completed", { turn: { status: "completed" } }); @@ -242,7 +689,7 @@ describe("CodexAppServerAgent", () => { await stub.invokeRequest("item/fileChange/requestApproval", { itemId: "i", }), - ).toBe("decline"); + ).toEqual({ decision: "decline" }); }); it("maps a cancelled approval to cancel", async () => { @@ -259,6 +706,444 @@ describe("CodexAppServerAgent", () => { itemId: "i", command: "ls", }), - ).toBe("cancel"); + ).toEqual({ decision: "cancel" }); + }); + + it("folds a mid-turn prompt into the running turn via turn/steer", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "turn/start": { turn: { id: "turn_1" } }, + }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const first = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "one" }], + } as unknown as PromptRequest); + + // The active turn id arrives via turn/started; it's the steer precondition. + stub.emit("turn/started", { threadId: "t", turn: { id: "turn_1" } }); + + const second = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "more context" }], + } as unknown as PromptRequest); + + // The single turn/completed resolves both the original and the folded prompt. + stub.emit("turn/completed", { turn: { status: "completed" } }); + expect((await first).stopReason).toBe("end_turn"); + expect((await second).stopReason).toBe("end_turn"); + + const steer = stub.requests.find((r) => r.method === "turn/steer"); + expect(steer?.params).toMatchObject({ + threadId: "t", + expectedTurnId: "turn_1", + input: [{ type: "text", text: "more context" }], + }); + // Only one turn/start — the second prompt steered rather than starting anew. + expect(stub.requests.filter((r) => r.method === "turn/start")).toHaveLength( + 1, + ); + }); + + it("emits _posthog/sdk_session when a taskRunId is present", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "thr_x" } } }); + const { client, extNotifications } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ + cwd: "/r", + _meta: { taskRunId: "run_42" }, + } as unknown as NewSessionRequest); + + expect(extNotifications).toContainEqual({ + method: "_posthog/sdk_session", + params: { taskRunId: "run_42", sessionId: "thr_x", adapter: "codex" }, + }); + }); + + it("does not emit _posthog/sdk_session without a taskRunId", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client, extNotifications } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + expect( + extNotifications.some((n) => n.method === "_posthog/sdk_session"), + ).toBe(false); + }); + + it("emits _posthog/turn_complete and usage breakdown on turn completion", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "turn/start": { turn: { id: "turn_1" } }, + }); + const { client, extNotifications } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ + cwd: "/r", + _meta: { taskRunId: "run_1", systemPrompt: "be terse" }, + } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "hi" }], + } as unknown as PromptRequest); + + stub.emit("thread/tokenUsage/updated", { + threadId: "t", + turnId: "turn_1", + tokenUsage: { + total: { + totalTokens: 100, + inputTokens: 60, + cachedInputTokens: 10, + outputTokens: 30, + reasoningOutputTokens: 5, + }, + modelContextWindow: 200000, + }, + }); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await done; + + const turnComplete = extNotifications.find( + (n) => n.method === "_posthog/turn_complete", + ); + expect(turnComplete?.params).toMatchObject({ + sessionId: "t", + stopReason: "end_turn", + usage: { + inputTokens: 60, + outputTokens: 30, + cachedReadTokens: 10, + cachedWriteTokens: 0, + totalTokens: 100, + }, + }); + // The breakdown variant carries a per-source `breakdown`, not `used`. + const breakdown = extNotifications.find( + (n) => + n.method === "_posthog/usage_update" && + (n.params as { breakdown?: unknown }).breakdown, + ); + expect(breakdown).toBeDefined(); + }); + + it("reports per-turn (not cumulative) usage in turn_complete across turns", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client, extNotifications } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + await agent.newSession({ + cwd: "/r", + _meta: { taskRunId: "run_u" }, + } as unknown as NewSessionRequest); + + // codex reports CUMULATIVE thread totals on each update. + const t1 = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "a" }], + } as unknown as PromptRequest); + stub.emit("thread/tokenUsage/updated", { + tokenUsage: { total: { inputTokens: 100, outputTokens: 50 } }, + }); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await t1; + + const t2 = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "b" }], + } as unknown as PromptRequest); + stub.emit("thread/tokenUsage/updated", { + tokenUsage: { total: { inputTokens: 250, outputTokens: 120 } }, + }); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await t2; + + const tcs = extNotifications.filter( + (n) => n.method === "_posthog/turn_complete", + ); + expect(tcs).toHaveLength(2); + expect( + (tcs[0].params as { usage: Record }).usage, + ).toMatchObject({ + inputTokens: 100, + outputTokens: 50, + }); + // Turn 2 is the DELTA (150/70), not the cumulative 250/120. + expect( + (tcs[1].params as { usage: Record }).usage, + ).toMatchObject({ + inputTokens: 150, + outputTokens: 70, + }); + }); + + it("loadSession resumes the thread and returns configOptions", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t1" } }, + "thread/resume": { thread: { id: "t1" } }, + }); + const { client, extNotifications } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + + const res = await agent.loadSession({ + sessionId: "t1", + cwd: "/r", + mcpServers: [], + _meta: { taskRunId: "run_load" }, + } as unknown as Parameters[0]); + + const resumeReq = stub.requests.find((r) => r.method === "thread/resume"); + expect(resumeReq?.params).toMatchObject({ threadId: "t1" }); + expect((res.configOptions as any[]).length).toBeGreaterThan(0); + // loadSession replays sdk_session so post-reload task tracking still works. + expect(extNotifications).toContainEqual({ + method: "_posthog/sdk_session", + params: { taskRunId: "run_load", sessionId: "t1", adapter: "codex" }, + }); + }); + + it("loadSession replays the resumed thread's persisted transcript", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t1" } }, + "thread/resume": { + thread: { + id: "t1", + turns: [ + { + items: [ + { + type: "userMessage", + id: "u1", + content: [{ type: "text", text: "fix the bug" }], + }, + { + type: "commandExecution", + id: "c1", + command: "ls", + status: "completed", + }, + { type: "agentMessage", id: "a1", text: "fixed it" }, + ], + }, + ], + }, + }, + }); + const { client, sessionUpdates } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + + await agent.loadSession({ + sessionId: "t1", + cwd: "/r", + mcpServers: [], + } as unknown as Parameters[0]); + + const kinds = (sessionUpdates as any[]).map((u) => u.update?.sessionUpdate); + expect(kinds).toEqual( + expect.arrayContaining([ + "user_message_chunk", + "tool_call", + "agent_message_chunk", + ]), + ); + expect(sessionUpdates).toContainEqual({ + sessionId: "t1", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "fix the bug" }, + }, + }); + }); + + it("forwards additionalDirectories to thread/start as writable_roots", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ + cwd: "/repo", + additionalDirectories: ["/repo/pkg-a", "/repo/pkg-b"], + } as unknown as NewSessionRequest); + + const threadStart = stub.requests.find((r) => r.method === "thread/start"); + expect(threadStart?.params).toMatchObject({ + config: { + sandbox_workspace_write: { + writable_roots: ["/repo/pkg-a", "/repo/pkg-b"], + }, + }, + }); + }); + + it("carries an image block through to turn/start input", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "turn/start": { turn: { id: "turn_1" } }, + }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [ + { type: "text", text: "look at this" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], + } as unknown as PromptRequest); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await done; + + const turnStart = stub.requests.find((r) => r.method === "turn/start"); + expect(turnStart?.params).toMatchObject({ + input: [ + { type: "text", text: "look at this", text_elements: [] }, + { type: "image", url: "data:image/png;base64,aGVsbG8=" }, + ], + }); + }); + + it("prepends _meta.prContext to the forwarded turn input but not the echo", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "turn/start": { turn: { id: "turn_1" } }, + }); + const { client, sessionUpdates } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "fix the bug" }], + _meta: { prContext: "PR #123 is open; review before editing." }, + } as unknown as PromptRequest); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await done; + + // prContext is prepended to the FORWARDED prompt (parity with claude + codex-acp). + const turnStart = stub.requests.find((r) => r.method === "turn/start"); + expect( + (turnStart?.params as { input: Array<{ text?: string }> }).input, + ).toEqual([ + { + type: "text", + text: "PR #123 is open; review before editing.", + text_elements: [], + }, + { type: "text", text: "fix the bug", text_elements: [] }, + ]); + // The echoed user turn shows only the real message (no prContext prefix). + const echoes = (sessionUpdates as any[]).filter( + (u) => u.update?.sessionUpdate === "user_message_chunk", + ); + expect(echoes).toEqual([ + { + sessionId: "t", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "fix the bug" }, + }, + }, + ]); + }); + + it("echoes an image-only user turn as a user_message_chunk", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "turn/start": { turn: { id: "turn_1" } }, + }); + const { client, sessionUpdates } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const image = { type: "image", data: "aGVsbG8=", mimeType: "image/png" }; + const done = agent.prompt({ + sessionId: "t", + prompt: [image], + } as unknown as PromptRequest); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await done; + + expect(sessionUpdates).toContainEqual({ + sessionId: "t", + update: { sessionUpdate: "user_message_chunk", content: image }, + }); + }); + + it("routes item/tool/requestUserInput through the richer-approval handler", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient({ + outcome: "selected", + optionId: "option_0", + }); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const response = await stub.invokeRequest("item/tool/requestUserInput", { + threadId: "t", + turnId: "turn_1", + itemId: "i1", + questions: [ + { + id: "q1", + header: "Pick", + question: "Which one?", + isOther: false, + isSecret: false, + options: [ + { label: "A", description: "" }, + { label: "B", description: "" }, + ], + }, + ], + autoResolutionMs: null, + }); + + // The richer handler returns a typed { answers } object, not a decision string. + expect(response).toEqual({ answers: { q1: { answers: ["A"] } } }); }); }); diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 88797060eb..40ea35826f 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -1,14 +1,25 @@ import type { AgentSideConnection, - ContentBlock, + ForkSessionRequest, + ForkSessionResponse, InitializeRequest, InitializeResponse, + ListSessionsRequest, + ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, + ResumeSessionRequest, + ResumeSessionResponse, + SessionConfigOption, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, StopReason, } from "@agentclientprotocol/sdk"; +import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions"; import { DEFAULT_CODEX_MODEL } from "../../gateway-models"; import type { ProcessSpawnedCallback } from "../../types"; import { Logger } from "../../utils/logger"; @@ -17,23 +28,78 @@ import { nodeWritableToWebWritable, } from "../../utils/streams"; import { BaseAcpAgent, type BaseSettingsManager } from "../base-acp-agent"; +import { + buildBreakdown, + type ContextBreakdownBaseline, + emptyBaseline, + estimateTokens, +} from "../claude/context-breakdown"; import { AppServerClient, type AppServerClientHandlers, type AppServerRpc, } from "./app-server-client"; -import { mapAppServerNotification } from "./mapping"; +import { handleServerRequest } from "./approvals"; +import { + type AccumulatedUsage, + buildSdkSessionParams, + buildTurnCompleteParams, + buildUsageBreakdownParams, +} from "./ext-notifications"; +import { toCodexInput } from "./input"; +import { buildLocalToolsServer, type LocalToolsMeta } from "./local-tools-mcp"; +import { mapAppServerNotification, mapHistoryItem } from "./mapping"; +import { toCodexMcpServers } from "./mcp-config"; import { APP_SERVER_METHODS, APP_SERVER_NOTIFICATIONS, APP_SERVER_REQUESTS, } from "./protocol"; +import { + buildConfigOptions, + DEFAULT_MODE, + modeApprovalPolicy, + resolveInitialMode, +} from "./session-config"; import { type CodexAppServerProcess, type CodexAppServerProcessOptions, spawnCodexAppServerProcess, } from "./spawn"; +/** + * Subset of the host-supplied `_meta` the app-server adapter consumes. The host + * (agent-server) sets these on `newSession`; see its `clientConnection.newSession` + * call. `systemPrompt` carries the task instructions, `jsonSchema` constrains the + * final assistant message for structured output. The remaining fields (taskRunId, + * taskId/persistence, environment/channelMode, baseBranch) drive the cloud + * ext-notifications, local-tools gating, and the context-breakdown baseline — + * mirroring the codex-acp adapter's `NewSessionMeta`. + */ +type AppServerSessionMeta = { + // The host sends either a plain string or the Claude-style `{ append }` form. + systemPrompt?: string | { append?: string }; + jsonSchema?: Record | null; + /** Initial approval mode the host requests (mapped to a codex mode). */ + permissionMode?: string; + taskRunId?: string; + taskId?: string; + persistence?: { taskId?: string }; + environment?: "local" | "cloud"; + channelMode?: boolean; + baseBranch?: string; +}; + +/** + * The subset of codex's `Thread` the adapter reads: its id, plus the persisted + * `turns` (each a list of `ThreadItem`s) that `thread/resume` returns for + * `loadSession` history replay. + */ +type AppServerThread = { + id?: string; + turns?: Array<{ items?: Parameters[1][] }>; +}; + // The native app-server owns its own configuration, so there is nothing for the // host to manage. BaseAcpAgent only calls dispose() on this. class NoopSettingsManager implements BaseSettingsManager { @@ -56,6 +122,12 @@ export interface CodexAppServerAgentOptions { reasoningEffort?: string; processCallbacks?: ProcessSpawnedCallback; logger?: Logger; + /** + * Invoked once per turn with the structured output the agent produced, parsed + * from the schema-constrained final assistant message. Mirrors the codex-acp + * adapter's callback so the host's `setTaskRunOutput` contract is unchanged. + */ + onStructuredOutput?: (output: Record) => Promise; /** Test seam: build the JSON-RPC client (defaults to spawning the process). */ rpcFactory?: (handlers: AppServerClientHandlers) => AppServerRpc; } @@ -65,21 +137,75 @@ export interface CodexAppServerAgentOptions { * ACP surface to PostHog Code as the codex-acp adapter, but talks to Codex's own * JSON-RPC protocol underneath instead of going through the Zed translation layer. * - * Spike scope: covers the core lifecycle (initialize, thread/start, turn/start - * with streamed agent messages, interrupt, approvals). Resume/fork, tool-call - * rendering, structured output and usage accounting are follow-ups. + * At parity with codex-acp on the adapter surface: lifecycle (initialize, + * thread/start, turn/start), resume/fork/list, streamed agent + reasoning text, + * tool-call rendering (command/file-diff/MCP), token usage (+ `_posthog/usage_update`), + * configOptions/modes (model + effort selectors, `setSessionConfigOption`, + * `current_mode_update`, mode→approvalPolicy), `available_commands_update` (skills), + * plan rendering, interrupt, command/file approvals, MCP injection, structured + * output via native `outputSchema`, image prompt input, steering (turn/steer), + * the local-tools MCP, richer approvals (AskUserQuestion / elicitation / permission + * profiles), the cloud ext-notifications (`_posthog/sdk_session`, + * `_posthog/turn_complete`, usage breakdown), and `loadSession`. */ export class CodexAppServerAgent extends BaseAcpAgent { readonly adapterName = "codex"; private readonly rpc: AppServerRpc; private readonly proc?: CodexAppServerProcess; - private readonly model: string; - private readonly reasoningEffort?: string; + // Mutable: switchable mid-session via setSessionConfigOption, applied per-turn. + private model: string; + private reasoningEffort?: string; + private mode = DEFAULT_MODE; + private availableModels: Array<{ id: string; name: string }> = []; + private availableEfforts: string[] = []; + private configOptions: SessionConfigOption[] = []; + private readonly onStructuredOutput?: ( + output: Record, + ) => Promise; + /** Codex-specific guidance injected at spawn time; replayed per-thread. */ + private readonly developerInstructions?: string; private threadId?: string; + /** JSON schema constraining the final message; set per session via `_meta`. */ + private jsonSchema?: Record; + /** Final assistant message text for the in-flight turn (structured output). */ + private lastAgentMessage = ""; + /** Maps the host's taskRunId to this session, replayed for cloud notifications. */ + private taskRunId?: string; + private cwd?: string; + /** Active turn id from turn/started — the steering precondition + interrupt target. */ + private turnId?: string; + /** + * Per-source context baseline (systemPrompt floor + skills/mcp estimates) the + * usage breakdown is derived from; codex doesn't attribute input tokens. + */ + private contextBreakdownBaseline: ContextBreakdownBaseline = emptyBaseline(); + /** Latest live input-token count from thread/tokenUsage; feeds the breakdown. */ + private contextUsed?: number; + /** + * Latest CUMULATIVE thread token totals from thread/tokenUsage (overwritten + * latest-wins, since codex reports cumulative `total`). The per-turn usage for + * `_posthog/turn_complete` is this minus `turnStartUsage` — matching codex-acp, + * which reports per-turn (not cumulative) totals. + */ + private threadUsageTotal = { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }; + /** Cumulative thread totals snapshotted at the start of the in-flight turn. */ + private turnStartUsage = { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }; private pendingTurn?: { resolve: (reason: StopReason) => void; reject: (err: Error) => void; }; + /** The in-flight turn's completion promise, so steering can await the original. */ + private pendingCompletion?: Promise; constructor( client: AgentSideConnection, @@ -91,6 +217,8 @@ export class CodexAppServerAgent extends BaseAcpAgent { new Logger({ debug: true, prefix: "[CodexAppServerAgent]" }); this.model = options.model ?? DEFAULT_CODEX_MODEL; this.reasoningEffort = options.reasoningEffort; + this.onStructuredOutput = options.onStructuredOutput; + this.developerInstructions = options.processOptions.developerInstructions; const handlers: AppServerClientHandlers = { logger: this.logger, @@ -134,83 +262,540 @@ export class CodexAppServerAgent extends BaseAcpAgent { title: "PostHog Code", version: "0.1.0", }, - capabilities: { experimentalApi: false }, + capabilities: { experimentalApi: false, requestAttestation: false }, }); this.rpc.notify(APP_SERVER_NOTIFICATIONS.INITIALIZED, {}); return { protocolVersion: request.protocolVersion, + agentCapabilities: { + // Image prompt input now flows through toCodexInput (data URL / remote / + // localImage). embeddedContext mirrors the Claude adapter's advertisement. + promptCapabilities: { + image: true, + embeddedContext: true, + }, + // Only http is advertised: toCodexMcpServers translates stdio + http + // (url) servers. SSE isn't a distinct transport in the codex config we + // emit, so we don't claim it rather than mistranslate an SSE server into + // the http shape. + mcpCapabilities: { + http: true, + }, + loadSession: true, + sessionCapabilities: { + list: {}, + fork: {}, + resume: {}, + // Extra workspace roots are forwarded to codex as writable_roots. + additionalDirectories: {}, + }, + _meta: { + posthog: { + resumeSession: true, + // turn/steer folds a mid-turn prompt into the running turn. + steering: "native", + }, + }, + }, agentInfo: { name: "codex", title: "Codex (app-server)", version: "0.1.0", }, + authMethods: [], }; } async newSession(params: NewSessionRequest): Promise { - const result = await this.rpc.request<{ thread?: { id?: string } }>( + const { threadId } = await this.setupThread( APP_SERVER_METHODS.THREAD_START, - { model: this.model, cwd: params.cwd }, + { + cwd: params.cwd, + mcpServers: params.mcpServers, + meta: params._meta as AppServerSessionMeta | undefined, + additionalDirectories: params.additionalDirectories ?? undefined, + }, + ); + return { sessionId: threadId, configOptions: this.configOptions }; + } + + /** thread/resume — the host calls this on every reconnect to restore a session. */ + async resumeSession( + params: ResumeSessionRequest, + ): Promise { + await this.setupThread(APP_SERVER_METHODS.THREAD_RESUME, { + cwd: params.cwd, + mcpServers: params.mcpServers, + meta: params._meta as AppServerSessionMeta | undefined, + threadId: params.sessionId, + additionalDirectories: params.additionalDirectories ?? undefined, + }); + return { configOptions: this.configOptions }; + } + + /** + * loadSession — the host re-attaches to an existing thread (e.g. page reload) + * without starting a new turn. Restores it through the same thread/resume path + * as resumeSession (model config, configOptions, commands, `_posthog/sdk_session`) + * and replays the persisted transcript from the resumed thread's turns so the + * host shows prior history. Returns no `modes` since codex exposes approval + * modes only through configOptions, not a SessionModeState. + */ + async loadSession(params: LoadSessionRequest): Promise { + const { thread } = await this.setupThread( + APP_SERVER_METHODS.THREAD_RESUME, + { + cwd: params.cwd, + mcpServers: params.mcpServers, + meta: params._meta as AppServerSessionMeta | undefined, + threadId: params.sessionId, + additionalDirectories: params.additionalDirectories ?? undefined, + }, + ); + this.replayHistory(thread); + return { configOptions: this.configOptions }; + } + + /** thread/fork — branch a new thread from an existing one. */ + async unstable_forkSession( + params: ForkSessionRequest, + ): Promise { + const { threadId } = await this.setupThread( + APP_SERVER_METHODS.THREAD_FORK, + { + cwd: params.cwd, + mcpServers: params.mcpServers, + meta: params._meta as AppServerSessionMeta | undefined, + threadId: params.sessionId, + additionalDirectories: params.additionalDirectories ?? undefined, + }, + ); + return { sessionId: threadId, configOptions: this.configOptions }; + } + + /** + * Replay a resumed thread's persisted turns as ACP session updates so a + * reattaching host renders the prior transcript. Native: the turns come from + * the `thread/resume` response (codex populates `thread.turns` there), so no + * extra round-trip — and each item reuses the live `mapHistoryItem` renderer. + */ + private replayHistory(thread: AppServerThread | undefined): void { + if (!this.sessionId || !thread?.turns?.length) return; + for (const turn of thread.turns) { + for (const item of turn.items ?? []) { + for (const update of mapHistoryItem(this.sessionId, item)) { + void this.client.sessionUpdate(update).catch(() => undefined); + } + } + } + } + + /** thread/list — past sessions for the session picker. */ + async listSessions( + params: ListSessionsRequest, + ): Promise { + try { + const res = await this.rpc.request<{ + data?: Array<{ + id?: string; + cwd?: string; + name?: string | null; + preview?: string; + }>; + }>(APP_SERVER_METHODS.THREAD_LIST, { cwd: params.cwd }); + const sessions = (res?.data ?? []) + .filter((t) => t?.id) + .map((t) => ({ + sessionId: t.id as string, + cwd: t.cwd ?? params.cwd ?? "", + ...(t.name || t.preview + ? { title: t.name ?? t.preview ?? undefined } + : {}), + })); + return { sessions }; + } catch (err) { + this.logger.warn("thread/list failed", { error: String(err) }); + return { sessions: [] }; + } + } + + /** + * Shared thread setup for start/resume/fork: injects task instructions + MCP + * servers, opens the thread, loads model config, and emits the configOptions. + * `threadId` present => resume/fork an existing thread; absent => new thread. + */ + private async setupThread( + method: string, + params: { + cwd?: string; + mcpServers?: NewSessionRequest["mcpServers"]; + meta?: AppServerSessionMeta; + threadId?: string; + additionalDirectories?: string[]; + }, + ): Promise<{ threadId: string; thread: AppServerThread | undefined }> { + this.jsonSchema = params.meta?.jsonSchema ?? undefined; + this.cwd = params.cwd; + this.taskRunId = params.meta?.taskRunId; + // Honor the host's initial approval mode (mirrors codex-acp). A non-codex + // value falls back to the default; setSessionConfigOption can change it later. + this.mode = resolveInitialMode(params.meta?.permissionMode); + // Codex doesn't attribute input tokens by source, so seed the breakdown with + // the always-resident floor + the host's system prompt (mirrors codex-acp's + // buildCodexBaseline) and let the live contextUsed count fill conversation. + this.contextBreakdownBaseline = buildBaseline(params.meta); + // Codex's own guidance (set at spawn) plus the host's task system prompt. + // The host sends systemPrompt as `string | { append }` and, for codex, ALSO + // pre-flattens it into developerInstructions — so flatten the {append} form + // (else it stringifies to "[object Object]") and dedupe identical parts (else + // the prod prompt is duplicated). Set per-thread; the task prompt is only + // known here. + const developerInstructions = [ + ...new Set( + [ + this.developerInstructions, + flattenSystemPrompt(params.meta?.systemPrompt), + ].filter((s): s is string => !!s), + ), + ].join("\n\n"); + // Fold the host's MCP servers and the local-tools stdio server into one map + // — the local-tools server (signed-git etc.) is gated by the same cwd + meta + // the codex-acp adapter uses, so local/desktop runs without a token get none. + const localTools = buildLocalToolsServer( + { cwd: params.cwd }, + this.localToolsMeta(params.meta), + ); + const mcpServers = toCodexMcpServers([ + ...(params.mcpServers ?? []), + ...(localTools ? [localTools] : []), + ]); + const config = buildThreadConfig(mcpServers, params.additionalDirectories); + + const result = await this.rpc.request<{ thread?: AppServerThread }>( + method, + { + model: this.model, + cwd: params.cwd, + ...(params.threadId ? { threadId: params.threadId } : {}), + ...(developerInstructions ? { developerInstructions } : {}), + ...(config ? { config } : {}), + }, ); - const threadId = result?.thread?.id; + const thread = result?.thread; + const threadId = thread?.id ?? params.threadId; if (!threadId) { - throw new Error("codex app-server thread/start returned no thread id"); + throw new Error(`codex app-server ${method} returned no thread id`); } this.threadId = threadId; this.sessionId = threadId; - this.logger.info("Codex app-server session created", { threadId }); - return { sessionId: threadId }; + await this.loadModelConfig(); + this.rebuildConfigOptions(); + this.emitConfigOptions(); + await this.emitAvailableCommands(); + // Map the host's taskRunId to this session so it can resume later. Mirrors + // the codex-acp adapter; only emitted when the host supplied a taskRunId. + await this.emitSdkSession(); + this.logger.info("Codex app-server thread ready", { + method, + threadId, + mcpServers: mcpServers ? Object.keys(mcpServers) : [], + hasOutputSchema: !!this.jsonSchema, + hasLocalTools: !!localTools, + }); + return { threadId, thread }; + } + + /** Project the session meta onto the local-tools gate inputs. */ + private localToolsMeta( + meta: AppServerSessionMeta | undefined, + ): LocalToolsMeta | undefined { + if (!meta) return undefined; + return { + environment: meta.environment, + channelMode: meta.channelMode, + taskId: meta.taskId, + persistence: meta.persistence, + baseBranch: meta.baseBranch, + }; + } + + /** Emit `_posthog/sdk_session` once a thread is ready, when a taskRunId exists. */ + private async emitSdkSession(): Promise { + if (!this.taskRunId || !this.sessionId) return; + await this.client + .extNotification( + POSTHOG_NOTIFICATIONS.SDK_SESSION, + buildSdkSessionParams( + this.sessionId, + this.taskRunId, + ) as unknown as Record, + ) + .catch((err) => + this.logger.warn("sdk_session extNotification failed", err), + ); + } + + /** Switch model / reasoning effort / approval mode mid-session. */ + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + const { configId } = params as { configId?: string }; + const value = (params as { value?: unknown }).value; + if (typeof value === "string") { + if (configId === "model") this.model = value; + else if (configId === "effort") this.reasoningEffort = value; + else if (configId === "mode") { + this.mode = value; + this.emitCurrentMode(value); + } + } + this.rebuildConfigOptions(); + this.emitConfigOptions(); + return { configOptions: this.configOptions }; + } + + /** codex-acp emits current_mode_update on mode change; mirror it for the host's mode cache. */ + private emitCurrentMode(modeId: string): void { + if (!this.sessionId) return; + void this.client + .sessionUpdate({ + sessionId: this.sessionId, + update: { sessionUpdate: "current_mode_update", currentModeId: modeId }, + } as unknown as Parameters[0]) + .catch(() => undefined); + } + + /** Populate the model/effort selectors from the app-server's model/list. */ + private async loadModelConfig(): Promise { + try { + const res = await this.rpc.request<{ data?: any[] }>( + APP_SERVER_METHODS.MODEL_LIST, + {}, + ); + const models = res?.data ?? []; + this.availableModels = models + .filter((m) => !m?.hidden) + .map((m) => ({ + id: m.id ?? m.model, + name: m.displayName ?? m.id ?? m.model, + })); + const current = models.find( + (m) => m.id === this.model || m.model === this.model, + ); + this.availableEfforts = (current?.supportedReasoningEfforts ?? []) + .map((e: any) => e?.reasoningEffort ?? e) + .filter((e: unknown): e is string => typeof e === "string"); + } catch (err) { + this.logger.warn("model/list failed; using current model only", { + error: String(err), + }); + this.availableModels = []; + this.availableEfforts = []; + } + } + + private rebuildConfigOptions(): void { + this.configOptions = buildConfigOptions({ + model: this.model, + effort: this.reasoningEffort, + models: this.availableModels, + efforts: this.availableEfforts, + }); + } + + private emitConfigOptions(): void { + if (!this.sessionId) return; + void this.client + .sessionUpdate({ + sessionId: this.sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions: this.configOptions, + }, + } as unknown as Parameters[0]) + .catch((err) => this.logger.warn("config_option_update failed", err)); + } + + /** skills/list → available_commands_update so the slash-command menu fills. */ + private async emitAvailableCommands(): Promise { + if (!this.sessionId) return; + let commands: Array<{ name: string; description: string }> = []; + try { + const res = await this.rpc.request<{ data?: Array<{ skills?: any[] }> }>( + APP_SERVER_METHODS.SKILLS_LIST, + {}, + ); + commands = (res?.data ?? []) + .flatMap((entry) => entry?.skills ?? []) + .filter((s) => s?.name) + .map((s: any) => ({ name: s.name, description: s.description ?? "" })); + } catch (err) { + this.logger.warn("skills/list failed", { error: String(err) }); + } + void this.client + .sessionUpdate({ + sessionId: this.sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: commands, + }, + } as unknown as Parameters[0]) + .catch(() => undefined); } async prompt(params: PromptRequest): Promise { if (!this.threadId) { throw new Error("prompt() called before newSession()"); } - if (this.pendingTurn) { - // The host serializes turns; a concurrent prompt would clobber the - // single pendingTurn slot, so fail fast rather than corrupt it. - throw new Error("prompt() called while a turn is already in progress"); - } + // Reopen the notification gate for any prompt being processed (fresh or + // steer), not only the fresh-turn branch below — a prior interrupt may have + // left session.cancelled set. this.session.cancelled = false; - const input = toTurnInput(params.prompt); - const dropped = params.prompt.length - input.length; + // Prepend `_meta.prContext` (set by the host on PR-follow-up / Slack runs) to + // the FORWARDED prompt as a text block — mirroring claude (acp-to-sdk) and + // codex-acp (prependPrContext). Without it, codex cloud follow-up runs lose + // the PR-review context. The original prompt (no prefix) is what we echo. + const prContext = (params._meta as { prContext?: unknown } | undefined) + ?.prContext; + const promptBlocks = + typeof prContext === "string" && prContext.length > 0 + ? [{ type: "text" as const, text: prContext }, ...params.prompt] + : params.prompt; + const input = toCodexInput(promptBlocks); + if (input.length === 0) { + // Every block was unmappable (audio / malformed image). turn/start + // requires a non-empty input, so end the turn cleanly instead of sending + // an empty one the server would reject. + this.logger.warn("prompt() had no usable input blocks; ending turn"); + return { stopReason: "end_turn" }; + } + // Count by type (not input.length) since a resource block can fan out to a + // link + a trailing block — only audio/malformed images drop. + const dropped = params.prompt.filter( + (b) => + b.type !== "text" && + b.type !== "image" && + b.type !== "resource" && + b.type !== "resource_link", + ).length; if (dropped > 0) { - this.logger.warn("Dropped non-text prompt blocks", { dropped }); + this.logger.warn("Dropped non-text/non-image prompt blocks", { dropped }); + } + // Echo the user prompt so the host's session log/UI shows the user turn — + // codex doesn't emit one (mirrors codex-acp's broadcastUserMessage). Done + // for both fresh turns and steering so the folded message still renders. + this.broadcastUserInput(params.prompt); + + if (this.pendingTurn && this.turnId) { + // A turn is already running: rather than fail (the codex-acp/Claude + // behavior is to fold the message in), steer it via turn/steer with the + // active turnId as the precondition. The existing pendingTurn keeps + // resolving on the original turn/completed. + await this.rpc + .request(APP_SERVER_METHODS.TURN_STEER, { + threadId: this.threadId, + input, + expectedTurnId: this.turnId, + }) + .catch((err) => this.logger.warn("turn/steer failed", err)); + return { stopReason: await this.pendingTurnCompletion() }; } + if (this.pendingTurn) { + // A turn is pending but we never saw turn/started (no turnId yet), so we + // can't steer. Fail fast rather than clobber the single pendingTurn slot. + throw new Error("prompt() called while a turn is already in progress"); + } + + this.lastAgentMessage = ""; + this.resetUsage(); const completion = new Promise((resolve, reject) => { this.pendingTurn = { resolve, reject }; }); + this.pendingCompletion = completion; try { + const approvalPolicy = modeApprovalPolicy(this.mode); await this.rpc.request(APP_SERVER_METHODS.TURN_START, { threadId: this.threadId, input, + model: this.model, ...(this.reasoningEffort ? { effort: this.reasoningEffort } : {}), + // Synthesized mode → codex approval policy (applied per-turn since there + // is no app-server mode RPC). Sandbox stays as spawned. + ...(approvalPolicy ? { approvalPolicy } : {}), + // Constrain the final assistant message to the task's schema so it is + // valid JSON we can parse for structured output (replaces the codex-acp + // `create_output` MCP, which the native app-server has no need for). + ...(this.jsonSchema ? { outputSchema: this.jsonSchema } : {}), }); return { stopReason: await completion }; } finally { this.pendingTurn = undefined; + this.pendingCompletion = undefined; + } + } + + /** + * Echo each user prompt block as a `user_message_chunk` for the host log/UI. + * Echoes the original ACP blocks (text + image) so an image-only turn still + * renders, mirroring codex-acp/Claude rather than the text-only codex input. + */ + private broadcastUserInput(prompt: PromptRequest["prompt"]): void { + if (!this.sessionId) return; + for (const block of prompt) { + if (block.type !== "text" && block.type !== "image") continue; + void this.client + .sessionUpdate({ + sessionId: this.sessionId, + update: { + sessionUpdate: "user_message_chunk", + content: block, + }, + }) + .catch(() => undefined); } } + private pendingTurnCompletion(): Promise { + return this.pendingCompletion ?? Promise.resolve("end_turn"); + } + + private resetUsage(): void { + // Snapshot the cumulative thread total at turn start; finalizeTurn reports + // the delta as the per-turn usage. (Cumulative only grows, so the delta is + // always >= 0, including 0 for a turn that consumes no tokens.) + this.turnStartUsage = { ...this.threadUsageTotal }; + this.contextUsed = undefined; + } + protected async interrupt(): Promise { - // Tell the server to stop first, then report the turn cancelled, so the - // caller never sees "cancelled" while Codex is still running. + // Tell the server to stop first, then finalize through the shared path so a + // cancelled turn still emits the cloud notifications (_posthog/turn_complete) + // the host treats as the idle/queue-dispatch signal — matching codex-acp. + // finalizeTurn claims the turn idempotently, so the server's later + // turn/completed(interrupted) is a no-op. if (this.threadId) { await this.rpc .request(APP_SERVER_METHODS.TURN_INTERRUPT, { threadId: this.threadId }) .catch((err) => this.logger.warn("turn/interrupt failed", err)); } - this.pendingTurn?.resolve("cancelled"); - this.pendingTurn = undefined; + await this.finalizeTurn("cancelled"); } async closeSession(): Promise { this.session.abortController.abort(); + this.turnId = undefined; this.pendingTurn?.resolve("cancelled"); this.pendingTurn = undefined; this.session.settingsManager.dispose(); + // Close the transport BEFORE killing the process: kill() destroys the + // stdio streams, so awaiting writer.close()/reader.cancel() afterwards + // would block on an ack that never arrives. Bounded so cleanup can never + // hang the caller even if the stream is wedged. + await Promise.race([ + this.rpc.close().catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, 2000)), + ]); this.proc?.kill(); - await this.rpc.close(); } private handleNotification(method: string, params: unknown): void { @@ -228,10 +813,176 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } + if (method === APP_SERVER_NOTIFICATIONS.TURN_STARTED && this.pendingTurn) { + // Capture the active turn id; it's the precondition turn/steer requires + // and the target turn/interrupt aborts. Gated on an active turn so a + // stale/duplicate turn/started can't install a turnId with no turn. + const id = (params as { turn?: { id?: string } })?.turn?.id; + if (typeof id === "string") this.turnId = id; + } + + if (method === APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED) { + this.captureAgentMessage(params); + } + + if (method === APP_SERVER_NOTIFICATIONS.TOKEN_USAGE_UPDATED) { + this.emitUsageExtNotification(params); + } + if (method === APP_SERVER_NOTIFICATIONS.TURN_COMPLETED) { const status = (params as { turn?: { status?: string } })?.turn?.status; - this.pendingTurn?.resolve(status === "failed" ? "refusal" : "end_turn"); - this.pendingTurn = undefined; + void this.finalizeTurn(mapTurnStopReason(status)); + } + + if (method === APP_SERVER_NOTIFICATIONS.ERROR) { + // A non-retried fatal error: resolve the turn so prompt() returns instead + // of hanging until the stream closes. (willRetry true → codex recovers.) + const willRetry = (params as { willRetry?: boolean })?.willRetry; + if (willRetry === false) { + this.logger.warn("codex app-server fatal error notification", { + params, + }); + void this.finalizeTurn("refusal"); + } + } + } + + /** Track the latest assistant message so the final one feeds structured output. */ + private captureAgentMessage(params: unknown): void { + const item = (params as { item?: { type?: string; text?: string } })?.item; + if (item?.type === "agentMessage" && typeof item.text === "string") { + this.lastAgentMessage = item.text; + } + } + + /** Mirror codex-acp's `_posthog/usage_update` so the host's token/cost UI fills. */ + private emitUsageExtNotification(params: unknown): void { + if (!this.sessionId) return; + const tu = (params as { tokenUsage?: any })?.tokenUsage; + const total = tu?.total; + if (!total) return; + // `total` is the cumulative thread total, so overwrite (latest wins). The + // per-turn delta is computed against turnStartUsage in finalizeTurn. + this.threadUsageTotal = { + inputTokens: total.inputTokens ?? 0, + outputTokens: total.outputTokens ?? 0, + cachedReadTokens: total.cachedInputTokens ?? 0, + cachedWriteTokens: 0, + }; + // Drives the per-source breakdown's "conversation" bucket on turn complete. + this.contextUsed = total.inputTokens ?? total.totalTokens; + void this.client + .extNotification(POSTHOG_NOTIFICATIONS.USAGE_UPDATE, { + sessionId: this.sessionId, + used: total.totalTokens, + size: tu.modelContextWindow ?? null, + usage: { + inputTokens: total.inputTokens, + outputTokens: total.outputTokens, + cachedReadTokens: total.cachedInputTokens, + reasoningTokens: total.reasoningOutputTokens, + totalTokens: total.totalTokens, + }, + }) + .catch((err) => this.logger.warn("usage extNotification failed", err)); + } + + /** + * Parses the schema-constrained final message into structured output and + * delivers it before resolving the turn, so the host's `setTaskRunOutput` + * has completed by the time `prompt()` returns. + */ + private async finalizeTurn(reason: StopReason): Promise { + // Idempotent: claim the pending turn synchronously (before any await) so a + // second finalize for the same turn — e.g. an `error` notification racing + // turn/completed — is a no-op and the structured-output callback + cloud + // notifications don't double-fire. + const pending = this.pendingTurn; + if (!pending) return; + // Claim the whole turn synchronously (before any await): clear pendingTurn + // AND turnId, and snapshot the turn-scoped telemetry. Otherwise a steer + // prompt() or a fresh turn racing into the await window below would see a + // live turnId with no pendingTurn (misrouting a steer into a new turn) or + // zero this turn's usage via the next turn's resetUsage(). + this.pendingTurn = undefined; + this.turnId = undefined; + const message = this.lastAgentMessage; + // Per-turn usage = cumulative thread total now − the snapshot at turn start + // (matches codex-acp's per-turn turn_complete, not a thread-cumulative total). + const usage = { + inputTokens: + this.threadUsageTotal.inputTokens - this.turnStartUsage.inputTokens, + outputTokens: + this.threadUsageTotal.outputTokens - this.turnStartUsage.outputTokens, + cachedReadTokens: + this.threadUsageTotal.cachedReadTokens - + this.turnStartUsage.cachedReadTokens, + cachedWriteTokens: + this.threadUsageTotal.cachedWriteTokens - + this.turnStartUsage.cachedWriteTokens, + }; + const contextUsed = this.contextUsed; + + if (this.jsonSchema && this.onStructuredOutput && message) { + const parsed = parseStructuredOutput(message); + if (parsed) { + try { + await this.onStructuredOutput(parsed); + } catch (err) { + this.logger.warn("onStructuredOutput callback threw", { error: err }); + } + } else { + this.logger.warn( + "Could not parse structured output from final message", + { + preview: message.slice(0, 200), + }, + ); + } + } + await this.emitTurnComplete(reason, usage, contextUsed); + pending.resolve(reason); + } + + /** + * Emit the cloud per-turn notifications, mirroring codex-acp's runPrompt: + * `_posthog/turn_complete` (only with a taskRunId — it's a task-tracking + * signal) plus the `_posthog/usage_update` breakdown variant (always, so local + * sessions get the ContextBreakdownPopover too). + */ + private async emitTurnComplete( + reason: StopReason, + usage: AccumulatedUsage, + contextUsed: number | undefined, + ): Promise { + if (!this.sessionId) return; + if (this.taskRunId) { + await this.client + .extNotification( + POSTHOG_NOTIFICATIONS.TURN_COMPLETE, + buildTurnCompleteParams( + this.sessionId, + reason, + usage, + ) as unknown as Record, + ) + .catch((err) => + this.logger.warn("turn_complete extNotification failed", err), + ); + } + if (contextUsed !== undefined) { + await this.client + .extNotification( + POSTHOG_NOTIFICATIONS.USAGE_UPDATE, + buildUsageBreakdownParams( + this.sessionId, + this.contextBreakdownBaseline, + contextUsed, + ) as unknown as Record, + ) + .catch((err) => + this.logger.warn("usage breakdown extNotification failed", err), + ); } } @@ -242,16 +993,32 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.pendingTurn = undefined; } + /** + * Server-initiated requests. The two simple approvals resolve to a + * `{ decision }` envelope (codex's `CommandExecutionRequestApprovalResponse` / + * `FileChangeRequestApprovalResponse` — a bare string is rejected); the richer + * requests (AskUserQuestion / permission profile / MCP elicitation) carry + * distinct typed response objects and are delegated to `handleServerRequest`. + * The AppServerClient sends whatever we return straight back as the JSON-RPC + * result. + */ private async handleApproval( method: string, params: unknown, - ): Promise { + ): Promise { + const richer = await handleServerRequest(method, params, this.client, { + sessionId: this.sessionId, + logger: this.logger, + }); + if (richer.handled) { + return richer.response; + } if ( method !== APP_SERVER_REQUESTS.COMMAND_APPROVAL && method !== APP_SERVER_REQUESTS.FILE_CHANGE_APPROVAL ) { this.logger.warn("Unrecognized server request; declining", { method }); - return "decline"; + return { decision: "decline" }; } const detail = params as { itemId?: string; command?: string }; const title = @@ -272,27 +1039,107 @@ export class CodexAppServerAgent extends BaseAcpAgent { response.outcome.outcome === "selected" && response.outcome.optionId === "allow" ) { - return "accept"; + return { decision: "accept" }; } if (response.outcome.outcome === "cancelled") { - return "cancel"; + return { decision: "cancel" }; } - return "decline"; + return { decision: "decline" }; } catch (err) { this.logger.warn("requestPermission failed; declining", err); - return "decline"; + return { decision: "decline" }; } } } -function toTurnInput( - prompt: ContentBlock[], -): Array<{ type: "text"; text: string }> { - const input: Array<{ type: "text"; text: string }> = []; - for (const block of prompt) { - if (block.type === "text") { - input.push({ type: "text", text: block.text }); +// codex-rs/protocol/src/protocol.rs BASELINE_TOKENS — the always-resident floor +// (MCP schemas, skills, preset prompt) we can't attribute per-source. Matches +// the codex-acp adapter's CODEX_BASELINE_TOKENS so the breakdown agrees across +// transports. +const CODEX_BASELINE_TOKENS = 12000; + +/** + * codex `TurnStatus` → ACP `StopReason`. `interrupted` is a cancel (not a clean + * end), `failed` surfaces as a refusal; `completed`/unknown end the turn. + */ +function mapTurnStopReason(status: string | undefined): StopReason { + if (status === "interrupted") return "cancelled"; + if (status === "failed") return "refusal"; + return "end_turn"; +} + +/** + * The codex `thread/start` / `thread/resume` `config` override map (the JSON form + * of `-c key=value`). Folds in the host MCP servers and — mirroring the codex-acp + * `-c sandbox_workspace_write.writable_roots=[...]` spawn arg — makes any extra + * workspace roots writable. Returns undefined when there is nothing to override. + */ +function buildThreadConfig( + mcpServers: ReturnType, + additionalDirectories: string[] | undefined, +): Record | undefined { + const config: Record = {}; + if (mcpServers) { + config.mcp_servers = mcpServers; + } + if (additionalDirectories?.length) { + config.sandbox_workspace_write = { writable_roots: additionalDirectories }; + } + return Object.keys(config).length > 0 ? config : undefined; +} + +/** + * Seed the context-breakdown baseline with the resident floor plus the host's + * system prompt, mirroring codex-acp's buildCodexBaseline. The live contextUsed + * count fills the conversation bucket once the turn produces token usage. + */ +function buildBaseline( + meta: AppServerSessionMeta | undefined, +): ContextBreakdownBaseline { + const baseline = emptyBaseline(); + baseline.systemPrompt = + CODEX_BASELINE_TOKENS + + estimateTokens(flattenSystemPrompt(meta?.systemPrompt)); + return baseline; +} + +/** + * The host sends systemPrompt as a plain string OR the Claude-style + * `{ append }` form. Flatten to the underlying string so it isn't stringified + * to "[object Object]" when folded into developer_instructions / token estimates. + */ +function flattenSystemPrompt( + systemPrompt: string | { append?: string } | undefined, +): string | undefined { + if (typeof systemPrompt === "string") return systemPrompt || undefined; + if (systemPrompt && typeof systemPrompt.append === "string") { + return systemPrompt.append || undefined; + } + return undefined; +} + +/** + * Parses structured output from the final assistant message. `outputSchema` + * should make the message pure JSON, but parse defensively (fenced block / first + * object) so a stray wrapper never throws or drops the result. + */ +function parseStructuredOutput(text: string): Record | null { + const trimmed = text.trim(); + const candidates = [trimmed]; + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenced) candidates.push(fenced[1].trim()); + const brace = trimmed.match(/\{[\s\S]*\}/); + if (brace) candidates.push(brace[0]); + + for (const candidate of candidates) { + try { + const parsed: unknown = JSON.parse(candidate); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Try the next candidate. } } - return input; + return null; } diff --git a/packages/agent/src/adapters/codex-app-server/ext-notifications.test.ts b/packages/agent/src/adapters/codex-app-server/ext-notifications.test.ts new file mode 100644 index 0000000000..93538ee006 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/ext-notifications.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { emptyBaseline } from "../claude/context-breakdown"; +import { + buildSdkSessionParams, + buildTurnCompleteParams, + buildUsageBreakdownParams, +} from "./ext-notifications"; + +describe("ext-notifications builders", () => { + it("buildSdkSessionParams tags the codex adapter so resume keys on the family", () => { + expect(buildSdkSessionParams("sess-1", "run-42")).toEqual({ + taskRunId: "run-42", + sessionId: "sess-1", + adapter: "codex", + }); + }); + + it("buildTurnCompleteParams derives totalTokens from all four counts", () => { + const params = buildTurnCompleteParams("sess-1", "end_turn", { + inputTokens: 100, + outputTokens: 20, + cachedReadTokens: 5, + cachedWriteTokens: 3, + }); + + expect(params).toEqual({ + sessionId: "sess-1", + stopReason: "end_turn", + usage: { + inputTokens: 100, + outputTokens: 20, + cachedReadTokens: 5, + cachedWriteTokens: 3, + totalTokens: 128, + }, + }); + }); + + it("buildTurnCompleteParams forwards non-default stop reasons", () => { + expect( + buildTurnCompleteParams("sess-1", "refusal", { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }).stopReason, + ).toBe("refusal"); + }); + + it("buildUsageBreakdownParams attributes overflow above the baseline to conversation", () => { + const baseline = { ...emptyBaseline(), systemPrompt: 1000, tools: 500 }; + + expect(buildUsageBreakdownParams("sess-1", baseline, 2000)).toEqual({ + sessionId: "sess-1", + breakdown: { + systemPrompt: 1000, + tools: 500, + rules: 0, + skills: 0, + mcp: 0, + subagents: 0, + conversation: 500, + }, + }); + }); + + it("buildUsageBreakdownParams floors conversation at 0 when usage is below baseline", () => { + const baseline = { ...emptyBaseline(), systemPrompt: 1000 }; + + expect( + buildUsageBreakdownParams("sess-1", baseline, 200).breakdown.conversation, + ).toBe(0); + }); +}); diff --git a/packages/agent/src/adapters/codex-app-server/ext-notifications.ts b/packages/agent/src/adapters/codex-app-server/ext-notifications.ts new file mode 100644 index 0000000000..f05147c498 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/ext-notifications.ts @@ -0,0 +1,119 @@ +/** + * Pure builders for the PostHog `_posthog/*` ext-notification params the + * app-server adapter emits, mirroring the codex-acp adapter so the host's log + * consumers (parse_sandbox_log.py) and the renderer see the same shapes. + * + * These are param-only builders (no I/O, no client) so each can be unit-tested + * in isolation and the hub agent just forwards the result to + * `client.extNotification(method, params)`. + */ + +import type { StopReason } from "@agentclientprotocol/sdk"; +import { + buildBreakdown, + type ContextBreakdown, + type ContextBreakdownBaseline, +} from "../claude/context-breakdown"; + +/** + * Adapter tag carried on `_posthog/sdk_session`. Kept as `"codex"` (not + * `"codex-app-server"`) so the host's resume/keying logic treats both Codex + * transports as the same agent family — the codex-acp adapter emits the same + * value. + */ +const CODEX_ADAPTER = "codex" as const; + +export interface SdkSessionParams { + taskRunId: string; + sessionId: string; + adapter: typeof CODEX_ADAPTER; +} + +/** + * `_posthog/sdk_session` — maps a taskRunId to the agent's sessionId so the + * host can resume the session later. Emitted once per session creation/load. + */ +export function buildSdkSessionParams( + sessionId: string, + taskRunId: string, +): SdkSessionParams { + return { + taskRunId, + sessionId, + adapter: CODEX_ADAPTER, + }; +} + +/** Per-turn token usage. `totalTokens` is derived so consumers don't re-sum. */ +export interface TurnCompleteUsage { + inputTokens: number; + outputTokens: number; + cachedReadTokens: number; + cachedWriteTokens: number; + totalTokens: number; +} + +export interface TurnCompleteParams { + sessionId: string; + stopReason: StopReason; + usage: TurnCompleteUsage; +} + +/** The four component counts the caller accumulates; total is computed here. */ +export interface AccumulatedUsage { + inputTokens: number; + outputTokens: number; + cachedReadTokens: number; + cachedWriteTokens: number; +} + +/** + * `_posthog/turn_complete` — fired when a prompt turn finishes. `parse_sandbox_log.py` + * reads `usage.{inputTokens,outputTokens,cachedReadTokens,totalTokens}`; the + * renderer reads `stopReason`. `totalTokens` is the sum of all four component + * counts, matching the codex-acp adapter so totals don't diverge between + * transports. + */ +export function buildTurnCompleteParams( + sessionId: string, + stopReason: StopReason, + usage: AccumulatedUsage, +): TurnCompleteParams { + return { + sessionId, + stopReason, + usage: { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedReadTokens: usage.cachedReadTokens, + cachedWriteTokens: usage.cachedWriteTokens, + totalTokens: + usage.inputTokens + + usage.outputTokens + + usage.cachedReadTokens + + usage.cachedWriteTokens, + }, + }; +} + +export interface UsageBreakdownParams { + sessionId: string; + breakdown: ContextBreakdown; +} + +/** + * `_posthog/usage_update` (breakdown variant) — per-source context attribution + * the renderer's ContextBreakdownPopover consumes. Codex's app-server doesn't + * attribute input tokens by source, so we fold the resident-baseline estimate + * with the live `contextUsed` count via the shared `buildBreakdown` helper. + */ +export function buildUsageBreakdownParams( + sessionId: string, + baseline: ContextBreakdownBaseline, + contextUsed: number, +): UsageBreakdownParams { + return { + sessionId, + breakdown: buildBreakdown(baseline, contextUsed), + }; +} diff --git a/packages/agent/src/adapters/codex-app-server/input.test.ts b/packages/agent/src/adapters/codex-app-server/input.test.ts new file mode 100644 index 0000000000..21acc0e023 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/input.test.ts @@ -0,0 +1,112 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vitest"; +import { toCodexInput } from "./input"; + +describe("toCodexInput", () => { + it("passes text blocks through with empty text_elements", () => { + const prompt: ContentBlock[] = [ + { type: "text", text: "hello" }, + { type: "text", text: "world" }, + ]; + + expect(toCodexInput(prompt)).toEqual([ + { type: "text", text: "hello", text_elements: [] }, + { type: "text", text: "world", text_elements: [] }, + ]); + }); + + it("maps a base64 image block to the codex image variant as a data URL", () => { + const prompt: ContentBlock[] = [ + { type: "image", data: "AAAA", mimeType: "image/png" }, + ]; + + expect(toCodexInput(prompt)).toEqual([ + { type: "image", url: "data:image/png;base64,AAAA" }, + ]); + }); + + it("maps an http(s) image URI to a remote image and file:// to localImage", () => { + const prompt: ContentBlock[] = [ + { + type: "image", + data: "", + mimeType: "image/png", + uri: "https://x/y.png", + }, + { + type: "image", + data: "", + mimeType: "image/png", + uri: "file:///tmp/pic.png", + }, + ]; + + expect(toCodexInput(prompt)).toEqual([ + { type: "image", url: "https://x/y.png" }, + { type: "localImage", path: "/tmp/pic.png" }, + ]); + }); + + it("drops only audio and unusable images, keeping text", () => { + const prompt: ContentBlock[] = [ + { type: "text", text: "keep" }, + { type: "audio", data: "AAAA", mimeType: "audio/wav" }, + { type: "image", data: "", mimeType: "image/png", uri: "ftp://nope" }, + ]; + + expect(toCodexInput(prompt)).toEqual([ + { type: "text", text: "keep", text_elements: [] }, + ]); + }); + + it("surfaces a file:// resource_link as its on-disk path", () => { + const prompt: ContentBlock[] = [ + { type: "resource_link", uri: "file:///repo/doc.md", name: "doc" }, + ]; + + expect(toCodexInput(prompt)).toEqual([ + { + type: "text", + text: "Attached workspace file (read it from disk): /repo/doc.md", + text_elements: [], + }, + ]); + }); + + it("inlines a non-file resource's text as a trailing block", () => { + const prompt: ContentBlock[] = [ + { type: "text", text: "use the snippet" }, + { + type: "resource", + resource: { uri: "https://x/snippet", text: "const a = 1;" }, + }, + ]; + + expect(toCodexInput(prompt)).toEqual([ + { type: "text", text: "use the snippet", text_elements: [] }, + { type: "text", text: "https://x/snippet", text_elements: [] }, + { + type: "text", + text: '\nconst a = 1;\n', + text_elements: [], + }, + ]); + }); + + it("surfaces a file:// resource as its path, not inline text", () => { + const prompt: ContentBlock[] = [ + { + type: "resource", + resource: { uri: "file:///repo/a.ts", text: "stale on-disk copy" }, + }, + ]; + + expect(toCodexInput(prompt)).toEqual([ + { + type: "text", + text: "Attached workspace file (read it from disk): /repo/a.ts", + text_elements: [], + }, + ]); + }); +}); diff --git a/packages/agent/src/adapters/codex-app-server/input.ts b/packages/agent/src/adapters/codex-app-server/input.ts new file mode 100644 index 0000000000..02081917e0 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/input.ts @@ -0,0 +1,113 @@ +import { fileURLToPath } from "node:url"; +import type { ContentBlock } from "@agentclientprotocol/sdk"; + +/** + * Codex app-server `UserInput` (version-pinned shape from + * codex-schema/v2/UserInput.ts). We only emit the three variants an ACP prompt + * can produce — `text`, remote `image`, and `localImage` — so the union is + * narrowed here rather than importing the full generated type. + */ +export type CodexUserInput = + | { type: "text"; text: string; text_elements: [] } + | { type: "image"; url: string } + | { type: "localImage"; path: string }; + +function textInput(text: string): CodexUserInput { + return { type: "text", text, text_elements: [] }; +} + +/** A `file://` resource is surfaced as its path so codex reads it from disk. */ +function resourceLinkText(uri: string): string { + if (uri.startsWith("file://")) { + try { + return `Attached workspace file (read it from disk): ${fileURLToPath(uri)}`; + } catch { + return `Attached file: ${uri}`; + } + } + return `Attached resource: ${uri}`; +} + +/** + * Maps ACP prompt content blocks to codex app-server `UserInput[]`. + * + * Text blocks pass through unchanged (codex requires `text_elements`, empty + * when the host has no UI spans). Image blocks map to `image`/`localImage` + * (base64 → data URL, `http(s)` → remote `image`, `file://` → `localImage`). + * `embeddedContext` blocks are honored rather than dropped — mirroring the + * Claude adapter: a `resource_link` (or `file://` `resource`) becomes a + * path/link note, and a non-file `resource` with inline text is inlined as a + * `` block appended after the main content (kept salient by + * trailing it, as Claude does). Dropped: audio, malformed images, and binary + * (blob) embedded resources — only the text variant of a resource is inlined. + */ +export function toCodexInput(prompt: ContentBlock[]): CodexUserInput[] { + const input: CodexUserInput[] = []; + const context: string[] = []; + for (const block of prompt) { + if (block.type === "text") { + input.push(textInput(block.text)); + continue; + } + if (block.type === "image") { + const mapped = imageToCodexInput(block); + if (mapped) { + input.push(mapped); + } + continue; + } + if (block.type === "resource_link") { + input.push(textInput(resourceLinkText(block.uri))); + continue; + } + if (block.type === "resource" && "text" in block.resource) { + const uri = block.resource.uri ?? ""; + if (uri.startsWith("file://")) { + input.push(textInput(resourceLinkText(uri))); + continue; + } + input.push(textInput(uri)); + context.push( + `\n${block.resource.text}\n`, + ); + } + } + if (context.length > 0) { + input.push(textInput(context.join("\n"))); + } + return input; +} + +/** + * ACP `ImageContent` always declares `data`/`mimeType`, but `data` may be empty + * when the image is referenced by `uri` instead. Prefer inline base64 (carried + * as a data URL on codex's `image.url`), then fall back to the URI: `http(s)` + * stays a remote `image`, `file://` becomes a `localImage` path. + */ +function imageToCodexInput(block: { + data: string; + mimeType: string; + uri?: string | null; +}): CodexUserInput | undefined { + if (block.data) { + return { + type: "image", + url: `data:${block.mimeType};base64,${block.data}`, + }; + } + const uri = block.uri; + if (!uri) { + return undefined; + } + if (uri.startsWith("http://") || uri.startsWith("https://")) { + return { type: "image", url: uri }; + } + if (uri.startsWith("file://")) { + try { + return { type: "localImage", path: fileURLToPath(uri) }; + } catch { + return undefined; + } + } + return undefined; +} diff --git a/packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts b/packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts new file mode 100644 index 0000000000..8c291d0760 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LOCAL_TOOLS_MCP_NAME } from "../local-tools"; +import { buildLocalToolsServer } from "./local-tools-mcp"; + +// The resolver walks up looking for the compiled MCP script via existsSync; the +// dist asset isn't on the walk-up path in unit tests, so make the first +// candidate succeed. Nothing here spawns the script — we only inspect the path. +vi.mock("node:fs", async (importActual) => { + const actual = await importActual(); + return { ...actual, existsSync: vi.fn().mockReturnValue(true) }; +}); + +describe("buildLocalToolsServer", () => { + const saved = { + sandbox: process.env.IS_SANDBOX, + ghToken: process.env.GH_TOKEN, + githubToken: process.env.GITHUB_TOKEN, + }; + + beforeEach(() => { + // The signed-git gate keys off isCloudRun, which also reads IS_SANDBOX — + // clear it so meta.environment is the only cloud signal under test, and + // clear the token vars so each case controls them explicitly. + delete process.env.IS_SANDBOX; + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + }); + + afterEach(() => { + restore("IS_SANDBOX", saved.sandbox); + restore("GH_TOKEN", saved.ghToken); + restore("GITHUB_TOKEN", saved.githubToken); + }); + + function restore(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + it("returns a stdio server config with command/args/env on a cloud run with a token", () => { + process.env.GH_TOKEN = "ghs_test"; + + const server = buildLocalToolsServer( + { cwd: "/repo" }, + { environment: "cloud" }, + ); + + expect(server).not.toBeNull(); + expect(server?.name).toBe(LOCAL_TOOLS_MCP_NAME); + expect(server?.command).toBe(process.execPath); + expect(server?.args).toHaveLength(1); + expect(server?.args[0]).toMatch(/local-tools-mcp-server\.js$/); + + const envNames = server?.env.map((e) => e.name) ?? []; + expect(envNames).toContain("POSTHOG_LOCAL_TOOLS_CTX"); + expect(envNames).toContain("POSTHOG_LOCAL_TOOLS_ENABLED"); + // Token is forwarded to the child so its own git remote ops authenticate. + expect(envNames).toContain("GH_TOKEN"); + expect(envNames).toContain("GITHUB_TOKEN"); + + const ctxEntry = server?.env.find( + (e) => e.name === "POSTHOG_LOCAL_TOOLS_CTX", + ); + const ctx = JSON.parse( + Buffer.from(ctxEntry?.value ?? "", "base64").toString("utf-8"), + ); + expect(ctx.cwd).toBe("/repo"); + expect(ctx.token).toBe("ghs_test"); + }); + + it("returns a server but omits token env vars when no token is present", () => { + const server = buildLocalToolsServer( + { cwd: "/repo" }, + { environment: "cloud" }, + ); + + expect(server).not.toBeNull(); + const envNames = server?.env.map((e) => e.name) ?? []; + expect(envNames).toContain("POSTHOG_LOCAL_TOOLS_CTX"); + expect(envNames).not.toContain("GH_TOKEN"); + expect(envNames).not.toContain("GITHUB_TOKEN"); + }); + + it("returns null when no cwd is present", () => { + process.env.GH_TOKEN = "ghs_test"; + + expect( + buildLocalToolsServer({ cwd: undefined }, { environment: "cloud" }), + ).toBeNull(); + }); + + it("returns null when no tool's gate passes (desktop run)", () => { + process.env.GH_TOKEN = "ghs_test"; + + expect( + buildLocalToolsServer({ cwd: "/repo" }, { environment: "local" }), + ).toBeNull(); + }); +}); diff --git a/packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts b/packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts new file mode 100644 index 0000000000..e7ca8984d1 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts @@ -0,0 +1,119 @@ +/** + * Builds the stdio local-tools MCP server config to inject into a Codex + * app-server thread's `config.mcp_servers`. Ported from the codex-acp adapter + * (`adapters/codex/codex-agent.ts` buildLocalToolsMcpServer + applyLocalTools + + * enabledLocalTools gating). + * + * The two adapters share the SAME bundled stdio server script + * (`adapters/codex/local-tools-mcp-server.js`), the SAME registry + * (`adapters/local-tools`), and the SAME `LocalToolCtx`. Only the injection + * boundary differs: codex-acp pushes an `McpServerStdio` into `newSession`'s + * `mcpServers`, whereas the app-server adapter passes the result through + * `toCodexMcpServers` into the thread config. We return the ACP `McpServerStdio` + * shape so the existing translation layer stays the single owner of the + * ACP -> Codex map. + */ + +import { existsSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; +import type { McpServerStdio } from "@agentclientprotocol/sdk"; +import { ghTokenEnv } from "@posthog/git/signed-commit"; +import { resolveGithubToken } from "../../utils/github-token"; +import { + enabledLocalTools, + LOCAL_TOOLS_MCP_NAME, + type LocalToolCtx, + type LocalToolGateMeta, +} from "../local-tools"; +import { resolveTaskId } from "../session-meta"; + +/** + * Gate inputs the local-tools server needs beyond what `LocalToolGateMeta` + * carries: the task id (top-level or nested under `persistence`) and the base + * branch the signed-git tools default to. Kept self-contained so this module + * does not depend on the hub agent's session-meta type. + */ +export interface LocalToolsMeta extends LocalToolGateMeta { + taskId?: string; + persistence?: { taskId?: string }; + baseBranch?: string; +} + +/** + * Path resolves relative to the compiled adapter location. When bundled into + * different entry points (dist/agent.js, dist/server/bin.cjs, etc), the + * adapter sits at different depths, so walk up until we find the shared dist + * asset. Mirrors `resolveBundledMcpScript` in the codex-acp adapter. + */ +function resolveBundledMcpScript(rel: string): string { + let dir = import.meta.dirname ?? __dirname; + for (let i = 0; i < 5; i++) { + const candidate = resolvePath(dir, rel); + if (existsSync(candidate)) return candidate; + dir = resolvePath(dir, ".."); + } + throw new Error( + `Could not locate ${rel} relative to ${import.meta.dirname ?? __dirname}.`, + ); +} + +function toMcpServerStdio( + ctx: LocalToolCtx, + enabledNames: string[], +): McpServerStdio { + const scriptPath = resolveBundledMcpScript( + "adapters/codex/local-tools-mcp-server.js", + ); + const ctxBase64 = Buffer.from(JSON.stringify(ctx)).toString("base64"); + const env = [ + { name: "POSTHOG_LOCAL_TOOLS_CTX", value: ctxBase64 }, + { name: "POSTHOG_LOCAL_TOOLS_ENABLED", value: enabledNames.join(",") }, + ]; + if (ctx.token) { + // Token also on the child env so its own git remote ops (fetch/ls-remote) + // authenticate; the var names come from the single shared source. + env.push( + ...Object.entries(ghTokenEnv(ctx.token)).map(([name, value]) => ({ + name, + value, + })), + ); + } + return { + name: LOCAL_TOOLS_MCP_NAME, + command: process.execPath, + args: [scriptPath], + env, + }; +} + +/** + * Returns the local-tools stdio server config to inject, or null when no tool's + * gate passes (e.g. local/desktop run with no GH token — signed-git tools are + * cloud-only). Tools self-gate via the registry; the server is only injected + * when at least one passes. Their instructions already live in the shared cloud + * system prompt, so only the server config needs returning here. + */ +export function buildLocalToolsServer( + ctx: { cwd?: string }, + meta: LocalToolsMeta | undefined, +): McpServerStdio | null { + const cwd = ctx.cwd; + if (!cwd) { + return null; + } + const toolCtx: LocalToolCtx = { + cwd, + token: resolveGithubToken(), + taskId: resolveTaskId(meta), + baseBranch: meta?.baseBranch, + }; + const tools = enabledLocalTools(toolCtx, meta); + if (tools.length === 0) { + return null; + } + return toMcpServerStdio( + toolCtx, + tools.map((t) => t.name), + ); +} diff --git a/packages/agent/src/adapters/codex-app-server/mapping.test.ts b/packages/agent/src/adapters/codex-app-server/mapping.test.ts index fd4f1882d0..71b4fe2a6e 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.test.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { mapAppServerNotification } from "./mapping"; +import { + mapAppServerNotification, + mapHistoryItem, + parseUnifiedDiff, +} from "./mapping"; import { APP_SERVER_NOTIFICATIONS } from "./protocol"; describe("mapAppServerNotification", () => { @@ -7,7 +11,7 @@ describe("mapAppServerNotification", () => { const result = mapAppServerNotification( "s-1", APP_SERVER_NOTIFICATIONS.AGENT_MESSAGE_DELTA, - { itemId: "item_1", text: "Hello" }, + { itemId: "item_1", delta: "Hello" }, ); expect(result).toEqual({ @@ -19,7 +23,23 @@ describe("mapAppServerNotification", () => { }); }); - it("returns null when the text is missing or empty", () => { + it("maps a reasoning text delta to an ACP agent_thought_chunk", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.REASONING_TEXT_DELTA, + { itemId: "item_1", delta: "thinking", contentIndex: 0 }, + ); + + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "thinking" }, + }, + }); + }); + + it("returns null when the delta is missing or empty", () => { expect( mapAppServerNotification( "s-1", @@ -31,16 +51,539 @@ describe("mapAppServerNotification", () => { mapAppServerNotification( "s-1", APP_SERVER_NOTIFICATIONS.AGENT_MESSAGE_DELTA, - { itemId: "item_1", text: "" }, + { itemId: "item_1", delta: "" }, ), ).toBeNull(); }); - it("returns null for notifications not yet mapped in the spike", () => { + it("maps a started command execution item to a tool_call", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_STARTED, + { item: { type: "commandExecution", id: "i1", command: "ls -la" } }, + ); + + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "i1", + title: "ls -la", + kind: "execute", + status: "in_progress", + }, + }); + }); + + it("maps a completed command execution item to a tool_call_update with output", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, + { + item: { + type: "commandExecution", + id: "i1", + command: "ls", + status: "completed", + aggregatedOutput: "file.txt", + }, + }, + ); + + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "i1", + status: "completed", + content: [ + { type: "content", content: { type: "text", text: "file.txt" } }, + ], + }, + }); + }); + + it("maps a started mcp tool call item, surfacing arguments as rawInput", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_STARTED, + { + item: { + type: "mcpToolCall", + id: "m1", + server: "posthog", + tool: "execute-sql", + arguments: { query: "SELECT 1" }, + }, + }, + ); + + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "m1", + title: "posthog/execute-sql", + kind: "other", + status: "in_progress", + rawInput: { query: "SELECT 1" }, + }, + }); + }); + + it("drops agent message items (their deltas already streamed)", () => { + expect( + mapAppServerNotification("s-1", APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, { + item: { type: "agentMessage", id: "a1", text: "done" }, + }), + ).toBeNull(); + }); + + it("maps thread/tokenUsage/updated to a usage_update", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.TOKEN_USAGE_UPDATED, + { + threadId: "t", + turnId: "u", + tokenUsage: { + total: { + totalTokens: 1500, + inputTokens: 1000, + outputTokens: 500, + cachedInputTokens: 0, + reasoningOutputTokens: 0, + }, + last: {}, + modelContextWindow: 200000, + }, + }, + ); + expect(result).toEqual({ + sessionId: "s-1", + update: { sessionUpdate: "usage_update", used: 1500, size: 200000 }, + }); + }); + + it("maps turn/plan/updated to a plan update", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.TURN_PLAN_UPDATED, + { + threadId: "t", + turnId: "u", + plan: [ + { step: "Read files", status: "completed" }, + { step: "Edit", status: "inProgress" }, + ], + }, + ); + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "plan", + entries: [ + { content: "Read files", priority: "medium", status: "completed" }, + { content: "Edit", priority: "medium", status: "in_progress" }, + ], + }, + }); + }); + + it("maps a completed fileChange to a tool_call_update with diff content", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, + { + item: { + type: "fileChange", + id: "f1", + status: "completed", + changes: [{ path: "a.txt", diff: "@@ -1 +1 @@\n-old\n+new" }], + }, + }, + ); + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "f1", + status: "completed", + content: [ + { type: "diff", path: "a.txt", oldText: "old", newText: "new" }, + ], + }, + }); + }); + + it("includes cwd as a follow-along location on a started command execution", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_STARTED, + { + item: { + type: "commandExecution", + id: "c1", + command: "pytest", + cwd: "/repo", + }, + }, + ); + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "c1", + title: "pytest", + kind: "execute", + status: "in_progress", + locations: [{ path: "/repo" }], + }, + }); + }); + + it("prefers command-action paths over cwd for read commands", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_STARTED, + { + item: { + type: "commandExecution", + id: "c2", + command: "cat foo.txt", + cwd: "/repo", + commandActions: [ + { type: "read", path: "/repo/foo.txt" }, + { type: "read", path: "/repo/foo.txt" }, + ], + }, + }, + ); + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "c2", + title: "cat foo.txt", + kind: "read", + status: "in_progress", + locations: [{ path: "/repo/foo.txt" }], + }, + }); + }); + + it("titles a started fileChange with its path and exposes locations", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_STARTED, + { + item: { + type: "fileChange", + id: "f2", + changes: [{ path: "src/a.ts" }, { path: "src/b.ts" }], + }, + }, + ); + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "f2", + title: "src/a.ts (+1 more)", + kind: "edit", + status: "in_progress", + locations: [{ path: "src/a.ts" }, { path: "src/b.ts" }], + }, + }); + }); + + it("streams command output deltas as in-progress tool_call_update text", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.COMMAND_OUTPUT_DELTA, + { threadId: "t", turnId: "u", itemId: "c1", delta: "line 1\n" }, + ); + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "c1", + status: "in_progress", + content: [ + { type: "content", content: { type: "text", text: "line 1\n" } }, + ], + }, + }); + }); + + it("echoes terminal interaction stdin into the tool call output", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.TERMINAL_INTERACTION, + { + threadId: "t", + turnId: "u", + itemId: "c1", + processId: "p1", + stdin: "y\n", + }, + ); + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "c1", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: "y\n" } }], + }, + }); + }); + + it("returns null for an output delta missing itemId or delta", () => { + expect( + mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.COMMAND_OUTPUT_DELTA, + { itemId: "c1", delta: "" }, + ), + ).toBeNull(); + expect( + mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.COMMAND_OUTPUT_DELTA, + { delta: "x" }, + ), + ).toBeNull(); + }); + + it("streams fileChange patch updates as in-progress diff content", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.FILE_CHANGE_PATCH_UPDATED, + { + threadId: "t", + turnId: "u", + itemId: "f1", + changes: [ + { + path: "a.txt", + kind: { type: "update" }, + diff: "@@ -1 +1 @@\n-x\n+y", + }, + ], + }, + ); + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "f1", + status: "in_progress", + content: [{ type: "diff", path: "a.txt", oldText: "x", newText: "y" }], + }, + }); + }); + + it("returns null for the turn completion notification", () => { expect( mapAppServerNotification("s-1", APP_SERVER_NOTIFICATIONS.TURN_COMPLETED, { - usage: { input_tokens: 10 }, + turn: { status: "completed" }, }), ).toBeNull(); }); }); + +describe("mapHistoryItem", () => { + it("replays a userMessage's text inputs as user_message_chunks", () => { + expect( + mapHistoryItem("s-1", { + type: "userMessage", + id: "u1", + content: [ + { type: "text", text: "hello", text_elements: [] }, + { type: "image", url: "data:image/png;base64,AAAA" }, + { type: "text", text: "world", text_elements: [] }, + ], + }), + ).toEqual([ + { + sessionId: "s-1", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "hello" }, + }, + }, + { + sessionId: "s-1", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "world" }, + }, + }, + ]); + }); + + it("replays an agentMessage as an agent_message_chunk", () => { + expect( + mapHistoryItem("s-1", { type: "agentMessage", id: "a1", text: "done" }), + ).toEqual([ + { + sessionId: "s-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "done" }, + }, + }, + ]); + }); + + it("replays a completed command as one tool_call carrying status + output", () => { + expect( + mapHistoryItem("s-1", { + type: "commandExecution", + id: "c1", + command: "ls -la", + status: "completed", + commandActions: [{ type: "read", path: "/repo/a.ts" }], + aggregatedOutput: "a.ts\n", + }), + ).toEqual([ + { + sessionId: "s-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "c1", + title: "ls -la", + kind: "read", + status: "completed", + locations: [{ path: "/repo/a.ts" }], + content: [ + { type: "content", content: { type: "text", text: "a.ts\n" } }, + ], + }, + }, + ]); + }); + + it("replays a fileChange as a tool_call with diff content", () => { + const [update] = mapHistoryItem("s-1", { + type: "fileChange", + id: "f1", + status: "completed", + changes: [{ path: "a.txt", diff: "-x\n+y", kind: "modify" }], + }); + expect(update.update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: "f1", + kind: "edit", + status: "completed", + content: [{ type: "diff", path: "a.txt", oldText: "x", newText: "y" }], + }); + }); + + it("does not replay ephemeral reasoning/plan items", () => { + expect(mapHistoryItem("s-1", { type: "reasoning", id: "r1" })).toEqual([]); + expect( + mapHistoryItem("s-1", { type: "plan", id: "p1", text: "the plan" }), + ).toEqual([]); + }); +}); + +describe("parseUnifiedDiff", () => { + it("keeps added/removed content lines whose payload starts with ++ or --", () => { + // "---count;" is a removed line of content "--count;"; "+++count;" is an + // added line of content "++count;" — neither is a file header. + expect(parseUnifiedDiff("@@ -1 +1 @@\n---count;\n+++count;")).toEqual({ + oldText: "--count;", + newText: "++count;", + }); + }); + + it("skips file headers and the no-newline marker", () => { + expect( + parseUnifiedDiff( + "--- a/x.ts\n+++ b/x.ts\n@@ -1 +1 @@\n-old\n+new\n\\ No newline at end of file", + ), + ).toEqual({ oldText: "old", newText: "new" }); + }); +}); + +describe("mcpToolCall result rendering", () => { + it("renders a completed mcpToolCall's result content as text", () => { + expect( + mapAppServerNotification("s-1", APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, { + item: { + type: "mcpToolCall", + id: "m1", + server: "posthog", + tool: "query", + status: "completed", + arguments: { sql: "SELECT 1" }, + result: { content: [{ type: "text", text: "42 rows" }] }, + }, + }), + ).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "m1", + status: "completed", + content: [ + { type: "content", content: { type: "text", text: "42 rows" } }, + ], + }, + }); + }); + + it("renders a failed mcpToolCall's error message", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, + { + item: { + type: "mcpToolCall", + id: "m2", + server: "x", + tool: "y", + status: "failed", + error: { message: "boom" }, + }, + }, + ); + expect(result?.update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "m2", + status: "failed", + content: [{ type: "content", content: { type: "text", text: "boom" } }], + }); + }); + + it("renders a dynamicToolCall (not dropped) with its inputText output", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, + { + item: { + type: "dynamicToolCall", + id: "d1", + namespace: "ns", + tool: "doit", + status: "completed", + arguments: { x: 1 }, + contentItems: [{ type: "inputText", text: "result" }], + }, + }, + ); + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "d1", + status: "completed", + content: [ + { type: "content", content: { type: "text", text: "result" } }, + ], + }, + }); + }); +}); diff --git a/packages/agent/src/adapters/codex-app-server/mapping.ts b/packages/agent/src/adapters/codex-app-server/mapping.ts index d282981e14..8e3821f18b 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.ts @@ -1,14 +1,20 @@ -import type { SessionNotification } from "@agentclientprotocol/sdk"; +import type { + SessionNotification, + ToolCallContent, + ToolCallLocation, +} from "@agentclientprotocol/sdk"; import { APP_SERVER_NOTIFICATIONS } from "./protocol"; /** * Translates a native app-server notification into an ACP SessionNotification * so the rest of PostHog Code, which speaks ACP, stays unchanged. * - * Spike scope: only the streaming agent-message path is mapped, which is what - * Phase A proves end to end. item/tool events, token usage and approvals are - * mapped in Phase B once the generated schema pins their exact shapes. - * Notifications without a mapping return null and are dropped. + * Streamed text (agent message + reasoning) maps to chunks; item lifecycle + * notifications for tool-like items (command execution, file changes, MCP tool + * calls, web search) map to `tool_call` / `tool_call_update`. Agent-message and + * reasoning *items* are intentionally dropped here because their deltas already + * streamed the content — re-emitting the completed item would double-render. + * Structured-output capture is handled in the agent, not here. */ export function mapAppServerNotification( sessionId: string, @@ -17,22 +23,525 @@ export function mapAppServerNotification( ): SessionNotification | null { switch (method) { case APP_SERVER_NOTIFICATIONS.AGENT_MESSAGE_DELTA: { - // `item/agentMessage/delta` carries { itemId, text }. - const text = readStringField(params, "text"); - if (!text) return null; + const delta = readStringField(params, "delta"); + if (!delta) return null; return { sessionId, update: { sessionUpdate: "agent_message_chunk", - content: { type: "text", text }, + content: { type: "text", text: delta }, }, }; } + case APP_SERVER_NOTIFICATIONS.REASONING_TEXT_DELTA: { + const delta = readStringField(params, "delta"); + if (!delta) return null; + return { + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: delta }, + }, + }; + } + case APP_SERVER_NOTIFICATIONS.TOKEN_USAGE_UPDATED: { + // Context/token indicator: the renderer reads `used`/`size` off the + // update (same shape codex-acp forwards). Detailed token breakdown is + // additionally emitted as a `_posthog/usage_update` ext-notification by + // the agent. + const tu = (params as { tokenUsage?: any })?.tokenUsage; + const used = tu?.total?.totalTokens ?? tu?.total?.inputTokens; + if (used == null) return null; + const size = tu?.modelContextWindow; + // `usage_update` is a PostHog-convention update, not in the ACP union. + return { + sessionId, + update: { + sessionUpdate: "usage_update", + used, + ...(size != null ? { size } : {}), + }, + } as unknown as SessionNotification; + } + case APP_SERVER_NOTIFICATIONS.TURN_PLAN_UPDATED: { + const plan = ( + params as { plan?: Array<{ step?: string; status?: string }> } + )?.plan; + if (!Array.isArray(plan)) return null; + return { + sessionId, + update: { + sessionUpdate: "plan", + entries: plan.map((s) => ({ + content: s.step ?? "", + priority: "medium", + status: mapPlanStatus(s.status), + })), + }, + } as unknown as SessionNotification; + } + case APP_SERVER_NOTIFICATIONS.ITEM_STARTED: + case APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED: { + const item = readItem(params); + if (!item) return null; + return mapItem( + sessionId, + item, + method === APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, + ); + } + case APP_SERVER_NOTIFICATIONS.COMMAND_OUTPUT_DELTA: { + // Live stdout/stderr for an in-progress command: surface as streamed text + // on the tool call so output appears before the item completes. The host + // renderer accumulates these chunks the same way the Claude adapter does + // for its terminal output. + const itemId = readStringField(params, "itemId"); + const delta = readStringField(params, "delta"); + if (!itemId || !delta) return null; + return toolOutputChunk(sessionId, itemId, delta); + } + case APP_SERVER_NOTIFICATIONS.TERMINAL_INTERACTION: { + // PTY stdin echoed back for an interactive command. Echo it into the same + // tool call so the transcript shows what was typed, mirroring how a real + // terminal renders local echo. + const itemId = readStringField(params, "itemId"); + const stdin = readStringField(params, "stdin"); + if (!itemId || !stdin) return null; + return toolOutputChunk(sessionId, itemId, stdin); + } + case APP_SERVER_NOTIFICATIONS.FILE_CHANGE_PATCH_UPDATED: { + // Incremental diff for an in-progress fileChange: push the latest diff so + // the edit renders before the patch is applied. + const itemId = readStringField(params, "itemId"); + if (!itemId) return null; + const changes = (params as { changes?: AppServerItem["changes"] }) + ?.changes; + const content = diffContent(changes); + if (!content) return null; + return { + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: itemId, + status: "in_progress", + content, + }, + }; + } + default: + return null; + } +} + +/** + * A streamed text chunk attached to an in-progress tool call. ACP's + * `tool_call_update.content` semantically replaces the collection; the host + * renderer treats successive single-chunk updates as appended output, which is + * how live command output streams without owning an ACP terminal lifecycle. + */ +function toolOutputChunk( + sessionId: string, + toolCallId: string, + text: string, +): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + content: [{ type: "content", content: { type: "text", text } }], + }, + }; +} + +function mapPlanStatus( + status: string | undefined, +): "pending" | "in_progress" | "completed" { + if (status === "inProgress") return "in_progress"; + if (status === "completed") return "completed"; + return "pending"; +} + +/** + * Extracts {oldText,newText} from a unified diff so a codex `fileChange` renders + * as an ACP diff. Hunk-level (context + ± lines), which is what the renderer + * needs to show the change. Known cosmetic limit: a content line whose payload + * begins with "-- " / "++ " (diff line "--- " / "+++ ") is misread as a file + * header and dropped from the rendered diff — it never affects the actual edit. + */ +export function parseUnifiedDiff(diff: string): { + oldText: string; + newText: string; +} { + const oldLines: string[] = []; + const newLines: string[] = []; + for (const line of diff.split("\n")) { + // Skip diff/hunk metadata. The file headers are "--- a/..." / "+++ b/..." + // and the no-newline marker is "\ No newline...". Match the trailing space + // on --- / +++ so a real added/removed CONTENT line like "++i;" (diff line + // "+++i;") or "--count" isn't mistaken for a header and dropped. + if ( + line.startsWith("@@") || + line.startsWith("diff ") || + line.startsWith("index ") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("\\ ") + ) { + continue; + } + if (line.startsWith("-")) oldLines.push(line.slice(1)); + else if (line.startsWith("+")) newLines.push(line.slice(1)); + else { + const ctx = line.startsWith(" ") ? line.slice(1) : line; + oldLines.push(ctx); + newLines.push(ctx); + } + } + return { oldText: oldLines.join("\n"), newText: newLines.join("\n") }; +} + +type AppServerItem = { + type?: string; + id?: string; + command?: string; + cwd?: string; + commandActions?: Array<{ type?: string; path?: string } | string>; + server?: string; + tool?: string; + namespace?: string | null; + contentItems?: unknown; + query?: string; + status?: string; + arguments?: unknown; + aggregatedOutput?: string | null; + changes?: Array<{ path?: string; diff?: string; kind?: unknown }>; + // mcpToolCall result / error (McpToolCallResult / McpToolCallError). + result?: { content?: unknown } | null; + error?: { message?: string } | null; + // Present on message/reasoning items replayed from thread history. + text?: string; + content?: unknown; +}; + +/** Text rendering of a completed mcpToolCall: the error message, else the + * result's text content blocks joined. */ +function mcpResultText( + result: AppServerItem["result"], + error: AppServerItem["error"], +): string | null { + if (error?.message) return error.message; + const content = result?.content; + if (!Array.isArray(content)) return null; + const text = content + .filter( + (c) => + c && typeof c === "object" && (c as { type?: string }).type === "text", + ) + .map((c) => (c as { text?: string }).text ?? "") + .filter(Boolean) + .join("\n"); + return text || null; +} + +/** Text rendering of a dynamicToolCall's output (its `inputText` content items). */ +function dynamicToolText(items: unknown): string | null { + if (!Array.isArray(items)) return null; + const text = items + .filter( + (c) => + c && + typeof c === "object" && + (c as { type?: string }).type === "inputText", + ) + .map((c) => (c as { text?: string }).text ?? "") + .filter(Boolean) + .join("\n"); + return text || null; +} + +/** + * Re-renders a persisted `ThreadItem` (from `thread/resume`'s `thread.turns`) as + * the ACP updates a live stream would have produced, so a reattaching host shows + * the full transcript. A tool item collapses to a single completed `tool_call` + * (there is no prior start to update); messages map to their chunk. Reuses the + * live `describeTool`/`completedContent`/`mapStatus` so there is no second + * rendering surface to drift. Ephemeral items (reasoning, plan, hookPrompt) are + * not replayed. + */ +export function mapHistoryItem( + sessionId: string, + item: AppServerItem, +): SessionNotification[] { + switch (item.type) { + case "userMessage": + return userMessageChunks(sessionId, item.content); + case "agentMessage": + return item.text + ? [ + { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: item.text }, + }, + }, + ] + : []; + case "reasoning": + case "plan": + return []; + default: { + const tool = describeTool(item); + if (!tool || !item.id) return []; + const content = completedContent(item, tool); + return [ + { + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: item.id, + title: tool.title, + kind: tool.kind, + status: mapStatus(item.status), + ...(tool.rawInput !== undefined ? { rawInput: tool.rawInput } : {}), + ...(tool.locations?.length ? { locations: tool.locations } : {}), + ...(content ? { content } : {}), + }, + }, + ]; + } + } +} + +/** + * A persisted `userMessage`'s `content` is codex `UserInput[]`. Replay the text + * inputs as `user_message_chunk`s; historical image attachments aren't + * re-rendered (the live echo handles new ones), keeping the replay lossless for + * the text transcript without reconstructing data URLs here. + */ +function userMessageChunks( + sessionId: string, + content: unknown, +): SessionNotification[] { + if (!Array.isArray(content)) return []; + const out: SessionNotification[] = []; + for (const block of content) { + if ( + block && + typeof block === "object" && + (block as { type?: string }).type === "text" + ) { + const text = (block as { text?: string }).text; + if (typeof text === "string" && text) { + out.push({ + sessionId, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text }, + }, + }); + } + } + } + return out; +} + +type ToolDescriptor = { + title: string; + kind: "execute" | "edit" | "fetch" | "other" | "read" | "search"; + rawInput?: unknown; + output?: string | null; + locations?: ToolCallLocation[]; +}; + +/** + * Classify a shell command by its parsed actions so read-only commands (`cat`, + * `ls`, `grep`) render as read/search rather than execute — matching how the + * codex-acp adapter surfaces them. + */ +function commandKind( + actions: AppServerItem["commandActions"], +): "read" | "search" | "execute" { + if (!actions?.length) return "execute"; + const types = actions.map((a) => (typeof a === "string" ? a : a?.type)); + if (types.every((t) => t === "read")) return "read"; + if (types.every((t) => t === "search" || t === "listFiles")) return "search"; + return "execute"; +} + +function describeTool(item: AppServerItem): ToolDescriptor | null { + switch (item.type) { + case "commandExecution": + return { + title: item.command ?? "Run command", + kind: commandKind(item.commandActions), + output: item.aggregatedOutput ?? null, + locations: commandLocations(item), + }; + case "fileChange": { + const paths = changePaths(item.changes); + return { + // Title with the changed path(s) instead of a generic label so the + // tool call reads like Claude's edit summary. + title: fileChangeTitle(paths), + kind: "edit", + locations: paths.map((path) => ({ path })), + }; + } + case "mcpToolCall": + return { + title: `${item.server ?? "mcp"}/${item.tool ?? "tool"}`, + kind: "other", + rawInput: item.arguments, + output: mcpResultText(item.result, item.error), + }; + case "dynamicToolCall": + return { + title: item.namespace + ? `${item.namespace}/${item.tool ?? "tool"}` + : (item.tool ?? "tool"), + kind: "other", + rawInput: item.arguments, + output: dynamicToolText(item.contentItems), + }; + case "webSearch": + return { title: item.query ?? "Web search", kind: "fetch" }; default: return null; } } +/** Distinct, non-empty changed paths for a fileChange item, order-preserved. */ +function changePaths(changes: AppServerItem["changes"]): string[] { + if (!changes?.length) return []; + const seen = new Set(); + const paths: string[] = []; + for (const change of changes) { + const path = change?.path; + if (path && !seen.has(path)) { + seen.add(path); + paths.push(path); + } + } + return paths; +} + +function fileChangeTitle(paths: string[]): string { + if (!paths.length) return "Edit files"; + if (paths.length === 1) return paths[0]; + return `${paths[0]} (+${paths.length - 1} more)`; +} + +/** + * Clickable locations for a commandExecution: the read/search/listFiles command + * actions carry concrete paths, otherwise fall back to the working directory so + * the UI can still anchor "follow-along" navigation somewhere. + */ +function commandLocations(item: AppServerItem): ToolCallLocation[] | undefined { + const paths: string[] = []; + const seen = new Set(); + for (const action of item.commandActions ?? []) { + const path = typeof action === "string" ? undefined : action?.path; + if (path && !seen.has(path)) { + seen.add(path); + paths.push(path); + } + } + if (!paths.length && item.cwd) paths.push(item.cwd); + if (!paths.length) return undefined; + return paths.map((path) => ({ path })); +} + +function mapItem( + sessionId: string, + item: AppServerItem, + completed: boolean, +): SessionNotification | null { + const tool = describeTool(item); + if (!tool || !item.id) { + return null; + } + + if (!completed) { + return { + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: item.id, + title: tool.title, + kind: tool.kind, + status: "in_progress", + ...(tool.rawInput !== undefined ? { rawInput: tool.rawInput } : {}), + ...(tool.locations?.length ? { locations: tool.locations } : {}), + }, + }; + } + + const content = completedContent(item, tool); + return { + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: item.id, + status: mapStatus(item.status), + ...(content ? { content } : {}), + }, + }; +} + +/** Content for a completed tool call: file diffs for fileChange, else output text. */ +function completedContent( + item: AppServerItem, + tool: ToolDescriptor, +): ToolCallContent[] | undefined { + if (item.type === "fileChange") { + const diffs = diffContent(item.changes); + if (diffs) return diffs; + } + if (tool.output) { + return [{ type: "content", content: { type: "text", text: tool.output } }]; + } + return undefined; +} + +/** Maps a fileChange's `changes[]` to ACP `diff` content blocks. */ +function diffContent( + changes: AppServerItem["changes"], +): ToolCallContent[] | undefined { + if (!changes?.length) return undefined; + const diffs = changes + .filter((c) => c?.diff) + .map( + (c) => + ({ + type: "diff", + path: c.path, + ...parseUnifiedDiff(c.diff ?? ""), + }) as unknown as ToolCallContent, + ); + return diffs.length ? diffs : undefined; +} + +function mapStatus( + status: string | undefined, +): "completed" | "failed" | "in_progress" { + if (status === "completed") return "completed"; + if (status === "failed" || status === "declined") return "failed"; + return "in_progress"; +} + +function readItem(params: unknown): AppServerItem | null { + if (params && typeof params === "object" && "item" in params) { + const item = (params as Record).item; + if (item && typeof item === "object") { + return item as AppServerItem; + } + } + return null; +} + function readStringField(params: unknown, key: string): string | null { if (params && typeof params === "object" && key in params) { const value = (params as Record)[key]; diff --git a/packages/agent/src/adapters/codex-app-server/mcp-config.test.ts b/packages/agent/src/adapters/codex-app-server/mcp-config.test.ts new file mode 100644 index 0000000000..912d253b38 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/mcp-config.test.ts @@ -0,0 +1,60 @@ +import type { McpServer } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vitest"; +import { toCodexMcpServers } from "./mcp-config"; + +describe("toCodexMcpServers", () => { + it("returns undefined for empty input", () => { + expect(toCodexMcpServers(undefined)).toBeUndefined(); + expect(toCodexMcpServers([])).toBeUndefined(); + }); + + it("translates a stdio server, folding env pairs into a map", () => { + const servers = [ + { + name: "posthog", + command: "node", + args: ["server.js"], + env: [ + { name: "TOKEN", value: "abc" }, + { name: "BASE", value: "http://x" }, + ], + }, + ] as unknown as McpServer[]; + + expect(toCodexMcpServers(servers)).toEqual({ + posthog: { + command: "node", + args: ["server.js"], + env: { TOKEN: "abc", BASE: "http://x" }, + }, + }); + }); + + it("omits env when there are no pairs", () => { + const servers = [ + { name: "bare", command: "run", args: [], env: [] }, + ] as unknown as McpServer[]; + + expect(toCodexMcpServers(servers)).toEqual({ + bare: { command: "run", args: [] }, + }); + }); + + it("translates an http server, folding headers into http_headers", () => { + const servers = [ + { + type: "http", + name: "remote", + url: "https://mcp.example/mcp", + headers: [{ name: "Authorization", value: "Bearer t" }], + }, + ] as unknown as McpServer[]; + + expect(toCodexMcpServers(servers)).toEqual({ + remote: { + url: "https://mcp.example/mcp", + http_headers: { Authorization: "Bearer t" }, + }, + }); + }); +}); diff --git a/packages/agent/src/adapters/codex-app-server/mcp-config.ts b/packages/agent/src/adapters/codex-app-server/mcp-config.ts new file mode 100644 index 0000000000..594e22f436 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/mcp-config.ts @@ -0,0 +1,58 @@ +import type { McpServer } from "@agentclientprotocol/sdk"; + +/** + * Codex's per-thread `mcp_servers` config entry. Stdio servers carry a + * command/args/env; HTTP servers carry a url + headers. The native app-server + * accepts this map under `thread/start`'s `config.mcp_servers`. + */ +export type CodexMcpServerConfig = + | { command: string; args: string[]; env?: Record } + | { url: string; http_headers?: Record }; + +/** + * Translates the ACP `McpServer[]` the host passes in `newSession` into the + * shape Codex's app-server expects under `config.mcp_servers`. ACP encodes env + * and headers as `{ name, value }[]`; Codex wants plain string maps. + * + * Returns undefined when there is nothing to inject so callers can omit the key. + */ +export function toCodexMcpServers( + servers: McpServer[] | undefined, +): Record | undefined { + if (!servers || servers.length === 0) { + return undefined; + } + + const out: Record = {}; + for (const server of servers) { + if ("command" in server && server.command) { + const env = pairsToRecord(server.env); + out[server.name] = { + command: server.command, + args: server.args ?? [], + ...(env ? { env } : {}), + }; + } else if ("url" in server && server.url) { + const headers = pairsToRecord(server.headers); + out[server.name] = { + url: server.url, + ...(headers ? { http_headers: headers } : {}), + }; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +function pairsToRecord( + pairs: Array<{ name: string; value: string }> | undefined, +): Record | undefined { + if (!pairs || pairs.length === 0) { + return undefined; + } + const record: Record = {}; + for (const { name, value } of pairs) { + record[name] = value; + } + return record; +} diff --git a/packages/agent/src/adapters/codex-app-server/protocol.ts b/packages/agent/src/adapters/codex-app-server/protocol.ts index 0448513366..22b9e971d7 100644 --- a/packages/agent/src/adapters/codex-app-server/protocol.ts +++ b/packages/agent/src/adapters/codex-app-server/protocol.ts @@ -17,27 +17,59 @@ export const APP_SERVER_METHODS = { THREAD_RESUME: "thread/resume", THREAD_FORK: "thread/fork", TURN_START: "turn/start", + // Inject input into the active turn instead of starting a new one — used to + // mirror Claude's mid-turn steering. Fails unless `expectedTurnId` matches. + TURN_STEER: "turn/steer", TURN_INTERRUPT: "turn/interrupt", + MODEL_LIST: "model/list", + SKILLS_LIST: "skills/list", + THREAD_LIST: "thread/list", } as const; export const APP_SERVER_NOTIFICATIONS = { INITIALIZED: "initialized", THREAD_STARTED: "thread/started", + // Carries the active turn id (`turn.id`) — captured as the turn/steer + + // turn/interrupt precondition. + TURN_STARTED: "turn/started", ITEM_STARTED: "item/started", ITEM_COMPLETED: "item/completed", AGENT_MESSAGE_DELTA: "item/agentMessage/delta", + REASONING_TEXT_DELTA: "item/reasoning/textDelta", + TURN_PLAN_UPDATED: "turn/plan/updated", TURN_COMPLETED: "turn/completed", + // Fatal turn error; `willRetry:false` means it won't recover on its own. + ERROR: "error", TOKEN_USAGE_UPDATED: "thread/tokenUsage/updated", + // Streamed stdout/stderr chunks for an in-progress commandExecution item. + COMMAND_OUTPUT_DELTA: "item/commandExecution/outputDelta", + // PTY-level stdin echoed back for an interactive terminal command. + TERMINAL_INTERACTION: "item/commandExecution/terminalInteraction", + // Incremental patch/diff updates for an in-progress fileChange item. + FILE_CHANGE_PATCH_UPDATED: "item/fileChange/patchUpdated", } as const; -/** Server-initiated requests the client must answer (approvals). */ +/** + * Server-initiated requests the client must answer. The two approvals are + * handled in handleApproval (yes/no decision). The richer requests carry + * distinct response shapes, not the approval decision: + * - TOOL_USER_INPUT — AskUserQuestion-style multi-question prompt. + * - PERMISSIONS_APPROVAL — grant a permission profile for a turn/session. + * - MCP_ELICITATION — an MCP server asking the user for structured input. + */ export const APP_SERVER_REQUESTS = { COMMAND_APPROVAL: "item/commandExecution/requestApproval", FILE_CHANGE_APPROVAL: "item/fileChange/requestApproval", + TOOL_USER_INPUT: "item/tool/requestUserInput", + PERMISSIONS_APPROVAL: "item/permissions/requestApproval", + MCP_ELICITATION: "mcpServer/elicitation/request", } as const; +/** JSON-RPC ids are `string | number` per the codex schema (`RequestId.ts`). */ +export type RequestId = string | number; + export interface JsonRpcRequest { - id: number; + id: RequestId; method: string; params?: unknown; } @@ -54,7 +86,7 @@ export interface JsonRpcError { } export interface JsonRpcResponse { - id: number; + id: RequestId; result?: unknown; error?: JsonRpcError; } diff --git a/packages/agent/src/adapters/codex-app-server/session-config.test.ts b/packages/agent/src/adapters/codex-app-server/session-config.test.ts new file mode 100644 index 0000000000..45112462b8 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/session-config.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + buildConfigOptions, + CODEX_MODES, + DEFAULT_EFFORTS, + modeApprovalPolicy, +} from "./session-config"; + +describe("modeApprovalPolicy", () => { + it.each([ + ["read-only", "untrusted"], + ["auto", "on-request"], + ["full-access", "never"], + ])("maps mode %s to approval policy %s", (mode, policy) => { + expect(modeApprovalPolicy(mode)).toBe(policy); + }); + + it("returns undefined for an unknown mode", () => { + expect(modeApprovalPolicy("nonsense")).toBeUndefined(); + expect(modeApprovalPolicy(undefined)).toBeUndefined(); + }); + + it("every CODEX_MODES entry has a resolvable policy", () => { + for (const mode of CODEX_MODES) { + expect(modeApprovalPolicy(mode.id)).toBe(mode.approvalPolicy); + } + }); +}); + +describe("buildConfigOptions", () => { + it("emits a model + thought_level selector from the live lists", () => { + const opts = buildConfigOptions({ + model: "gpt-5.5", + effort: "high", + models: [ + { id: "gpt-5.5", name: "GPT-5.5" }, + { id: "gpt-5-mini", name: "GPT-5 mini" }, + ], + efforts: ["low", "high"], + }); + expect(opts.map((o) => (o as { category: string }).category)).toEqual([ + "model", + "thought_level", + ]); + expect((opts[0] as { currentValue: string }).currentValue).toBe("gpt-5.5"); + expect( + (opts[0] as { options: Array<{ value: string }> }).options.map( + (o) => o.value, + ), + ).toEqual(["gpt-5.5", "gpt-5-mini"]); + }); + + it("keeps the active model/effort selectable even if the lists omit them", () => { + const opts = buildConfigOptions({ + model: "gpt-5.5", + effort: "max", + models: [{ id: "gpt-5-mini", name: "GPT-5 mini" }], + efforts: ["low", "high"], + }); + const model = opts[0] as { + currentValue: string; + options: Array<{ value: string }>; + }; + const effort = opts[1] as { + currentValue: string; + options: Array<{ value: string }>; + }; + expect(model.currentValue).toBe("gpt-5.5"); + expect(model.options.map((o) => o.value)).toContain("gpt-5.5"); + expect(effort.currentValue).toBe("max"); + expect(effort.options.map((o) => o.value)).toContain("max"); + }); + + it("falls back to the single current model and DEFAULT_EFFORTS when lists are empty", () => { + const opts = buildConfigOptions({ + model: "gpt-5.5", + models: [], + efforts: [], + }); + expect((opts[0] as { options: Array<{ value: string }> }).options).toEqual([ + { name: "gpt-5.5", value: "gpt-5.5" }, + ]); + expect( + (opts[1] as { options: Array<{ value: string }> }).options.map( + (o) => o.value, + ), + ).toEqual(DEFAULT_EFFORTS); + }); +}); diff --git a/packages/agent/src/adapters/codex-app-server/session-config.ts b/packages/agent/src/adapters/codex-app-server/session-config.ts new file mode 100644 index 0000000000..19f758b6d1 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/session-config.ts @@ -0,0 +1,111 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; + +/** + * Session config + mode synthesis for the codex app-server adapter. + * + * PostHog Code expects ACP `configOptions` (model + reasoning-effort selectors) + * and a `mode` switcher. The native app-server has no "mode" RPC — a thread is + * configured by `approvalPolicy` + `sandbox` — so the modes are synthesized here + * and applied per-turn. We mirror the codex-acp adapter, which surfaces only + * `model` + `thought_level` configOptions (mode is driven via + * `setSessionConfigOption`, not listed as a configOption). + */ + +export interface CodexMode { + id: string; + name: string; + description: string; + /** codex AskForApproval the mode maps to, applied per-turn on turn/start. */ + approvalPolicy: string; +} + +export const CODEX_MODES: CodexMode[] = [ + { + id: "read-only", + name: "Read only", + description: "Asks before any change", + approvalPolicy: "untrusted", + }, + { + id: "auto", + name: "Auto", + description: "Asks before risky operations", + approvalPolicy: "on-request", + }, + { + id: "full-access", + name: "Full access", + description: "Auto-approves all operations", + approvalPolicy: "never", + }, +]; + +export const DEFAULT_MODE = "auto"; + +export function modeApprovalPolicy( + modeId: string | undefined, +): string | undefined { + return CODEX_MODES.find((m) => m.id === modeId)?.approvalPolicy; +} + +/** + * Resolve the host's initial `_meta.permissionMode` to a codex mode — mirroring + * codex-acp's toCodexPermissionMode. A recognized codex mode is honored; any + * other value (e.g. a Claude-style "bypassPermissions") falls back to the + * default so the session starts in a sane approval policy. + */ +export function resolveInitialMode(permissionMode: string | undefined): string { + return permissionMode && CODEX_MODES.some((m) => m.id === permissionMode) + ? permissionMode + : DEFAULT_MODE; +} + +/** Codex's standard reasoning efforts; used when model/list doesn't expose them. */ +export const DEFAULT_EFFORTS = ["low", "medium", "high"]; + +export interface SessionConfigState { + model: string; + effort?: string; + /** From model/list; falls back to the single current model when empty. */ + models: Array<{ id: string; name: string }>; + /** Reasoning efforts supported by the current model. */ + efforts: string[]; +} + +/** Builds the ACP configOptions (model + thought_level) the host renders. */ +export function buildConfigOptions( + s: SessionConfigState, +): SessionConfigOption[] { + const baseModels = s.models.length + ? s.models + : [{ id: s.model, name: s.model }]; + // Ensure the active model/effort is always a selectable option, even if + // model/list omitted it or a mid-session switch moved off the listed set — + // otherwise the selector's currentValue points at nothing. + const models = baseModels.some((m) => m.id === s.model) + ? baseModels + : [...baseModels, { id: s.model, name: s.model }]; + const baseEfforts = s.efforts.length ? s.efforts : DEFAULT_EFFORTS; + const currentEffort = s.effort ?? baseEfforts[0]; + const efforts = baseEfforts.includes(currentEffort) + ? baseEfforts + : [...baseEfforts, currentEffort]; + return [ + { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: s.model, + options: models.map((m) => ({ name: m.name, value: m.id })), + } as unknown as SessionConfigOption, + { + type: "select", + id: "effort", + name: "Reasoning effort", + category: "thought_level", + currentValue: currentEffort, + options: efforts.map((e) => ({ name: e, value: e })), + } as unknown as SessionConfigOption, + ]; +} diff --git a/packages/agent/src/adapters/codex-app-server/spawn.test.ts b/packages/agent/src/adapters/codex-app-server/spawn.test.ts index 0be0058b4b..f20d990764 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.test.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.test.ts @@ -19,13 +19,17 @@ describe("buildAppServerArgs", () => { ); }); - it("passes guidance via developer_instructions, never the replacing key", () => { + it("does not set instructions at spawn (developer_instructions are per-thread)", () => { const args = buildAppServerArgs({ binaryPath: "/bundle/codex", developerInstructions: "Follow PostHog rules.", }); - expect(args).toContain('developer_instructions="Follow PostHog rules."'); + // Guidance is injected per-thread in thread/start (combined with the host's + // task system prompt), so the spawn args carry no instructions of any kind. + expect(args.some((arg) => arg.startsWith("developer_instructions="))).toBe( + false, + ); expect(args.some((arg) => arg.startsWith("instructions="))).toBe(false); }); }); diff --git a/packages/agent/src/adapters/codex-app-server/spawn.ts b/packages/agent/src/adapters/codex-app-server/spawn.ts index 2db7a633b0..6a784082a9 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.ts @@ -4,6 +4,7 @@ import { delimiter, dirname } from "node:path"; import type { Readable, Writable } from "node:stream"; import type { ProcessSpawnedCallback } from "../../types"; import { Logger } from "../../utils/logger"; +import { CodexSettingsManager } from "../codex/settings"; export interface CodexAppServerProcessOptions { /** Path to the native `codex` CLI binary (the one that exposes `app-server`). */ @@ -31,6 +32,24 @@ export function buildAppServerArgs( args.push("-c", "features.remote_models=false"); + // The agent already runs inside PostHog's isolated sandbox (docker/Modal with + // agentsh egress + filesystem controls), so Codex's own OS-level sandbox is + // redundant — and its `linux-sandbox` launcher is unavailable there, so the + // default mode panics ("sandbox launcher unavailable") and wedges the session. + // Run with no nested sandbox; the enclosing sandbox provides the isolation. + args.push("-c", `sandbox_mode="danger-full-access"`); + + // Disable the user's ambient ~/.codex MCP servers (linear/figma/etc.) so the + // adapter only exposes MCP servers PostHog Code injects per-thread — matching + // the codex-acp adapter. Without this, codex tries (and fails) to connect to + // the user's local MCP servers, polluting the session. Only the first key + // segment is disabled (`mcp_servers..enabled=false`) — see settings.ts. + for (const name of new CodexSettingsManager( + options.cwd ?? process.cwd(), + ).getSettings().mcpServerNames) { + args.push("-c", `mcp_servers.${name}.enabled=false`); + } + if (options.apiBaseUrl) { args.push("-c", `model_provider="posthog"`); args.push("-c", `model_providers.posthog.name="PostHog Gateway"`); @@ -42,14 +61,9 @@ export function buildAppServerArgs( ); } - if (options.developerInstructions) { - const escaped = options.developerInstructions - .replace(/\\/g, "\\\\") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/"/g, '\\"'); - args.push("-c", `developer_instructions="${escaped}"`); - } + // developer_instructions are set per-thread in thread/start (combined with the + // host's task system prompt) rather than as a spawn-level global default, so + // the task prompt — only known at newSession — reaches the model too. return args; } diff --git a/packages/agent/src/adapters/codex/spawn.ts b/packages/agent/src/adapters/codex/spawn.ts index 9e14e1a8cd..e1c00f8b64 100644 --- a/packages/agent/src/adapters/codex/spawn.ts +++ b/packages/agent/src/adapters/codex/spawn.ts @@ -39,6 +39,14 @@ function buildConfigArgs(options: CodexProcessOptions): string[] { args.push("-c", `features.remote_models=false`); + // The agent already runs inside PostHog's isolated sandbox (docker/Modal with + // agentsh egress + filesystem controls), so Codex's own OS-level sandbox is + // redundant — and its `linux-sandbox` launcher is unavailable inside that + // sandbox, so the default workspace-write mode panics ("sandbox launcher + // unavailable" → require_escalated) and wedges the session. Run Codex with no + // nested sandbox; the enclosing sandbox provides the isolation. + args.push("-c", `sandbox_mode="danger-full-access"`); + // Disable the user's local MCPs one-by-one so Codex only uses the MCPs we // provide via ACP. We can't use `-c mcp_servers={}` because that makes Codex // ignore MCPs entirely, including the ones we inject later. diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 28d26d627c..85c78eab47 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -129,6 +129,7 @@ export class Agent { logger: this.logger, processCallbacks: options.processCallbacks, onStructuredOutput: options.onStructuredOutput, + useCodexAppServer: options.useCodexAppServer, allowedModelIds, posthogApiConfig: this.posthogApiConfig, enricherEnabled: this.enricherEnabled, diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 5d64177928..abba65b07a 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1156,6 +1156,11 @@ export class AgentServer { cwd: this.config.repositoryPath ?? "/tmp/workspace", apiBaseUrl: gatewayEnv.openaiBaseUrl, apiKey: this.config.apiKey, + // Path to the bundled codex-acp binary; the native app-server + // adapter derives `codex` from the same directory. Set in the + // sandbox image (POSTHOG_CODEX_BINARY_PATH); when unset the + // adapter falls back to npx codex-acp. + binaryPath: process.env.POSTHOG_CODEX_BINARY_PATH, model: this.config.model ?? DEFAULT_CODEX_MODEL, reasoningEffort: this.config.reasoningEffort, developerInstructions: codexInstructions, diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 0056590678..d5c10e6169 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -65,6 +65,12 @@ export interface TaskExecutionOptions { onStructuredOutput?: (output: Record) => Promise; /** Additional directories the agent process can access beyond cwd. */ additionalDirectories?: string[]; + /** + * Codex-only feature-flag lever: `true` selects the native app-server adapter, + * `false` codex-acp. The host evaluates a PostHog flag and passes the result; + * undefined falls back to env overrides then the bundled-binary default. + */ + useCodexAppServer?: boolean; } export type LogLevel = "debug" | "info" | "warn" | "error"; diff --git a/packages/agent/vitest.e2e.config.ts b/packages/agent/vitest.e2e.config.ts new file mode 100644 index 0000000000..01e95543af --- /dev/null +++ b/packages/agent/vitest.e2e.config.ts @@ -0,0 +1,24 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +// Live, opt-in e2e suite. Separate from the default `vitest.config.ts` (which +// only includes `src/**`), so these never run under `pnpm test` or in CI — only +// via `pnpm test:e2e`. Sequential, generous timeouts: each test drives two real +// model turns end to end. +export default defineConfig({ + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, + test: { + globals: true, + environment: "node", + include: ["e2e/**/*.e2e.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + isolate: true, + fileParallelism: false, + testTimeout: 300_000, + hookTimeout: 120_000, + }, +}); From 128136840c2635a0c840a6d6ebc26357534d6aa8 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Mon, 29 Jun 2026 12:15:52 +0100 Subject: [PATCH 02/20] add feature flag locally --- .../agent/src/adapters/acp-connection.test.ts | 13 +++++-- packages/agent/src/adapters/acp-connection.ts | 35 ++++++++++++------- packages/core/src/sessions/sessionService.ts | 35 +++++++++++++++++++ .../features/sessions/sessionServiceHost.ts | 2 ++ .../src/services/agent/agent.ts | 10 ++++++ .../src/services/agent/schemas.ts | 8 +++++ 6 files changed, 87 insertions(+), 16 deletions(-) diff --git a/packages/agent/src/adapters/acp-connection.test.ts b/packages/agent/src/adapters/acp-connection.test.ts index b9ec4c5c64..b1b1d82833 100644 --- a/packages/agent/src/adapters/acp-connection.test.ts +++ b/packages/agent/src/adapters/acp-connection.test.ts @@ -7,7 +7,8 @@ describe("resolveUseCodexAppServer", () => { acp: process.env.POSTHOG_CODEX_USE_ACP, }; afterEach(() => { - if (saved.app === undefined) delete process.env.POSTHOG_CODEX_USE_APP_SERVER; + if (saved.app === undefined) + delete process.env.POSTHOG_CODEX_USE_APP_SERVER; else process.env.POSTHOG_CODEX_USE_APP_SERVER = saved.app; if (saved.acp === undefined) delete process.env.POSTHOG_CODEX_USE_ACP; else process.env.POSTHOG_CODEX_USE_ACP = saved.acp; @@ -32,9 +33,15 @@ describe("resolveUseCodexAppServer", () => { expect(resolveUseCodexAppServer({})).toBe(false); }); - it("defaults to app-server when nothing is set", () => { + it("defaults to codex-acp when nothing is set (app-server is opt-in)", () => { delete process.env.POSTHOG_CODEX_USE_APP_SERVER; delete process.env.POSTHOG_CODEX_USE_ACP; - expect(resolveUseCodexAppServer({})).toBe(true); + expect(resolveUseCodexAppServer({})).toBe(false); + }); + + it("host flag false beats POSTHOG_CODEX_USE_APP_SERVER=1", () => { + process.env.POSTHOG_CODEX_USE_APP_SERVER = "1"; + delete process.env.POSTHOG_CODEX_USE_ACP; + expect(resolveUseCodexAppServer({ useCodexAppServer: false })).toBe(false); }); }); diff --git a/packages/agent/src/adapters/acp-connection.ts b/packages/agent/src/adapters/acp-connection.ts index f083bad881..41c9358217 100644 --- a/packages/agent/src/adapters/acp-connection.ts +++ b/packages/agent/src/adapters/acp-connection.ts @@ -28,11 +28,11 @@ export type AcpConnectionConfig = { codexOptions?: CodexProcessOptions; allowedModelIds?: Set; /** - * Feature-flag lever for the codex sub-adapter, passed by the host from a - * PostHog flag (gradual rollout / kill-switch). `true` => native app-server, - * `false` => codex-acp. When undefined, falls back to env overrides then the - * bundled-binary default (app-server). Lets app-server run alongside codex-acp - * under controlled rollout without a code change. + * Feature-flag lever for the codex sub-adapter, passed by the host from the + * `codex-app-server` PostHog flag (gradual rollout / kill-switch). `true` => + * native app-server, `false` => codex-acp. When undefined, falls back to env + * overrides then the default (codex-acp). Lets app-server roll out alongside + * codex-acp without a code change. */ useCodexAppServer?: boolean; /** Callback invoked when the agent calls the create_output tool for structured output */ @@ -80,12 +80,12 @@ function resolveEnricherApiConfig( /** * Resolves which codex sub-adapter to use. Precedence: host flag - * (`config.useCodexAppServer`, from a PostHog flag) > env overrides - * (`POSTHOG_CODEX_USE_APP_SERVER=1` / `POSTHOG_CODEX_USE_ACP=1`) > bundled-binary - * default (app-server). The host flag is the rollout / kill-switch lever that - * lets app-server run alongside codex-acp without a code change. To run a gradual - * opt-in instead (default codex-acp), the host passes `useCodexAppServer: false` - * by default and `true` for the enabled cohort. + * (`config.useCodexAppServer`, from the `codex-app-server` PostHog flag) > env + * overrides (`POSTHOG_CODEX_USE_APP_SERVER=1` / `POSTHOG_CODEX_USE_ACP=1`) > + * default (codex-acp, the proven fallback). The native app-server is opt-in: + * the host turns it on per-user via the flag (cloud passes the resolved env; + * desktop passes `useCodexAppServer`), so it can roll out alongside codex-acp + * without a code change and be killed instantly by flipping the flag off. */ export function resolveUseCodexAppServer(config: AcpConnectionConfig): boolean { if (typeof config.useCodexAppServer === "boolean") { @@ -93,7 +93,7 @@ export function resolveUseCodexAppServer(config: AcpConnectionConfig): boolean { } if (process.env.POSTHOG_CODEX_USE_APP_SERVER === "1") return true; if (process.env.POSTHOG_CODEX_USE_ACP === "1") return false; - return true; + return false; } function createClaudeConnection(config: AcpConnectionConfig): AcpConnection { @@ -238,7 +238,16 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { // Use the native app-server when its binary is bundled AND the host (flag) // / env selects it. See resolveUseCodexAppServer for precedence. - if (nativeBinary && resolveUseCodexAppServer(config)) { + const useAppServer = !!nativeBinary && resolveUseCodexAppServer(config); + logger.info( + `Codex sub-adapter selected: ${useAppServer ? "app-server (native codex)" : "codex-acp"}`, + { + useAppServer, + nativeBinaryFound: !!nativeBinary, + hostFlag: config.useCodexAppServer, + }, + ); + if (useAppServer) { agent = new CodexAppServerAgent(client, { processOptions: { binaryPath: nativeBinary, diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 36b18a1bd6..b215e31ab3 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -252,6 +252,13 @@ export interface SessionServiceHelpers { ) => Promise; } +/** + * PostHog flag gating the native codex app-server sub-adapter. When enabled for + * the user, a codex session uses the app-server adapter instead of codex-acp. + * Resolved at session start and passed to the agent as `useCodexAppServer`. + */ +export const CODEX_APP_SERVER_FLAG = "codex-app-server"; + export interface SessionServiceDeps { trpc: SessionTrpc; store: ISessionStore; @@ -267,6 +274,12 @@ export interface SessionServiceDeps { info: (msg: any, opts?: any) => unknown; }; track: (event: string, props?: Record) => void; + /** + * Evaluates a PostHog feature flag for the current user. Used to resolve + * {@link CODEX_APP_SERVER_FLAG} at session start. Optional so non-desktop + * hosts (stubbed web, tests) can omit it — absent is treated as "flag off". + */ + featureFlags?: { isEnabled(flagKey: string): boolean }; buildPermissionToolMetadata: (...args: any[]) => any; notifyPermissionRequest: (...args: any[]) => any; notifyPromptComplete: (...args: any[]) => any; @@ -954,6 +967,7 @@ export class SessionService { logUrl, sessionId, adapter: resolvedAdapter, + useCodexAppServer: this.resolveUseCodexAppServer(resolvedAdapter), permissionMode: persistedMode, model: persistedModel, customInstructions: customInstructions || undefined, @@ -1245,6 +1259,26 @@ export class SessionService { ); } + /** + * Resolve the `codex-app-server` flag for a session. Only meaningful for the + * codex adapter (Claude ignores it), so returns undefined otherwise. + * + * One-way opt-in: when the flag is ON we force the app-server adapter (`true`). + * When off/unloaded (or no flags service on non-desktop hosts) we return + * `undefined` rather than `false`, so the agent falls through to its env + * override (`POSTHOG_CODEX_USE_APP_SERVER`) and then the codex-acp default — + * hard-passing `false` would shadow that env, since the host value has the + * highest precedence in resolveUseCodexAppServer. + */ + private resolveUseCodexAppServer( + adapter: "claude" | "codex" | undefined, + ): boolean | undefined { + if (adapter !== "codex") return undefined; + return this.d.featureFlags?.isEnabled(CODEX_APP_SERVER_FLAG) + ? true + : undefined; + } + private async createNewLocalSession( taskId: string, taskTitle: string, @@ -1277,6 +1311,7 @@ export class SessionService { projectId: auth.projectId, permissionMode: executionMode, adapter, + useCodexAppServer: this.resolveUseCodexAppServer(adapter), customInstructions: startCustomInstructions || undefined, effort: effortLevelSchema.safeParse(reasoningLevel).success ? (reasoningLevel as EffortLevel) diff --git a/packages/ui/src/features/sessions/sessionServiceHost.ts b/packages/ui/src/features/sessions/sessionServiceHost.ts index 9a57f913e7..c0d0e30b80 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.ts @@ -37,6 +37,7 @@ import { WORKSPACE_QUERY_KEY } from "@posthog/ui/features/workspace/identifiers" import { toast } from "@posthog/ui/primitives/toast"; import { buildPermissionToolMetadata, + posthogFeatureFlags, track, } from "@posthog/ui/shell/posthogAnalyticsImpl"; import { logger } from "../../shell/logger"; @@ -80,6 +81,7 @@ function buildSessionServiceDeps(): SessionServiceDeps { ); }, buildPermissionToolMetadata, + featureFlags: posthogFeatureFlags, notifyPermissionRequest: (taskTitle, taskId) => resolveService(NotificationBus).notifyPermissionRequest( taskTitle, diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 83341d0c1e..3e7d82a215 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -262,6 +262,12 @@ interface SessionConfig { /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ sessionId?: string; adapter?: "claude" | "codex"; + /** + * Resolved `codex-app-server` flag for the current user. When true and the + * adapter is codex, the agent uses the native app-server sub-adapter; when + * false/undefined it uses codex-acp. Ignored by the Claude adapter. + */ + useCodexAppServer?: boolean; /** Permission mode to use for the session */ permissionMode?: string; /** Custom instructions injected into the system prompt */ @@ -675,6 +681,7 @@ If a repository IS genuinely required, attach one in this priority order: credentials, logUrl, adapter, + useCodexAppServer, permissionMode, customInstructions, systemPromptOverride, @@ -787,6 +794,7 @@ If a repository IS genuinely required, attach one in this priority order: const acpConnection = await agent.run(taskId, taskRunId, { adapter, + useCodexAppServer, gatewayUrl: proxyUrl, codexBinaryPath: adapter === "codex" ? this.getCodexBinaryPath() : undefined, @@ -1901,6 +1909,8 @@ For git operations while detached: logUrl: "logUrl" in params ? params.logUrl : undefined, sessionId: "sessionId" in params ? params.sessionId : undefined, adapter: "adapter" in params ? params.adapter : undefined, + useCodexAppServer: + "useCodexAppServer" in params ? params.useCodexAppServer : undefined, permissionMode: "permissionMode" in params ? params.permissionMode : undefined, customInstructions: diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 493e79943e..5e2494ade8 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -52,6 +52,12 @@ export const startSessionInput = z.object({ autoProgress: z.boolean().optional(), runMode: z.enum(["local", "cloud"]).optional(), adapter: z.enum(["claude", "codex"]).optional(), + /** + * Resolved value of the `codex-app-server` PostHog flag (evaluated host-side + * for the current user). When true and adapter is "codex", the agent uses the + * native app-server sub-adapter instead of codex-acp. Ignored for Claude. + */ + useCodexAppServer: z.boolean().optional(), additionalDirectories: z.array(z.string()).optional(), customInstructions: z.string().max(2000).optional(), /** @@ -194,6 +200,8 @@ export const reconnectSessionInput = z.object({ logUrl: z.string().optional(), sessionId: z.string().optional(), adapter: z.enum(["claude", "codex"]).optional(), + /** See startSessionInput.useCodexAppServer — re-resolved on reconnect. */ + useCodexAppServer: z.boolean().optional(), /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories: z.array(z.string()).optional(), permissionMode: z.string().optional(), From 054fbc5c6fdbf348ddbbdc38c8ba142a7916834c Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Mon, 29 Jun 2026 16:58:47 +0100 Subject: [PATCH 03/20] add posthog meta for tool calling --- CODEX_APP_SERVER_TESTING.md | 71 ++++++++++++++++ .../adapters/codex-app-server/mapping.test.ts | 32 ++++++++ .../src/adapters/codex-app-server/mapping.ts | 24 ++++++ packages/shared/src/index.ts | 9 ++ packages/shared/src/tool-meta.test.ts | 82 +++++++++++++++++++ packages/shared/src/tool-meta.ts | 82 +++++++++++++++++++ .../features/permissions/McpPermission.tsx | 5 +- .../permissions/PermissionSelector.tsx | 8 +- .../posthog-mcp/utils/posthog-exec-display.ts | 10 ++- .../new-thread/buildThreadGroups.ts | 6 +- .../session-update/ToolCallBlock.tsx | 13 ++- 11 files changed, 321 insertions(+), 21 deletions(-) create mode 100644 CODEX_APP_SERVER_TESTING.md create mode 100644 packages/shared/src/tool-meta.test.ts create mode 100644 packages/shared/src/tool-meta.ts diff --git a/CODEX_APP_SERVER_TESTING.md b/CODEX_APP_SERVER_TESTING.md new file mode 100644 index 0000000000..4e6b778dd7 --- /dev/null +++ b/CODEX_APP_SERVER_TESTING.md @@ -0,0 +1,71 @@ +# Codex app-server — manual test checklist + +Manual QA for the native **codex app-server** sub-adapter in PostHog Code. +Tick items as you verify them. + +## Before you start + +- [ ] **Confirm you're actually on app-server** (not codex-acp). With a codex session running: + - `pgrep -fl "codex app-server"` → a PID means app-server. A `codex-acp` process means the old adapter. + - Or grep the dev log for `Codex sub-adapter selected: app-server (native codex)`. +- [ ] App-server requires: the native `codex` binary present (`apps/code/resources/codex-acp/codex`; run `node apps/code/scripts/download-binaries.mjs` if missing) **and** the opt-in on — the `codex-app-server` flag enabled for your user, or `POSTHOG_CODEX_USE_APP_SERVER=1`. With neither, you get codex-acp by design. +- [ ] Build/run with the adapter changes in the working tree (the flag is inert without them). + +Triage tip: if something looks wrong, run the same action on **codex-acp** (flag off) and **claude**. Breaks only on app-server → adapter bug. Breaks everywhere → upstream of the adapter. + +--- + +## Tier 1 — Regression-critical (these were genuinely broken in app-server and fixed) + +- [ ] **PostHog system prompt takes effect** (was rendering as `[object Object]`) + - Do: ask it to make a git commit, and to create a branch. Also set a custom instruction in settings. + - Expect: commit message uses PostHog trailers (`Generated-By: PostHog Code`, `Task-Id: …`), **not** Claude's default attribution; new branches prefixed `posthog-code/`; custom instruction honored. +- [ ] **Initial permission mode is honored** (was ignored — always started in default) + - Do: start three separate sessions, one each in **Read only**, **Auto**, **Full access**, then ask each to edit a file / run a command. + - Expect: Read-only asks before *any* change; Auto asks only for risky ops; Full access auto-approves. The **first** action already respects the mode. +- [ ] **Pending / PR context prepend** (was being dropped) + - Do: with a session running, trigger a context change — focus/unfocus a worktree (CWD move / detached HEAD), or reconnect a task that had queued context. + - Expect: the next turn acknowledges the new working-dir/branch context. + +## Tier 2 — App-server-specific protocol paths (new code, most likely to break) + +- [ ] **Steering** (`turn/steer`): send a follow-up message *while a turn is running*. + - Expect: injected into the live turn, not queued as a separate turn. +- [ ] **Interrupt / cancel mid-turn**: stop a running turn. + - Expect: halts promptly **and** `_posthog/turn_complete` still fires (usage updates, UI returns to idle). +- [ ] **Structured output** (native `outputSchema`): run a task that has a JSON schema. + - Expect: structured output emitted and the task run's `output` is populated. +- [ ] **Mode → approval-policy synthesis**: switch mode mid-session (Read-only ↔ Auto ↔ Full access). + - Expect: subsequent tool calls respect the new policy (synthesized per-turn; no native mode RPC). +- [ ] **loadSession / resume**: close the task and reopen it (or reconnect). + - Expect: prior history replays; you can continue prompting on the same thread. + +## Tier 3 — Config controls (UI selectors → adapter) + +- [ ] **Model selector**: switch model mid-session. + - Expect: the next turn uses the new model. +- [ ] **Reasoning effort selector**: switch effort (low/medium/high…). + - Expect: applies; reasoning/thinking text still streams. + +## Tier 4 — Tool calls & integrations (rendering + approvals) + +- [ ] **File edits**: read / write / edit a file. + - Expect: diff / file-change rendering looks correct in the UI. +- [ ] **Command execution**: run a bash command. + - Expect: command-approval prompt (per mode) + output renders. +- [ ] **Permission prompts**: exercise Allow once / Allow always / Reject. + - Expect: "Allow always" sticks for the rest of the session. +- [ ] **MCP (PostHog tools)**: ask it to query PostHog via MCP. + - Expect: read-only tools auto-approve (if configured), writes prompt; tools actually execute. +- [ ] **Skills / commands** (`available_commands_update`): confirm skill/slash commands appear and one runs. +- [ ] **AskUserQuestion / elicitation**: get the agent to ask a question. + - Expect: UI renders the options and your answer flows back. +- [ ] **Image input**: paste/attach an image in a prompt. + - Expect: image is sent and understood. +- [ ] **Additional directories** (worktree): confirm it can read/edit files outside the primary cwd. + +## Tier 5 — Usage display & special modes + +- [ ] **Token usage + context breakdown**: confirm the usage counter updates and the breakdown popover populates (systemPrompt / tools / skills / mcp / conversation). Driven by `_posthog/usage_update`. +- [ ] **Channel mode (repo-less task)**: start a task with no repo. + - Expect: behaves as a general assistant; only attaches/clones a repo when actually needed. diff --git a/packages/agent/src/adapters/codex-app-server/mapping.test.ts b/packages/agent/src/adapters/codex-app-server/mapping.test.ts index 71b4fe2a6e..e6f63024e7 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.test.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.test.ts @@ -127,10 +127,42 @@ describe("mapAppServerNotification", () => { kind: "other", status: "in_progress", rawInput: { query: "SELECT 1" }, + _meta: { + posthog: { + toolName: "mcp__posthog__execute-sql", + mcp: { server: "posthog", tool: "execute-sql" }, + }, + }, }, }); }); + it("tags an mcp exec tool call with the structured posthog channel the renderer routes on", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_STARTED, + { + item: { + type: "mcpToolCall", + id: "m2", + server: "posthog", + tool: "exec", + arguments: { command: "call execute-sql {}" }, + }, + }, + ); + + // The host renderer routes MCP rendering (and the PostHog `exec` unwrapping) + // off the structured `_meta.posthog` channel. + const meta = (result?.update as { _meta?: unknown })._meta as { + posthog?: { toolName?: string; mcp?: { server: string; tool: string } }; + }; + expect(meta.posthog).toEqual({ + toolName: "mcp__posthog__exec", + mcp: { server: "posthog", tool: "exec" }, + }); + }); + it("drops agent message items (their deltas already streamed)", () => { expect( mapAppServerNotification("s-1", APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, { diff --git a/packages/agent/src/adapters/codex-app-server/mapping.ts b/packages/agent/src/adapters/codex-app-server/mapping.ts index 8e3821f18b..66d991525c 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.ts @@ -3,6 +3,7 @@ import type { ToolCallContent, ToolCallLocation, } from "@agentclientprotocol/sdk"; +import { mcpToolKey, posthogToolMeta } from "@posthog/shared"; import { APP_SERVER_NOTIFICATIONS } from "./protocol"; /** @@ -307,6 +308,14 @@ export function mapHistoryItem( status: mapStatus(item.status), ...(tool.rawInput !== undefined ? { rawInput: tool.rawInput } : {}), ...(tool.locations?.length ? { locations: tool.locations } : {}), + ...(tool.mcp + ? { + _meta: posthogToolMeta({ + toolName: mcpToolKey(tool.mcp), + mcp: tool.mcp, + }), + } + : {}), ...(content ? { content } : {}), }, }, @@ -354,6 +363,12 @@ type ToolDescriptor = { rawInput?: unknown; output?: string | null; locations?: ToolCallLocation[]; + /** + * Originating MCP server + tool for MCP tool calls. Surfaced on the canonical + * `_meta.posthog` channel so the host renderer routes MCP rendering (and the + * PostHog `exec` display) the same way it does for every adapter. + */ + mcp?: { server: string; tool: string }; }; /** @@ -396,6 +411,7 @@ function describeTool(item: AppServerItem): ToolDescriptor | null { kind: "other", rawInput: item.arguments, output: mcpResultText(item.result, item.error), + mcp: { server: item.server ?? "mcp", tool: item.tool ?? "tool" }, }; case "dynamicToolCall": return { @@ -475,6 +491,14 @@ function mapItem( status: "in_progress", ...(tool.rawInput !== undefined ? { rawInput: tool.rawInput } : {}), ...(tool.locations?.length ? { locations: tool.locations } : {}), + ...(tool.mcp + ? { + _meta: posthogToolMeta({ + toolName: mcpToolKey(tool.mcp), + mcp: tool.mcp, + }), + } + : {}), }, }; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c8516c74ec..65520c6cf7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -217,6 +217,15 @@ export { formatRelativeTimeShort, getRelativeDateGroup, } from "./time"; +export { + mcpToolKey, + type PosthogToolMeta, + parseMcpToolName, + posthogToolMeta, + readAgentToolName, + readMcpToolDescriptor, + readMcpToolName, +} from "./tool-meta"; export { TypedEventEmitter } from "./typed-event-emitter"; export { isSafeExternalUrl } from "./url"; export { getCloudUrlFromRegion } from "./urls"; diff --git a/packages/shared/src/tool-meta.test.ts b/packages/shared/src/tool-meta.test.ts new file mode 100644 index 0000000000..8e718d8616 --- /dev/null +++ b/packages/shared/src/tool-meta.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + parseMcpToolName, + readAgentToolName, + readMcpToolDescriptor, + readMcpToolName, +} from "./tool-meta"; + +describe("parseMcpToolName", () => { + it("splits the first __ after the prefix as the server boundary", () => { + expect(parseMcpToolName("mcp__posthog__exec")).toEqual({ + server: "posthog", + tool: "exec", + }); + }); + + it("keeps single underscores inside server and tool names", () => { + expect( + parseMcpToolName("mcp__plugin_posthog_posthog__execute-sql"), + ).toEqual({ server: "plugin_posthog_posthog", tool: "execute-sql" }); + }); + + it("returns undefined for non-MCP or malformed names", () => { + expect(parseMcpToolName("Bash")).toBeUndefined(); + expect(parseMcpToolName("mcp__posthog__")).toBeUndefined(); + expect(parseMcpToolName("mcp____exec")).toBeUndefined(); + }); +}); + +describe("readAgentToolName", () => { + it("prefers the posthog channel over the legacy claudeCode fallback", () => { + expect( + readAgentToolName({ + posthog: { toolName: "mcp__posthog__exec" }, + claudeCode: { toolName: "stale" }, + }), + ).toBe("mcp__posthog__exec"); + }); + + it("falls back to claudeCode when posthog is absent", () => { + expect(readAgentToolName({ claudeCode: { toolName: "Bash" } })).toBe( + "Bash", + ); + }); + + it("returns undefined for non-tool meta", () => { + expect(readAgentToolName(undefined)).toBeUndefined(); + expect(readAgentToolName({})).toBeUndefined(); + }); +}); + +describe("readMcpToolDescriptor / readMcpToolName", () => { + it("uses the structured mcp descriptor when present (no name parsing)", () => { + const meta = { + posthog: { + toolName: "ignored", + mcp: { server: "posthog", tool: "exec" }, + }, + }; + expect(readMcpToolDescriptor(meta)).toEqual({ + server: "posthog", + tool: "exec", + }); + expect(readMcpToolName(meta)).toBe("mcp__posthog__exec"); + }); + + it("parses the legacy claudeCode mcp__ name when there is no structured channel", () => { + const meta = { claudeCode: { toolName: "mcp__posthog__execute-sql" } }; + expect(readMcpToolDescriptor(meta)).toEqual({ + server: "posthog", + tool: "execute-sql", + }); + expect(readMcpToolName(meta)).toBe("mcp__posthog__execute-sql"); + }); + + it("returns undefined for non-MCP tool calls", () => { + expect( + readMcpToolDescriptor({ claudeCode: { toolName: "Bash" } }), + ).toBeUndefined(); + expect(readMcpToolName({ posthog: { toolName: "Bash" } })).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/tool-meta.ts b/packages/shared/src/tool-meta.ts new file mode 100644 index 0000000000..8ef62cc324 --- /dev/null +++ b/packages/shared/src/tool-meta.ts @@ -0,0 +1,82 @@ +/** + * Canonical, harness-neutral tool metadata carried on an ACP tool call's + * `_meta.posthog`. Each adapter (the native-protocol → ACP boundary) populates + * it, so the renderer never has to know which harness produced a tool call. + * + * The renderer reads through {@link readAgentToolName} / {@link readMcpToolName}, + * which prefer this channel and fall back to the legacy `_meta.claudeCode.toolName` + * the Claude adapter still writes. New adapters should only populate `posthog`. + */ +export interface PosthogToolMeta { + /** Agent-facing tool name, e.g. "Bash" or "mcp__posthog__exec". */ + toolName: string; + /** Set only for MCP tool calls — the originating server + tool. */ + mcp?: { server: string; tool: string }; +} + +/** `_meta` fragment for adapters to spread onto a tool_call update. */ +export function posthogToolMeta(meta: PosthogToolMeta): { + posthog: PosthogToolMeta; +} { + return { posthog: meta }; +} + +/** Build the canonical `mcp____` key. */ +export function mcpToolKey(mcp: { server: string; tool: string }): string { + return `mcp__${mcp.server}__${mcp.tool}`; +} + +/** + * Parse a `mcp____` name into its parts; undefined when the name + * isn't MCP-shaped. The server segment never contains `__`, so the first `__` + * after the prefix terminates it and the remainder is the tool. + */ +export function parseMcpToolName( + toolName: string, +): { server: string; tool: string } | undefined { + const PREFIX = "mcp__"; + if (!toolName.startsWith(PREFIX)) return undefined; + const rest = toolName.slice(PREFIX.length); + const sep = rest.indexOf("__"); + if (sep <= 0 || sep + 2 >= rest.length) return undefined; + return { server: rest.slice(0, sep), tool: rest.slice(sep + 2) }; +} + +interface ToolCallMeta { + posthog?: PosthogToolMeta; + /** Legacy Claude-adapter channel, read only as a fallback. */ + claudeCode?: { toolName?: string }; +} + +function asToolCallMeta(meta: unknown): ToolCallMeta | undefined { + return meta && typeof meta === "object" ? (meta as ToolCallMeta) : undefined; +} + +/** Canonical agent-facing tool name: neutral channel first, legacy fallback. */ +export function readAgentToolName(meta: unknown): string | undefined { + const m = asToolCallMeta(meta); + return m?.posthog?.toolName ?? m?.claudeCode?.toolName; +} + +/** + * The MCP `{ server, tool }` descriptor for a tool call, or undefined for a + * non-MCP call. Prefers the structured channel, else parses the legacy + * `mcp__…` name. + */ +export function readMcpToolDescriptor( + meta: unknown, +): { server: string; tool: string } | undefined { + const m = asToolCallMeta(meta); + if (m?.posthog?.mcp) return m.posthog.mcp; + const name = m?.posthog?.toolName ?? m?.claudeCode?.toolName; + return name ? parseMcpToolName(name) : undefined; +} + +/** + * Canonical `mcp__server__tool` key for a tool call, or undefined for a non-MCP + * call. Convenience for components still keyed on the string form. + */ +export function readMcpToolName(meta: unknown): string | undefined { + const mcp = readMcpToolDescriptor(meta); + return mcp ? mcpToolKey(mcp) : undefined; +} diff --git a/packages/ui/src/features/permissions/McpPermission.tsx b/packages/ui/src/features/permissions/McpPermission.tsx index 9b89780d3d..7fbac089aa 100644 --- a/packages/ui/src/features/permissions/McpPermission.tsx +++ b/packages/ui/src/features/permissions/McpPermission.tsx @@ -1,3 +1,4 @@ +import { readMcpToolName } from "@posthog/shared"; import { parseMcpToolKey } from "@posthog/ui/features/mcp-apps/utils/mcp-app-host-utils"; import { formatPosthogExecBody, @@ -16,9 +17,7 @@ export function McpPermission({ onSelect, onCancel, }: BasePermissionProps) { - const mcpToolName = ( - toolCall._meta as { claudeCode?: { toolName?: string } } | undefined - )?.claudeCode?.toolName; + const mcpToolName = readMcpToolName(toolCall._meta); if (!mcpToolName) { return ( diff --git a/packages/ui/src/features/permissions/PermissionSelector.tsx b/packages/ui/src/features/permissions/PermissionSelector.tsx index b89ad00d02..3030a9d56c 100644 --- a/packages/ui/src/features/permissions/PermissionSelector.tsx +++ b/packages/ui/src/features/permissions/PermissionSelector.tsx @@ -1,4 +1,5 @@ import type { PermissionOption } from "@agentclientprotocol/sdk"; +import { readMcpToolName } from "@posthog/shared"; import { DefaultPermission } from "./DefaultPermission"; import { DeletePermission } from "./DeletePermission"; import { EditPermission } from "./EditPermission"; @@ -31,11 +32,8 @@ export function PermissionSelector({ onCancel, }: PermissionSelectorProps) { const props = { toolCall, options, onSelect, onCancel }; - const meta = toolCall._meta as - | { codeToolKind?: string; claudeCode?: { toolName?: string } } - | undefined; - const agentToolName = meta?.claudeCode?.toolName; - if (agentToolName?.startsWith("mcp__")) { + const meta = toolCall._meta as { codeToolKind?: string } | undefined; + if (readMcpToolName(toolCall._meta)) { return ; } const kind = meta?.codeToolKind ?? (toolCall.kind as string); diff --git a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts index 7642bb7ed9..50cda82a30 100644 --- a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts +++ b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts @@ -18,7 +18,12 @@ * call [--json] — invoke a tool */ -const POSTHOG_EXEC_TOOL_RE = /^mcp__(?:plugin_)?posthog(?:_[^_]+)*__exec$/; +import { parseMcpToolName } from "@posthog/shared"; + +// A PostHog MCP server name: optional `plugin_` prefix, `posthog`, then any +// number of `_` parts (e.g. `posthog`, `posthog_cloud`, +// `plugin_posthog_posthog`). The `exec` dispatcher lives on these servers. +const POSTHOG_SERVER_RE = /^(?:plugin_)?posthog(?:_[^_]+)*$/; const POSTHOG_VERB_RE = /^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/; @@ -33,7 +38,8 @@ export interface PostHogExecDisplay { } export function isPostHogExecTool(toolName: string): boolean { - return POSTHOG_EXEC_TOOL_RE.test(toolName); + const mcp = parseMcpToolName(toolName); + return !!mcp && mcp.tool === "exec" && POSTHOG_SERVER_RE.test(mcp.server); } export function getPostHogExecDisplay( diff --git a/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts index e4ea31e509..9253179fc1 100644 --- a/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts +++ b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts @@ -1,4 +1,5 @@ import type { Icon } from "@phosphor-icons/react"; +import { readAgentToolName } from "@posthog/shared"; import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { buildDoneLabel, @@ -66,10 +67,7 @@ export interface ThreadGrouping { } function getToolName(update: { _meta?: unknown }): string | undefined { - const meta = update._meta as - | { claudeCode?: { toolName?: string } } - | undefined; - return meta?.claudeCode?.toolName; + return readAgentToolName(update._meta); } function isMcpToolItem(item: ConversationItem): boolean { diff --git a/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx index fe3a9b8a73..dcfe1732db 100644 --- a/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx @@ -1,4 +1,5 @@ import { useServiceOptional } from "@posthog/di/react"; +import { readAgentToolName, readMcpToolName } from "@posthog/shared"; import { DeleteToolView } from "@posthog/ui/features/sessions/components/session-update/DeleteToolView"; import { EditToolView } from "@posthog/ui/features/sessions/components/session-update/EditToolView"; import { ExecuteToolView } from "@posthog/ui/features/sessions/components/session-update/ExecuteToolView"; @@ -36,10 +37,8 @@ export function ToolCallBlock({ const McpToolBlock = useServiceOptional( MCP_TOOL_BLOCK_COMPONENT, ); - const meta = toolCall._meta as - | { claudeCode?: { toolName?: string } } - | undefined; - const toolName = meta?.claudeCode?.toolName; + const toolName = readAgentToolName(toolCall._meta); + const mcpToolName = readMcpToolName(toolCall._meta); const chatChrome = useChatThreadChrome(); if (toolName === "EnterPlanMode") { @@ -70,13 +69,13 @@ export function ToolCallBlock({ ); } - if (toolName?.startsWith("mcp__")) { + if (mcpToolName) { return ( {McpToolBlock ? ( - + ) : ( - + )} ); From e06863a837ab6762072ec64fec31cf97a2eb6bd5 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Tue, 30 Jun 2026 10:04:35 +0100 Subject: [PATCH 04/20] add more e2e testing and fix some bugs --- CODEX_APP_SERVER_TESTING.md | 138 +++++++ .../agent/e2e/session-lifecycle.e2e.test.ts | 64 ++- .../codex-app-server/approvals.test.ts | 50 +++ .../adapters/codex-app-server/approvals.ts | 20 +- .../codex-app-server-agent.test.ts | 382 +++++++++++++++++- .../codex-app-server-agent.ts | 194 ++++++++- .../adapters/codex-app-server/mapping.test.ts | 17 +- .../src/adapters/codex-app-server/mapping.ts | 11 +- .../src/adapters/codex-app-server/protocol.ts | 5 + .../codex-app-server/session-config.test.ts | 104 ++++- .../codex-app-server/session-config.ts | 74 +++- .../adapters/codex-app-server/spawn.test.ts | 28 ++ .../src/adapters/codex-app-server/spawn.ts | 29 +- .../core/src/sessions/contextUsage.test.ts | 21 + packages/core/src/sessions/contextUsage.ts | 14 +- packages/core/src/sessions/sessionService.ts | 25 +- ...timestamp-1782808001712-f0f037a43e4af8.mjs | 20 + packages/shared/src/index.ts | 2 + packages/shared/src/sessions.test.ts | 73 ++++ packages/shared/src/sessions.ts | 45 +++ .../components/PromptInput.test.tsx | 139 +++++++ .../components/ContextUsageIndicator.test.tsx | 69 ++++ .../components/ContextUsageIndicator.tsx | 14 +- .../ReasoningLevelSelector.test.tsx | 89 ++++ .../sessions/components/SessionView.tsx | 10 +- .../components/SteerQueueToggle.test.tsx | 75 ++++ .../sessions/components/SteerQueueToggle.tsx | 19 +- .../components/UnifiedModelSelector.test.tsx | 131 ++++++ .../session-update/ToolCallBlock.test.tsx | 118 ++++++ .../sessions/hooks/useMessagingMode.ts | 11 +- .../src/services/agent/agent.ts | 21 +- .../src/services/agent/schemas.ts | 5 + 32 files changed, 1922 insertions(+), 95 deletions(-) create mode 100644 packages/electron-trpc/vitest.config.ts.timestamp-1782808001712-f0f037a43e4af8.mjs create mode 100644 packages/shared/src/sessions.test.ts create mode 100644 packages/ui/src/features/message-editor/components/PromptInput.test.tsx create mode 100644 packages/ui/src/features/sessions/components/ContextUsageIndicator.test.tsx create mode 100644 packages/ui/src/features/sessions/components/ReasoningLevelSelector.test.tsx create mode 100644 packages/ui/src/features/sessions/components/SteerQueueToggle.test.tsx create mode 100644 packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx create mode 100644 packages/ui/src/features/sessions/components/session-update/ToolCallBlock.test.tsx diff --git a/CODEX_APP_SERVER_TESTING.md b/CODEX_APP_SERVER_TESTING.md index 4e6b778dd7..fbb2377734 100644 --- a/CODEX_APP_SERVER_TESTING.md +++ b/CODEX_APP_SERVER_TESTING.md @@ -69,3 +69,141 @@ Triage tip: if something looks wrong, run the same action on **codex-acp** (flag - [ ] **Token usage + context breakdown**: confirm the usage counter updates and the breakdown popover populates (systemPrompt / tools / skills / mcp / conversation). Driven by `_posthog/usage_update`. - [ ] **Channel mode (repo-less task)**: start a task with no repo. - Expect: behaves as a general assistant; only attaches/clones a repo when actually needed. + +## Known issues / follow-ups + +- [ ] **MCP `exec` permission prompt shows raw codex text** — the approval prompt for the PostHog MCP `exec` tool renders `Allow the posthog MCP server to run tool "exec"?` instead of the unwrapped sub-command (e.g. `posthog - execute-sql {…}`). The transcript tool-call rendering is fixed; only the **permission prompt** is wrong. + - **Root cause:** codex has no MCP-specific approval — it reuses `item/commandExecution/requestApproval`, which carries no server/tool. The adapter now caches `mcpToolCall` items by id and enriches the approval `toolCall` with `_meta.posthog` when the approval's `itemId` matches (`codex-app-server-agent.ts` `captureMcpToolCall` + `handleApproval`). That enrichment is **not firing** in testing. + - **Likely cause (unconfirmed):** the approval references the item by a *different* id (the schema mentions a sub-callback `approvalId` for "zsh-exec-bridge subcommand approvals") → cache miss. Or it's arriving via a different request method. Or the rebuild didn't include the `packages/ui` renderer changes. + - **Next step:** from the ACP log, grab the `session/request_permission` message + the preceding `mcpToolCall` `tool_call`, and check (a) whether the permission `toolCall` carries `_meta.posthog`, and (b) whether its `toolCallId` matches the `mcpToolCall` id. That pins it to adapter-correlation vs renderer/build. + - **Touches:** `packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts` (`handleApproval`, `captureMcpToolCall`), `packages/ui/src/features/permissions/{PermissionSelector,McpPermission}.tsx`. + +- [x] **Mode picker on app-server (flattened, Claude-style) — IMPLEMENTED.** App-server now emits a `category:"mode"` config option (`session-config.ts buildConfigOptions`) with four presets — **Plan / Read only / Auto / Full access** — so the existing `ModeSelector` shows a switcher for app-server only (codex-acp/claude unchanged). Each preset maps to a `(collaborationMode, approvalPolicy)` tuple applied per-turn on `turn/start`. The adapter now negotiates `experimentalApi: true` (required for the experimental `collaborationMode` field). Verified against the real binary: `collaborationMode/list` → `[Plan, Default]`, `thread/start` accepts `collaborationMode:{mode,settings:{model}}`. + - **To verify live:** switch the picker to **Plan** → codex should propose a plan, make no edits, and `request_user_input` (AskUserQuestion) should fire; switching to Auto/Read-only/Full-access should behave per approval policy. Exit Plan = switch the picker to a coding preset (sets `collaborationMode=default` next turn). + - **Watch:** `experimentalApi: true` is a session-wide flip; confirm normal (non-plan) turns are unaffected. + +- [ ] **AskUserQuestion / `request_user_input`** — unblocked by the Plan preset above (codex only injects the tool in `plan` collaboration mode). Test it by selecting **Plan** and asking codex something under-specified; the structured card should render via `approvals.ts handleToolUserInput`. (Was "N/A for codex"; now reachable on app-server.) + +--- + +## Parity audit + RED-GREEN session (against the live binary + gateway) + +A 15-feature parity audit (vs the Claude adapter, codex-acp, and the real codex protocol schema) found **42 confirmed items** (full list stashed at `scratchpad/audit-confirmed.json`; workflow `wf_f53857b7-a94`). The headline lesson: several existing tests were **false-greens** (they passed even with the feature broken). The live e2e runs here (gateway up at `localhost:3308`, token via `e2e/run-e2e.sh`). + +### Fixed this session (RED → GREEN, with regression tests) + +- [x] **Cancel/interrupt — the real bug.** Two layered defects, both fixed: + 1. `turn/interrupt` was sent with only `{ threadId }`; the schema requires `{ threadId, turnId }` (native binary rejects `-32600`). The error was swallowed and a local `finalizeTurn("cancelled")` masked it → false-green. Fixed: send `turnId`; the stub now enforces the schema (`makeStubRpc` throws on a turnId-less interrupt). + 2. Once interrupt actually fired, codex's **late `turn/completed(interrupted)`** for the cancelled turn finalized the *follow-up* turn as cancelled. Fixed with a `cancelledTurnIds` guard (drop the stale completion by `turn.id`). **Verified live** — the interrupt e2e now sends a follow-up prompt and asserts `end_turn`. +- [x] **Steering** — `turn/steer` response `turnId` was discarded, so `this.turnId` went stale and a later steer/interrupt targeted the wrong turn. Fixed + unit test. +- [x] **Skills** — disabled skills (`enabled:false`) were advertised in `available_commands_update`. Fixed (`!== false` filter) + unit test. +- [x] **Reasoning (mapping)** — only the raw `item/reasoning/textDelta` was mapped; gpt-5-family streams the **default** `summaryTextDelta`, which was dropped → no thinking reached the host. Mapping + `summary:"detailed"` added + parameterized unit test. *Live trigger still unconfirmed* (see deferred). +- [x] **MCP ambient-disable** — `mcp_servers..enabled=false` was emitted without name validation; a dotted/spaced server name wedges the whole session. Fixed (mirrors codex-acp's `/^[A-Za-z0-9_-]+$/` guard). + +### Remaining (prioritized — from `scratchpad/audit-confirmed.json`) + +Code bugs (unit-testable): +- [ ] **structured-output (#25)** — final-message capture ignores codex `MessagePhase`; a trailing `commentary` agent message can clobber the `final_answer` used for structured output. Prefer `final_answer` text. +- [ ] **usage `totalTokens` (#29)** — recomputed total drops `reasoningOutputTokens`; the e2e assertion is a tautology against the producer formula. Carry codex's authoritative total incl. reasoning. + +False-green e2e strengthenings (live): +- [ ] **loadSession (#6)** — doesn't prove the tool transcript replays against a real persisted thread. +- [ ] **steering echo (#8)** — asserts only echo count (fires before the fold). +- [ ] **fileChange diff (#9)** — golden turn never asserts diff content (`parseUnifiedDiff`). +- [ ] **instructions {append}→flatten (#2)** — the prod `[object Object]` fix has no real-binary coverage. +- [ ] **structured-output (#24)** — passes even if `outputSchema` is never sent. +- [ ] tool-kind classification (#27), MCP-injection/local-tools e2e (#16), image-input e2e (#13), plan-rendering e2e (#35), command/file approval round-trip via read-only mode (#12). + +### Deferred design issues (not quick fixes) + +- [ ] **Modes are neutralized by the sandbox — plan mode is currently cosmetic.** The audit DISPROVED the earlier "Mode picker IMPLEMENTED" claim: `collaborationMode` on `turn/start` is **silently dropped** (codex ignores unknown fields — a `totallyBogusField123` is accepted too; acceptance ≠ effect). It only lives in *server→client* `ThreadSettings`, not any client turn/thread param. And `approvalPolicy` is neutralized because spawn forces `sandbox_mode=danger-full-access` (codex auto-approves everything). So all four presets currently behave identically. Making plan/read-only actually restrict needs per-turn `sandboxPolicy` — but `sandboxPolicy:readOnly` risks re-engaging the OS sandbox that spawn deliberately disables (linux-sandbox panics on cloud). Needs design. **Action:** at minimum remove the dead `collaborationMode` field + `collaborationModeFor` and correct the misleading comments/tests. +- [ ] **Reasoning live trigger (#11)** — `summary:"detailed"` did not surface `agent_thought_chunk` on the gpt-5-mini golden turn. Confirm the right lever (the `summary` turn field vs `-c show_raw_agent_reasoning=true` spawn config). The mapping fix + unit test stand regardless; the live e2e assertion is intentionally omitted until the trigger is confirmed. + +--- + +## Ship-readiness RED-GREEN session (host/UI integration + CI guard) + +### The CI-coverage truth (important) +The live e2e suite (`packages/agent/e2e`, `vitest.e2e.config.ts`) **does not run in CI** — it +is opt-in (`pnpm test:e2e`) and needs a live gateway + real codex binary + a minted token. So +the **unit suite (`src/**/*.test.ts`, the default `vitest run`) is the only automated regression +guard**, and its power depends on **stub fidelity**. Every bug the e2e can find now also has a +unit regression test. Practical rule going forward: when the e2e catches something, add the +unit test too, or CI won't protect it. + +### Fixed (RED → GREEN, each with a unit regression test that runs in CI) +- [x] **Modes are real, not cosmetic — PROVEN LIVE.** Removed the dead `collaborationMode` + turn/start field (silently dropped) and wired a per-turn `sandboxPolicy: {type:readOnly}` for + plan/read-only. The first live e2e exposed that this alone did NOTHING: the edit still went + through, because `spawn.ts` forced `sandbox_mode="danger-full-access"` on *every* platform, which + disables codex's OS sandbox at the process level so a per-turn `sandboxPolicy` can't re-engage it. + Fix: gate the spawn sandbox on `process.platform` (which mirrors sandbox availability) — macOS gets + `workspace-write` (Seatbelt present → per-turn read-only can tighten and block edits), cloud/linux + keeps `danger-full-access` (its linux-sandbox launcher is absent and would panic). A new live e2e + (`read-only mode actually blocks a file edit`) now passes — read-only blocks the write while auto + still edits — and a `spawn.test.ts` case locks the platform gating. This was the headline + "deferred design issue"; it is now closed for local/desktop (cloud stays permissive by necessity, + documented). `session-config.test.ts`, `spawn.test.ts`, and the live codex e2e arm (13/13). +- [x] **Native steering reaches codex.** The host hardcoded `adapter === "claude"` in both the + `sendPrompt` gate and `useSupportsNativeSteer`, so codex's `turn/steer` was dead. Now + capability-driven: the adapter's advertised `agentCapabilities._meta.posthog.steering` ("native" + vs codex-acp's "interrupt-resend") flows host→session via the start/reconnect response, and + both gates use the shared `sessionSupportsNativeSteer` helper. Belt-and-suspenders: Claude + falls back to native if the capability is unset, so the rollout can't regress it. + `shared/sessions.test.ts`. +- [x] **AskUserQuestion renders.** Codex `requestUserInput` emitted a bare `_meta:{header}` that + failed `QuestionMetaSchema`, leaving an empty "Review your answers" card. Now emits a valid + single-question `questions` array. `approvals.test.ts`. +- [x] **Bypass-mode revert is adapter-safe.** `maybeRevertBypassMode` forced `"default"`, which is + not a codex mode (left an undefined approval state). New pure `resolveBypassRevertMode` picks a + valid mode from the session's own options. `shared/sessions.test.ts`. +- [x] **Command/file approvals render richly.** Codex approvals lacked `kind`/`content` so they + fell back to `DefaultPermission`. Now set `kind:"execute"` + command text / `kind:"edit"` + diff + (reusing `mapping.diffContent`/`changePaths`) → ExecutePermission / EditPermission. +- [x] **Reasoning-effort labels** humanized (`Low`/`Medium`/`High`) to match Claude/codex-acp. +- [x] **Usage indicator survives an unknown context window.** `extractAggregate` no longer drops + the whole aggregate when `size` is absent; the indicator shows the token count without a + misleading "/0 · 0%". `contextUsage.test.ts` + `ContextUsageIndicator.test.tsx`. +- [x] **Unit false-greens killed + stub hardened.** The cancel test at `817-844` passed without + ever sending `turn/interrupt`; it now emits `turn/started` and asserts the RPC fired, plus a new + test locks the turnId-undefined skip path. `makeStubRpc` now enforces the real required-field + contract for `turn/interrupt` ({threadId,turnId}) and `turn/steer` ({threadId,input,expectedTurnId}). + +### Verified non-issues (traced to the consumer, intentionally NOT changed) +- `usage.reasoningTokens` / `usage.cachedWriteTokens` / `usage.totalTokens` rename concerns — the + host (`contextUsage.ts`) reads only `used`/`size`/`cost`; the `usage` sub-object is unread. +- `cachedWriteTokens: 0` — codex's app-server `TokenUsage.total` has no cache-write field; 0 is + authoritative, not a dropped value (comment added). +- usage `totalTokens` (#29) — the adapter forwards codex's authoritative `total.totalTokens`, not a + recompute, so reasoning isn't dropped. + +### Adversarial re-review of the above (2nd workflow pass) +A second review workflow re-checked the working-tree diff across four dimensions. Three — +**steer-plumbing, host/UI-fixes, test-quality** — came back 10/10 (the steer capability is complete +end-to-end with no path that silently degrades a codex session and no claude regression; the new +unit tests are genuine guards that fail if their fix is reverted). The **missed-gaps** pass surfaced +five; the dispositions: +- [x] **cancelledTurnIds could accumulate** across a long-lived process if an interrupted turn's late + completion never arrived — now cleared in `closeSession`. +- [x] **Question option descriptions were dropped** by the requestUserInput fix — now carried + (non-empty only), with a test assertion. +- **MCP capture "race" — not a bug.** Capture is registered on BOTH `item/started` and + `item/completed`, so whichever arrives first populates the cache; the proposed "only started" patch + would *narrow* the window. The only true gap (approval before either event) is inherent. +- Two observability nits (debug-log a skill missing `enabled`; warn when a session has no non-bypass + mode to revert to) intentionally skipped — both are effectively-impossible states and the logs + would be noise. + +### Still open +- [x] **Live e2e RAN against the real gateway + binary** — the codex arm is **13/13** (steering folds + mid-turn, interrupt halts in-flight, structured output delivers, and the new behavioral + `read-only mode actually blocks a file edit` passes). This is the strongest ship-readiness signal: + the steer/interrupt/modes fixes are confirmed end-to-end, not just unit-mocked. +- [ ] **A few e2e assertions remain intentionally loose** for live-model variance (the working-turn + asserts `contains FOO`, not an exact diff; loadSession asserts replay count, not order). These are + defensible against a non-deterministic model; tighten only if a real regression motivates it. The + CI guard remains the unit layer (e2e does not run in CI). +- [ ] **Reasoning live trigger (#11)** — unchanged from above. +- [ ] **structured-output `MessagePhase` (#25)** / **mcp partial-fields** — deferred: no evidence + codex's `agentMessage` carries a phase discriminator, and the `server && tool` cache guard is + correct (caching partial entries would render "undefined"). Revisit only with a real repro. diff --git a/packages/agent/e2e/session-lifecycle.e2e.test.ts b/packages/agent/e2e/session-lifecycle.e2e.test.ts index 34585e3ac3..88d6997f26 100644 --- a/packages/agent/e2e/session-lifecycle.e2e.test.ts +++ b/packages/agent/e2e/session-lifecycle.e2e.test.ts @@ -124,6 +124,12 @@ for (const adapter of ADAPTERS) { // codex additionally emits _posthog/turn_complete (claude signals turn // completion via the prompt response, not this ext-notification). if (adapter === "codex") { + // NOTE: reasoning (agent_thought_chunk) parity is covered by the unit + // test for both reasoning streams (mapping.test.ts). A live assertion is + // intentionally omitted: the cheap gpt-5-mini turn doesn't reliably emit + // a reasoning summary, so asserting > 0 here would be flaky. Confirming + // the live trigger (summary field vs show_raw_agent_reasoning config) is + // tracked in CODEX_APP_SERVER_TESTING.md. expect(turn.capture.extMethods()).toContain("_posthog/turn_complete"); // turn_complete carries real, well-formed usage (totalTokens = sum). const tc = turn.capture.events.find( @@ -220,6 +226,52 @@ for (const adapter of ADAPTERS) { } }, 60_000); + // The behavioral proof that the mode picker is NOT cosmetic: read-only maps + // to a per-turn sandboxPolicy:readOnly (codex app-server), which must block a + // write at the OS level even though the host auto-approves. If this fails the + // edit went through → the restriction is not actually applied. + itCodex( + "read-only mode actually blocks a file edit (sandbox restricts, not just approval)", + async () => { + if (adapter === "codex") killCodexStragglers(); + const s = await openSession({ + adapter, + cwd: repo, + codexOptions: codexOptions(), + meta: meta(), + }); + try { + // Switch to read-only; the per-turn sandboxPolicy applies on the next turn. + await s.conn.setSessionConfigOption({ + sessionId: s.sessionId, + configId: "mode", + value: "read-only", + }); + const before = readTarget(repo); + const res = await s.conn.prompt({ + sessionId: s.sessionId, + prompt: [ + { + type: "text", + text: + "Edit target.txt so its second line reads SENTINEL_RO_EDIT. " + + "Then stop.", + }, + ], + }); + // The turn must complete (a read-only sandbox must not panic/hang)... + expect(res.stopReason).toBeTruthy(); + // ...and the on-disk file is byte-for-byte unchanged: the readOnly + // sandbox blocked the write despite the host auto-approving. + expect(readTarget(repo)).toBe(before); + expect(readTarget(repo)).not.toContain("SENTINEL_RO_EDIT"); + } finally { + await s.cleanup(); + } + }, + 180_000, + ); + it("handles the host's refresh_session extMethod per adapter", async () => { if (adapter === "codex") killCodexStragglers(); const s = await openSession({ @@ -403,10 +455,20 @@ for (const adapter of ADAPTERS) { await s.conn.cancel({ sessionId: s.sessionId }); const res = await p; expect(res.stopReason).toBe("cancelled"); + + // Real-world impact: after a cancel the session must be usable again. + // With the old bug (turn/interrupt sent without the required turnId) the + // server-side turn kept running, so a follow-up turn could not start + // cleanly. A bounded follow-up must now complete normally. + const followUp = await s.conn.prompt({ + sessionId: s.sessionId, + prompt: [{ type: "text", text: "Stop. Reply with just: OK" }], + }); + expect(followUp.stopReason).toBe("end_turn"); } finally { await s.cleanup(); } - }, 90_000); + }, 120_000); it("resumeSession reconnects and returns config options", async () => { if (adapter === "codex") killCodexStragglers(); diff --git a/packages/agent/src/adapters/codex-app-server/approvals.test.ts b/packages/agent/src/adapters/codex-app-server/approvals.test.ts index e1da8e6381..e5166438e3 100644 --- a/packages/agent/src/adapters/codex-app-server/approvals.test.ts +++ b/packages/agent/src/adapters/codex-app-server/approvals.test.ts @@ -3,6 +3,7 @@ import type { RequestPermissionResponse, } from "@agentclientprotocol/sdk"; import { describe, expect, it, vi } from "vitest"; +import { QuestionMetaSchema } from "../claude/questions/utils"; import { handleServerRequest } from "./approvals"; import { APP_SERVER_REQUESTS } from "./protocol"; @@ -72,6 +73,55 @@ describe("handleServerRequest", () => { ]); }); + it("carries a QuestionMetaSchema-valid questions array so the host card renders", async () => { + const { client, calls } = fakeClient([ + { outcome: "selected", optionId: "option_0" }, + ]); + + const params = { + threadId: "t", + turnId: "turn", + itemId: "item-1", + autoResolutionMs: null, + questions: [ + { + id: "q1", + header: "Environment", + question: "Which environment?", + isOther: false, + isSecret: false, + options: [ + { label: "staging", description: "" }, + { label: "production", description: "danger" }, + ], + }, + ], + }; + + await handleServerRequest( + APP_SERVER_REQUESTS.TOOL_USER_INPUT, + params, + client, + opts, + ); + + // The bug: a bare `{ header }` _meta fails QuestionMetaSchema, so the host's + // QuestionPermission renders an empty "Review your answers" screen. + const parsed = QuestionMetaSchema.safeParse(calls[0].toolCall?._meta); + expect(parsed.success).toBe(true); + expect(parsed.data?.questions).toEqual([ + { + question: "Which environment?", + header: "Environment", + // The non-empty description rides along; the empty one is dropped. + options: [ + { label: "staging" }, + { label: "production", description: "danger" }, + ], + }, + ]); + }); + it("defaults a cancelled question to an empty answer", async () => { const { client } = fakeClient([{ outcome: "cancelled" }]); diff --git a/packages/agent/src/adapters/codex-app-server/approvals.ts b/packages/agent/src/adapters/codex-app-server/approvals.ts index b98e85af79..0bdb365084 100644 --- a/packages/agent/src/adapters/codex-app-server/approvals.ts +++ b/packages/agent/src/adapters/codex-app-server/approvals.ts @@ -252,7 +252,25 @@ async function handleToolUserInput( toolCallId: `${params.itemId}:${question.id}`, title: question.question, kind: "other", - _meta: { codeToolKind: "question", header: question.header }, + // The host's QuestionPermission renders from `_meta.questions` + // (QuestionMetaSchema) — a bare `header` left it empty ("Review your + // answers" with nothing). codex prompts one question per request, so + // carry exactly this question; selection still flows through `options`. + _meta: { + codeToolKind: "question", + questions: [ + { + question: question.question, + header: question.header, + options: (question.options ?? []).map((opt) => ({ + label: opt.label, + ...(opt.description?.trim() + ? { description: opt.description } + : {}), + })), + }, + ], + }, }, }); } catch (err) { diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index ed3055e8bd..5879976225 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -11,6 +11,24 @@ import type { } from "./app-server-client"; import { CodexAppServerAgent } from "./codex-app-server-agent"; +// The required-field invariants the native codex app-server enforces on each +// client request (verified against the binary). turn/interrupt needs both ids; +// turn/steer needs the precondition turnId plus the thread and the message. +const REQUIRED_FIELDS: Record = { + "turn/interrupt": ["threadId", "turnId"], + "turn/steer": ["threadId", "input", "expectedTurnId"], +}; + +function requiredFieldMissing( + method: string, + params: unknown, +): string | undefined { + const p = (params ?? {}) as Record; + return REQUIRED_FIELDS[method]?.find( + (f) => p[f] === undefined || p[f] === null || p[f] === "", + ); +} + function makeStubRpc(responses: Record) { let handlers: AppServerClientHandlers | undefined; const requests: Array<{ method: string; params?: unknown }> = []; @@ -18,6 +36,14 @@ function makeStubRpc(responses: Record) { const rpc: AppServerRpc = { async request(method: string, params?: unknown): Promise { requests.push({ method, params }); + // Enforce the real app-server schema contract so a dropped required field + // fails loudly here, not silently in production. The native binary rejects + // these with -32600; a stub that accepted anything would let an adapter + // regression (a missing required field) sail through CI as a false-green. + const missing = requiredFieldMissing(method, params); + if (missing) { + throw { code: -32600, message: `Invalid request: missing field \`${missing}\`` }; + } return (responses[method] ?? {}) as T; }, notify() {}, @@ -109,6 +135,130 @@ describe("CodexAppServerAgent", () => { }); }); + it("enriches an MCP tool-call approval with the structured posthog channel", async () => { + const stub = makeStubRpc({ + initialize: {}, + "thread/start": { thread: { id: "thr_1" } }, + }); + const permissionToolCalls: unknown[] = []; + const client = { + sessionUpdate: async () => {}, + requestPermission: async (params: { toolCall: unknown }) => { + permissionToolCalls.push(params.toolCall); + return { outcome: { outcome: "selected", optionId: "allow" } }; + }, + extNotification: async () => {}, + } as unknown as AgentSideConnection; + + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/bundle/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.initialize(init); + await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); + + // The MCP tool call item arrives first, then codex asks to approve it via + // the command-execution approval (it has no MCP-specific approval request). + stub.emit("item/started", { + item: { + type: "mcpToolCall", + id: "m1", + server: "posthog", + tool: "exec", + arguments: { command: "call execute-sql {}" }, + }, + }); + const decision = await stub.invokeRequest( + "item/commandExecution/requestApproval", + { + itemId: "m1", + command: 'Allow the posthog MCP server to run tool "exec"?', + }, + ); + + expect(decision).toEqual({ decision: "accept" }); + expect(permissionToolCalls).toHaveLength(1); + expect(permissionToolCalls[0]).toMatchObject({ + toolCallId: "m1", + kind: "other", + rawInput: { command: "call execute-sql {}" }, + _meta: { + posthog: { + toolName: "mcp__posthog__exec", + mcp: { server: "posthog", tool: "exec" }, + }, + }, + }); + }); + + function makeApprovalAgent() { + const stub = makeStubRpc({ + initialize: {}, + "thread/start": { thread: { id: "thr_1" } }, + }); + const permissionToolCalls: Array> = []; + const client = { + sessionUpdate: async () => {}, + requestPermission: async (params: { toolCall: Record }) => { + permissionToolCalls.push(params.toolCall); + return { outcome: { outcome: "selected", optionId: "allow" } }; + }, + extNotification: async () => {}, + } as unknown as AgentSideConnection; + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/bundle/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + return { agent, stub, permissionToolCalls }; + } + + it("routes a non-MCP command approval to an execute permission (kind + command body)", async () => { + // The bug: a bare { toolCallId, title } routed to the DefaultPermission + // fallback, losing the command styling/monospace body. kind:"execute" plus + // the command as text content makes the host render ExecutePermission. + const { agent, stub, permissionToolCalls } = makeApprovalAgent(); + await agent.initialize(init); + await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); + + await stub.invokeRequest("item/commandExecution/requestApproval", { + itemId: "c1", + command: "rm -rf build", + }); + + expect(permissionToolCalls).toHaveLength(1); + expect(permissionToolCalls[0]).toEqual({ + toolCallId: "c1", + title: "rm -rf build", + kind: "execute", + content: [ + { type: "content", content: { type: "text", text: "rm -rf build" } }, + ], + }); + }); + + it("routes a non-MCP file-change approval to an edit permission (kind + diff + locations)", async () => { + const { agent, stub, permissionToolCalls } = makeApprovalAgent(); + await agent.initialize(init); + await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); + + await stub.invokeRequest("item/fileChange/requestApproval", { + itemId: "f1", + changes: [ + { path: "src/a.ts", diff: "@@ -1 +1 @@\n-old\n+new\n" }, + ], + }); + + expect(permissionToolCalls).toHaveLength(1); + const tc = permissionToolCalls[0]; + expect(tc.kind).toBe("edit"); + expect(tc.locations).toEqual([{ path: "src/a.ts" }]); + // A diff content block so the host's EditPermission renders the change. + expect(Array.isArray(tc.content)).toBe(true); + expect((tc.content as Array<{ type?: string }>)[0]?.type).toBe("diff"); + }); + it("passes outputSchema to turn/start and fires onStructuredOutput", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); const { client } = makeFakeClient(); @@ -296,7 +446,73 @@ describe("CodexAppServerAgent", () => { ).toBe("on-request"); }); - it("returns model + thought_level configOptions and emits config_option_update", async () => { + it("applies a read-only sandboxPolicy + approvalPolicy when the picker is Plan", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "turn/start": { turn: { id: "turn_1" } }, + }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + // Switch the flattened picker to Plan, then run a turn. + await agent.setSessionConfigOption({ + configId: "mode", + value: "plan", + sessionId: "t", + } as never); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await done; + + const turnStart = stub.requests.find((r) => r.method === "turn/start"); + const params = turnStart?.params as { + sandboxPolicy?: unknown; + approvalPolicy?: string; + }; + // Plan blocks edits via a read-only sandbox (collaborationMode is dropped by + // the binary, so this is the only honored lever). + expect(params.sandboxPolicy).toEqual({ + type: "readOnly", + networkAccess: true, + }); + expect(params.approvalPolicy).toBe("on-request"); + }); + + it("omits sandboxPolicy for an editing preset (auto) so the spawned full-access stays", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "turn/start": { turn: { id: "turn_1" } }, + }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + // Default mode is "auto" → editing allowed, no sandbox override. + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await done; + + const turnStart = stub.requests.find((r) => r.method === "turn/start"); + expect( + (turnStart?.params as { sandboxPolicy?: unknown }).sandboxPolicy, + ).toBeUndefined(); + }); + + + it("returns mode + model + thought_level configOptions and emits config_option_update", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } }, "model/list": { @@ -324,7 +540,14 @@ describe("CodexAppServerAgent", () => { cwd: "/r", } as unknown as NewSessionRequest); const opts = (session.configOptions ?? []) as any[]; - expect(opts.map((o) => o.category)).toEqual(["model", "thought_level"]); + expect(opts.map((o) => o.category)).toEqual([ + "mode", + "model", + "thought_level", + ]); + expect( + opts.find((o) => o.category === "mode").options.map((x: any) => x.value), + ).toEqual(["plan", "read-only", "auto", "full-access"]); expect( opts .find((o) => o.category === "thought_level") @@ -574,7 +797,7 @@ describe("CodexAppServerAgent", () => { await expect(done).rejects.toThrow(/exited before the turn completed/); }); - it("interrupts by sending turn/interrupt before reporting cancelled", async () => { + it("interrupts by sending turn/interrupt with the live threadId + turnId", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); const { client } = makeFakeClient(); const agent = new CodexAppServerAgent(client, { @@ -587,11 +810,54 @@ describe("CodexAppServerAgent", () => { sessionId: "t", prompt: [{ type: "text", text: "go" }], } as unknown as PromptRequest); + // turn/started carries the live turnId the real server REQUIRES on + // turn/interrupt — without it codex rejects the RPC (-32600) and the turn + // keeps running while the adapter falsely reports "cancelled". + stub.emit("turn/started", { turn: { id: "turn_1" } }); await agent.cancel({ sessionId: "t" }); expect((await done).stopReason).toBe("cancelled"); - expect(stub.requests.some((r) => r.method === "turn/interrupt")).toBe(true); + const req = stub.requests.find((r) => r.method === "turn/interrupt"); + expect(req?.params).toEqual({ threadId: "t", turnId: "turn_1" }); + }); + + it("a cancelled turn's late completion does not cancel the follow-up turn", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "turn/start": { turn: { id: "turn_1" } }, + }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + + // Turn 1, then cancel it (records turn_1 as interrupted). + const first = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + stub.emit("turn/started", { turn: { id: "turn_1" } }); + await agent.cancel({ sessionId: "t" }); + expect((await first).stopReason).toBe("cancelled"); + + // Follow-up turn 2. + const second = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "again" }], + } as unknown as PromptRequest); + stub.emit("turn/started", { turn: { id: "turn_2" } }); + // codex's late completion of the cancelled turn arrives during turn 2 — it + // must be ignored, not finalize turn 2 as cancelled. + stub.emit("turn/completed", { + turn: { id: "turn_1", status: "interrupted" }, + }); + stub.emit("turn/completed", { + turn: { id: "turn_2", status: "completed" }, + }); + expect((await second).stopReason).toBe("end_turn"); }); it("emits _posthog/turn_complete with cancelled on interrupt (matches codex-acp)", async () => { @@ -610,10 +876,18 @@ describe("CodexAppServerAgent", () => { sessionId: "t", prompt: [{ type: "text", text: "go" }], } as unknown as PromptRequest); + // Emit turn/started so the interrupt actually reaches the binary — without + // it turnId is undefined, turn/interrupt is skipped, and this test would + // pass on the local finalize alone (the false-green it used to be). + stub.emit("turn/started", { turn: { id: "turn_1" } }); await agent.cancel({ sessionId: "t" }); expect((await done).stopReason).toBe("cancelled"); - // A cancelled turn still emits the cloud idle signal, exactly once. + // The interrupt RPC was genuinely sent (not just locally finalized)... + expect( + stub.requests.find((r) => r.method === "turn/interrupt")?.params, + ).toEqual({ threadId: "t", turnId: "turn_1" }); + // ...and a cancelled turn still emits the cloud idle signal, exactly once. const tcs = extNotifications.filter( (n) => n.method === "_posthog/turn_complete", ); @@ -623,6 +897,30 @@ describe("CodexAppServerAgent", () => { ); }); + it("skips turn/interrupt (but still finalizes cancelled) when no turn/started arrived", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + // No turn/started → no live turnId. interrupt() must NOT send a turnId-less + // turn/interrupt (the binary would reject it -32600); it guards on turnId and + // falls back to the local finalize so cancel never hangs. + await agent.cancel({ sessionId: "t" }); + + expect((await done).stopReason).toBe("cancelled"); + expect( + stub.requests.some((r) => r.method === "turn/interrupt"), + ).toBe(false); + }); + it("rejects a concurrent prompt while a turn is in progress", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); const { client } = makeFakeClient(); @@ -751,6 +1049,80 @@ describe("CodexAppServerAgent", () => { ); }); + it("refreshes the live turnId from each turn/steer response", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "turn/start": { turn: { id: "turn_1" } }, + "turn/steer": { turnId: "turn_2" }, // the server rotates the active turn id + }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const first = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "one" }], + } as unknown as PromptRequest); + stub.emit("turn/started", { turn: { id: "turn_1" } }); + + const second = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "two" }], + } as unknown as PromptRequest); + // Let the first steer's response (turnId: turn_2) be applied before the next + // steer reads this.turnId — turn/started is not re-emitted for a steer. + await new Promise((r) => setTimeout(r, 0)); + const third = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "three" }], + } as unknown as PromptRequest); + + stub.emit("turn/completed", { turn: { status: "completed" } }); + await Promise.all([first, second, third]); + + const steers = stub.requests.filter((r) => r.method === "turn/steer"); + expect(steers).toHaveLength(2); + expect( + (steers[0].params as { expectedTurnId?: string }).expectedTurnId, + ).toBe("turn_1"); + // After the first steer rotated the id, the second steer must target turn_2. + expect( + (steers[1].params as { expectedTurnId?: string }).expectedTurnId, + ).toBe("turn_2"); + }); + + it("omits disabled skills from available_commands_update", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "skills/list": { + data: [ + { + skills: [ + { name: "deploy", description: "Deploy", enabled: true }, + { name: "danger", description: "Disabled", enabled: false }, + ], + }, + ], + }, + }); + const { client, sessionUpdates } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + + const cmds = ( + sessionUpdates.find( + (u: any) => u.update?.sessionUpdate === "available_commands_update", + ) as any + )?.update?.availableCommands; + expect(cmds.map((c: { name: string }) => c.name)).toEqual(["deploy"]); + }); + it("emits _posthog/sdk_session when a taskRunId is present", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "thr_x" } } }); const { client, extNotifications } = makeFakeClient(); diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 40ea35826f..fde2e23880 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -19,6 +19,7 @@ import type { SetSessionConfigOptionResponse, StopReason, } from "@agentclientprotocol/sdk"; +import { mcpToolKey, posthogToolMeta } from "@posthog/shared"; import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions"; import { DEFAULT_CODEX_MODEL } from "../../gateway-models"; import type { ProcessSpawnedCallback } from "../../types"; @@ -48,7 +49,13 @@ import { } from "./ext-notifications"; import { toCodexInput } from "./input"; import { buildLocalToolsServer, type LocalToolsMeta } from "./local-tools-mcp"; -import { mapAppServerNotification, mapHistoryItem } from "./mapping"; +import { + type AppServerItem, + changePaths, + diffContent, + mapAppServerNotification, + mapHistoryItem, +} from "./mapping"; import { toCodexMcpServers } from "./mcp-config"; import { APP_SERVER_METHODS, @@ -60,6 +67,7 @@ import { DEFAULT_MODE, modeApprovalPolicy, resolveInitialMode, + sandboxPolicyFor, } from "./session-config"; import { type CodexAppServerProcess, @@ -172,8 +180,33 @@ export class CodexAppServerAgent extends BaseAcpAgent { /** Maps the host's taskRunId to this session, replayed for cloud notifications. */ private taskRunId?: string; private cwd?: string; + /** + * Deployment environment from the host `_meta`. Gates the per-turn + * `sandboxPolicy` mode override: on "cloud" a non-danger sandbox would + * re-engage the unavailable linux-sandbox and panic, so we leave the spawned + * danger-full-access in place there. + */ + private environment?: "local" | "cloud"; + /** + * In-flight MCP tool calls keyed by item id. Codex has no MCP-specific + * approval request — it reuses the command-execution approval — so we + * correlate the approval's `itemId` back to the originating mcpToolCall here + * to render the approval prompt with the real server/tool/args (see + * handleApproval). Session-scoped and small (one entry per MCP call). + */ + private readonly mcpToolCallsByItemId = new Map< + string, + { server: string; tool: string; args: unknown } + >(); /** Active turn id from turn/started — the steering precondition + interrupt target. */ private turnId?: string; + /** + * Turn ids we've interrupted. After a real interrupt codex emits a late + * turn/completed(interrupted) for the cancelled turn; if a follow-up turn is + * already running, that stale completion would otherwise finalize it as + * cancelled. We drop the completion for any id in here (once). + */ + private readonly cancelledTurnIds = new Set(); /** * Per-source context baseline (systemPrompt floor + skills/mcp estimates) the * usage breakdown is derived from; codex doesn't attribute input tokens. @@ -262,7 +295,11 @@ export class CodexAppServerAgent extends BaseAcpAgent { title: "PostHog Code", version: "0.1.0", }, - capabilities: { experimentalApi: false, requestAttestation: false }, + // Opt into codex's experimental API surface. Experimental fields are + // additive (unknown ones are ignored), so the adapter's known + // methods/notifications are unaffected; we keep it on so experimental + // turn/start fields are honored rather than silently dropped. + capabilities: { experimentalApi: true, requestAttestation: false }, }); this.rpc.notify(APP_SERVER_NOTIFICATIONS.INITIALIZED, {}); return { @@ -437,6 +474,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.jsonSchema = params.meta?.jsonSchema ?? undefined; this.cwd = params.cwd; this.taskRunId = params.meta?.taskRunId; + this.environment = params.meta?.environment; // Honor the host's initial approval mode (mirrors codex-acp). A non-codex // value falls back to the default; setSessionConfigOption can change it later. this.mode = resolveInitialMode(params.meta?.permissionMode); @@ -596,6 +634,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { private rebuildConfigOptions(): void { this.configOptions = buildConfigOptions({ + mode: this.mode, model: this.model, effort: this.reasoningEffort, models: this.availableModels, @@ -627,7 +666,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { ); commands = (res?.data ?? []) .flatMap((entry) => entry?.skills ?? []) - .filter((s) => s?.name) + // `enabled` is a required boolean in the schema; drop explicitly-disabled + // skills so the slash-command menu doesn't advertise unusable commands + // (lenient `!== false` so a malformed payload that omits it still shows). + .filter((s) => s?.name && s?.enabled !== false) .map((s: any) => ({ name: s.name, description: s.description ?? "" })); } catch (err) { this.logger.warn("skills/list failed", { error: String(err) }); @@ -691,13 +733,21 @@ export class CodexAppServerAgent extends BaseAcpAgent { // behavior is to fold the message in), steer it via turn/steer with the // active turnId as the precondition. The existing pendingTurn keeps // resolving on the original turn/completed. - await this.rpc - .request(APP_SERVER_METHODS.TURN_STEER, { + // turn/steer returns the (possibly rotated) active turnId; refresh + // this.turnId so a later turn/steer's expectedTurnId precondition and a + // turn/interrupt still target the live turn (turn/started is not re-emitted + // for a steer). + const steerRes = await this.rpc + .request<{ turnId?: string }>(APP_SERVER_METHODS.TURN_STEER, { threadId: this.threadId, input, expectedTurnId: this.turnId, }) - .catch((err) => this.logger.warn("turn/steer failed", err)); + .catch((err) => { + this.logger.warn("turn/steer failed", err); + return undefined; + }); + if (typeof steerRes?.turnId === "string") this.turnId = steerRes.turnId; return { stopReason: await this.pendingTurnCompletion() }; } if (this.pendingTurn) { @@ -719,9 +769,20 @@ export class CodexAppServerAgent extends BaseAcpAgent { input, model: this.model, ...(this.reasoningEffort ? { effort: this.reasoningEffort } : {}), - // Synthesized mode → codex approval policy (applied per-turn since there - // is no app-server mode RPC). Sandbox stays as spawned. + // Always request a reasoning summary so the host surfaces thinking; the + // default "auto" can skip summaries on trivial turns (and raw reasoning + // is off, so without this the host sees no thought stream at all). + summary: "detailed", + // The picker's preset, applied per-turn (no app-server mode RPC): + // approvalPolicy plus a sandboxPolicy override that actually restricts + // edits (plan/read-only → readOnly). Skipped on cloud, where a + // non-danger sandbox re-engages the unavailable linux-sandbox and panics + // — there it stays at the spawned danger-full-access. Switching the + // preset takes effect on the next turn. ...(approvalPolicy ? { approvalPolicy } : {}), + ...(this.environment !== "cloud" && sandboxPolicyFor(this.mode) + ? { sandboxPolicy: sandboxPolicyFor(this.mode) } + : {}), // Constrain the final assistant message to the task's schema so it is // valid JSON we can parse for structured output (replaces the codex-acp // `create_output` MCP, which the native app-server has no need for). @@ -773,9 +834,20 @@ export class CodexAppServerAgent extends BaseAcpAgent { // the host treats as the idle/queue-dispatch signal — matching codex-acp. // finalizeTurn claims the turn idempotently, so the server's later // turn/completed(interrupted) is a no-op. - if (this.threadId) { + // TurnInterruptParams requires BOTH threadId and turnId (the native binary + // rejects a turnId-less request with -32600, leaving the turn running). The + // live turnId is captured from turn/started and is still set here — + // finalizeTurn clears it afterwards. When no turn/started was seen yet there + // is no server-side turn to abort, so skip the RPC and just finalize. + if (this.threadId && this.turnId) { + // Remember it so its late turn/completed(interrupted) is dropped rather + // than finalizing a follow-up turn. + this.cancelledTurnIds.add(this.turnId); await this.rpc - .request(APP_SERVER_METHODS.TURN_INTERRUPT, { threadId: this.threadId }) + .request(APP_SERVER_METHODS.TURN_INTERRUPT, { + threadId: this.threadId, + turnId: this.turnId, + }) .catch((err) => this.logger.warn("turn/interrupt failed", err)); } await this.finalizeTurn("cancelled"); @@ -786,6 +858,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.turnId = undefined; this.pendingTurn?.resolve("cancelled"); this.pendingTurn = undefined; + // Drain any interrupted-turn ids still awaiting their late completion so the + // set can't accumulate across a long-lived process (each entry is normally + // removed when that completion arrives, but a dropped one would linger). + this.cancelledTurnIds.clear(); this.session.settingsManager.dispose(); // Close the transport BEFORE killing the process: kill() destroys the // stdio streams, so awaiting writer.close()/reader.cancel() afterwards @@ -821,6 +897,13 @@ export class CodexAppServerAgent extends BaseAcpAgent { if (typeof id === "string") this.turnId = id; } + if ( + method === APP_SERVER_NOTIFICATIONS.ITEM_STARTED || + method === APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED + ) { + this.captureMcpToolCall(params); + } + if (method === APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED) { this.captureAgentMessage(params); } @@ -830,8 +913,12 @@ export class CodexAppServerAgent extends BaseAcpAgent { } if (method === APP_SERVER_NOTIFICATIONS.TURN_COMPLETED) { - const status = (params as { turn?: { status?: string } })?.turn?.status; - void this.finalizeTurn(mapTurnStopReason(status)); + const turn = (params as { turn?: { id?: string; status?: string } }) + ?.turn; + // Drop the late completion of a turn we already interrupted so it can't + // finalize the current (follow-up) turn as cancelled. + if (turn?.id && this.cancelledTurnIds.delete(turn.id)) return; + void this.finalizeTurn(mapTurnStopReason(turn?.status)); } if (method === APP_SERVER_NOTIFICATIONS.ERROR) { @@ -847,6 +934,29 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } + /** Remember an MCP tool call's server/tool/args so a later approval prompt for + * the same item can render the real tool instead of codex's generic text. */ + private captureMcpToolCall(params: unknown): void { + const item = ( + params as { + item?: { + type?: string; + id?: string; + server?: string; + tool?: string; + arguments?: unknown; + }; + } + )?.item; + if (item?.type === "mcpToolCall" && item.id && item.server && item.tool) { + this.mcpToolCallsByItemId.set(item.id, { + server: item.server, + tool: item.tool, + args: item.arguments, + }); + } + } + /** Track the latest assistant message so the final one feeds structured output. */ private captureAgentMessage(params: unknown): void { const item = (params as { item?: { type?: string; text?: string } })?.item; @@ -867,6 +977,9 @@ export class CodexAppServerAgent extends BaseAcpAgent { inputTokens: total.inputTokens ?? 0, outputTokens: total.outputTokens ?? 0, cachedReadTokens: total.cachedInputTokens ?? 0, + // codex's app-server TokenUsage.total has no cache-write/creation field + // (unlike the codex-acp upstream stream), so there is nothing to report — + // 0 is authoritative here, not a dropped value. cachedWriteTokens: 0, }; // Drives the per-source breakdown's "conversation" bucket on turn complete. @@ -1020,16 +1133,61 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.logger.warn("Unrecognized server request; declining", { method }); return { decision: "decline" }; } - const detail = params as { itemId?: string; command?: string }; + const isFileChange = method === APP_SERVER_REQUESTS.FILE_CHANGE_APPROVAL; + const detail = params as { + itemId?: string; + command?: string; + changes?: AppServerItem["changes"]; + }; const title = - detail.command ?? - (method === APP_SERVER_REQUESTS.FILE_CHANGE_APPROVAL - ? "Apply file changes" - : "Run command"); + detail.command ?? (isFileChange ? "Apply file changes" : "Run command"); + const toolCallId = detail.itemId ?? "codex-approval"; + // Codex has no MCP-specific approval; an MCP tool call surfaces as a + // command-execution approval. When the item is a known MCP call, surface the + // real server/tool/args so the host renders the proper MCP permission + // (incl. the PostHog `exec` unwrapping) instead of codex's generic text. + const mcp = detail.itemId + ? this.mcpToolCallsByItemId.get(detail.itemId) + : undefined; + // Set kind + content so the host routes plain command/file approvals to + // ExecutePermission / EditPermission (command styling, diff body) rather + // than the bare DefaultPermission fallback. + const toolCall = mcp + ? { + toolCallId, + title, + kind: "other" as const, + rawInput: mcp.args, + _meta: posthogToolMeta({ + toolName: mcpToolKey({ server: mcp.server, tool: mcp.tool }), + mcp: { server: mcp.server, tool: mcp.tool }, + }), + } + : isFileChange + ? { + toolCallId, + title, + kind: "edit" as const, + content: diffContent(detail.changes), + locations: changePaths(detail.changes).map((path) => ({ path })), + } + : { + toolCallId, + title, + kind: "execute" as const, + content: detail.command + ? [ + { + type: "content" as const, + content: { type: "text" as const, text: detail.command }, + }, + ] + : undefined, + }; try { const response = await this.client.requestPermission({ sessionId: this.sessionId, - toolCall: { toolCallId: detail.itemId ?? "codex-approval", title }, + toolCall, options: [ { optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "reject", name: "Reject", kind: "reject_once" }, diff --git a/packages/agent/src/adapters/codex-app-server/mapping.test.ts b/packages/agent/src/adapters/codex-app-server/mapping.test.ts index e6f63024e7..65e42b7a18 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.test.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.test.ts @@ -23,12 +23,17 @@ describe("mapAppServerNotification", () => { }); }); - it("maps a reasoning text delta to an ACP agent_thought_chunk", () => { - const result = mapAppServerNotification( - "s-1", - APP_SERVER_NOTIFICATIONS.REASONING_TEXT_DELTA, - { itemId: "item_1", delta: "thinking", contentIndex: 0 }, - ); + it.each([ + ["raw textDelta", APP_SERVER_NOTIFICATIONS.REASONING_TEXT_DELTA], + // The DEFAULT stream for gpt-5-family models — must map identically, else + // the host sees no reasoning at all (raw reasoning is off by default). + ["summaryTextDelta", APP_SERVER_NOTIFICATIONS.REASONING_SUMMARY_TEXT_DELTA], + ])("maps a reasoning %s to an ACP agent_thought_chunk", (_label, method) => { + const result = mapAppServerNotification("s-1", method, { + itemId: "item_1", + delta: "thinking", + contentIndex: 0, + }); expect(result).toEqual({ sessionId: "s-1", diff --git a/packages/agent/src/adapters/codex-app-server/mapping.ts b/packages/agent/src/adapters/codex-app-server/mapping.ts index 66d991525c..80cc3430d0 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.ts @@ -34,7 +34,10 @@ export function mapAppServerNotification( }, }; } - case APP_SERVER_NOTIFICATIONS.REASONING_TEXT_DELTA: { + // Both reasoning streams (raw textDelta + the default summaryTextDelta) carry + // a `delta: string` and map to the same ACP thought chunk. + case APP_SERVER_NOTIFICATIONS.REASONING_TEXT_DELTA: + case APP_SERVER_NOTIFICATIONS.REASONING_SUMMARY_TEXT_DELTA: { const delta = readStringField(params, "delta"); if (!delta) return null; return { @@ -203,7 +206,7 @@ export function parseUnifiedDiff(diff: string): { return { oldText: oldLines.join("\n"), newText: newLines.join("\n") }; } -type AppServerItem = { +export type AppServerItem = { type?: string; id?: string; command?: string; @@ -430,7 +433,7 @@ function describeTool(item: AppServerItem): ToolDescriptor | null { } /** Distinct, non-empty changed paths for a fileChange item, order-preserved. */ -function changePaths(changes: AppServerItem["changes"]): string[] { +export function changePaths(changes: AppServerItem["changes"]): string[] { if (!changes?.length) return []; const seen = new Set(); const paths: string[] = []; @@ -531,7 +534,7 @@ function completedContent( } /** Maps a fileChange's `changes[]` to ACP `diff` content blocks. */ -function diffContent( +export function diffContent( changes: AppServerItem["changes"], ): ToolCallContent[] | undefined { if (!changes?.length) return undefined; diff --git a/packages/agent/src/adapters/codex-app-server/protocol.ts b/packages/agent/src/adapters/codex-app-server/protocol.ts index 22b9e971d7..681b9ff8e8 100644 --- a/packages/agent/src/adapters/codex-app-server/protocol.ts +++ b/packages/agent/src/adapters/codex-app-server/protocol.ts @@ -36,6 +36,11 @@ export const APP_SERVER_NOTIFICATIONS = { ITEM_COMPLETED: "item/completed", AGENT_MESSAGE_DELTA: "item/agentMessage/delta", REASONING_TEXT_DELTA: "item/reasoning/textDelta", + // The DEFAULT reasoning stream for gpt-5-family models (summaries via + // model_reasoning_summary="auto"). Raw textDelta only fires when + // show_raw_agent_reasoning=true, which we don't set — so without this the host + // sees no reasoning at all. + REASONING_SUMMARY_TEXT_DELTA: "item/reasoning/summaryTextDelta", TURN_PLAN_UPDATED: "turn/plan/updated", TURN_COMPLETED: "turn/completed", // Fatal turn error; `willRetry:false` means it won't recover on its own. diff --git a/packages/agent/src/adapters/codex-app-server/session-config.test.ts b/packages/agent/src/adapters/codex-app-server/session-config.test.ts index 45112462b8..dbd1896387 100644 --- a/packages/agent/src/adapters/codex-app-server/session-config.test.ts +++ b/packages/agent/src/adapters/codex-app-server/session-config.test.ts @@ -4,6 +4,7 @@ import { CODEX_MODES, DEFAULT_EFFORTS, modeApprovalPolicy, + sandboxPolicyFor, } from "./session-config"; describe("modeApprovalPolicy", () => { @@ -27,9 +28,42 @@ describe("modeApprovalPolicy", () => { }); }); +describe("sandboxPolicyFor", () => { + it("restricts plan + read-only to a read-only sandbox", () => { + expect(sandboxPolicyFor("plan")).toEqual({ + type: "readOnly", + networkAccess: true, + }); + expect(sandboxPolicyFor("read-only")).toEqual({ + type: "readOnly", + networkAccess: true, + }); + }); + + it("leaves auto + full-access at the spawned full-access sandbox (no override)", () => { + expect(sandboxPolicyFor("auto")).toBeUndefined(); + expect(sandboxPolicyFor("full-access")).toBeUndefined(); + }); + + it("returns undefined for unknown ids", () => { + expect(sandboxPolicyFor("bypassPermissions")).toBeUndefined(); + expect(sandboxPolicyFor(undefined)).toBeUndefined(); + }); +}); + describe("buildConfigOptions", () => { - it("emits a model + thought_level selector from the live lists", () => { + const byCategory = ( + opts: ReturnType, + category: string, + ) => + opts.find((o) => (o as { category: string }).category === category) as { + currentValue: string; + options: Array<{ value: string; name: string }>; + }; + + it("emits mode + model + thought_level selectors from the live lists", () => { const opts = buildConfigOptions({ + mode: "auto", model: "gpt-5.5", effort: "high", models: [ @@ -39,51 +73,83 @@ describe("buildConfigOptions", () => { efforts: ["low", "high"], }); expect(opts.map((o) => (o as { category: string }).category)).toEqual([ + "mode", "model", "thought_level", ]); - expect((opts[0] as { currentValue: string }).currentValue).toBe("gpt-5.5"); - expect( - (opts[0] as { options: Array<{ value: string }> }).options.map( - (o) => o.value, - ), - ).toEqual(["gpt-5.5", "gpt-5-mini"]); + const model = byCategory(opts, "model"); + expect(model.currentValue).toBe("gpt-5.5"); + expect(model.options.map((o) => o.value)).toEqual([ + "gpt-5.5", + "gpt-5-mini", + ]); + }); + + it("surfaces the flattened codex presets (incl. Plan) with the current mode selected", () => { + const mode = byCategory( + buildConfigOptions({ + mode: "plan", + model: "gpt-5.5", + models: [], + efforts: [], + }), + "mode", + ); + expect(mode.currentValue).toBe("plan"); + expect(mode.options.map((o) => o.value)).toEqual([ + "plan", + "read-only", + "auto", + "full-access", + ]); }); it("keeps the active model/effort selectable even if the lists omit them", () => { const opts = buildConfigOptions({ + mode: "auto", model: "gpt-5.5", effort: "max", models: [{ id: "gpt-5-mini", name: "GPT-5 mini" }], efforts: ["low", "high"], }); - const model = opts[0] as { - currentValue: string; - options: Array<{ value: string }>; - }; - const effort = opts[1] as { - currentValue: string; - options: Array<{ value: string }>; - }; + const model = byCategory(opts, "model"); + const effort = byCategory(opts, "thought_level"); expect(model.currentValue).toBe("gpt-5.5"); expect(model.options.map((o) => o.value)).toContain("gpt-5.5"); expect(effort.currentValue).toBe("max"); expect(effort.options.map((o) => o.value)).toContain("max"); }); + it("humanizes reasoning-effort labels (Title case) while keeping raw values", () => { + const effort = byCategory( + buildConfigOptions({ + mode: "auto", + model: "gpt-5.5", + effort: "high", + models: [], + efforts: ["low", "medium", "high"], + }), + "thought_level", + ); + expect(effort.options).toEqual([ + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + ]); + }); + it("falls back to the single current model and DEFAULT_EFFORTS when lists are empty", () => { const opts = buildConfigOptions({ + mode: "auto", model: "gpt-5.5", models: [], efforts: [], }); - expect((opts[0] as { options: Array<{ value: string }> }).options).toEqual([ + expect(byCategory(opts, "model").options).toEqual([ { name: "gpt-5.5", value: "gpt-5.5" }, ]); expect( - (opts[1] as { options: Array<{ value: string }> }).options.map( - (o) => o.value, - ), + byCategory(opts, "thought_level").options.map((o) => o.value), ).toEqual(DEFAULT_EFFORTS); }); }); diff --git a/packages/agent/src/adapters/codex-app-server/session-config.ts b/packages/agent/src/adapters/codex-app-server/session-config.ts index 19f758b6d1..c1590824c1 100644 --- a/packages/agent/src/adapters/codex-app-server/session-config.ts +++ b/packages/agent/src/adapters/codex-app-server/session-config.ts @@ -11,25 +11,55 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; * `setSessionConfigOption`, not listed as a configOption). */ +/** + * Per-turn sandbox the mode maps to (a subset of codex's SandboxPolicy). The + * picker's restriction lives here, NOT in `collaborationMode` — that field is + * silently dropped by the app-server (it only exists in server→client + * ThreadSettings), and `approvalPolicy` alone is neutralized because the process + * spawns under `danger-full-access`. `readOnly` is the only honored way to make + * plan/read-only actually block edits. + */ +export type CodexSandboxPolicy = + | { type: "readOnly"; networkAccess: boolean } + | { type: "dangerFullAccess" }; + export interface CodexMode { id: string; name: string; description: string; /** codex AskForApproval the mode maps to, applied per-turn on turn/start. */ approvalPolicy: string; + /** + * Per-turn sandbox override (turn/start `sandboxPolicy`). Undefined means keep + * the spawned `danger-full-access` (can edit). Applied only off the cloud + * sandbox, where a non-danger policy would re-engage the unavailable + * linux-sandbox and panic — see codex-app-server-agent.ts. + */ + sandboxPolicy?: CodexSandboxPolicy; } +// Flattened Claude-style presets. Restriction is driven by approvalPolicy + +// sandboxPolicy (the only honored levers); plan/read-only block edits via a +// read-only sandbox, auto/full-access keep the spawned full-access sandbox. export const CODEX_MODES: CodexMode[] = [ + { + id: "plan", + name: "Plan", + description: "Plan first — inspect and propose; makes no changes", + approvalPolicy: "on-request", + sandboxPolicy: { type: "readOnly", networkAccess: true }, + }, { id: "read-only", name: "Read only", - description: "Asks before any change", + description: "Read-only — can inspect but not modify files", approvalPolicy: "untrusted", + sandboxPolicy: { type: "readOnly", networkAccess: true }, }, { id: "auto", name: "Auto", - description: "Asks before risky operations", + description: "Edits the workspace; asks before risky operations", approvalPolicy: "on-request", }, { @@ -48,6 +78,13 @@ export function modeApprovalPolicy( return CODEX_MODES.find((m) => m.id === modeId)?.approvalPolicy; } +/** Per-turn sandbox for a mode id (undefined keeps the spawned full-access). */ +export function sandboxPolicyFor( + modeId: string | undefined, +): CodexSandboxPolicy | undefined { + return CODEX_MODES.find((m) => m.id === modeId)?.sandboxPolicy; +} + /** * Resolve the host's initial `_meta.permissionMode` to a codex mode — mirroring * codex-acp's toCodexPermissionMode. A recognized codex mode is honored; any @@ -63,7 +100,24 @@ export function resolveInitialMode(permissionMode: string | undefined): string { /** Codex's standard reasoning efforts; used when model/list doesn't expose them. */ export const DEFAULT_EFFORTS = ["low", "medium", "high"]; +// Display labels for reasoning efforts. Mirrors codex/models.ts and +// claude/session/models.ts so the live selector matches the preview path +// (the host renders `name` verbatim — raw "low" would show lowercase). +const EFFORT_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; + +function humanizeEffort(effort: string): string { + return EFFORT_LABELS[effort] ?? effort; +} + export interface SessionConfigState { + /** Current permission/collaboration preset id (one of CODEX_MODES). */ + mode: string; model: string; effort?: string; /** From model/list; falls back to the single current model when empty. */ @@ -72,7 +126,7 @@ export interface SessionConfigState { efforts: string[]; } -/** Builds the ACP configOptions (model + thought_level) the host renders. */ +/** Builds the ACP configOptions (mode + model + thought_level) the host renders. */ export function buildConfigOptions( s: SessionConfigState, ): SessionConfigOption[] { @@ -91,6 +145,18 @@ export function buildConfigOptions( ? baseEfforts : [...baseEfforts, currentEffort]; return [ + { + type: "select", + id: "mode", + name: "Mode", + category: "mode", + currentValue: s.mode, + options: CODEX_MODES.map((m) => ({ + name: m.name, + value: m.id, + description: m.description, + })), + } as unknown as SessionConfigOption, { type: "select", id: "model", @@ -105,7 +171,7 @@ export function buildConfigOptions( name: "Reasoning effort", category: "thought_level", currentValue: currentEffort, - options: efforts.map((e) => ({ name: e, value: e })), + options: efforts.map((e) => ({ name: humanizeEffort(e), value: e })), } as unknown as SessionConfigOption, ]; } diff --git a/packages/agent/src/adapters/codex-app-server/spawn.test.ts b/packages/agent/src/adapters/codex-app-server/spawn.test.ts index f20d990764..24ee03604a 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.test.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.test.ts @@ -19,6 +19,34 @@ describe("buildAppServerArgs", () => { ); }); + it.each([ + ["darwin", 'sandbox_mode="workspace-write"'], + ["linux", 'sandbox_mode="danger-full-access"'], + ["win32", 'sandbox_mode="danger-full-access"'], + ])( + "on %s spawns with %s (macOS keeps the sandbox engaged so read-only can restrict; cloud/linux avoids the linux-sandbox panic)", + (platform, expected) => { + const original = process.platform; + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); + try { + const args = buildAppServerArgs({ binaryPath: "/bundle/codex" }); + expect(args).toContain(expected); + // Exactly one sandbox_mode override is emitted. + expect( + args.filter((a) => a.startsWith("sandbox_mode=")), + ).toHaveLength(1); + } finally { + Object.defineProperty(process, "platform", { + value: original, + configurable: true, + }); + } + }, + ); + it("does not set instructions at spawn (developer_instructions are per-thread)", () => { const args = buildAppServerArgs({ binaryPath: "/bundle/codex", diff --git a/packages/agent/src/adapters/codex-app-server/spawn.ts b/packages/agent/src/adapters/codex-app-server/spawn.ts index 6a784082a9..302cb85b9f 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.ts @@ -32,12 +32,24 @@ export function buildAppServerArgs( args.push("-c", "features.remote_models=false"); - // The agent already runs inside PostHog's isolated sandbox (docker/Modal with - // agentsh egress + filesystem controls), so Codex's own OS-level sandbox is - // redundant — and its `linux-sandbox` launcher is unavailable there, so the - // default mode panics ("sandbox launcher unavailable") and wedges the session. - // Run with no nested sandbox; the enclosing sandbox provides the isolation. - args.push("-c", `sandbox_mode="danger-full-access"`); + // OS sandbox mode is gated on the platform, which mirrors sandbox AVAILABILITY: + // - macOS (local desktop + e2e): Seatbelt is available, so spawn with the + // `workspace-write` sandbox. This keeps the OS sandbox engaged so a per-turn + // `sandboxPolicy:readOnly` (the Plan / Read-only presets) can actually + // TIGHTEN it and block edits — a process spawned `danger-full-access` has + // the sandbox fully disabled and can't re-engage it per-turn, which made the + // mode picker cosmetic. + // - cloud (linux containers): codex's `linux-sandbox` launcher is unavailable, + // so the default mode panics ("sandbox launcher unavailable") and wedges the + // session. Run `danger-full-access` (PostHog's enclosing docker/Modal + // sandbox provides the real isolation there). Windows falls here too, + // conservatively, until its sandbox is verified. + args.push( + "-c", + process.platform === "darwin" + ? `sandbox_mode="workspace-write"` + : `sandbox_mode="danger-full-access"`, + ); // Disable the user's ambient ~/.codex MCP servers (linear/figma/etc.) so the // adapter only exposes MCP servers PostHog Code injects per-thread — matching @@ -47,6 +59,11 @@ export function buildAppServerArgs( for (const name of new CodexSettingsManager( options.cwd ?? process.cwd(), ).getSettings().mcpServerNames) { + // codex's `-c` parser rejects quoted/special key segments; a dotted or + // spaced server name would emit an override that fails to load and wedges + // the whole session. Skip it (the server stays enabled, which is harmless) + // — mirrors the guard in codex/spawn.ts. + if (!/^[A-Za-z0-9_-]+$/.test(name)) continue; args.push("-c", `mcp_servers.${name}.enabled=false`); } diff --git a/packages/core/src/sessions/contextUsage.test.ts b/packages/core/src/sessions/contextUsage.test.ts index 4280d146c5..fb295c5818 100644 --- a/packages/core/src/sessions/contextUsage.test.ts +++ b/packages/core/src/sessions/contextUsage.test.ts @@ -56,6 +56,27 @@ describe("extractContextUsage", () => { expect(result?.breakdown).toBeNull(); }); + it("surfaces token count even when the context window size is unknown", () => { + // codex omits `size` when the protocol has no modelContextWindow — the + // aggregate must still render (size 0, no percentage) rather than vanish. + const event: AcpMessage = { + type: "acp_message", + ts: 1, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "s1", + update: { sessionUpdate: "usage_update", used: 50_000 }, + }, + }, + }; + const result = extractContextUsage([event]); + expect(result?.used).toBe(50_000); + expect(result?.size).toBe(0); + expect(result?.percentage).toBe(0); + }); + it("merges breakdown from a _posthog/usage_update notification", () => { const result = extractContextUsage([ usageUpdateEvent(50_000, 200_000), diff --git a/packages/core/src/sessions/contextUsage.ts b/packages/core/src/sessions/contextUsage.ts index fb59a55060..22f33280e9 100644 --- a/packages/core/src/sessions/contextUsage.ts +++ b/packages/core/src/sessions/contextUsage.ts @@ -82,16 +82,18 @@ function extractAggregate( const update = params?.update; if ( update?.sessionUpdate === "usage_update" && - typeof update.used === "number" && - typeof update.size === "number" + typeof update.used === "number" ) { + // The model context window (`size`) may be unknown — e.g. codex omits it + // when the protocol doesn't report `modelContextWindow`. Still surface the + // raw token count (size 0 → the indicator shows used tokens, no + // percentage) rather than dropping the whole aggregate. + const size = typeof update.size === "number" ? update.size : 0; const percentage = - update.size > 0 - ? Math.min(100, Math.round((update.used / update.size) * 100)) - : 0; + size > 0 ? Math.min(100, Math.round((update.used / size) * 100)) : 0; return { used: update.used, - size: update.size, + size, percentage, cost: update.cost ?? null, }; diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index b215e31ab3..a06bc432f5 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -25,6 +25,8 @@ import { mergeConfigOptions, type OptimisticItem, type PermissionRequest, + resolveBypassRevertMode, + sessionSupportsNativeSteer, type QueuedMessage, type StoredLogEntry, type TaskRunStatus, @@ -992,6 +994,7 @@ export class SessionService { this.d.store.updateSession(taskRunId, { status: "connected", configOptions, + steering: (result as { steering?: string }).steering, }); // Persist the merged config options @@ -1347,6 +1350,7 @@ export class SessionService { | SessionConfigOption[] | undefined; session.configOptions = configOptions; + session.steering = (result as { steering?: string }).steering; // Persist the config options if (configOptions) { @@ -2191,22 +2195,18 @@ export class SessionService { } // Steer: the user sent a message mid-turn and asked to fold it into the - // running turn rather than queue it. Native (Claude, local) injects at the - // next tool boundary; local Codex interrupts the turn and resends below as - // a fresh prompt. - // - // Cloud has no real mid-turn steer: the backend only delivers user messages - // between turns, so a cloud "steer" would cancel the running turn for no - // gain (the message lands next turn either way) while surfacing a jarring - // interruption. Until the backend supports true steering, cloud steer falls - // through to the queue like a normal message. Compaction also falls through. + // running turn rather than queue it. Adapters that negotiated + // `steering: "native"` (Claude, codex app-server) inject at the next tool + // boundary; codex-acp ("interrupt-resend") and unknown adapters cancel and + // resend. Cloud has no real mid-turn steer (the backend only delivers + // messages between turns), so it falls through to the queue; compaction too. if ( options?.steer && !session.isCloud && session.isPromptPending && !session.isCompacting ) { - if (session.adapter === "claude") { + if (sessionSupportsNativeSteer(session)) { return this.sendSteerPrompt(session, prompt); } await this.cancelPrompt(taskId); @@ -4582,6 +4582,7 @@ export class SessionService { isCloud: boolean; allowBypassPermissions: boolean; currentModeId: string | boolean | undefined; + modeOption: SessionConfigOption | undefined; }, ): void { if (options.allowBypassPermissions) return; @@ -4590,7 +4591,9 @@ export class SessionService { options.currentModeId === "bypassPermissions" || options.currentModeId === "full-access"; if (!isBypass || !taskId) return; - this.setSessionConfigOptionByCategory(taskId, "mode", "default"); + const target = resolveBypassRevertMode(options.modeOption); + if (!target) return; + this.setSessionConfigOptionByCategory(taskId, "mode", target); } /** diff --git a/packages/electron-trpc/vitest.config.ts.timestamp-1782808001712-f0f037a43e4af8.mjs b/packages/electron-trpc/vitest.config.ts.timestamp-1782808001712-f0f037a43e4af8.mjs new file mode 100644 index 0000000000..1ae63460bc --- /dev/null +++ b/packages/electron-trpc/vitest.config.ts.timestamp-1782808001712-f0f037a43e4af8.mjs @@ -0,0 +1,20 @@ +// vitest.config.ts +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "file:///Users/js/dev/ph/code/node_modules/vite/dist/node/index.js"; +var __vite_injected_original_import_meta_url = "file:///Users/js/dev/ph/code/packages/electron-trpc/vitest.config.ts"; +var __dirname = path.dirname(fileURLToPath(__vite_injected_original_import_meta_url)); +var vitest_config_default = defineConfig({ + test: { + coverage: { + all: true, + include: ["src/**/*"], + reporter: ["text", "cobertura", "html"], + reportsDirectory: path.resolve(__dirname, "./coverage/") + } + } +}); +export { + vitest_config_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZXN0LmNvbmZpZy50cyJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIi9Vc2Vycy9qcy9kZXYvcGgvY29kZS9wYWNrYWdlcy9lbGVjdHJvbi10cnBjXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvVXNlcnMvanMvZGV2L3BoL2NvZGUvcGFja2FnZXMvZWxlY3Ryb24tdHJwYy92aXRlc3QuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9Vc2Vycy9qcy9kZXYvcGgvY29kZS9wYWNrYWdlcy9lbGVjdHJvbi10cnBjL3ZpdGVzdC5jb25maWcudHNcIjsvLy8gPHJlZmVyZW5jZSB0eXBlcz1cInZpdGVzdFwiIC8+XG5pbXBvcnQgcGF0aCBmcm9tIFwibm9kZTpwYXRoXCI7XG5pbXBvcnQgeyBmaWxlVVJMVG9QYXRoIH0gZnJvbSBcIm5vZGU6dXJsXCI7XG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiO1xuXG5jb25zdCBfX2Rpcm5hbWUgPSBwYXRoLmRpcm5hbWUoZmlsZVVSTFRvUGF0aChpbXBvcnQubWV0YS51cmwpKTtcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcbiAgdGVzdDoge1xuICAgIGNvdmVyYWdlOiB7XG4gICAgICBhbGw6IHRydWUsXG4gICAgICBpbmNsdWRlOiBbXCJzcmMvKiovKlwiXSxcbiAgICAgIHJlcG9ydGVyOiBbXCJ0ZXh0XCIsIFwiY29iZXJ0dXJhXCIsIFwiaHRtbFwiXSxcbiAgICAgIHJlcG9ydHNEaXJlY3Rvcnk6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsIFwiLi9jb3ZlcmFnZS9cIiksXG4gICAgfSxcbiAgfSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUNBLE9BQU8sVUFBVTtBQUNqQixTQUFTLHFCQUFxQjtBQUM5QixTQUFTLG9CQUFvQjtBQUhxSyxJQUFNLDJDQUEyQztBQUtuUCxJQUFNLFlBQVksS0FBSyxRQUFRLGNBQWMsd0NBQWUsQ0FBQztBQUU3RCxJQUFPLHdCQUFRLGFBQWE7QUFBQSxFQUMxQixNQUFNO0FBQUEsSUFDSixVQUFVO0FBQUEsTUFDUixLQUFLO0FBQUEsTUFDTCxTQUFTLENBQUMsVUFBVTtBQUFBLE1BQ3BCLFVBQVUsQ0FBQyxRQUFRLGFBQWEsTUFBTTtBQUFBLE1BQ3RDLGtCQUFrQixLQUFLLFFBQVEsV0FBVyxhQUFhO0FBQUEsSUFDekQ7QUFBQSxFQUNGO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 65520c6cf7..967c6eb9ca 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -184,7 +184,9 @@ export { type OptimisticItem, type PermissionRequest, type QueuedMessage, + resolveBypassRevertMode, type SessionStatus, + sessionSupportsNativeSteer, } from "./sessions"; export type { SignalReportOrderingField, diff --git a/packages/shared/src/sessions.test.ts b/packages/shared/src/sessions.test.ts new file mode 100644 index 0000000000..071e571eba --- /dev/null +++ b/packages/shared/src/sessions.test.ts @@ -0,0 +1,73 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vitest"; +import { + type AgentSession, + resolveBypassRevertMode, + sessionSupportsNativeSteer, +} from "./sessions"; + +function modeOption(values: string[], currentValue: string): SessionConfigOption { + return { + type: "select", + id: "mode", + name: "Mode", + category: "mode", + currentValue, + options: values.map((v) => ({ name: v, value: v })), + } as unknown as SessionConfigOption; +} + +describe("resolveBypassRevertMode", () => { + it("reverts a claude session to 'default'", () => { + const opt = modeOption( + ["default", "acceptEdits", "plan", "bypassPermissions"], + "bypassPermissions", + ); + expect(resolveBypassRevertMode(opt)).toBe("default"); + }); + + it("reverts a codex session to 'auto', never the claude-only 'default'", () => { + const opt = modeOption( + ["plan", "read-only", "auto", "full-access"], + "full-access", + ); + const target = resolveBypassRevertMode(opt); + expect(target).toBe("auto"); + expect(target).not.toBe("default"); + }); + + it("falls back to the first non-bypass option when neither default nor auto exist", () => { + expect(resolveBypassRevertMode(modeOption(["read-only", "full-access"], "full-access"))).toBe( + "read-only", + ); + }); + + it("returns undefined for a missing or non-select option", () => { + expect(resolveBypassRevertMode(undefined)).toBeUndefined(); + expect( + resolveBypassRevertMode({ type: "boolean" } as unknown as SessionConfigOption), + ).toBeUndefined(); + }); +}); + +describe("sessionSupportsNativeSteer", () => { + type Case = Pick; + + it.each<[string, Case, boolean]>([ + // Capability-driven: "native" folds the message into the running turn. + ["claude advertises native", { isCloud: false, steering: "native", adapter: "claude" }, true], + ["codex app-server advertises native", { isCloud: false, steering: "native", adapter: "codex" }, true], + // codex-acp advertises "interrupt-resend" — must NOT steer natively. + ["codex-acp interrupt-resend", { isCloud: false, steering: "interrupt-resend", adapter: "codex" }, false], + // Fallback: pre-capability start paths leave steering unset; never regress claude. + ["claude with no capability (fallback)", { isCloud: false, steering: undefined, adapter: "claude" }, true], + ["codex with no capability (no fallback)", { isCloud: false, steering: undefined, adapter: "codex" }, false], + // An explicit non-native capability overrides the claude fallback. + ["claude explicitly non-native", { isCloud: false, steering: "interrupt-resend", adapter: "claude" }, false], + // Cloud runs queue/resend; they never steer locally regardless of capability. + ["cloud claude native", { isCloud: true, steering: "native", adapter: "claude" }, false], + ["cloud codex native", { isCloud: true, steering: "native", adapter: "codex" }, false], + ])("%s", (_label, session, expected) => { + expect(sessionSupportsNativeSteer(session)).toBe(expected); + }); +}); diff --git a/packages/shared/src/sessions.ts b/packages/shared/src/sessions.ts index 0724dddfac..a278771721 100644 --- a/packages/shared/src/sessions.ts +++ b/packages/shared/src/sessions.ts @@ -65,6 +65,13 @@ export interface AgentSession { framework?: "claude"; adapter?: Adapter; configOptions?: SessionConfigOption[]; + /** + * Adapter's negotiated steering capability (`_meta.posthog.steering` from + * initialize). "native" means a mid-turn message folds into the running turn + * (claude, codex app-server); "interrupt-resend" (codex-acp) or undefined + * means the host must cancel + resend. Drives the steer-vs-resend decision. + */ + steering?: string; pendingPermissions: Map; pausedDurationMs: number; messageQueue: QueuedMessage[]; @@ -160,3 +167,41 @@ export function getCurrentModeFromConfigOptions( const modeOption = getConfigOptionByCategory(configOptions, "mode"); return modeOption?.currentValue as ExecutionMode | undefined; } + +/** + * The safe non-bypass mode to revert to when "Bypass permissions" is turned + * off, chosen from the session's OWN mode options so it's always valid for that + * adapter. Claude exposes "default"; codex has no "default" (its presets are + * plan/read-only/auto/full-access) so it falls back to "auto" — reverting codex + * to "default" would set an unknown mode (no approvalPolicy → an undefined + * approval state). Returns undefined when there is no usable mode option. + */ +export function resolveBypassRevertMode( + modeOption: SessionConfigOption | undefined, +): string | undefined { + if (modeOption?.type !== "select") return undefined; + const opts = flattenSelectOptions(modeOption.options); + const isBypass = (v: string) => + v === "bypassPermissions" || v === "full-access"; + if (opts.some((o) => o.value === "default")) return "default"; + if (opts.some((o) => o.value === "auto")) return "auto"; + return opts.find((o) => !isBypass(o.value))?.value; +} + +/** + * Whether a mid-turn message can be folded into the running turn (steered) + * rather than interrupt-and-resent. Decided by the adapter's negotiated + * `steering` capability: "native" folds (claude, codex app-server); + * "interrupt-resend" (codex-acp) does not. Cloud runs never steer locally. + * + * Fallback: if `steering` is unset (a start path that predates capability + * plumbing), Claude is still treated as native — it has always steered — so the + * capability rollout can never regress it. + */ +export function sessionSupportsNativeSteer( + session: Pick, +): boolean { + if (session.isCloud) return false; + if (session.steering === "native") return true; + return session.steering == null && session.adapter === "claude"; +} diff --git a/packages/ui/src/features/message-editor/components/PromptInput.test.tsx b/packages/ui/src/features/message-editor/components/PromptInput.test.tsx new file mode 100644 index 0000000000..bd0a282946 --- /dev/null +++ b/packages/ui/src/features/message-editor/components/PromptInput.test.tsx @@ -0,0 +1,139 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const editorState = vi.hoisted(() => ({ isEmpty: false })); +const settingsState = vi.hoisted(() => ({ slotMachineMode: false })); + +vi.mock("../tiptap/useTiptapEditor", () => ({ + useTiptapEditor: () => ({ + editor: null, + isReady: true, + isEmpty: editorState.isEmpty, + isBashMode: false, + submit: vi.fn(), + focus: vi.fn(), + blur: vi.fn(), + clear: vi.fn(), + getText: vi.fn(), + getContent: vi.fn(), + setContent: vi.fn(), + insertChip: vi.fn(), + removeChipById: vi.fn(), + replaceChipAttrs: vi.fn(), + attachments: [], + addAttachment: vi.fn(), + removeAttachment: vi.fn(), + }), +})); + +vi.mock("@posthog/ui/features/settings/settingsStore", () => ({ + useSettingsStore: (selector: (s: typeof settingsState) => unknown) => + selector(settingsState), +})); + +vi.mock("../../skills/useSkills", () => ({ + useSkills: () => ({ data: [] }), +})); + +vi.mock("../draftStore", () => ({ + useDraftStore: Object.assign( + (selector: (s: unknown) => unknown) => + selector({ focusRequested: {}, actions: { clearFocusRequest: vi.fn() } }), + { + getState: () => ({ + actions: { setCommands: vi.fn(), clearCommands: vi.fn() }, + }), + }, + ), +})); + +vi.mock("./AttachmentMenu", () => ({ AttachmentMenu: () => null })); +vi.mock("./AttachmentsBar", () => ({ AttachmentsBar: () => null })); +vi.mock("./SlotMachineSubmit", () => ({ + SlotMachineSubmit: ({ + disabled, + onSubmit, + }: { + disabled?: boolean; + onSubmit?: () => void; + }) => ( + + ), +})); + +import { PromptInput } from "./PromptInput"; + +function renderInput(props: Partial>) { + return render( + + + , + ); +} + +describe("PromptInput submit/stop affordance", () => { + beforeEach(() => { + vi.clearAllMocks(); + editorState.isEmpty = false; + settingsState.slotMachineMode = false; + }); + + it("shows Stop (not Send) while loading and calls onCancel when clicked", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + + renderInput({ isLoading: true, onCancel }); + + const stop = screen.getByRole("button", { name: "Stop" }); + expect( + screen.queryByRole("button", { name: "Send message" }), + ).not.toBeInTheDocument(); + + await user.click(stop); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("keeps Send enabled mid-turn when no cancel handler (queue/steer path)", () => { + // isLoading true but no onCancel => inStopMode is false, so the composer + // must still expose an enabled Send so messages queue/steer mid-turn. + // Regression guard: adding `|| isLoading` to submitBlocked disables this. + renderInput({ isLoading: true }); + + const send = screen.getByRole("button", { name: "Send message" }); + expect(send).toBeEnabled(); + }); + + it("disables Send when the editor is empty", () => { + editorState.isEmpty = true; + + renderInput({}); + + const send = screen.getByRole("button", { name: "Send message" }); + expect(send).toBeDisabled(); + }); +}); diff --git a/packages/ui/src/features/sessions/components/ContextUsageIndicator.test.tsx b/packages/ui/src/features/sessions/components/ContextUsageIndicator.test.tsx new file mode 100644 index 0000000000..49945a0f7d --- /dev/null +++ b/packages/ui/src/features/sessions/components/ContextUsageIndicator.test.tsx @@ -0,0 +1,69 @@ +import type { ContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ContextUsageIndicator } from "./ContextUsageIndicator"; + +function usage(overrides?: Partial): ContextUsage { + return { + used: 50_000, + size: 200_000, + percentage: 25, + cost: null, + breakdown: null, + ...overrides, + }; +} + +describe("ContextUsageIndicator", () => { + it("renders nothing when usage is null", () => { + const { container } = render( + + + , + ); + expect(container.querySelector("button")).toBeNull(); + }); + + it("renders the compact used/size label, percentage, and aria-label", () => { + render( + + + , + ); + expect(screen.getByText(/50K\/200K · 25%/)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Context usage: 25%" }), + ).toBeInTheDocument(); + }); + + it("shows only the token count when the context window is unknown (size 0)", () => { + render( + + + , + ); + // No misleading "/0 · 0%" — just the used tokens. + expect(screen.getByText("50K")).toBeInTheDocument(); + expect(screen.queryByText(/\/0/)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Context usage: 50K tokens" }), + ).toBeInTheDocument(); + }); + + it("renders a finite stroke offset at 0% (no NaN/Infinity)", () => { + const { container } = render( + + + , + ); + const progress = container.querySelectorAll("circle")[1]; + const offset = Number(progress?.getAttribute("stroke-dashoffset")); + expect(Number.isFinite(offset)).toBe(true); + expect(screen.getByText(/0\/200K · 0%/)).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/sessions/components/ContextUsageIndicator.tsx b/packages/ui/src/features/sessions/components/ContextUsageIndicator.tsx index 94c0f599a2..1ae3ac46a1 100644 --- a/packages/ui/src/features/sessions/components/ContextUsageIndicator.tsx +++ b/packages/ui/src/features/sessions/components/ContextUsageIndicator.tsx @@ -19,6 +19,9 @@ export function ContextUsageIndicator({ usage }: ContextUsageIndicatorProps) { if (!usage) return null; const { used, size, percentage } = usage; + // The context window can be unknown (size 0) — show just the token count + // rather than a misleading "X/0 · 0%". + const hasSize = size > 0; const strokeDashoffset = CIRCUMFERENCE - (percentage / 100) * CIRCUMFERENCE; const color = getOverallUsageColor(percentage); @@ -28,7 +31,11 @@ export function ContextUsageIndicator({ usage }: ContextUsageIndicatorProps) { diff --git a/packages/ui/src/features/sessions/components/ReasoningLevelSelector.test.tsx b/packages/ui/src/features/sessions/components/ReasoningLevelSelector.test.tsx new file mode 100644 index 0000000000..9631603355 --- /dev/null +++ b/packages/ui/src/features/sessions/components/ReasoningLevelSelector.test.tsx @@ -0,0 +1,89 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { Theme } from "@radix-ui/themes"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ReasoningLevelSelector } from "./ReasoningLevelSelector"; + +function codexThoughtOption( + overrides?: Partial, +): SessionConfigOption { + return { + type: "select", + id: "effort", + name: "Reasoning effort", + category: "thought_level", + currentValue: "high", + options: [ + { name: "low", value: "low" }, + { name: "high", value: "high" }, + { name: "max", value: "max" }, + ], + ...overrides, + } as unknown as SessionConfigOption; +} + +describe("ReasoningLevelSelector", () => { + it("renders the active level as the trigger label for a codex thought_level option", () => { + render( + + + , + ); + expect( + screen.getByRole("button", { name: "Reasoning: high" }), + ).toBeInTheDocument(); + }); + + it("emits the raw value via onChange once the menu closes", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole("button", { name: "Reasoning: high" })); + const lowItem = await screen.findByRole("menuitemradio", { name: "low" }); + await user.click(lowItem); + + await waitFor(() => expect(onChange).toHaveBeenCalledWith("low")); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("uses the 'Effort' label for the claude adapter", () => { + render( + + + , + ); + expect( + screen.getByRole("button", { name: "Effort: medium" }), + ).toBeInTheDocument(); + }); + + it.each([ + ["undefined option", undefined], + ["non-select type", codexThoughtOption({ type: "boolean" })], + ["empty options", codexThoughtOption({ options: [] })], + ])("renders no trigger for %s", (_label, option) => { + render( + , + ); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index ab832577da..84e7780658 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -192,8 +192,16 @@ export function SessionView({ isCloud, allowBypassPermissions, currentModeId, + modeOption, }); - }, [allowBypassPermissions, currentModeId, taskId, isCloud, sessionService]); + }, [ + allowBypassPermissions, + currentModeId, + taskId, + isCloud, + sessionService, + modeOption, + ]); const handleModeChange = useCallback( (nextMode: string) => { diff --git a/packages/ui/src/features/sessions/components/SteerQueueToggle.test.tsx b/packages/ui/src/features/sessions/components/SteerQueueToggle.test.tsx new file mode 100644 index 0000000000..f6f0627b31 --- /dev/null +++ b/packages/ui/src/features/sessions/components/SteerQueueToggle.test.tsx @@ -0,0 +1,75 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useSupportsNativeSteer } from "../hooks/useMessagingMode"; +import { + type AgentSession, + sessionStoreSetters, + useSessionStore, +} from "../sessionStore"; +import { steerQueueTooltip } from "./SteerQueueToggle"; + +function seedSession(overrides: Partial): void { + sessionStoreSetters.setSession({ + taskRunId: "run-1", + taskId: "task-1", + taskTitle: "Test", + channel: "agent-event:run-1", + events: [], + startedAt: 0, + status: "connected", + isPromptPending: false, + isCompacting: false, + promptStartedAt: null, + pendingPermissions: new Map(), + pausedDurationMs: 0, + messageQueue: [], + optimisticItems: [], + ...overrides, + }); +} + +describe("steer tooltip copy follows the session's native-steer capability", () => { + beforeEach(() => { + useSessionStore.setState((state) => { + state.sessions = {}; + state.taskIdIndex = {}; + }); + }); + + it.each([ + { + name: "codex (local): interrupts and resends", + session: { adapter: "codex" as const, isCloud: false }, + expectNative: false, + }, + { + name: "claude cloud: interrupts and resends", + session: { adapter: "claude" as const, isCloud: true }, + expectNative: false, + }, + { + name: "claude (local): folds natively at the next tool boundary", + session: { adapter: "claude" as const, isCloud: false }, + expectNative: true, + }, + ])( + "$name — supportsNativeSteer and rendered tooltip agree", + ({ session, expectNative }) => { + seedSession(session); + + const { result } = renderHook(() => useSupportsNativeSteer("task-1")); + expect(result.current).toBe(expectNative); + + const tooltip = steerQueueTooltip(true, result.current, "Cmd+S"); + if (expectNative) { + expect(tooltip).toContain( + "injects your message mid-turn at the next tool boundary", + ); + } else { + expect(tooltip).toContain( + "interrupts the current turn and resends with your message", + ); + } + }, + ); +}); diff --git a/packages/ui/src/features/sessions/components/SteerQueueToggle.tsx b/packages/ui/src/features/sessions/components/SteerQueueToggle.tsx index 113a56ad79..5b51da75bd 100644 --- a/packages/ui/src/features/sessions/components/SteerQueueToggle.tsx +++ b/packages/ui/src/features/sessions/components/SteerQueueToggle.tsx @@ -16,6 +16,19 @@ interface SteerQueueToggleProps { taskId: string; } +export function steerQueueTooltip( + isSteer: boolean, + supportsNativeSteer: boolean, + shortcut: string, +): string { + if (!isSteer) { + return `Queue: holds messages until the current turn ends. ${shortcut} to switch to Steer.`; + } + return supportsNativeSteer + ? `Steer: injects your message mid-turn at the next tool boundary. ${shortcut} to switch to Queue.` + : `Steer: interrupts the current turn and resends with your message. ${shortcut} to switch to Queue.`; +} + export function SteerQueueToggle({ taskId }: SteerQueueToggleProps) { const mode = useMessagingMode(taskId); const supportsNativeSteer = useSupportsNativeSteer(taskId); @@ -30,11 +43,7 @@ export function SteerQueueToggle({ taskId }: SteerQueueToggleProps) { ? `Queue (${queuedCount})` : "Queue"; - const tooltip = isSteer - ? supportsNativeSteer - ? `Steer: injects your message mid-turn at the next tool boundary. ${shortcut} to switch to Queue.` - : `Steer: interrupts the current turn and resends with your message. ${shortcut} to switch to Queue.` - : `Queue: holds messages until the current turn ends. ${shortcut} to switch to Steer.`; + const tooltip = steerQueueTooltip(isSteer, supportsNativeSteer, shortcut); const colorClass = isSteer ? "text-purple-11" : "text-gray-11"; diff --git a/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx b/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx new file mode 100644 index 0000000000..fcd635cec7 --- /dev/null +++ b/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx @@ -0,0 +1,131 @@ +import type { + SessionConfigOption, + SessionConfigSelectGroup, +} from "@agentclientprotocol/sdk"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { UnifiedModelSelector } from "./UnifiedModelSelector"; + +const groupedCodexModel: SessionConfigOption = { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: "gpt-5.5", + options: [ + { + group: "openai", + name: "OpenAI", + options: [ + { value: "gpt-5.5", name: "GPT-5.5" }, + { value: "gpt-5.5-codex", name: "GPT-5.5 Codex" }, + ], + }, + { + group: "fable", + name: "Fable", + options: [{ value: "fable", name: "Fable" }], + }, + ] satisfies SessionConfigSelectGroup[], +}; + +const flatCodexModel: SessionConfigOption = { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: "gpt-5.5", + options: [ + { value: "gpt-5.5", name: "GPT-5.5" }, + { value: "fable", name: "Fable" }, + ], +}; + +function renderSelector(props: Partial< + React.ComponentProps +> = {}) { + return render( + + + , + ); +} + +describe("UnifiedModelSelector", () => { + it("renders the codex adapter label, group labels, and grouped model items", async () => { + const user = userEvent.setup(); + renderSelector(); + + await user.click(screen.getByRole("button", { name: "Model" })); + + // Every model in every group renders as a radio item. + expect( + await screen.findByRole("menuitemradio", { name: "GPT-5.5" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitemradio", { name: "GPT-5.5 Codex" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitemradio", { name: "Fable" }), + ).toBeInTheDocument(); + // Adapter MenuLabel + group MenuLabels render. + expect(screen.getByText("Codex")).toBeInTheDocument(); + expect(screen.getByText("OpenAI")).toBeInTheDocument(); + }); + + it("renders flat (ungrouped) model items", async () => { + const user = userEvent.setup(); + renderSelector({ modelOption: flatCodexModel }); + + await user.click(screen.getByRole("button", { name: "Model" })); + + expect( + await screen.findByRole("menuitemradio", { name: "GPT-5.5" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitemradio", { name: "Fable" }), + ).toBeInTheDocument(); + }); + + it("fires onModelChange exactly once with the picked value after the menu closes", async () => { + const user = userEvent.setup(); + const onModelChange = vi.fn(); + renderSelector({ onModelChange }); + + await user.click(screen.getByRole("button", { name: "Model" })); + await user.click( + await screen.findByRole("menuitemradio", { name: "GPT-5.5 Codex" }), + ); + + expect(onModelChange).toHaveBeenCalledExactlyOnceWith("gpt-5.5-codex"); + }); + + it("switches adapter via the 'Switch to Claude' item", async () => { + const user = userEvent.setup(); + const onAdapterChange = vi.fn(); + renderSelector({ onAdapterChange }); + + await user.click(screen.getByRole("button", { name: "Model" })); + await user.click(await screen.findByText("Switch to Claude")); + + expect(onAdapterChange).toHaveBeenCalledExactlyOnceWith("claude"); + }); + + it("renders a disabled loading button with no menu while connecting", () => { + renderSelector({ isConnecting: true }); + + const button = screen.getByRole("button", { name: /loading/i }); + expect(button).toHaveAttribute("aria-disabled", "true"); + expect( + screen.queryByRole("button", { name: "Model" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.test.tsx b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.test.tsx new file mode 100644 index 0000000000..8ac51cfdc8 --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.test.tsx @@ -0,0 +1,118 @@ +import { ServiceProvider } from "@posthog/di/react"; +import { posthogToolMeta } from "@posthog/shared"; +import type { ToolCall } from "@posthog/ui/features/sessions/types"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { Container } from "inversify"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { MCP_TOOL_BLOCK_COMPONENT } from "./identifiers"; +import { ToolCallBlock } from "./ToolCallBlock"; +import type { ToolViewProps } from "./toolCallUtils"; + +// EditToolView's leaf renderers reach outside the unit under test: FileMentionChip +// pulls workspace/tRPC context, and CodePreview mounts a web component that needs +// a real CSSStyleSheet. The edit-routing test only cares that ToolCallBlock +// dispatched to EditToolView, so stub both to their load-bearing inputs. +vi.mock("./FileMentionChip", () => ({ + FileMentionChip: ({ filePath }: { filePath: string }) => ( + {filePath} + ), +})); +vi.mock("./CodePreview", () => ({ + CodePreview: () => code-preview, +})); + +function renderBlock( + toolCall: ToolCall, + mcpToolBlock?: (props: ToolViewProps & { mcpToolName: string }) => ReactNode, +) { + const container = new Container(); + if (mcpToolBlock) { + container.bind(MCP_TOOL_BLOCK_COMPONENT).toConstantValue(mcpToolBlock); + } + return render( + + + + + , + ); +} + +describe("ToolCallBlock codex routing", () => { + it("routes a codex MCP descriptor to the bound McpToolBlock with the canonical name", () => { + const seen: { mcpToolName?: string } = {}; + const McpToolBlock = vi.fn( + ({ mcpToolName }: ToolViewProps & { mcpToolName: string }) => { + seen.mcpToolName = mcpToolName; + return
mcp-block-rendered
; + }, + ); + + renderBlock( + { + toolCallId: "tc-mcp", + title: "exec", + kind: "other", + status: "completed", + rawInput: { query: "select 1" }, + _meta: posthogToolMeta({ + toolName: "mcp__posthog__exec", + mcp: { server: "posthog", tool: "exec" }, + }), + }, + McpToolBlock, + ); + + expect(screen.getByText("mcp-block-rendered")).toBeInTheDocument(); + expect(seen.mcpToolName).toBe("mcp__posthog__exec"); + }); + + it("falls back to the generic tool view for an MCP call when no McpToolBlock is bound", () => { + renderBlock({ + toolCallId: "tc-mcp-fallback", + title: "exec", + kind: "other", + status: "completed", + rawInput: { query: "select 1" }, + _meta: posthogToolMeta({ + toolName: "mcp__posthog__exec", + mcp: { server: "posthog", tool: "exec" }, + }), + }); + + // The MCP branch renders the title in its header; assert it lands somewhere + // (i.e. the call did not blow up unbound) without an MCP block present. + expect(screen.getByText("exec")).toBeInTheDocument(); + }); + + it("routes a codex edit tool call (no _meta) to the edit view with diff stats", () => { + renderBlock({ + toolCallId: "tc-edit", + title: "Edit a.ts", + kind: "edit", + status: "completed", + content: [{ type: "diff", path: "a.ts", oldText: "x", newText: "y" }], + locations: [{ path: "a.ts" }], + }); + + expect(screen.getByText("a.ts")).toBeInTheDocument(); + expect(screen.getByText("+1")).toBeInTheDocument(); + expect(screen.getByText("-1")).toBeInTheDocument(); + }); + + it("routes a codex execute tool call (no _meta) to the execute view header", () => { + renderBlock({ + toolCallId: "tc-exec", + title: "run tests", + kind: "execute", + status: "completed", + rawInput: { command: "pnpm test", description: "Run tests" }, + content: [{ type: "content", content: { type: "text", text: "ok" } }], + }); + + expect(screen.getByText("Run tests")).toBeInTheDocument(); + expect(screen.getByText("pnpm test")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/sessions/hooks/useMessagingMode.ts b/packages/ui/src/features/sessions/hooks/useMessagingMode.ts index e63d40e790..1d00ceecdc 100644 --- a/packages/ui/src/features/sessions/hooks/useMessagingMode.ts +++ b/packages/ui/src/features/sessions/hooks/useMessagingMode.ts @@ -1,3 +1,4 @@ +import { sessionSupportsNativeSteer } from "@posthog/shared"; import { type MessagingMode, useMessagingModeStore, @@ -15,9 +16,11 @@ export function useMessagingMode(taskId: string | undefined): MessagingMode { } /** - * Whether the task's session steers natively (Claude, local) versus falling - * back to interrupt-and-resend (Codex, cloud). Drives the steer label/tooltip, - * not whether steer is allowed: every adapter supports steer in some form. + * Whether the task's session steers natively (folds a mid-turn message into the + * running turn) versus falling back to interrupt-and-resend. Driven by the + * adapter's negotiated `steering` capability — same decision as the host's + * sendPrompt gate — so Claude and codex app-server steer, codex-acp and cloud + * resend. Drives the steer label/tooltip, not whether steer is allowed. */ export function useSupportsNativeSteer(taskId: string | undefined): boolean { return useSessionStore((s) => { @@ -25,6 +28,6 @@ export function useSupportsNativeSteer(taskId: string | undefined): boolean { const taskRunId = s.taskIdIndex[taskId]; if (!taskRunId) return false; const session = s.sessions[taskRunId]; - return !!session && !session.isCloud && session.adapter === "claude"; + return !!session && sessionSupportsNativeSteer(session); }); } diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 3e7d82a215..b07de98a48 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -290,6 +290,16 @@ interface SessionConfig { importedSessionId?: string; } +/** Pull the adapter's `agentCapabilities._meta.posthog.steering` from initialize. */ +function extractSteeringCapability(init: unknown): string | undefined { + const steering = ( + init as { + agentCapabilities?: { _meta?: { posthog?: { steering?: unknown } } }; + } + )?.agentCapabilities?._meta?.posthog?.steering; + return typeof steering === "string" ? steering : undefined; +} + interface ManagedSession { taskRunId: string; taskId: string; @@ -304,6 +314,8 @@ interface ManagedSession { promptPending: boolean; pendingContext?: string; configOptions?: SessionConfigOption[]; + /** Adapter's negotiated steering capability from initialize (`_meta.posthog.steering`). */ + steering?: string; /** Tracks in-flight MCP tool calls (toolCallId → toolKey) for cancellation */ inFlightMcpToolCalls: Map; /** MCP tool approval states fetched at session start */ @@ -847,7 +859,7 @@ If a repository IS genuinely required, attach one in this priority order: clientStreams, ); - await connection.initialize({ + const initResult = await connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { @@ -857,6 +869,11 @@ If a repository IS genuinely required, attach one in this priority order: terminal: true, }, }); + // The adapter advertises whether mid-turn steering folds natively into the + // running turn (`steering: "native"`) vs needs cancel+resend. Surface it so + // the host gates steer-vs-resend on the negotiated capability, not on a + // hardcoded adapter name (codex-acp advertises "interrupt-resend"). + const steering = extractSteeringCapability(initResult); const { servers: mcpServers, @@ -1062,6 +1079,7 @@ If a repository IS genuinely required, attach one in this priority order: config, promptPending: false, configOptions, + steering, inFlightMcpToolCalls: new Map(), mcpToolApprovals: toolApprovals, toolInstallations, @@ -1934,6 +1952,7 @@ For git operations while detached: sessionId: session.taskRunId, channel: session.channel, configOptions: session.configOptions, + steering: session.steering, }; } diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 5e2494ade8..477630edfb 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -142,6 +142,11 @@ export const sessionResponseSchema = z.object({ sessionId: z.string(), channel: z.string(), configOptions: z.array(sessionConfigOptionSchema).optional(), + // The adapter's negotiated steering capability from initialize + // (`_meta.posthog.steering`): "native" folds a mid-turn message into the + // running turn; "interrupt-resend" (codex-acp) or absent means the host must + // cancel + resend instead. Drives the host's steer-vs-resend decision. + steering: z.string().optional(), }); export type SessionResponse = z.infer; From e166ee667d7e6391f6af27811bc0a8a482543e53 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Tue, 30 Jun 2026 18:02:24 +0100 Subject: [PATCH 05/20] cover execution mode bugs and testing --- CODEX_APP_SERVER_TESTING.md | 35 ++-- packages/agent/e2e/driver.ts | 8 +- .../agent/e2e/session-lifecycle.e2e.test.ts | 57 ++++++ .../codex-app-server-agent.test.ts | 184 +++++++++++++++++- .../codex-app-server-agent.ts | 110 ++++++++++- .../codex-app-server/session-config.test.ts | 11 ++ .../codex-app-server/session-config.ts | 28 ++- packages/agent/src/execution-mode.test.ts | 3 +- packages/agent/src/execution-mode.ts | 9 + .../src/sessions/cloudSessionConfig.test.ts | 3 +- packages/core/src/sessions/executionModes.ts | 8 + 11 files changed, 419 insertions(+), 37 deletions(-) diff --git a/CODEX_APP_SERVER_TESTING.md b/CODEX_APP_SERVER_TESTING.md index fbb2377734..058c39a7cc 100644 --- a/CODEX_APP_SERVER_TESTING.md +++ b/CODEX_APP_SERVER_TESTING.md @@ -29,16 +29,11 @@ Triage tip: if something looks wrong, run the same action on **codex-acp** (flag ## Tier 2 — App-server-specific protocol paths (new code, most likely to break) -- [ ] **Steering** (`turn/steer`): send a follow-up message *while a turn is running*. - - Expect: injected into the live turn, not queued as a separate turn. -- [ ] **Interrupt / cancel mid-turn**: stop a running turn. - - Expect: halts promptly **and** `_posthog/turn_complete` still fires (usage updates, UI returns to idle). -- [ ] **Structured output** (native `outputSchema`): run a task that has a JSON schema. - - Expect: structured output emitted and the task run's `output` is populated. -- [ ] **Mode → approval-policy synthesis**: switch mode mid-session (Read-only ↔ Auto ↔ Full access). - - Expect: subsequent tool calls respect the new policy (synthesized per-turn; no native mode RPC). -- [ ] **loadSession / resume**: close the task and reopen it (or reconnect). - - Expect: prior history replays; you can continue prompting on the same thread. +- [x] **Steering** (`turn/steer`) — ✅ live e2e (`folds a mid-turn prompt into the running turn via steering`) + capability now reaches the host (was hardcoded to claude). +- [x] **Interrupt / cancel mid-turn** — ✅ live e2e (`interrupts an in-flight turn` + follow-up asserts `end_turn`); the false-green that hid the broken cancel is fixed. +- [x] **Structured output** (native `outputSchema`) — ✅ live e2e (`structured-output.e2e`). +- [x] **Mode → approval-policy synthesis** — ✅ live e2e: read-only **actually blocks an edit**, plan **engages codex's plan collaboration + reverts** on switch back. Modes are real, not cosmetic. +- [~] **loadSession / resume** — basic resume + list/fork pass live; the audit still wants a test proving the **tool transcript replays** against a persisted thread (not just count). ## Tier 3 — Config controls (UI selectors → adapter) @@ -58,8 +53,7 @@ Triage tip: if something looks wrong, run the same action on **codex-acp** (flag - [ ] **MCP (PostHog tools)**: ask it to query PostHog via MCP. - Expect: read-only tools auto-approve (if configured), writes prompt; tools actually execute. - [ ] **Skills / commands** (`available_commands_update`): confirm skill/slash commands appear and one runs. -- [ ] **AskUserQuestion / elicitation**: get the agent to ask a question. - - Expect: UI renders the options and your answer flows back. +- [x] **AskUserQuestion / elicitation** — ✅ live e2e (plan-mode round-trip): codex's `request_user_input` fires only in plan collaboration, and the `_meta.questions` shape now renders (was an empty "Review your answers" card). - [ ] **Image input**: paste/attach an image in a prompt. - Expect: image is sent and understood. - [ ] **Additional directories** (worktree): confirm it can read/edit files outside the primary cwd. @@ -194,6 +188,23 @@ five; the dispositions: mode to revert to) intentionally skipped — both are effectively-impossible states and the logs would be noise. +### Plan mode is now a REAL mode (collaboration, not just sandbox) — PROVEN LIVE +The earlier "collaborationMode is silently dropped" conclusion was **wrong** — it was confused by +codex tolerating unknown fields. The truth, found by probing the binary's method registry + schema: +- collaboration mode is a **per-turn `turn/start` field** (`collaborationMode`), NOT a thread setting + (`thread/settings/update` accepts it but doesn't honor it). +- Its shape is `{ mode, settings: { model, reasoning_effort? } }` — the `settings.model` must be a + string (NOT the verbatim `collaborationMode/list` output, whose `model` is null). Sending a Default + struct breaks `turn/start` ("Internal error: missing field/expected string"), so only **Plan** is + sent; Default is codex's implicit mode (omitted). +Now `plan` sends `collaborationMode:{mode:"plan", settings:{model}}` on every turn, which unlocks +codex's plan proposals + `request_user_input` (AskUserQuestion). The behavioral e2e +`plan mode engages codex's plan collaboration (request_user_input becomes available)` proves it: it +instructs codex to call request_user_input and asserts the question reaches the host — which **only +happens in plan collaboration mode** (in default codex replies "request_user_input is unavailable in +this mode"). RED before the fix, GREEN after. Plan also keeps the read-only sandbox, so it both +proposes and can't edit. (Creation now also offers Plan — `execution-mode.ts` + core `executionModes.ts`.) + ### Still open - [x] **Live e2e RAN against the real gateway + binary** — the codex arm is **13/13** (steering folds mid-turn, interrupt halts in-flight, structured output delivers, and the new behavioral diff --git a/packages/agent/e2e/driver.ts b/packages/agent/e2e/driver.ts index 0908caa1e5..8d6e8cb227 100644 --- a/packages/agent/e2e/driver.ts +++ b/packages/agent/e2e/driver.ts @@ -116,7 +116,13 @@ export function openConnection(opts: { async requestPermission(p: any): Promise { events.push({ kind: "requestPermission", - data: { title: p?.toolCall?.title, kind: p?.toolCall?.kind }, + data: { + title: p?.toolCall?.title, + kind: p?.toolCall?.kind, + // request_user_input (AskUserQuestion) surfaces as a permission with + // `_meta.codeToolKind: "question"`; codex only offers it in Plan mode. + codeToolKind: p?.toolCall?._meta?.codeToolKind, + }, }); const options = p?.options ?? []; const allow = diff --git a/packages/agent/e2e/session-lifecycle.e2e.test.ts b/packages/agent/e2e/session-lifecycle.e2e.test.ts index 88d6997f26..5e60c2a15e 100644 --- a/packages/agent/e2e/session-lifecycle.e2e.test.ts +++ b/packages/agent/e2e/session-lifecycle.e2e.test.ts @@ -272,6 +272,63 @@ for (const adapter of ADAPTERS) { 180_000, ); + // The proof that Plan is a REAL mode, not a relabeled read-only: codex only + // offers request_user_input (AskUserQuestion) in its "plan" collaboration + // mode (pushed via the turn/start `collaborationMode` field). This also + // covers the revert — codex's collaboration mode is sticky, so switching back + // to auto must push `default` explicitly or it stays stuck in Plan. + itCodex( + "plan mode engages codex's plan collaboration, and reverts when switched back to auto", + async () => { + if (adapter === "codex") killCodexStragglers(); + const s = await openSession({ + adapter, + cwd: repo, + codexOptions: codexOptions(), + meta: meta(), + }); + const askToUseTool = + "Before doing anything else, you MUST call the request_user_input tool " + + "to ask the user a single question: whether to proceed with approach A " + + "or approach B. Ask exactly that one question via the tool, then stop."; + const questionCount = () => + s.capture + .approvals() + .filter((e) => e.data?.codeToolKind === "question").length; + try { + // Plan: request_user_input is available → a question reaches the host. + await s.conn.setSessionConfigOption({ + sessionId: s.sessionId, + configId: "mode", + value: "plan", + }); + await s.conn.prompt({ + sessionId: s.sessionId, + prompt: [{ type: "text", text: askToUseTool }], + }); + const afterPlan = questionCount(); + expect(afterPlan).toBeGreaterThan(0); + + // Switch back to auto (default collaboration): request_user_input is no + // longer available, so the SAME prompt yields no new question. If the + // collaboration mode didn't revert, codex would ask again. + await s.conn.setSessionConfigOption({ + sessionId: s.sessionId, + configId: "mode", + value: "auto", + }); + await s.conn.prompt({ + sessionId: s.sessionId, + prompt: [{ type: "text", text: askToUseTool }], + }); + expect(questionCount()).toBe(afterPlan); + } finally { + await s.cleanup(); + } + }, + 240_000, + ); + it("handles the host's refresh_session extMethod per adapter", async () => { if (adapter === "codex") killCodexStragglers(); const s = await openSession({ diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index 5879976225..9026f52f2d 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -192,17 +192,23 @@ describe("CodexAppServerAgent", () => { }); }); - function makeApprovalAgent() { + function makeApprovalAgent(chooseOptionId = "allow") { const stub = makeStubRpc({ initialize: {}, "thread/start": { thread: { id: "thr_1" } }, }); const permissionToolCalls: Array> = []; + const permissionOptions: Array> = + []; const client = { sessionUpdate: async () => {}, - requestPermission: async (params: { toolCall: Record }) => { + requestPermission: async (params: { + toolCall: Record; + options: Array<{ optionId?: string; kind?: string }>; + }) => { permissionToolCalls.push(params.toolCall); - return { outcome: { outcome: "selected", optionId: "allow" } }; + permissionOptions.push(params.options); + return { outcome: { outcome: "selected", optionId: chooseOptionId } }; }, extNotification: async () => {}, } as unknown as AgentSideConnection; @@ -211,7 +217,7 @@ describe("CodexAppServerAgent", () => { model: "gpt-5.5", rpcFactory: stub.factory, }); - return { agent, stub, permissionToolCalls }; + return { agent, stub, permissionToolCalls, permissionOptions }; } it("routes a non-MCP command approval to an execute permission (kind + command body)", async () => { @@ -238,6 +244,106 @@ describe("CodexAppServerAgent", () => { }); }); + it("surfaces Allow-always and echoes codex's remember decision when offered", async () => { + const { agent, stub, permissionOptions } = makeApprovalAgent("allow_always"); + await agent.initialize(init); + await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); + + // codex offers the command-prefix allowlist decision for this approval. + const decision = await stub.invokeRequest( + "item/commandExecution/requestApproval", + { + itemId: "c1", + command: "pnpm test", + available_decisions: ["approved_execpolicy_amendment", "denied"], + }, + ); + + // The host gets an allow_always option (UI already renders this kind)... + expect(permissionOptions[0].map((o) => o.kind)).toContain("allow_always"); + // ...and picking it echoes codex's own decision so it applies the amendment. + expect(decision).toEqual({ decision: "approved_execpolicy_amendment" }); + }); + + it("omits Allow-always when codex offers no remember decision", async () => { + const { agent, stub, permissionOptions } = makeApprovalAgent("allow"); + await agent.initialize(init); + await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); + + const decision = await stub.invokeRequest( + "item/commandExecution/requestApproval", + { itemId: "c1", command: "ls" }, + ); + + expect(permissionOptions[0].map((o) => o.kind)).not.toContain( + "allow_always", + ); + expect(permissionOptions[0].map((o) => o.optionId)).toEqual([ + "allow", + "reject", + "reject_with_feedback", + ]); + expect(decision).toEqual({ decision: "accept" }); + }); + + it("reject-with-feedback declines and steers the user's guidance into the running turn", async () => { + const stub = makeStubRpc({ + initialize: {}, + "thread/start": { thread: { id: "thr_1" } }, + "turn/start": { turn: { id: "turn_1" } }, + }); + const offeredOptions: Array> = + []; + const client = { + sessionUpdate: async () => {}, + requestPermission: async (params: { + options: Array<{ optionId?: string; kind?: string }>; + }) => { + offeredOptions.push(params.options); + return { + outcome: { outcome: "selected", optionId: "reject_with_feedback" }, + _meta: { customInput: "use the SDK instead of shelling out" }, + }; + }, + extNotification: async () => {}, + } as unknown as AgentSideConnection; + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.initialize(init); + await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); + // Start a turn so there's a live turnId for the steer to target. + const done = agent.prompt({ + sessionId: "thr_1", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + stub.emit("turn/started", { turn: { id: "turn_1" } }); + + // codex asks to run a command mid-turn; user rejects with guidance. + const decision = await stub.invokeRequest( + "item/commandExecution/requestApproval", + { itemId: "c1", command: "rm -rf build" }, + ); + + expect(decision).toEqual({ decision: "decline" }); + // The "tell Codex what to do differently" option was offered (free-text). + const feedbackOpt = offeredOptions[0].find( + (o) => o.optionId === "reject_with_feedback", + ); + expect(feedbackOpt).toBeTruthy(); + // The guidance was steered into the running turn (codex's response carries + // no feedback field, so it's injected as a follow-up user message). + const steer = stub.requests.find((r) => r.method === "turn/steer"); + expect((steer?.params as { expectedTurnId?: string })?.expectedTurnId).toBe( + "turn_1", + ); + + stub.emit("turn/completed", { turn: { status: "completed" } }); + await done; + }); + it("routes a non-MCP file-change approval to an edit permission (kind + diff + locations)", async () => { const { agent, stub, permissionToolCalls } = makeApprovalAgent(); await agent.initialize(init); @@ -475,9 +581,14 @@ describe("CodexAppServerAgent", () => { const params = turnStart?.params as { sandboxPolicy?: unknown; approvalPolicy?: string; + collaborationMode?: unknown; }; - // Plan blocks edits via a read-only sandbox (collaborationMode is dropped by - // the binary, so this is the only honored lever). + // Plan engages codex's native plan collaboration (unlocks request_user_input + // + plan proposals) AND blocks edits via a read-only sandbox. + expect(params.collaborationMode).toEqual({ + mode: "plan", + settings: { model: "gpt-5.5" }, + }); expect(params.sandboxPolicy).toEqual({ type: "readOnly", networkAccess: true, @@ -506,9 +617,17 @@ describe("CodexAppServerAgent", () => { await done; const turnStart = stub.requests.find((r) => r.method === "turn/start"); - expect( - (turnStart?.params as { sandboxPolicy?: unknown }).sandboxPolicy, - ).toBeUndefined(); + const params = turnStart?.params as { + sandboxPolicy?: unknown; + collaborationMode?: unknown; + }; + expect(params.sandboxPolicy).toBeUndefined(); + // Default collaboration is pushed explicitly every turn so that switching + // back from Plan reverts (codex remembers the last collaboration mode). + expect(params.collaborationMode).toEqual({ + mode: "default", + settings: { model: "gpt-5.5" }, + }); }); @@ -560,6 +679,53 @@ describe("CodexAppServerAgent", () => { ).toBe(true); }); + it("drops Claude models from the picker and falls back to the codex effort map when model/list reports none", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t" } }, + "model/list": { + data: [ + { + id: "gpt-5.5", + model: "gpt-5.5", + displayName: "GPT-5.5", + hidden: false, + // The PostHog gateway populates no efforts (defaultReasoningEffort:"none"). + supportedReasoningEfforts: [], + }, + { + // The gateway also serves Claude models — they must not leak into a + // codex session's model picker. + id: "claude-opus-4-8", + model: "claude-opus-4-8", + hidden: false, + supportedReasoningEfforts: [], + }, + ], + }, + }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + const session = await agent.newSession({ + cwd: "/r", + } as unknown as NewSessionRequest); + const opts = (session.configOptions ?? []) as any[]; + + expect( + opts.find((o) => o.category === "model").options.map((x: any) => x.value), + ).toEqual(["gpt-5.5"]); + // No live efforts → shared codex map, which exposes xhigh for the gpt-5.5 + // family (instead of the bare low/medium/high fallback). + expect( + opts + .find((o) => o.category === "thought_level") + .options.map((x: any) => x.value), + ).toContain("xhigh"); + }); + it("setSessionConfigOption switches the model and re-emits config", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); const { client } = makeFakeClient(); diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index fde2e23880..755cd38f43 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -21,8 +21,13 @@ import type { } from "@agentclientprotocol/sdk"; import { mcpToolKey, posthogToolMeta } from "@posthog/shared"; import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions"; -import { DEFAULT_CODEX_MODEL } from "../../gateway-models"; +import { + DEFAULT_CODEX_MODEL, + type GatewayModel, + isOpenAIModel, +} from "../../gateway-models"; import type { ProcessSpawnedCallback } from "../../types"; +import { getReasoningEffortOptions as getCodexReasoningEfforts } from "../codex/models"; import { Logger } from "../../utils/logger"; import { nodeReadableToWebReadable, @@ -64,6 +69,7 @@ import { } from "./protocol"; import { buildConfigOptions, + collaborationModeFor, DEFAULT_MODE, modeApprovalPolicy, resolveInitialMode, @@ -585,6 +591,8 @@ export class CodexAppServerAgent extends BaseAcpAgent { else if (configId === "mode") { this.mode = value; this.emitCurrentMode(value); + // collaborationMode rides the next turn/start (see prompt()), so nothing + // to push here — the switch takes effect on the next turn. } } this.rebuildConfigOptions(); @@ -603,6 +611,23 @@ export class CodexAppServerAgent extends BaseAcpAgent { .catch(() => undefined); } + /** + * codex's collaboration mode for this turn, sent as the `turn/start` + * `collaborationMode` field — `plan` unlocks plan proposals + request_user_input; + * `default` is the normal coding mode. Sent on EVERY turn (not just Plan): + * codex's collaboration mode is sticky, so switching Plan→Default must push + * `default` explicitly to revert — omitting it leaves the thread in Plan. The + * shape is `{ mode, settings: { model } }` (NOT the verbatim collaborationMode/list + * output, whose model is null) — model must be a string, so we pass the current + * model; the turn's own `effort` field handles reasoning. + */ + private collaborationModeForTurn(): unknown { + return { + mode: collaborationModeFor(this.mode), + settings: { model: this.model }, + }; + } + /** Populate the model/effort selectors from the app-server's model/list. */ private async loadModelConfig(): Promise { try { @@ -613,6 +638,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { const models = res?.data ?? []; this.availableModels = models .filter((m) => !m?.hidden) + // model/list comes through the PostHog gateway, which also serves Claude + // models; a codex session must only offer codex (OpenAI) models, so drop + // the rest (mirrors the isOpenAIModel filter the creation picker uses). + .filter((m) => isOpenAIModel(m as unknown as GatewayModel)) .map((m) => ({ id: m.id ?? m.model, name: m.displayName ?? m.id ?? m.model, @@ -620,9 +649,16 @@ export class CodexAppServerAgent extends BaseAcpAgent { const current = models.find( (m) => m.id === this.model || m.model === this.model, ); - this.availableEfforts = (current?.supportedReasoningEfforts ?? []) + const liveEfforts = (current?.supportedReasoningEfforts ?? []) .map((e: any) => e?.reasoningEffort ?? e) .filter((e: unknown): e is string => typeof e === "string"); + // The gateway's model/list doesn't populate reasoning efforts, so fall + // back to the shared codex model→effort map (the same source the creation + // picker uses) — this surfaces "xhigh" for the gpt-5.5 family instead of + // capping at "high". If the gateway starts reporting efforts they win. + this.availableEfforts = liveEfforts.length + ? liveEfforts + : getCodexReasoningEfforts(this.model).map((o) => o.value); } catch (err) { this.logger.warn("model/list failed; using current model only", { error: String(err), @@ -780,6 +816,11 @@ export class CodexAppServerAgent extends BaseAcpAgent { // — there it stays at the spawned danger-full-access. Switching the // preset takes effect on the next turn. ...(approvalPolicy ? { approvalPolicy } : {}), + // codex's collaboration mode (per-turn field, verified against the + // binary). Sent every turn — plan unlocks plan proposals + + // request_user_input, default reverts; codex remembers the last mode, so + // it must be pushed explicitly to switch back. + collaborationMode: this.collaborationModeForTurn(), ...(this.environment !== "cloud" && sandboxPolicyFor(this.mode) ? { sandboxPolicy: sandboxPolicyFor(this.mode) } : {}), @@ -1138,7 +1179,21 @@ export class CodexAppServerAgent extends BaseAcpAgent { itemId?: string; command?: string; changes?: AppServerItem["changes"]; + available_decisions?: unknown; }; + // codex tells us which decisions are valid for THIS approval. The standard + // accept/decline/cancel always apply; when codex also offers an + // "approve and remember" decision — a command-prefix exec-policy allowlist + // ("don't ask again for commands beginning with X") or whole-session approval + // — surface it as an Allow-always option and echo that exact decision back. + const availableDecisions = Array.isArray(detail.available_decisions) + ? detail.available_decisions.filter( + (d): d is string => typeof d === "string", + ) + : []; + const rememberDecision = + availableDecisions.find((d) => d === "approved_execpolicy_amendment") ?? + availableDecisions.find((d) => d === "approved_for_session"); const title = detail.command ?? (isFileChange ? "Apply file changes" : "Run command"); const toolCallId = detail.itemId ?? "codex-approval"; @@ -1190,14 +1245,55 @@ export class CodexAppServerAgent extends BaseAcpAgent { toolCall, options: [ { optionId: "allow", name: "Allow", kind: "allow_once" }, + ...(rememberDecision + ? [ + { + optionId: "allow_always", + name: isFileChange + ? "Allow for the rest of this session" + : "Allow and don't ask again", + kind: "allow_always" as const, + }, + ] + : []), { optionId: "reject", name: "Reject", kind: "reject_once" }, + { + optionId: "reject_with_feedback", + name: "No, and tell Codex what to do differently", + kind: "reject_once", + _meta: { customInput: true }, + }, ], }); - if ( - response.outcome.outcome === "selected" && - response.outcome.optionId === "allow" - ) { - return { decision: "accept" }; + if (response.outcome.outcome === "selected") { + if (response.outcome.optionId === "allow_always" && rememberDecision) { + // Echo codex's own "approve and remember" decision so it applies the + // exec-policy/session amendment it proposed in the request. + return { decision: rememberDecision }; + } + if (response.outcome.optionId === "allow") { + return { decision: "accept" }; + } + if (response.outcome.optionId === "reject_with_feedback") { + // codex's approval response carries no feedback field, so decline the + // action and inject the user's guidance into the still-running turn — + // exactly what codex's TUI does (Denied + a follow-up user message). + const feedback = ( + response as { _meta?: { customInput?: unknown } } + )._meta?.customInput; + if (typeof feedback === "string" && feedback.trim() && this.turnId) { + void this.rpc + .request(APP_SERVER_METHODS.TURN_STEER, { + threadId: this.threadId, + input: toCodexInput([{ type: "text", text: feedback.trim() }]), + expectedTurnId: this.turnId, + }) + .catch((err) => + this.logger.warn("turn/steer (reject feedback) failed", err), + ); + } + return { decision: "decline" }; + } } if (response.outcome.outcome === "cancelled") { return { decision: "cancel" }; diff --git a/packages/agent/src/adapters/codex-app-server/session-config.test.ts b/packages/agent/src/adapters/codex-app-server/session-config.test.ts index dbd1896387..b52751df87 100644 --- a/packages/agent/src/adapters/codex-app-server/session-config.test.ts +++ b/packages/agent/src/adapters/codex-app-server/session-config.test.ts @@ -4,6 +4,7 @@ import { CODEX_MODES, DEFAULT_EFFORTS, modeApprovalPolicy, + collaborationModeFor, sandboxPolicyFor, } from "./session-config"; @@ -51,6 +52,16 @@ describe("sandboxPolicyFor", () => { }); }); +describe("collaborationModeFor", () => { + it("maps only Plan to codex's plan collaboration; everything else is default", () => { + expect(collaborationModeFor("plan")).toBe("plan"); + expect(collaborationModeFor("read-only")).toBe("default"); + expect(collaborationModeFor("auto")).toBe("default"); + expect(collaborationModeFor("full-access")).toBe("default"); + expect(collaborationModeFor(undefined)).toBe("default"); + }); +}); + describe("buildConfigOptions", () => { const byCategory = ( opts: ReturnType, diff --git a/packages/agent/src/adapters/codex-app-server/session-config.ts b/packages/agent/src/adapters/codex-app-server/session-config.ts index c1590824c1..80b4737ed6 100644 --- a/packages/agent/src/adapters/codex-app-server/session-config.ts +++ b/packages/agent/src/adapters/codex-app-server/session-config.ts @@ -12,12 +12,11 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; */ /** - * Per-turn sandbox the mode maps to (a subset of codex's SandboxPolicy). The - * picker's restriction lives here, NOT in `collaborationMode` — that field is - * silently dropped by the app-server (it only exists in server→client - * ThreadSettings), and `approvalPolicy` alone is neutralized because the process - * spawns under `danger-full-access`. `readOnly` is the only honored way to make - * plan/read-only actually block edits. + * Per-turn sandbox the mode maps to (a subset of codex's SandboxPolicy). This is + * what makes read-only/plan actually BLOCK edits — `approvalPolicy` alone is + * neutralized because the process spawns under `workspace-write`/`danger-full-access`. + * (Plan ALSO sets codex's `collaborationMode` on turn/start — a separate axis, + * see codex-app-server-agent.ts — which unlocks plan proposals + request_user_input.) */ export type CodexSandboxPolicy = | { type: "readOnly"; networkAccess: boolean } @@ -36,6 +35,13 @@ export interface CodexMode { * linux-sandbox and panic — see codex-app-server-agent.ts. */ sandboxPolicy?: CodexSandboxPolicy; + /** + * codex's native collaboration mode, sent per-turn on `turn/start`. "plan" + * makes codex propose a plan and unlocks `request_user_input` (AskUserQuestion); + * everything else runs in "default". This is what makes Plan a real mode rather + * than a relabeled read-only sandbox. + */ + collaborationMode?: "plan" | "default"; } // Flattened Claude-style presets. Restriction is driven by approvalPolicy + @@ -48,6 +54,7 @@ export const CODEX_MODES: CodexMode[] = [ description: "Plan first — inspect and propose; makes no changes", approvalPolicy: "on-request", sandboxPolicy: { type: "readOnly", networkAccess: true }, + collaborationMode: "plan", }, { id: "read-only", @@ -85,6 +92,15 @@ export function sandboxPolicyFor( return CODEX_MODES.find((m) => m.id === modeId)?.sandboxPolicy; } +/** + * codex collaboration mode for a preset — "plan" only for the Plan preset, else + * "default". Switching away from Plan must reset to "default", so this never + * returns undefined. + */ +export function collaborationModeFor(modeId: string | undefined): "plan" | "default" { + return CODEX_MODES.find((m) => m.id === modeId)?.collaborationMode ?? "default"; +} + /** * Resolve the host's initial `_meta.permissionMode` to a codex mode — mirroring * codex-acp's toCodexPermissionMode. A recognized codex mode is honored; any diff --git a/packages/agent/src/execution-mode.test.ts b/packages/agent/src/execution-mode.test.ts index be59649062..669715968e 100644 --- a/packages/agent/src/execution-mode.test.ts +++ b/packages/agent/src/execution-mode.test.ts @@ -12,8 +12,9 @@ describe("execution modes", () => { ]); }); - it("includes full access for codex sessions", () => { + it("exposes the same presets as a live codex session (incl. plan)", () => { expect(getAvailableCodexModes().map((mode) => mode.id)).toEqual([ + "plan", "read-only", "auto", "full-access", diff --git a/packages/agent/src/execution-mode.ts b/packages/agent/src/execution-mode.ts index 99f6799183..c90925e631 100644 --- a/packages/agent/src/execution-mode.ts +++ b/packages/agent/src/execution-mode.ts @@ -73,7 +73,16 @@ export function isCodexNativeMode(mode: string): mode is CodexNativeMode { return (CODEX_NATIVE_MODES as readonly string[]).includes(mode); } +// Mirrors the codex app-server adapter's CODEX_MODES (session-config.ts) so the +// task-creation picker offers the same presets as a live session. "plan" is a +// valid CodeExecutionMode that codex-acp maps to read-only, and the app-server +// gives it a read-only sandbox — so it is safe on both sub-adapters. const codexModes: ModeInfo[] = [ + { + id: "plan", + name: "Plan", + description: "Plan first — inspect and propose; makes no changes", + }, { id: "read-only", name: "Read Only", diff --git a/packages/core/src/sessions/cloudSessionConfig.test.ts b/packages/core/src/sessions/cloudSessionConfig.test.ts index d0712992ec..8bcf537ba7 100644 --- a/packages/core/src/sessions/cloudSessionConfig.test.ts +++ b/packages/core/src/sessions/cloudSessionConfig.test.ts @@ -61,7 +61,8 @@ describe("buildCloudDefaultConfigOptions", () => { it.each([ { initialMode: "auto", expected: "auto" }, { initialMode: "full-access", expected: "full-access" }, - { initialMode: "plan", expected: "auto" }, + // plan is now a valid codex preset (mirrors the app-server), so it's kept. + { initialMode: "plan", expected: "plan" }, { initialMode: "default", expected: "auto" }, ])( "validates codex initial mode $initialMode", diff --git a/packages/core/src/sessions/executionModes.ts b/packages/core/src/sessions/executionModes.ts index 8d471d44f1..dc36a4a6a8 100644 --- a/packages/core/src/sessions/executionModes.ts +++ b/packages/core/src/sessions/executionModes.ts @@ -32,7 +32,15 @@ const availableModes: ModeInfo[] = [ }, ]; +// Mirrors the codex app-server adapter's CODEX_MODES so the picker offers the +// same presets as a live session. "plan" is a CodeExecutionMode codex-acp maps +// to read-only and the app-server gives a read-only sandbox — safe on both. const codexModes: ModeInfo[] = [ + { + id: "plan", + name: "Plan", + description: "Plan first — inspect and propose; makes no changes", + }, { id: "read-only", name: "Read Only", From 14b1281a273c183923126f07664487faf8415503 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Tue, 30 Jun 2026 18:35:17 +0100 Subject: [PATCH 06/20] approvals --- CODEX_APP_SERVER_TESTING.md | 23 ++-- .../codex-app-server/approvals.test.ts | 69 +++++++++++ .../adapters/codex-app-server/approvals.ts | 40 ++++++- .../codex-app-server-agent.test.ts | 110 ++++++++++++++++++ .../codex-app-server-agent.ts | 53 ++++++--- 5 files changed, 269 insertions(+), 26 deletions(-) diff --git a/CODEX_APP_SERVER_TESTING.md b/CODEX_APP_SERVER_TESTING.md index 058c39a7cc..0f3a77e3ec 100644 --- a/CODEX_APP_SERVER_TESTING.md +++ b/CODEX_APP_SERVER_TESTING.md @@ -48,8 +48,8 @@ Triage tip: if something looks wrong, run the same action on **codex-acp** (flag - Expect: diff / file-change rendering looks correct in the UI. - [ ] **Command execution**: run a bash command. - Expect: command-approval prompt (per mode) + output renders. -- [ ] **Permission prompts**: exercise Allow once / Allow always / Reject. - - Expect: "Allow always" sticks for the rest of the session. +- [x] **Permission prompts** — ✅ verified manually: Allow once / Allow always / Reject / reject-with-feedback + all work; "Allow always" sticks for the rest of the session. - [ ] **MCP (PostHog tools)**: ask it to query PostHog via MCP. - Expect: read-only tools auto-approve (if configured), writes prompt; tools actually execute. - [ ] **Skills / commands** (`available_commands_update`): confirm skill/slash commands appear and one runs. @@ -66,11 +66,12 @@ Triage tip: if something looks wrong, run the same action on **codex-acp** (flag ## Known issues / follow-ups -- [ ] **MCP `exec` permission prompt shows raw codex text** — the approval prompt for the PostHog MCP `exec` tool renders `Allow the posthog MCP server to run tool "exec"?` instead of the unwrapped sub-command (e.g. `posthog - execute-sql {…}`). The transcript tool-call rendering is fixed; only the **permission prompt** is wrong. - - **Root cause:** codex has no MCP-specific approval — it reuses `item/commandExecution/requestApproval`, which carries no server/tool. The adapter now caches `mcpToolCall` items by id and enriches the approval `toolCall` with `_meta.posthog` when the approval's `itemId` matches (`codex-app-server-agent.ts` `captureMcpToolCall` + `handleApproval`). That enrichment is **not firing** in testing. - - **Likely cause (unconfirmed):** the approval references the item by a *different* id (the schema mentions a sub-callback `approvalId` for "zsh-exec-bridge subcommand approvals") → cache miss. Or it's arriving via a different request method. Or the rebuild didn't include the `packages/ui` renderer changes. - - **Next step:** from the ACP log, grab the `session/request_permission` message + the preceding `mcpToolCall` `tool_call`, and check (a) whether the permission `toolCall` carries `_meta.posthog`, and (b) whether its `toolCallId` matches the `mcpToolCall` id. That pins it to adapter-correlation vs renderer/build. - - **Touches:** `packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts` (`handleApproval`, `captureMcpToolCall`), `packages/ui/src/features/permissions/{PermissionSelector,McpPermission}.tsx`. +- [x] **MCP `exec` permission prompt shows raw codex text — FIXED.** The approval prompt for the PostHog MCP `exec` tool rendered `Allow the posthog MCP server to run tool "exec"?` instead of the real tool + command. Diagnosed from the session ACP logs (`~/.posthog-code/sessions/*/logs.ndjson`). + - **Real root cause (the earlier hypothesis was wrong):** the exec approval does **not** come through `item/commandExecution/requestApproval` (the `mcpToolCallsByItemId` path). It comes through **`mcpServer/elicitation/request`** — confirmed by the prompt's `toolCallId: "posthog:elicitation"` (built in `approvals.ts handleMcpElicitation`) and the Accept/Decline options. The logs show two back-to-back messages: a `tool_call` with the real data (`title:"posthog/exec"`, `rawInput:{command,context}`) and a **separate** `session/request_permission` carrying only codex's generic `params.message` — no `_meta.posthog`, no rawInput. The elicitation handler never correlated to the in-flight `mcpToolCall`. + - **Fix:** `handleMcpElicitation` now takes a `resolveMcpToolCall(serverName)` from `HandleServerRequestOptions`; the agent tracks `lastMcpToolCall` (set in `captureMcpToolCall`) and resolves it by matching `serverName`. When matched, the prompt carries `rawInput` + `_meta.posthog` (`mcp__posthog__exec`), mirroring the command-approval enrichment, so the host renders the proper MCP permission card. Falls back to codex's generic text when nothing correlates. + - **Tests:** `approvals.test.ts` (enriches vs falls-back) + `codex-app-server-agent.test.ts` (end-to-end: `item/started` mcpToolCall → `mcpServer/elicitation/request` → enriched prompt). 64/64 green. + - **Touched:** `approvals.ts` (`handleMcpElicitation`, `HandleServerRequestOptions`), `codex-app-server-agent.ts` (`captureMcpToolCall`, `handleApproval`, `lastMcpToolCall`). + - **To verify live:** ask codex (on app-server) to run a PostHog MCP query; the approval prompt should now show the real tool + command, not the generic "run tool exec?". - [x] **Mode picker on app-server (flattened, Claude-style) — IMPLEMENTED.** App-server now emits a `category:"mode"` config option (`session-config.ts buildConfigOptions`) with four presets — **Plan / Read only / Auto / Full access** — so the existing `ModeSelector` shows a switcher for app-server only (codex-acp/claude unchanged). Each preset maps to a `(collaborationMode, approvalPolicy)` tuple applied per-turn on `turn/start`. The adapter now negotiates `experimentalApi: true` (required for the experimental `collaborationMode` field). Verified against the real binary: `collaborationMode/list` → `[Plan, Default]`, `thread/start` accepts `collaborationMode:{mode,settings:{model}}`. - **To verify live:** switch the picker to **Plan** → codex should propose a plan, make no edits, and `request_user_input` (AskUserQuestion) should fire; switching to Auto/Read-only/Full-access should behave per approval policy. Exit Plan = switch the picker to a coding preset (sets `collaborationMode=default` next turn). @@ -158,6 +159,14 @@ unit test too, or CI won't protect it. - [x] **Usage indicator survives an unknown context window.** `extractAggregate` no longer drops the whole aggregate when `size` is absent; the indicator shows the token count without a misleading "/0 · 0%". `contextUsage.test.ts` + `ContextUsageIndicator.test.tsx`. +- [x] **Context indicator tracks the current turn, not the cumulative thread total.** `emitUsageExtNotification` + emitted `used` from `tokenUsage.total.totalTokens` (cumulative — grows every turn), so a real ~189k + context displayed as ~433k (43% of a 997k window) and crept toward 100% from accumulation alone. codex's + `ThreadTokenUsage` is `{ total, last, modelContextWindow }` (confirmed from the binary); `last` is this + turn's breakdown = the actual occupancy (matches codex's own context-left math). Now `used`/`contextUsed`/`usage` + read `tu.last` (fall back to `total` for turn-one/old builds); the cumulative `total` still feeds the per-turn + delta in `turn_complete`. Regression test seeded with the real session numbers (total 433289 vs last 189075 → + indicator asserts 189075). `codex-app-server-agent.test.ts`. - [x] **Unit false-greens killed + stub hardened.** The cancel test at `817-844` passed without ever sending `turn/interrupt`; it now emits `turn/started` and asserts the RPC fired, plus a new test locks the turnId-undefined skip path. `makeStubRpc` now enforces the real required-field diff --git a/packages/agent/src/adapters/codex-app-server/approvals.test.ts b/packages/agent/src/adapters/codex-app-server/approvals.test.ts index e5166438e3..a767efc756 100644 --- a/packages/agent/src/adapters/codex-app-server/approvals.test.ts +++ b/packages/agent/src/adapters/codex-app-server/approvals.test.ts @@ -278,6 +278,75 @@ describe("handleServerRequest", () => { }); }); + it("enriches an elicitation with the in-flight MCP tool call so the host renders the real tool", async () => { + const { client, calls } = fakeClient([ + { outcome: "selected", optionId: "accept" }, + ]); + + await handleServerRequest( + APP_SERVER_REQUESTS.MCP_ELICITATION, + { + threadId: "t", + turnId: "turn", + serverName: "posthog", + mode: "form", + message: 'Allow the posthog MCP server to run tool "exec"?', + }, + client, + { + ...opts, + resolveMcpToolCall: (serverName) => + serverName === "posthog" + ? { + server: "posthog", + tool: "exec", + args: { command: "search project|insight" }, + } + : undefined, + }, + ); + + // The prompt now carries the real MCP tool + args + _meta.posthog (not + // codex's bare "run tool exec" text), so the host renders the proper MCP + // permission card with the unwrapped command. + expect(calls[0].toolCall).toMatchObject({ + toolCallId: "posthog:elicitation", + rawInput: { command: "search project|insight" }, + _meta: { + posthog: { + toolName: "mcp__posthog__exec", + mcp: { server: "posthog", tool: "exec" }, + }, + }, + }); + }); + + it("falls back to codex's generic elicitation text when no MCP call correlates", async () => { + const { client, calls } = fakeClient([ + { outcome: "selected", optionId: "decline" }, + ]); + + await handleServerRequest( + APP_SERVER_REQUESTS.MCP_ELICITATION, + { + threadId: "t", + turnId: "t", + serverName: "posthog", + mode: "form", + message: "Confirm", + }, + client, + // resolveMcpToolCall absent (e.g. server mismatch) → no enrichment. + opts, + ); + + expect(calls[0].toolCall).not.toHaveProperty("_meta"); + expect(calls[0].toolCall).toMatchObject({ + toolCallId: "posthog:elicitation", + title: "Confirm", + }); + }); + it("returns handled:false for the simple command approval (caller owns it)", async () => { const { client, calls } = fakeClient([]); diff --git a/packages/agent/src/adapters/codex-app-server/approvals.ts b/packages/agent/src/adapters/codex-app-server/approvals.ts index 0bdb365084..1b028b3d80 100644 --- a/packages/agent/src/adapters/codex-app-server/approvals.ts +++ b/packages/agent/src/adapters/codex-app-server/approvals.ts @@ -27,6 +27,7 @@ import type { PermissionOption, RequestPermissionResponse, } from "@agentclientprotocol/sdk"; +import { mcpToolKey, posthogToolMeta } from "@posthog/shared"; import type { Logger } from "../../utils/logger"; // Shared with the Claude adapter so synthesized permission option ids round-trip // the same way across adapters. @@ -131,6 +132,18 @@ export interface HandleServerRequestResult { export interface HandleServerRequestOptions { sessionId: string; logger?: Logger; + /** + * Resolve the in-flight MCP tool call for an elicitation's `serverName`. codex + * gates an MCP tool behind a generic `mcpServer/elicitation/request` ("Allow + * the posthog MCP server to run tool X?") that carries no tool/args — but the + * originating `mcpToolCall` (real tool + arguments) is in flight at the same + * time. Supplying it lets the prompt render the actual operation (e.g. the + * PostHog `exec` command) with `_meta.posthog`, matching the command-approval + * path. Undefined → fall back to codex's generic text. + */ + resolveMcpToolCall?: ( + serverName: string, + ) => { server: string; tool: string; args: unknown } | undefined; } /** @@ -363,6 +376,27 @@ async function handleMcpElicitation( _meta: null, }; + // When the elicitation gates a known in-flight MCP tool call, carry its real + // tool + args + `_meta.posthog` so the host renders the proper MCP permission + // (incl. PostHog `exec` unwrapping) instead of codex's generic server text. + const mcp = opts.resolveMcpToolCall?.(params.serverName); + const toolCall = mcp + ? { + toolCallId: `${params.serverName}:elicitation`, + title: params.message || `${params.serverName} requests input`, + kind: "other" as const, + rawInput: mcp.args, + _meta: posthogToolMeta({ + toolName: mcpToolKey({ server: mcp.server, tool: mcp.tool }), + mcp: { server: mcp.server, tool: mcp.tool }, + }), + } + : { + toolCallId: `${params.serverName}:elicitation`, + title: params.message || `${params.serverName} requests input`, + kind: "other" as const, + }; + let response: RequestPermissionResponse; try { response = await client.requestPermission({ @@ -371,11 +405,7 @@ async function handleMcpElicitation( { kind: "allow_once", name: "Accept", optionId: "accept" }, { kind: "reject_once", name: "Decline", optionId: "decline" }, ], - toolCall: { - toolCallId: `${params.serverName}:elicitation`, - title: params.message || `${params.serverName} requests input`, - kind: "other", - }, + toolCall, }); } catch (err) { opts.logger?.warn("elicitation prompt failed; declining", { diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index 9026f52f2d..06bfd385cc 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -192,6 +192,63 @@ describe("CodexAppServerAgent", () => { }); }); + it("enriches the MCP elicitation approval (posthog exec) from the in-flight tool call", async () => { + // The real production path: codex gates the PostHog `exec` tool behind a + // generic mcpServer/elicitation/request (serverName only, no tool/args) — + // NOT the command approval. Without correlation the host showed a bare + // 'Allow the posthog MCP server to run tool "exec"?'. The adapter now + // resolves it to the in-flight mcpToolCall so the real tool + command render. + const stub = makeStubRpc({ + initialize: {}, + "thread/start": { thread: { id: "thr_1" } }, + }); + const permissionToolCalls: Array> = []; + const client = { + sessionUpdate: async () => {}, + requestPermission: async (params: { toolCall: Record }) => { + permissionToolCalls.push(params.toolCall); + return { outcome: { outcome: "selected", optionId: "accept" } }; + }, + extNotification: async () => {}, + } as unknown as AgentSideConnection; + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/bundle/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.initialize(init); + await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); + + stub.emit("item/started", { + item: { + type: "mcpToolCall", + id: "m1", + server: "posthog", + tool: "exec", + arguments: { command: "call execute-sql {}" }, + }, + }); + const decision = await stub.invokeRequest("mcpServer/elicitation/request", { + threadId: "thr_1", + turnId: "turn_1", + serverName: "posthog", + mode: "form", + message: 'Allow the posthog MCP server to run tool "exec"?', + }); + + expect(decision).toMatchObject({ action: "accept" }); + expect(permissionToolCalls[0]).toMatchObject({ + toolCallId: "posthog:elicitation", + rawInput: { command: "call execute-sql {}" }, + _meta: { + posthog: { + toolName: "mcp__posthog__exec", + mcp: { server: "posthog", tool: "exec" }, + }, + }, + }); + }); + function makeApprovalAgent(chooseOptionId = "allow") { const stub = makeStubRpc({ initialize: {}, @@ -1382,6 +1439,59 @@ describe("CodexAppServerAgent", () => { expect(breakdown).toBeDefined(); }); + it("context-usage indicator reports the latest turn, not the cumulative thread total", async () => { + // codex's ThreadTokenUsage is { total (cumulative, grows every turn), last + // (this turn's usage), modelContextWindow }. The window-occupancy indicator + // must track `last` — using `total` over-reports the window as filling up + // from accumulation alone (here a real 189k context shown as 433k = 43%). + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client, extNotifications } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + await agent.newSession({ + cwd: "/r", + _meta: { taskRunId: "run_ctx" }, + } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "hi" }], + } as unknown as PromptRequest); + + stub.emit("thread/tokenUsage/updated", { + tokenUsage: { + total: { + totalTokens: 433289, + inputTokens: 432636, + cachedInputTokens: 76928, + outputTokens: 595, + }, + last: { + totalTokens: 189075, + inputTokens: 111552, + cachedInputTokens: 76928, + outputTokens: 595, + }, + modelContextWindow: 997500, + }, + }); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await done; + + const usageUpdate = extNotifications.find( + (n) => + n.method === "_posthog/usage_update" && + typeof (n.params as { used?: unknown }).used === "number", + ); + // `used` is last.totalTokens (189075), NOT total.totalTokens (433289). + expect(usageUpdate?.params).toMatchObject({ + used: 189075, + size: 997500, + usage: { inputTokens: 111552, totalTokens: 189075 }, + }); + }); + it("reports per-turn (not cumulative) usage in turn_complete across turns", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); const { client, extNotifications } = makeFakeClient(); diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 755cd38f43..c552584d88 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -195,15 +195,23 @@ export class CodexAppServerAgent extends BaseAcpAgent { private environment?: "local" | "cloud"; /** * In-flight MCP tool calls keyed by item id. Codex has no MCP-specific - * approval request — it reuses the command-execution approval — so we - * correlate the approval's `itemId` back to the originating mcpToolCall here - * to render the approval prompt with the real server/tool/args (see - * handleApproval). Session-scoped and small (one entry per MCP call). + * approval; depending on the tool it surfaces either a command-execution + * approval (correlated by `itemId`) or — for the PostHog server's `exec` — + * a generic `mcpServer/elicitation/request` that carries no tool/args. Both + * paths recover the real server/tool/args from here (see handleApproval). + * Session-scoped and small (one entry per MCP call). */ private readonly mcpToolCallsByItemId = new Map< string, { server: string; tool: string; args: unknown } >(); + /** + * The most recently seen MCP tool call. An elicitation approval carries only + * `serverName` (no itemId), so it correlates to this rather than the map. MCP + * calls are sequential and an elicitation fires while its call is in flight, + * so the latest call for the matching server is the right one. + */ + private lastMcpToolCall?: { server: string; tool: string; args: unknown }; /** Active turn id from turn/started — the steering precondition + interrupt target. */ private turnId?: string; /** @@ -218,7 +226,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { * usage breakdown is derived from; codex doesn't attribute input tokens. */ private contextBreakdownBaseline: ContextBreakdownBaseline = emptyBaseline(); - /** Latest live input-token count from thread/tokenUsage; feeds the breakdown. */ + /** + * Latest live context occupancy — the most recent turn's input tokens + * (`tokenUsage.last`, not the cumulative `total`); feeds the breakdown. + */ private contextUsed?: number; /** * Latest CUMULATIVE thread token totals from thread/tokenUsage (overwritten @@ -990,11 +1001,13 @@ export class CodexAppServerAgent extends BaseAcpAgent { } )?.item; if (item?.type === "mcpToolCall" && item.id && item.server && item.tool) { - this.mcpToolCallsByItemId.set(item.id, { + const call = { server: item.server, tool: item.tool, args: item.arguments, - }); + }; + this.mcpToolCallsByItemId.set(item.id, call); + this.lastMcpToolCall = call; } } @@ -1023,19 +1036,27 @@ export class CodexAppServerAgent extends BaseAcpAgent { // 0 is authoritative here, not a dropped value. cachedWriteTokens: 0, }; + // Context-window occupancy is the MOST RECENT turn's usage, not the + // cumulative `total`. codex's ThreadTokenUsage is `{ total, last, + // modelContextWindow }`; `total` grows every turn, so using it over-reports + // the window as "filling up" purely from accumulation (e.g. a 189k context + // shown as 433k). `last` is this turn's breakdown — what actually occupies + // the window — matching codex's own context-left math. Fall back to `total` + // only if a build predates `last` (turn one, where last≈total anyway). + const context = tu.last ?? total; // Drives the per-source breakdown's "conversation" bucket on turn complete. - this.contextUsed = total.inputTokens ?? total.totalTokens; + this.contextUsed = context.inputTokens ?? context.totalTokens; void this.client .extNotification(POSTHOG_NOTIFICATIONS.USAGE_UPDATE, { sessionId: this.sessionId, - used: total.totalTokens, + used: context.totalTokens, size: tu.modelContextWindow ?? null, usage: { - inputTokens: total.inputTokens, - outputTokens: total.outputTokens, - cachedReadTokens: total.cachedInputTokens, - reasoningTokens: total.reasoningOutputTokens, - totalTokens: total.totalTokens, + inputTokens: context.inputTokens, + outputTokens: context.outputTokens, + cachedReadTokens: context.cachedInputTokens, + reasoningTokens: context.reasoningOutputTokens, + totalTokens: context.totalTokens, }, }) .catch((err) => this.logger.warn("usage extNotification failed", err)); @@ -1163,6 +1184,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { const richer = await handleServerRequest(method, params, this.client, { sessionId: this.sessionId, logger: this.logger, + resolveMcpToolCall: (serverName) => + this.lastMcpToolCall?.server === serverName + ? this.lastMcpToolCall + : undefined, }); if (richer.handled) { return richer.response; From 52b44840d22b7db544072e5d968e8a9dcf06d448 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Wed, 1 Jul 2026 12:05:27 +0100 Subject: [PATCH 07/20] add ci workflow, pull out app server agent --- .github/workflows/agent-live-e2e.yml | 61 --- .github/workflows/test.yml | 54 +++ CODEX_APP_SERVER_TESTING.md | 29 +- packages/agent/e2e/guard.e2e.test.ts | 26 ++ .../codex-app-server-agent.test.ts | 17 +- .../codex-app-server-agent.ts | 405 ++++-------------- .../adapters/codex-app-server/mcp-manager.ts | 59 +++ .../codex-app-server/session-config.ts | 148 ++++++- .../codex-app-server/turn-controller.ts | 111 +++++ .../codex-app-server/usage-tracker.ts | 96 +++++ 10 files changed, 620 insertions(+), 386 deletions(-) delete mode 100644 .github/workflows/agent-live-e2e.yml create mode 100644 packages/agent/e2e/guard.e2e.test.ts create mode 100644 packages/agent/src/adapters/codex-app-server/mcp-manager.ts create mode 100644 packages/agent/src/adapters/codex-app-server/turn-controller.ts create mode 100644 packages/agent/src/adapters/codex-app-server/usage-tracker.ts diff --git a/.github/workflows/agent-live-e2e.yml b/.github/workflows/agent-live-e2e.yml deleted file mode 100644 index 51f6985af1..0000000000 --- a/.github/workflows/agent-live-e2e.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Agent live e2e - -# Opt-in, gated live e2e for the @posthog/agent adapters (claude + codex). It -# drives real model turns against a live llm-gateway on cheap models, so it is -# deliberately NOT run per-PR: only on manual dispatch and a nightly schedule, -# and only when the repo variable AGENT_E2E_ENABLED is "true". Without the -# E2E_GATEWAY_TOKEN secret the suite self-skips every arm, and without a -# reachable E2E_GATEWAY_URL the run fails loudly rather than reporting a false -# green. The codex arm additionally self-skips if the native codex binary is not -# present on the runner. Enabling this needs: AGENT_E2E_ENABLED=true, an -# E2E_GATEWAY_TOKEN secret (a gateway OAuth token with llm_gateway:read scope), -# and an E2E_GATEWAY_URL variable pointing at a gateway reachable from the runner. - -on: - workflow_dispatch: - schedule: - - cron: "0 7 * * *" # 07:00 UTC, once a day - -concurrency: - group: agent-live-e2e - cancel-in-progress: false - -jobs: - live-e2e: - name: Agent live e2e (claude + codex) - if: ${{ vars.AGENT_E2E_ENABLED == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Setup pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - - - name: Set up Node 24 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 24 - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build agent dependencies - run: | - pnpm --filter @posthog/shared run build - pnpm --filter @posthog/git run build - pnpm --filter @posthog/enricher run build - - - name: Run live e2e (both adapters) - run: pnpm --filter agent run test:e2e - env: - E2E_GATEWAY_TOKEN: ${{ secrets.E2E_GATEWAY_TOKEN }} - E2E_GATEWAY_URL: ${{ vars.E2E_GATEWAY_URL }} - E2E_CLAUDE_MODEL: ${{ vars.E2E_CLAUDE_MODEL }} - E2E_CODEX_MODEL: ${{ vars.E2E_CODEX_MODEL }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d943aee3b..6437439081 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -147,3 +147,57 @@ jobs: name: playwright-report path: apps/code/playwright-report/ retention-days: 7 + + e2e: + # Live-model e2e for the @posthog/agent adapters (claude + codex). Runs only + # after the unit + integration jobs pass — a red tree never reaches the + # gateway. Opt-in and safe by default: without vars.AGENT_E2E_ENABLED it is + # skipped, and even when enabled it self-skips every arm unless the + # E2E_GATEWAY_TOKEN secret is present (fork PRs never see it) and + # E2E_GATEWAY_URL points at a runner-reachable gateway. Drives cheap models + # (claude-haiku-4-5 / gpt-5-mini), so an enabled run is a handful of short turns. + needs: [unit-test, integration-test] + # Enabled at the org level, and skipped on fork PRs — secrets (the gateway + # token) are withheld from forks, so the fail-loud token guard would red them + # spuriously. Same-repo PRs get the secret and enforce the guard. + if: ${{ vars.AGENT_E2E_ENABLED == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build agent dependencies + run: | + pnpm --filter @posthog/shared run build + pnpm --filter @posthog/git run build + pnpm --filter @posthog/enricher run build + + - name: Download native codex binary + # Non-fatal: if the download fails the codex arm self-skips (missing + # binary) and the claude arm still runs — never a false green. + run: node apps/code/scripts/download-binaries.mjs || echo "codex binary download failed; codex arm will self-skip" + + - name: Run live e2e (both adapters) + run: pnpm --filter agent run test:e2e + env: + E2E_GATEWAY_TOKEN: ${{ secrets.E2E_GATEWAY_TOKEN }} + E2E_GATEWAY_URL: ${{ vars.E2E_GATEWAY_URL }} + E2E_CLAUDE_MODEL: ${{ vars.E2E_CLAUDE_MODEL }} + E2E_CODEX_MODEL: ${{ vars.E2E_CODEX_MODEL }} diff --git a/CODEX_APP_SERVER_TESTING.md b/CODEX_APP_SERVER_TESTING.md index 0f3a77e3ec..7fd31bdb24 100644 --- a/CODEX_APP_SERVER_TESTING.md +++ b/CODEX_APP_SERVER_TESTING.md @@ -224,6 +224,29 @@ proposes and can't edit. (Creation now also offers Plan — `execution-mode.ts` defensible against a non-deterministic model; tighten only if a real regression motivates it. The CI guard remains the unit layer (e2e does not run in CI). - [ ] **Reasoning live trigger (#11)** — unchanged from above. -- [ ] **structured-output `MessagePhase` (#25)** / **mcp partial-fields** — deferred: no evidence - codex's `agentMessage` carries a phase discriminator, and the `server && tool` cache guard is - correct (caching partial entries would render "undefined"). Revisit only with a real repro. +- [ ] **structured-output `MessagePhase` (#25)** — deferred pending a live probe. The binary DOES + define a `MessagePhase` enum (`"final_answer"` / `"commentary"`) that "classifies an assistant + message", and `AgentMessageItem` has 4 elements — so the concept exists. But the ACP session logs + are post-translation (they carry `agent_message_chunk`, not the raw codex item), so I could not + confirm the **wire field name** (`phase`?) on the `item/completed` `agentMessage` we receive. + Rather than add speculative code, capture a real `agentMessage` item during the e2e round; if it + carries the phase, wire `captureAgentMessage` to keep the `final_answer` message (so a trailing + `commentary` can't clobber the structured-output source) with a last-wins fallback. +- [ ] **mcp partial-fields** — the `server && tool` cache guard is correct (caching partial entries + would render "undefined"). Revisit only with a real repro. + +## God-class refactor (agent file 1424 → 1201 lines, 47 → 31 fields) +`codex-app-server-agent.ts` was split into four focused collaborators, each behavior-preserving and +guarded by the existing 142 unit tests (all green + typecheck + lint clean at every step): +- **`TurnController`** (`turn-controller.ts`) — the turn state machine: `turnId`, pending completion, + the atomic `claim()`, steer folding, and the interrupted-turn drop set. +- **`UsageTracker`** (`usage-tracker.ts`) — token usage + context breakdown. Folds in **native + delegation win #1**: per-turn usage now comes from codex's `tokenUsage.last` directly, deleting the + cumulative-snapshot delta machinery (`threadUsageTotal`/`turnStartUsage`). Needs a live e2e sanity + check that per-turn numbers look right across turns. +- **`SessionConfigState`** (in `session-config.ts`) — model / effort / mode selectors + the derived + `configOptions` (the old input interface was renamed `ConfigSelectors`). +- **`McpManager`** (`mcp-manager.ts`) — session MCP tool-call state; correlates approval prompts to + the real tool. +The 140-line `handleApproval` was **deliberately left inline** — it's coupled orchestration (needs +rpc/client/turnId/mcp), so extracting it would just relocate the coupling. diff --git a/packages/agent/e2e/guard.e2e.test.ts b/packages/agent/e2e/guard.e2e.test.ts new file mode 100644 index 0000000000..35a3e57a19 --- /dev/null +++ b/packages/agent/e2e/guard.e2e.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { E2E } from "./config"; + +/** + * Fail-loud precondition for the live e2e suite. + * + * Without E2E_GATEWAY_TOKEN every adapter arm self-skips and `vitest run` exits + * 0 — a green run that exercised NOTHING. That false "adapters OK" is the whole + * reason the suite gave no real signal. This one non-skipped test turns a + * missing token into a RED run, so an enabled-but-misconfigured CI job (or a + * bare `pnpm test:e2e`) fails visibly instead of passing vacuously. + * + * The per-arm skips remain (so a missing token yields ONE clear failure here, + * not a wall of connection errors). Mint a token via `e2e/run-e2e.sh`, or set + * E2E_GATEWAY_TOKEN against a reachable gateway. + */ +describe("live e2e preconditions", () => { + it("requires E2E_GATEWAY_TOKEN (else the suite would skip-to-green)", () => { + expect( + E2E.hasToken, + "E2E_GATEWAY_TOKEN is not set — every adapter arm would skip and the run " + + "would pass without testing anything. Mint one via e2e/run-e2e.sh or " + + "set E2E_GATEWAY_TOKEN against a reachable E2E_GATEWAY_URL.", + ).toBe(true); + }); +}); diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index 06bfd385cc..8b5da22467 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -1492,7 +1492,7 @@ describe("CodexAppServerAgent", () => { }); }); - it("reports per-turn (not cumulative) usage in turn_complete across turns", async () => { + it("reports codex's per-turn `last` (not the cumulative total) in turn_complete", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); const { client, extNotifications } = makeFakeClient(); const agent = new CodexAppServerAgent(client, { @@ -1504,13 +1504,17 @@ describe("CodexAppServerAgent", () => { _meta: { taskRunId: "run_u" }, } as unknown as NewSessionRequest); - // codex reports CUMULATIVE thread totals on each update. + // codex carries both the cumulative `total` and this turn's `last`; we let + // `last` drive the per-turn number rather than diffing the cumulative. const t1 = agent.prompt({ sessionId: "t", prompt: [{ type: "text", text: "a" }], } as unknown as PromptRequest); stub.emit("thread/tokenUsage/updated", { - tokenUsage: { total: { inputTokens: 100, outputTokens: 50 } }, + tokenUsage: { + total: { inputTokens: 100, outputTokens: 50 }, + last: { inputTokens: 100, outputTokens: 50 }, + }, }); stub.emit("turn/completed", { turn: { status: "completed" } }); await t1; @@ -1520,7 +1524,10 @@ describe("CodexAppServerAgent", () => { prompt: [{ type: "text", text: "b" }], } as unknown as PromptRequest); stub.emit("thread/tokenUsage/updated", { - tokenUsage: { total: { inputTokens: 250, outputTokens: 120 } }, + tokenUsage: { + total: { inputTokens: 250, outputTokens: 120 }, + last: { inputTokens: 150, outputTokens: 70 }, + }, }); stub.emit("turn/completed", { turn: { status: "completed" } }); await t2; @@ -1535,7 +1542,7 @@ describe("CodexAppServerAgent", () => { inputTokens: 100, outputTokens: 50, }); - // Turn 2 is the DELTA (150/70), not the cumulative 250/120. + // Turn 2 is codex's `last` (150/70) — NOT the cumulative total (250/120). expect( (tcs[1].params as { usage: Record }).usage, ).toMatchObject({ diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index c552584d88..d743117b6f 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -14,20 +14,14 @@ import type { PromptResponse, ResumeSessionRequest, ResumeSessionResponse, - SessionConfigOption, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, } from "@agentclientprotocol/sdk"; import { mcpToolKey, posthogToolMeta } from "@posthog/shared"; import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions"; -import { - DEFAULT_CODEX_MODEL, - type GatewayModel, - isOpenAIModel, -} from "../../gateway-models"; +import { DEFAULT_CODEX_MODEL } from "../../gateway-models"; import type { ProcessSpawnedCallback } from "../../types"; -import { getReasoningEffortOptions as getCodexReasoningEfforts } from "../codex/models"; import { Logger } from "../../utils/logger"; import { nodeReadableToWebReadable, @@ -35,7 +29,6 @@ import { } from "../../utils/streams"; import { BaseAcpAgent, type BaseSettingsManager } from "../base-acp-agent"; import { - buildBreakdown, type ContextBreakdownBaseline, emptyBaseline, estimateTokens, @@ -62,24 +55,20 @@ import { mapHistoryItem, } from "./mapping"; import { toCodexMcpServers } from "./mcp-config"; +import { McpManager } from "./mcp-manager"; import { APP_SERVER_METHODS, APP_SERVER_NOTIFICATIONS, APP_SERVER_REQUESTS, } from "./protocol"; -import { - buildConfigOptions, - collaborationModeFor, - DEFAULT_MODE, - modeApprovalPolicy, - resolveInitialMode, - sandboxPolicyFor, -} from "./session-config"; +import { SessionConfigState } from "./session-config"; import { type CodexAppServerProcess, type CodexAppServerProcessOptions, spawnCodexAppServerProcess, } from "./spawn"; +import { TurnController } from "./turn-controller"; +import { UsageTracker } from "./usage-tracker"; /** * Subset of the host-supplied `_meta` the app-server adapter consumes. The host @@ -166,13 +155,8 @@ export class CodexAppServerAgent extends BaseAcpAgent { readonly adapterName = "codex"; private readonly rpc: AppServerRpc; private readonly proc?: CodexAppServerProcess; - // Mutable: switchable mid-session via setSessionConfigOption, applied per-turn. - private model: string; - private reasoningEffort?: string; - private mode = DEFAULT_MODE; - private availableModels: Array<{ id: string; name: string }> = []; - private availableEfforts: string[] = []; - private configOptions: SessionConfigOption[] = []; + /** Model / reasoning-effort / mode selectors + the derived configOptions. */ + private readonly config: SessionConfigState; private readonly onStructuredOutput?: ( output: Record, ) => Promise; @@ -193,69 +177,12 @@ export class CodexAppServerAgent extends BaseAcpAgent { * danger-full-access in place there. */ private environment?: "local" | "cloud"; - /** - * In-flight MCP tool calls keyed by item id. Codex has no MCP-specific - * approval; depending on the tool it surfaces either a command-execution - * approval (correlated by `itemId`) or — for the PostHog server's `exec` — - * a generic `mcpServer/elicitation/request` that carries no tool/args. Both - * paths recover the real server/tool/args from here (see handleApproval). - * Session-scoped and small (one entry per MCP call). - */ - private readonly mcpToolCallsByItemId = new Map< - string, - { server: string; tool: string; args: unknown } - >(); - /** - * The most recently seen MCP tool call. An elicitation approval carries only - * `serverName` (no itemId), so it correlates to this rather than the map. MCP - * calls are sequential and an elicitation fires while its call is in flight, - * so the latest call for the matching server is the right one. - */ - private lastMcpToolCall?: { server: string; tool: string; args: unknown }; - /** Active turn id from turn/started — the steering precondition + interrupt target. */ - private turnId?: string; - /** - * Turn ids we've interrupted. After a real interrupt codex emits a late - * turn/completed(interrupted) for the cancelled turn; if a follow-up turn is - * already running, that stale completion would otherwise finalize it as - * cancelled. We drop the completion for any id in here (once). - */ - private readonly cancelledTurnIds = new Set(); - /** - * Per-source context baseline (systemPrompt floor + skills/mcp estimates) the - * usage breakdown is derived from; codex doesn't attribute input tokens. - */ - private contextBreakdownBaseline: ContextBreakdownBaseline = emptyBaseline(); - /** - * Latest live context occupancy — the most recent turn's input tokens - * (`tokenUsage.last`, not the cumulative `total`); feeds the breakdown. - */ - private contextUsed?: number; - /** - * Latest CUMULATIVE thread token totals from thread/tokenUsage (overwritten - * latest-wins, since codex reports cumulative `total`). The per-turn usage for - * `_posthog/turn_complete` is this minus `turnStartUsage` — matching codex-acp, - * which reports per-turn (not cumulative) totals. - */ - private threadUsageTotal = { - inputTokens: 0, - outputTokens: 0, - cachedReadTokens: 0, - cachedWriteTokens: 0, - }; - /** Cumulative thread totals snapshotted at the start of the in-flight turn. */ - private turnStartUsage = { - inputTokens: 0, - outputTokens: 0, - cachedReadTokens: 0, - cachedWriteTokens: 0, - }; - private pendingTurn?: { - resolve: (reason: StopReason) => void; - reject: (err: Error) => void; - }; - /** The in-flight turn's completion promise, so steering can await the original. */ - private pendingCompletion?: Promise; + /** Session MCP tool-call state; correlates approval prompts to the real tool. */ + private readonly mcp = new McpManager(); + /** The turn state machine: turnId, pending completion, steer/interrupt races. */ + private readonly turns = new TurnController(); + /** Token usage + context-breakdown state, driven by codex's tokenUsage.last. */ + private readonly usage = new UsageTracker(); constructor( client: AgentSideConnection, @@ -265,8 +192,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.logger = options.logger ?? new Logger({ debug: true, prefix: "[CodexAppServerAgent]" }); - this.model = options.model ?? DEFAULT_CODEX_MODEL; - this.reasoningEffort = options.reasoningEffort; + this.config = new SessionConfigState( + options.model ?? DEFAULT_CODEX_MODEL, + options.reasoningEffort, + ); this.onStructuredOutput = options.onStructuredOutput; this.developerInstructions = options.processOptions.developerInstructions; @@ -370,7 +299,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { additionalDirectories: params.additionalDirectories ?? undefined, }, ); - return { sessionId: threadId, configOptions: this.configOptions }; + return { sessionId: threadId, configOptions: this.config.options }; } /** thread/resume — the host calls this on every reconnect to restore a session. */ @@ -384,7 +313,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { threadId: params.sessionId, additionalDirectories: params.additionalDirectories ?? undefined, }); - return { configOptions: this.configOptions }; + return { configOptions: this.config.options }; } /** @@ -407,7 +336,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { }, ); this.replayHistory(thread); - return { configOptions: this.configOptions }; + return { configOptions: this.config.options }; } /** thread/fork — branch a new thread from an existing one. */ @@ -424,7 +353,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { additionalDirectories: params.additionalDirectories ?? undefined, }, ); - return { sessionId: threadId, configOptions: this.configOptions }; + return { sessionId: threadId, configOptions: this.config.options }; } /** @@ -494,11 +423,11 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.environment = params.meta?.environment; // Honor the host's initial approval mode (mirrors codex-acp). A non-codex // value falls back to the default; setSessionConfigOption can change it later. - this.mode = resolveInitialMode(params.meta?.permissionMode); + this.config.setInitialMode(params.meta?.permissionMode); // Codex doesn't attribute input tokens by source, so seed the breakdown with // the always-resident floor + the host's system prompt (mirrors codex-acp's // buildCodexBaseline) and let the live contextUsed count fill conversation. - this.contextBreakdownBaseline = buildBaseline(params.meta); + this.usage.setBaseline(buildBaseline(params.meta)); // Codex's own guidance (set at spawn) plus the host's task system prompt. // The host sends systemPrompt as `string | { append }` and, for codex, ALSO // pre-flattens it into developerInstructions — so flatten the {append} form @@ -529,7 +458,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { const result = await this.rpc.request<{ thread?: AppServerThread }>( method, { - model: this.model, + model: this.config.model, cwd: params.cwd, ...(params.threadId ? { threadId: params.threadId } : {}), ...(developerInstructions ? { developerInstructions } : {}), @@ -544,7 +473,6 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.threadId = threadId; this.sessionId = threadId; await this.loadModelConfig(); - this.rebuildConfigOptions(); this.emitConfigOptions(); await this.emitAvailableCommands(); // Map the host's taskRunId to this session so it can resume later. Mirrors @@ -596,19 +524,12 @@ export class CodexAppServerAgent extends BaseAcpAgent { ): Promise { const { configId } = params as { configId?: string }; const value = (params as { value?: unknown }).value; - if (typeof value === "string") { - if (configId === "model") this.model = value; - else if (configId === "effort") this.reasoningEffort = value; - else if (configId === "mode") { - this.mode = value; - this.emitCurrentMode(value); - // collaborationMode rides the next turn/start (see prompt()), so nothing - // to push here — the switch takes effect on the next turn. - } - } - this.rebuildConfigOptions(); + const { modeChanged } = this.config.setOption(configId, value); + // collaborationMode rides the next turn/start (see prompt()), so a mode + // switch only needs current_mode_update here — it takes effect next turn. + if (modeChanged) this.emitCurrentMode(this.config.mode); this.emitConfigOptions(); - return { configOptions: this.configOptions }; + return { configOptions: this.config.options }; } /** codex-acp emits current_mode_update on mode change; mirror it for the host's mode cache. */ @@ -622,23 +543,6 @@ export class CodexAppServerAgent extends BaseAcpAgent { .catch(() => undefined); } - /** - * codex's collaboration mode for this turn, sent as the `turn/start` - * `collaborationMode` field — `plan` unlocks plan proposals + request_user_input; - * `default` is the normal coding mode. Sent on EVERY turn (not just Plan): - * codex's collaboration mode is sticky, so switching Plan→Default must push - * `default` explicitly to revert — omitting it leaves the thread in Plan. The - * shape is `{ mode, settings: { model } }` (NOT the verbatim collaborationMode/list - * output, whose model is null) — model must be a string, so we pass the current - * model; the turn's own `effort` field handles reasoning. - */ - private collaborationModeForTurn(): unknown { - return { - mode: collaborationModeFor(this.mode), - settings: { model: this.model }, - }; - } - /** Populate the model/effort selectors from the app-server's model/list. */ private async loadModelConfig(): Promise { try { @@ -646,49 +550,15 @@ export class CodexAppServerAgent extends BaseAcpAgent { APP_SERVER_METHODS.MODEL_LIST, {}, ); - const models = res?.data ?? []; - this.availableModels = models - .filter((m) => !m?.hidden) - // model/list comes through the PostHog gateway, which also serves Claude - // models; a codex session must only offer codex (OpenAI) models, so drop - // the rest (mirrors the isOpenAIModel filter the creation picker uses). - .filter((m) => isOpenAIModel(m as unknown as GatewayModel)) - .map((m) => ({ - id: m.id ?? m.model, - name: m.displayName ?? m.id ?? m.model, - })); - const current = models.find( - (m) => m.id === this.model || m.model === this.model, - ); - const liveEfforts = (current?.supportedReasoningEfforts ?? []) - .map((e: any) => e?.reasoningEffort ?? e) - .filter((e: unknown): e is string => typeof e === "string"); - // The gateway's model/list doesn't populate reasoning efforts, so fall - // back to the shared codex model→effort map (the same source the creation - // picker uses) — this surfaces "xhigh" for the gpt-5.5 family instead of - // capping at "high". If the gateway starts reporting efforts they win. - this.availableEfforts = liveEfforts.length - ? liveEfforts - : getCodexReasoningEfforts(this.model).map((o) => o.value); + this.config.loadModels(res?.data ?? []); } catch (err) { this.logger.warn("model/list failed; using current model only", { error: String(err), }); - this.availableModels = []; - this.availableEfforts = []; + this.config.clearModels(); } } - private rebuildConfigOptions(): void { - this.configOptions = buildConfigOptions({ - mode: this.mode, - model: this.model, - effort: this.reasoningEffort, - models: this.availableModels, - efforts: this.availableEfforts, - }); - } - private emitConfigOptions(): void { if (!this.sessionId) return; void this.client @@ -696,7 +566,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { sessionId: this.sessionId, update: { sessionUpdate: "config_option_update", - configOptions: this.configOptions, + configOptions: this.config.options, }, } as unknown as Parameters[0]) .catch((err) => this.logger.warn("config_option_update failed", err)); @@ -775,47 +645,44 @@ export class CodexAppServerAgent extends BaseAcpAgent { // for both fresh turns and steering so the folded message still renders. this.broadcastUserInput(params.prompt); - if (this.pendingTurn && this.turnId) { + if (this.turns.isRunning) { // A turn is already running: rather than fail (the codex-acp/Claude // behavior is to fold the message in), steer it via turn/steer with the - // active turnId as the precondition. The existing pendingTurn keeps - // resolving on the original turn/completed. - // turn/steer returns the (possibly rotated) active turnId; refresh - // this.turnId so a later turn/steer's expectedTurnId precondition and a - // turn/interrupt still target the live turn (turn/started is not re-emitted - // for a steer). + // active turnId as the precondition. The existing turn keeps resolving on + // its original turn/completed. + // turn/steer returns the (possibly rotated) active turnId; refresh it so a + // later turn/steer's expectedTurnId precondition and a turn/interrupt still + // target the live turn (turn/started is not re-emitted for a steer). const steerRes = await this.rpc .request<{ turnId?: string }>(APP_SERVER_METHODS.TURN_STEER, { threadId: this.threadId, input, - expectedTurnId: this.turnId, + expectedTurnId: this.turns.activeTurnId, }) .catch((err) => { this.logger.warn("turn/steer failed", err); return undefined; }); - if (typeof steerRes?.turnId === "string") this.turnId = steerRes.turnId; - return { stopReason: await this.pendingTurnCompletion() }; + this.turns.onSteered(steerRes?.turnId); + return { stopReason: await this.turns.awaitCompletion() }; } - if (this.pendingTurn) { + if (this.turns.isPending) { // A turn is pending but we never saw turn/started (no turnId yet), so we - // can't steer. Fail fast rather than clobber the single pendingTurn slot. + // can't steer. Fail fast rather than clobber the single pending slot. throw new Error("prompt() called while a turn is already in progress"); } this.lastAgentMessage = ""; this.resetUsage(); - const completion = new Promise((resolve, reject) => { - this.pendingTurn = { resolve, reject }; - }); - this.pendingCompletion = completion; + const completion = this.turns.begin(); try { - const approvalPolicy = modeApprovalPolicy(this.mode); + const approvalPolicy = this.config.approvalPolicy(); + const sandboxPolicy = this.config.sandboxPolicy(); await this.rpc.request(APP_SERVER_METHODS.TURN_START, { threadId: this.threadId, input, - model: this.model, - ...(this.reasoningEffort ? { effort: this.reasoningEffort } : {}), + model: this.config.model, + ...(this.config.effort ? { effort: this.config.effort } : {}), // Always request a reasoning summary so the host surfaces thinking; the // default "auto" can skip summaries on trivial turns (and raw reasoning // is off, so without this the host sees no thought stream at all). @@ -831,9 +698,9 @@ export class CodexAppServerAgent extends BaseAcpAgent { // binary). Sent every turn — plan unlocks plan proposals + // request_user_input, default reverts; codex remembers the last mode, so // it must be pushed explicitly to switch back. - collaborationMode: this.collaborationModeForTurn(), - ...(this.environment !== "cloud" && sandboxPolicyFor(this.mode) - ? { sandboxPolicy: sandboxPolicyFor(this.mode) } + collaborationMode: this.config.collaborationModeForTurn(), + ...(this.environment !== "cloud" && sandboxPolicy + ? { sandboxPolicy } : {}), // Constrain the final assistant message to the task's schema so it is // valid JSON we can parse for structured output (replaces the codex-acp @@ -842,8 +709,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { }); return { stopReason: await completion }; } finally { - this.pendingTurn = undefined; - this.pendingCompletion = undefined; + this.turns.finishPrompt(); } } @@ -868,16 +734,8 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } - private pendingTurnCompletion(): Promise { - return this.pendingCompletion ?? Promise.resolve("end_turn"); - } - private resetUsage(): void { - // Snapshot the cumulative thread total at turn start; finalizeTurn reports - // the delta as the per-turn usage. (Cumulative only grows, so the delta is - // always >= 0, including 0 for a turn that consumes no tokens.) - this.turnStartUsage = { ...this.threadUsageTotal }; - this.contextUsed = undefined; + this.usage.resetForTurn(); } protected async interrupt(): Promise { @@ -887,18 +745,17 @@ export class CodexAppServerAgent extends BaseAcpAgent { // finalizeTurn claims the turn idempotently, so the server's later // turn/completed(interrupted) is a no-op. // TurnInterruptParams requires BOTH threadId and turnId (the native binary - // rejects a turnId-less request with -32600, leaving the turn running). The - // live turnId is captured from turn/started and is still set here — - // finalizeTurn clears it afterwards. When no turn/started was seen yet there - // is no server-side turn to abort, so skip the RPC and just finalize. - if (this.threadId && this.turnId) { - // Remember it so its late turn/completed(interrupted) is dropped rather - // than finalizing a follow-up turn. - this.cancelledTurnIds.add(this.turnId); + // rejects a turnId-less request with -32600, leaving the turn running). + // markInterrupted returns the live turnId (and remembers it so its late + // turn/completed(interrupted) is dropped rather than finalizing a follow-up + // turn). When no turn/started was seen yet there is no server-side turn to + // abort, so skip the RPC and just finalize. + const turnId = this.turns.markInterrupted(); + if (this.threadId && turnId) { await this.rpc .request(APP_SERVER_METHODS.TURN_INTERRUPT, { threadId: this.threadId, - turnId: this.turnId, + turnId, }) .catch((err) => this.logger.warn("turn/interrupt failed", err)); } @@ -907,13 +764,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { async closeSession(): Promise { this.session.abortController.abort(); - this.turnId = undefined; - this.pendingTurn?.resolve("cancelled"); - this.pendingTurn = undefined; - // Drain any interrupted-turn ids still awaiting their late completion so the - // set can't accumulate across a long-lived process (each entry is normally - // removed when that completion arrives, but a dropped one would linger). - this.cancelledTurnIds.clear(); + // Resolve any in-flight turn and drain interrupted-turn ids so the set can't + // accumulate across a long-lived process (each is normally removed when its + // late completion arrives, but a dropped one would linger). + this.turns.close("cancelled"); this.session.settingsManager.dispose(); // Close the transport BEFORE killing the process: kill() destroys the // stdio streams, so awaiting writer.close()/reader.cancel() afterwards @@ -941,19 +795,18 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } - if (method === APP_SERVER_NOTIFICATIONS.TURN_STARTED && this.pendingTurn) { + if (method === APP_SERVER_NOTIFICATIONS.TURN_STARTED) { // Capture the active turn id; it's the precondition turn/steer requires - // and the target turn/interrupt aborts. Gated on an active turn so a - // stale/duplicate turn/started can't install a turnId with no turn. - const id = (params as { turn?: { id?: string } })?.turn?.id; - if (typeof id === "string") this.turnId = id; + // and the target turn/interrupt aborts. onStarted ignores it unless a turn + // is pending, so a stale/duplicate turn/started can't install a stray id. + this.turns.onStarted((params as { turn?: { id?: string } })?.turn?.id); } if ( method === APP_SERVER_NOTIFICATIONS.ITEM_STARTED || method === APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED ) { - this.captureMcpToolCall(params); + this.mcp.capture(params); } if (method === APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED) { @@ -969,7 +822,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { ?.turn; // Drop the late completion of a turn we already interrupted so it can't // finalize the current (follow-up) turn as cancelled. - if (turn?.id && this.cancelledTurnIds.delete(turn.id)) return; + if (this.turns.shouldDropCompletion(turn?.id)) return; void this.finalizeTurn(mapTurnStopReason(turn?.status)); } @@ -986,31 +839,6 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } - /** Remember an MCP tool call's server/tool/args so a later approval prompt for - * the same item can render the real tool instead of codex's generic text. */ - private captureMcpToolCall(params: unknown): void { - const item = ( - params as { - item?: { - type?: string; - id?: string; - server?: string; - tool?: string; - arguments?: unknown; - }; - } - )?.item; - if (item?.type === "mcpToolCall" && item.id && item.server && item.tool) { - const call = { - server: item.server, - tool: item.tool, - args: item.arguments, - }; - this.mcpToolCallsByItemId.set(item.id, call); - this.lastMcpToolCall = call; - } - } - /** Track the latest assistant message so the final one feeds structured output. */ private captureAgentMessage(params: unknown): void { const item = (params as { item?: { type?: string; text?: string } })?.item; @@ -1022,42 +850,12 @@ export class CodexAppServerAgent extends BaseAcpAgent { /** Mirror codex-acp's `_posthog/usage_update` so the host's token/cost UI fills. */ private emitUsageExtNotification(params: unknown): void { if (!this.sessionId) return; - const tu = (params as { tokenUsage?: any })?.tokenUsage; - const total = tu?.total; - if (!total) return; - // `total` is the cumulative thread total, so overwrite (latest wins). The - // per-turn delta is computed against turnStartUsage in finalizeTurn. - this.threadUsageTotal = { - inputTokens: total.inputTokens ?? 0, - outputTokens: total.outputTokens ?? 0, - cachedReadTokens: total.cachedInputTokens ?? 0, - // codex's app-server TokenUsage.total has no cache-write/creation field - // (unlike the codex-acp upstream stream), so there is nothing to report — - // 0 is authoritative here, not a dropped value. - cachedWriteTokens: 0, - }; - // Context-window occupancy is the MOST RECENT turn's usage, not the - // cumulative `total`. codex's ThreadTokenUsage is `{ total, last, - // modelContextWindow }`; `total` grows every turn, so using it over-reports - // the window as "filling up" purely from accumulation (e.g. a 189k context - // shown as 433k). `last` is this turn's breakdown — what actually occupies - // the window — matching codex's own context-left math. Fall back to `total` - // only if a build predates `last` (turn one, where last≈total anyway). - const context = tu.last ?? total; - // Drives the per-source breakdown's "conversation" bucket on turn complete. - this.contextUsed = context.inputTokens ?? context.totalTokens; + const update = this.usage.ingest(params); + if (!update) return; void this.client .extNotification(POSTHOG_NOTIFICATIONS.USAGE_UPDATE, { sessionId: this.sessionId, - used: context.totalTokens, - size: tu.modelContextWindow ?? null, - usage: { - inputTokens: context.inputTokens, - outputTokens: context.outputTokens, - cachedReadTokens: context.cachedInputTokens, - reasoningTokens: context.reasoningOutputTokens, - totalTokens: context.totalTokens, - }, + ...update, }) .catch((err) => this.logger.warn("usage extNotification failed", err)); } @@ -1071,32 +869,15 @@ export class CodexAppServerAgent extends BaseAcpAgent { // Idempotent: claim the pending turn synchronously (before any await) so a // second finalize for the same turn — e.g. an `error` notification racing // turn/completed — is a no-op and the structured-output callback + cloud - // notifications don't double-fire. - const pending = this.pendingTurn; + // notifications don't double-fire. claim() clears both the pending slot and + // the turnId in one step, so a steer/fresh prompt racing into the await + // window below sees no live turn. + const pending = this.turns.claim(); if (!pending) return; - // Claim the whole turn synchronously (before any await): clear pendingTurn - // AND turnId, and snapshot the turn-scoped telemetry. Otherwise a steer - // prompt() or a fresh turn racing into the await window below would see a - // live turnId with no pendingTurn (misrouting a steer into a new turn) or - // zero this turn's usage via the next turn's resetUsage(). - this.pendingTurn = undefined; - this.turnId = undefined; const message = this.lastAgentMessage; - // Per-turn usage = cumulative thread total now − the snapshot at turn start - // (matches codex-acp's per-turn turn_complete, not a thread-cumulative total). - const usage = { - inputTokens: - this.threadUsageTotal.inputTokens - this.turnStartUsage.inputTokens, - outputTokens: - this.threadUsageTotal.outputTokens - this.turnStartUsage.outputTokens, - cachedReadTokens: - this.threadUsageTotal.cachedReadTokens - - this.turnStartUsage.cachedReadTokens, - cachedWriteTokens: - this.threadUsageTotal.cachedWriteTokens - - this.turnStartUsage.cachedWriteTokens, - }; - const contextUsed = this.contextUsed; + // Per-turn usage is codex's own `tokenUsage.last` (not a reconstructed delta). + const usage = this.usage.perTurnUsage(); + const contextUsed = this.usage.contextTokens(); if (this.jsonSchema && this.onStructuredOutput && message) { const parsed = parseStructuredOutput(message); @@ -1151,7 +932,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { POSTHOG_NOTIFICATIONS.USAGE_UPDATE, buildUsageBreakdownParams( this.sessionId, - this.contextBreakdownBaseline, + this.usage.baselineBreakdown, contextUsed, ) as unknown as Record, ) @@ -1162,10 +943,9 @@ export class CodexAppServerAgent extends BaseAcpAgent { } private handleServerClosed(): void { - this.pendingTurn?.reject( + this.turns.fail( new Error("codex app-server exited before the turn completed"), ); - this.pendingTurn = undefined; } /** @@ -1184,10 +964,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { const richer = await handleServerRequest(method, params, this.client, { sessionId: this.sessionId, logger: this.logger, - resolveMcpToolCall: (serverName) => - this.lastMcpToolCall?.server === serverName - ? this.lastMcpToolCall - : undefined, + resolveMcpToolCall: (serverName) => this.mcp.byServer(serverName), }); if (richer.handled) { return richer.response; @@ -1226,9 +1003,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { // command-execution approval. When the item is a known MCP call, surface the // real server/tool/args so the host renders the proper MCP permission // (incl. the PostHog `exec` unwrapping) instead of codex's generic text. - const mcp = detail.itemId - ? this.mcpToolCallsByItemId.get(detail.itemId) - : undefined; + const mcp = this.mcp.byItemId(detail.itemId); // Set kind + content so the host routes plain command/file approvals to // ExecutePermission / EditPermission (command styling, diff body) rather // than the bare DefaultPermission fallback. @@ -1303,15 +1078,15 @@ export class CodexAppServerAgent extends BaseAcpAgent { // codex's approval response carries no feedback field, so decline the // action and inject the user's guidance into the still-running turn — // exactly what codex's TUI does (Denied + a follow-up user message). - const feedback = ( - response as { _meta?: { customInput?: unknown } } - )._meta?.customInput; - if (typeof feedback === "string" && feedback.trim() && this.turnId) { + const feedback = (response as { _meta?: { customInput?: unknown } }) + ._meta?.customInput; + const activeTurnId = this.turns.activeTurnId; + if (typeof feedback === "string" && feedback.trim() && activeTurnId) { void this.rpc .request(APP_SERVER_METHODS.TURN_STEER, { threadId: this.threadId, input: toCodexInput([{ type: "text", text: feedback.trim() }]), - expectedTurnId: this.turnId, + expectedTurnId: activeTurnId, }) .catch((err) => this.logger.warn("turn/steer (reject feedback) failed", err), diff --git a/packages/agent/src/adapters/codex-app-server/mcp-manager.ts b/packages/agent/src/adapters/codex-app-server/mcp-manager.ts new file mode 100644 index 0000000000..60811c0ca1 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/mcp-manager.ts @@ -0,0 +1,59 @@ +/** An MCP tool call codex is running: its server, tool, and arguments. */ +export interface McpCall { + server: string; + tool: string; + args: unknown; +} + +/** + * Manages the session's MCP tool-call state. + * + * Its main job today is correlating codex approval prompts back to the tool that + * triggered them. Codex has no MCP-specific approval: a tool surfaces either a + * command-execution approval (which carries the item id) or — for the PostHog + * `exec` tool — a generic `mcpServer/elicitation/request` carrying only the + * server name. Neither carries the real tool/args, so we remember each in-flight + * call from its `mcpToolCall` item and recover it at approval time: by item id + * for a command approval, or by server name for an elicitation (which has no id, + * so it correlates to the latest in-flight call for that server — MCP calls are + * sequential and an elicitation fires while its call is live). Session-scoped and + * small (one entry per MCP call). + */ +export class McpManager { + private readonly byId = new Map(); + private latest?: McpCall; + + /** Record an `mcpToolCall` item from an item/started or item/completed notification. */ + capture(params: unknown): void { + const item = ( + params as { + item?: { + type?: string; + id?: string; + server?: string; + tool?: string; + arguments?: unknown; + }; + } + )?.item; + if (item?.type === "mcpToolCall" && item.id && item.server && item.tool) { + const call: McpCall = { + server: item.server, + tool: item.tool, + args: item.arguments, + }; + this.byId.set(item.id, call); + this.latest = call; + } + } + + /** The MCP call for a command-execution approval's item id, if known. */ + byItemId(itemId: string | undefined): McpCall | undefined { + return itemId ? this.byId.get(itemId) : undefined; + } + + /** The in-flight MCP call for an elicitation's server (elicitations carry no item id). */ + byServer(serverName: string): McpCall | undefined { + return this.latest?.server === serverName ? this.latest : undefined; + } +} diff --git a/packages/agent/src/adapters/codex-app-server/session-config.ts b/packages/agent/src/adapters/codex-app-server/session-config.ts index 80b4737ed6..2a8ce48d52 100644 --- a/packages/agent/src/adapters/codex-app-server/session-config.ts +++ b/packages/agent/src/adapters/codex-app-server/session-config.ts @@ -1,4 +1,6 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { type GatewayModel, isOpenAIModel } from "../../gateway-models"; +import { getReasoningEffortOptions } from "../codex/models"; /** * Session config + mode synthesis for the codex app-server adapter. @@ -131,7 +133,8 @@ function humanizeEffort(effort: string): string { return EFFORT_LABELS[effort] ?? effort; } -export interface SessionConfigState { +/** The current selector values `buildConfigOptions` projects into ACP options. */ +export interface ConfigSelectors { /** Current permission/collaboration preset id (one of CODEX_MODES). */ mode: string; model: string; @@ -144,7 +147,7 @@ export interface SessionConfigState { /** Builds the ACP configOptions (mode + model + thought_level) the host renders. */ export function buildConfigOptions( - s: SessionConfigState, + s: ConfigSelectors, ): SessionConfigOption[] { const baseModels = s.models.length ? s.models @@ -191,3 +194,144 @@ export function buildConfigOptions( } as unknown as SessionConfigOption, ]; } + +/** A model entry from the app-server's `model/list` (loosely typed). */ +interface RawModel { + id?: string; + model?: string; + displayName?: string; + hidden?: boolean; + supportedReasoningEfforts?: Array<{ reasoningEffort?: string } | string>; +} + +/** + * Stateful holder for a codex session's model / reasoning-effort / mode + * selectors and the ACP `configOptions` derived from them. + * + * The native app-server has no `configOptions` or `mode` concept — it's + * configured by `model` + per-turn `approvalPolicy`/`sandbox`/`collaborationMode` + * — so this synthesizes the Claude-style picker the host renders, and rebuilds + * the options on every change. The agent owns the transport; this owns the state + * and its projection through the pure builders above. + */ +export class SessionConfigState { + private _model: string; + private _effort?: string; + private _mode = DEFAULT_MODE; + private models: Array<{ id: string; name: string }> = []; + private efforts: string[] = []; + private _options: SessionConfigOption[] = []; + + constructor(model: string, effort?: string) { + this._model = model; + this._effort = effort; + this.rebuild(); + } + + get model(): string { + return this._model; + } + get effort(): string | undefined { + return this._effort; + } + get mode(): string { + return this._mode; + } + get options(): SessionConfigOption[] { + return this._options; + } + + /** Apply the host's initial approval mode (from `_meta.permissionMode`). */ + setInitialMode(permissionMode: string | undefined): void { + this._mode = resolveInitialMode(permissionMode); + this.rebuild(); + } + + /** + * Apply a `setSessionConfigOption` change. Returns whether the mode changed, + * so the caller can emit `current_mode_update`. + */ + setOption( + configId: string | undefined, + value: unknown, + ): { modeChanged: boolean } { + let modeChanged = false; + if (typeof value === "string") { + if (configId === "model") this._model = value; + else if (configId === "effort") this._effort = value; + else if (configId === "mode") { + this._mode = value; + modeChanged = true; + } + } + this.rebuild(); + return { modeChanged }; + } + + /** + * Populate the model + reasoning-effort selectors from a `model/list` `data` + * array. model/list comes through the PostHog gateway, which also serves + * Claude models, so drop non-OpenAI ones. The gateway doesn't populate + * reasoning efforts, so fall back to the shared codex model→effort map (which + * surfaces "xhigh" for the gpt-5.5 family); if the gateway starts reporting + * efforts they win. + */ + loadModels(rawModels: RawModel[]): void { + this.models = rawModels + .filter((m) => !m?.hidden) + .filter((m) => isOpenAIModel(m as unknown as GatewayModel)) + .map((m) => ({ + id: (m.id ?? m.model) as string, + name: (m.displayName ?? m.id ?? m.model) as string, + })); + const current = rawModels.find( + (m) => m.id === this._model || m.model === this._model, + ); + const liveEfforts = (current?.supportedReasoningEfforts ?? []) + .map((e) => (typeof e === "string" ? e : e?.reasoningEffort)) + .filter((e): e is string => typeof e === "string"); + this.efforts = liveEfforts.length + ? liveEfforts + : getReasoningEffortOptions(this._model).map((o) => o.value); + this.rebuild(); + } + + /** Reset the model/effort lists (model/list failed); keeps the current model. */ + clearModels(): void { + this.models = []; + this.efforts = []; + this.rebuild(); + } + + /** + * codex's per-turn `collaborationMode` field: `{ mode, settings: { model } }`. + * `plan` unlocks plan proposals + request_user_input; `default` reverts. The + * model must be a string (not the null in collaborationMode/list output). + */ + collaborationModeForTurn(): unknown { + return { + mode: collaborationModeFor(this._mode), + settings: { model: this._model }, + }; + } + + /** The AskForApproval policy for the current mode (turn/start `approvalPolicy`). */ + approvalPolicy(): string | undefined { + return modeApprovalPolicy(this._mode); + } + + /** The per-turn sandbox override for the current mode, if any. */ + sandboxPolicy(): CodexSandboxPolicy | undefined { + return sandboxPolicyFor(this._mode); + } + + private rebuild(): void { + this._options = buildConfigOptions({ + mode: this._mode, + model: this._model, + effort: this._effort, + models: this.models, + efforts: this.efforts, + }); + } +} diff --git a/packages/agent/src/adapters/codex-app-server/turn-controller.ts b/packages/agent/src/adapters/codex-app-server/turn-controller.ts new file mode 100644 index 0000000000..99ed0f2426 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/turn-controller.ts @@ -0,0 +1,111 @@ +import type { StopReason } from "@agentclientprotocol/sdk"; + +interface PendingTurn { + resolve: (reason: StopReason) => void; + reject: (err: Error) => void; +} + +/** + * The turn state machine for one codex thread. + * + * A codex turn is asynchronous: `prompt()` starts it and awaits a completion + * promise that `turn/completed` (or an interrupt/error) resolves. This owns the + * pieces that dance across those events — the in-flight `turnId` (the precondition + * `turn/steer` requires and the target `turn/interrupt` aborts), the pending + * completion, and the ids of interrupted turns whose late `turn/completed` must be + * dropped so it can't finalize a follow-up turn as cancelled. + */ +export class TurnController { + private turnId?: string; + private pending?: PendingTurn; + private completion?: Promise; + private readonly cancelled = new Set(); + + /** Start a fresh turn; returns the promise that resolves when it completes. */ + begin(): Promise { + this.completion = new Promise((resolve, reject) => { + this.pending = { resolve, reject }; + }); + return this.completion; + } + + /** The live turn id (steer precondition / interrupt target), if a turn started. */ + get activeTurnId(): string | undefined { + return this.turnId; + } + + /** A turn is awaiting completion. */ + get isPending(): boolean { + return this.pending !== undefined; + } + + /** A turn is running AND has a turnId — i.e. it can be steered. */ + get isRunning(): boolean { + return this.pending !== undefined && this.turnId !== undefined; + } + + /** Capture the turn id from turn/started (only while a turn is pending). */ + onStarted(id: string | undefined): void { + if (this.pending && typeof id === "string") this.turnId = id; + } + + /** Update the turn id after a turn/steer rotates it. */ + onSteered(id: string | undefined): void { + if (typeof id === "string") this.turnId = id; + } + + /** Await the in-flight turn's completion (the steer path reuses the original). */ + awaitCompletion(): Promise { + return this.completion ?? Promise.resolve("end_turn"); + } + + /** + * Atomically claim the pending turn for finalization: clears the pending slot + * and turnId (synchronously, before any await, so a racing steer/finalize sees + * no live turn) and returns its resolver — or undefined if already claimed. + */ + claim(): PendingTurn | undefined { + const pending = this.pending; + if (!pending) return undefined; + this.pending = undefined; + this.turnId = undefined; + return pending; + } + + /** + * Mark the live turn interrupted (so its late turn/completed is dropped) and + * return its id for the turn/interrupt RPC, or undefined if no turn started. + */ + markInterrupted(): string | undefined { + if (!this.turnId) return undefined; + this.cancelled.add(this.turnId); + return this.turnId; + } + + /** True (once) if this completion is for an interrupted turn we should drop. */ + shouldDropCompletion(id: string | undefined): boolean { + return id ? this.cancelled.delete(id) : false; + } + + /** Clear the pending slot after prompt() returns (covers a turn/start throw). */ + finishPrompt(): void { + this.pending = undefined; + this.completion = undefined; + } + + /** Reject the in-flight turn (e.g. the server exited before it completed). */ + fail(err: Error): void { + this.pending?.reject(err); + this.pending = undefined; + this.completion = undefined; + } + + /** Resolve and clear everything on session close. */ + close(reason: StopReason): void { + this.turnId = undefined; + this.pending?.resolve(reason); + this.pending = undefined; + this.completion = undefined; + this.cancelled.clear(); + } +} diff --git a/packages/agent/src/adapters/codex-app-server/usage-tracker.ts b/packages/agent/src/adapters/codex-app-server/usage-tracker.ts new file mode 100644 index 0000000000..d56e5da53a --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/usage-tracker.ts @@ -0,0 +1,96 @@ +import { + type ContextBreakdownBaseline, + emptyBaseline, +} from "../claude/context-breakdown"; +import type { AccumulatedUsage } from "./ext-notifications"; + +/** The live `_posthog/usage_update` fields (context-window occupancy). */ +export interface UsageUpdate { + used: number; + size: number | null; + usage: { + inputTokens?: number; + outputTokens?: number; + cachedReadTokens?: number; + reasoningTokens?: number; + totalTokens?: number; + }; +} + +/** + * Tracks token usage for one codex thread. + * + * codex's `thread/tokenUsage/updated` carries `{ total (cumulative), last (this + * turn's breakdown), modelContextWindow }`. We let codex own the per-turn number + * rather than reconstructing it: `last` is both the context-window occupancy (as + * input tokens) and the per-turn usage reported on `_posthog/turn_complete` — so + * there's no cumulative snapshot to diff. `total` is only a fallback for a build + * that predates `last` (turn one, where `last` ≈ `total`). + */ +export class UsageTracker { + private baseline: ContextBreakdownBaseline = emptyBaseline(); + private lastTurn?: AccumulatedUsage; + private contextUsed?: number; + + setBaseline(baseline: ContextBreakdownBaseline): void { + this.baseline = baseline; + } + + get baselineBreakdown(): ContextBreakdownBaseline { + return this.baseline; + } + + /** Zero the per-turn view at turn start so a token-less turn reports 0. */ + resetForTurn(): void { + this.lastTurn = undefined; + this.contextUsed = undefined; + } + + /** + * Ingest a `thread/tokenUsage/updated` payload; returns the live usage_update + * to emit, or null if the payload has no usable totals. + */ + ingest(params: unknown): UsageUpdate | null { + const tu = (params as { tokenUsage?: any })?.tokenUsage; + const total = tu?.total; + if (!total) return null; + const context = tu.last ?? total; + // Drives the per-source breakdown's "conversation" bucket on turn complete. + this.contextUsed = context.inputTokens ?? context.totalTokens; + this.lastTurn = { + inputTokens: context.inputTokens ?? 0, + outputTokens: context.outputTokens ?? 0, + cachedReadTokens: context.cachedInputTokens ?? 0, + // codex's TokenUsageBreakdown has no cache-write field; 0 is authoritative. + cachedWriteTokens: 0, + }; + return { + used: context.totalTokens, + size: tu.modelContextWindow ?? null, + usage: { + inputTokens: context.inputTokens, + outputTokens: context.outputTokens, + cachedReadTokens: context.cachedInputTokens, + reasoningTokens: context.reasoningOutputTokens, + totalTokens: context.totalTokens, + }, + }; + } + + /** Per-turn usage for `_posthog/turn_complete` — codex's `last`, not a delta. */ + perTurnUsage(): AccumulatedUsage { + return ( + this.lastTurn ?? { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + } + ); + } + + /** Live context occupancy (last turn's input tokens), or undefined pre-usage. */ + contextTokens(): number | undefined { + return this.contextUsed; + } +} From dd7c551a2044288f492f29c596d33fcb67ff474c Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Wed, 1 Jul 2026 13:20:52 +0100 Subject: [PATCH 08/20] compaction --- CODEX_APP_SERVER_TESTING.md | 6 + packages/agent/e2e/compaction.e2e.test.ts | 117 ++++++++++++++++++ packages/agent/e2e/config.ts | 20 ++- packages/agent/e2e/run-e2e.sh | 24 ++-- packages/agent/src/adapters/acp-connection.ts | 1 + .../codex-app-server-agent.test.ts | 92 ++++++++++++-- .../codex-app-server-agent.ts | 68 ++++++++++ .../src/adapters/codex-app-server/protocol.ts | 4 + .../src/adapters/codex-app-server/spawn.ts | 11 ++ packages/agent/src/adapters/codex/spawn.ts | 6 + 10 files changed, 322 insertions(+), 27 deletions(-) create mode 100644 packages/agent/e2e/compaction.e2e.test.ts diff --git a/CODEX_APP_SERVER_TESTING.md b/CODEX_APP_SERVER_TESTING.md index 7fd31bdb24..7fdd6b2a8d 100644 --- a/CODEX_APP_SERVER_TESTING.md +++ b/CODEX_APP_SERVER_TESTING.md @@ -61,9 +61,15 @@ Triage tip: if something looks wrong, run the same action on **codex-acp** (flag ## Tier 5 — Usage display & special modes - [ ] **Token usage + context breakdown**: confirm the usage counter updates and the breakdown popover populates (systemPrompt / tools / skills / mcp / conversation). Driven by `_posthog/usage_update`. +- [ ] **Usage indicator tracks the CURRENT turn (win #1, fresh)**: over a multi-turn session the context % should track the real context and NOT ratchet up every turn (it now reads codex's `tokenUsage.last`, not the cumulative `total`). It should visibly **drop after a compaction**. +- [ ] **Context compaction (fresh — both adapters)**: force a compaction and confirm the UI shows it (a "Context compacted." / "Compacting completed." marker) and the usage indicator drops. Covered by `compaction.e2e.test.ts` (claude via `/compact`, codex via a low `auto_compact_token_limit`) — the e2e thresholds may need one tune on the first real run. - [ ] **Channel mode (repo-less task)**: start a task with no repo. - Expect: behaves as a general assistant; only attaches/clones a repo when actually needed. +## Tier 6 — Post-refactor regression (god-class split + native win #1) + +- [ ] **Refactor smoke**: after extracting `TurnController` / `UsageTracker` / `SessionConfigState` / `McpManager` and switching per-turn usage to codex's `last`, do a normal end-to-end session (turn → tool → edit → interrupt → resume). Unit-guarded (959 tests) + the live e2e is the real regression proof — run the e2e suite once to confirm the split didn't regress a path the unit tests miss. + ## Known issues / follow-ups - [x] **MCP `exec` permission prompt shows raw codex text — FIXED.** The approval prompt for the PostHog MCP `exec` tool rendered `Allow the posthog MCP server to run tool "exec"?` instead of the real tool + command. Diagnosed from the session ACP logs (`~/.posthog-code/sessions/*/logs.ndjson`). diff --git a/packages/agent/e2e/compaction.e2e.test.ts b/packages/agent/e2e/compaction.e2e.test.ts new file mode 100644 index 0000000000..96b67e4d14 --- /dev/null +++ b/packages/agent/e2e/compaction.e2e.test.ts @@ -0,0 +1,117 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { type Adapter, E2E } from "./config"; +import { + cleanupRepo, + killCodexStragglers, + openSession, + setupRepo, +} from "./driver"; + +/** + * Live compaction e2e — codex only (for now). + * + * codex auto-compacts when the context crosses its `model_auto_compact_token_limit`. + * We spawn with a low limit and make turn 1 a big cheap INPUT blob (tiny output), + * so the SECOND turn trips compaction; the adapter must surface it to the host via + * `_posthog/compact_boundary` (which clears `isCompacting` + drains the queue). + * Opt-in — self-skips without `E2E_GATEWAY_TOKEN` / the native binary. + * + * Claude is excluded: its manual `/compact` hangs the adapter's `prompt()` — the + * SDK signals /compact completion with a `status`/`compact_result` message, not + * the `result` message `prompt()` resolves on, so the turn never returns (a + * separate claude-adapter issue), and filling its ~200k window to force AUTO + * compaction is too costly for an e2e. Re-enable by adding "claude" to ADAPTERS + * once /compact resolves cleanly. + * + * NOTE: the codex limit/turn count may need tuning on a new model — if it never + * compacts, codex may clamp the limit or the baseline exceeds it; raise the limit + * and FILLER together. The failure message prints the methods seen. + */ +const ADAPTERS: Adapter[] = ["codex"]; + +// codex: a limit above codex's resident baseline (so turn 1 leaves real content +// to compact) with FILLER > limit so the crossing is baseline-independent. +const AUTO_COMPACT_TOKEN_LIMIT = 16000; +// ~20k tokens (~45 chars ≈ 11 tokens × 1800) — larger than the limit above. +const FILLER = "The quick brown fox jumps over the lazy dog. ".repeat(1800); +const MAX_CODEX_TURNS = 3; + +for (const adapter of ADAPTERS) { + const skip = E2E.skipReason(adapter); + const title = `compaction (${adapter})${skip ? ` — SKIPPED (${skip})` : ""}`; + + describe.skipIf(!!skip)(title, () => { + let repo: string; + + beforeAll(() => { + if (adapter === "codex") killCodexStragglers(); + E2E.configureEnv(adapter); + repo = setupRepo(); + }); + + afterAll(() => { + cleanupRepo(repo); + }); + + it("surfaces a compaction to the host via compact_boundary", async () => { + const s = await openSession({ + adapter, + cwd: repo, + codexOptions: + adapter === "codex" + ? E2E.codexOptions(repo, { + // The model-scoped key is the effective one; set both to be safe. + model_auto_compact_token_limit: AUTO_COMPACT_TOKEN_LIMIT, + auto_compact_token_limit: AUTO_COMPACT_TOKEN_LIMIT, + }) + : undefined, + meta: { + systemPrompt: "You are a coding assistant in a tiny test repo.", + model: E2E.model(adapter), + permissionMode: "bypassPermissions", + taskRunId: "e2e-compaction", + }, + }); + try { + const compacted = () => + s.capture.extMethods().includes("_posthog/compact_boundary"); + + if (adapter === "claude") { + // A little conversation so there's content to compact, then the + // cheap deterministic trigger: manual /compact. + await s.conn.prompt({ + sessionId: s.sessionId, + prompt: [{ type: "text", text: "Reply with only: hello." }], + }); + await s.conn.prompt({ + sessionId: s.sessionId, + prompt: [{ type: "text", text: "/compact" }], + }); + } else { + // codex: turn 1 is a big cheap input blob that fills the context past + // the low limit (tiny output); turn 2+ trips the auto-compaction on + // the way in. Stop as soon as the boundary is surfaced. + for (let i = 0; i < MAX_CODEX_TURNS && !compacted(); i++) { + const text = + i === 0 + ? `Reference text — do not summarize, reply with only: OK.\n\n${FILLER}` + : "Reply with only: DONE."; + await s.conn.prompt({ + sessionId: s.sessionId, + prompt: [{ type: "text", text }], + }); + } + } + + expect( + compacted(), + `expected a _posthog/compact_boundary; saw methods: ${s.capture + .extMethods() + .join(", ")}`, + ).toBe(true); + } finally { + await s.cleanup(); + } + }, 300_000); + }); +} diff --git a/packages/agent/e2e/config.ts b/packages/agent/e2e/config.ts index 98098ca2f9..78ba6529be 100644 --- a/packages/agent/e2e/config.ts +++ b/packages/agent/e2e/config.ts @@ -6,14 +6,19 @@ export type Adapter = "claude" | "codex"; /** * Live e2e configuration, resolved entirely from the environment so no secret is * ever committed. A run needs a local llm-gateway (`./bin/start` in the posthog - * repo) and an OAuth token it accepts in `E2E_GATEWAY_TOKEN` — see `run-e2e.sh`, - * which mints one. Without the token every arm self-skips, so `pnpm test` and CI - * spend nothing. + * repo) and a token in `E2E_GATEWAY_TOKEN`. We target the gateway's `llm_gateway` + * product, which accepts a **personal API key** (`allow_api_keys=True`) and all + * models — so a plain key works, no OAuth mint (unlike prod's `posthog_code` + * product, which is OAuth-only). `run-e2e.sh` supplies the local dev key. Without + * the token every arm self-skips, so `pnpm test` and CI spend nothing. + * + * The adapter code is product-agnostic (it just posts to `apiBaseUrl`), so + * `llm_gateway` exercises the exact same paths `posthog_code` would. */ // `||` not `??`: CI sets unset `vars.*` to an empty string, which should fall // back to the default rather than override it with "". const GATEWAY_URL = - process.env.E2E_GATEWAY_URL || "http://localhost:3308/posthog_code"; + process.env.E2E_GATEWAY_URL || "http://localhost:3308/llm_gateway"; const TOKEN = process.env.E2E_GATEWAY_TOKEN ?? ""; // apps/code/resources/codex-acp/codex (the native app-server binary), relative @@ -85,12 +90,16 @@ export const E2E = { }, /** The codexOptions the codex arm passes through `createAcpConnection`. */ - codexOptions(cwd: string): { + codexOptions( + cwd: string, + configOverrides?: Record, + ): { cwd: string; binaryPath: string; apiBaseUrl: string; apiKey: string; model: string; + configOverrides?: Record; } { return { cwd, @@ -98,6 +107,7 @@ export const E2E = { apiBaseUrl: openAiBase(), apiKey: TOKEN, model: this.model("codex"), + ...(configOverrides ? { configOverrides } : {}), }; }, }; diff --git a/packages/agent/e2e/run-e2e.sh b/packages/agent/e2e/run-e2e.sh index 5be1616e72..37e18e4054 100755 --- a/packages/agent/e2e/run-e2e.sh +++ b/packages/agent/e2e/run-e2e.sh @@ -1,9 +1,12 @@ #!/usr/bin/env bash # Run the live golden-path e2e for both adapters (claude + codex). # -# Needs a local llm-gateway (run `./bin/start` in the posthog repo) and an OAuth -# token it accepts. If E2E_GATEWAY_TOKEN is unset, this mints a short-lived one -# from the posthog repo (override its path with POSTHOG_REPO). +# Needs a local llm-gateway (run `./bin/start` in the posthog repo) and a token. +# The suite targets the gateway's `llm_gateway` product, which accepts a personal +# API key (no OAuth), so if E2E_GATEWAY_TOKEN is unset this reads the repo's +# hardcoded local dev key from ee/settings.py (override the repo with POSTHOG_REPO). +# That key must be registered in the local DB — run `python manage.py +# setup_local_api_key` in the posthog repo once if auth fails. # # Usage: # bash e2e/run-e2e.sh # both adapters, both suites @@ -16,22 +19,23 @@ AGENT_DIR="$(cd "$HERE/.." && pwd)" POSTHOG_REPO="${POSTHOG_REPO:-$(cd "$AGENT_DIR/../../.." && pwd)/posthog}" if [[ -z "${E2E_GATEWAY_TOKEN:-}" ]]; then - if [[ ! -d "$POSTHOG_REPO" ]]; then - echo "E2E_GATEWAY_TOKEN unset and posthog repo not found at $POSTHOG_REPO." >&2 + SETTINGS="$POSTHOG_REPO/ee/settings.py" + if [[ ! -f "$SETTINGS" ]]; then + echo "E2E_GATEWAY_TOKEN unset and posthog settings not found at $SETTINGS." >&2 echo "Set E2E_GATEWAY_TOKEN, or POSTHOG_REPO to the posthog checkout." >&2 exit 1 fi - echo "Minting a local-gateway OAuth token from $POSTHOG_REPO ..." - MINT='from posthog.models import User; from products.tasks.backend.logic.services.code_usage_gate import create_oauth_access_token_for_user; print(create_oauth_access_token_for_user(User.objects.get(id=1), 1, scopes=["llm_gateway:read"], include_internal_scopes=False))' - E2E_GATEWAY_TOKEN="$(cd "$POSTHOG_REPO" && flox activate -- bash -c ".venv/bin/python manage.py shell -c '$MINT'" 2>/dev/null | grep -oE 'pha_[A-Za-z0-9]+' | head -1)" + # The `llm_gateway` product accepts personal API keys, so no OAuth mint needed. + E2E_GATEWAY_TOKEN="$(grep -E '^DEV_API_KEY[[:space:]]*=' "$SETTINGS" | head -1 | sed -E 's/^DEV_API_KEY[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')" fi if [[ -z "${E2E_GATEWAY_TOKEN:-}" ]]; then - echo "Failed to obtain an E2E_GATEWAY_TOKEN." >&2 + echo "Failed to obtain an E2E_GATEWAY_TOKEN (no DEV_API_KEY in ee/settings.py?)." >&2 + echo "If auth then fails, run 'python manage.py setup_local_api_key' in the posthog repo." >&2 exit 1 fi export E2E_GATEWAY_TOKEN -echo "token: ${E2E_GATEWAY_TOKEN:0:8}… gateway: ${E2E_GATEWAY_URL:-http://localhost:3308/posthog_code}" +echo "token: ${E2E_GATEWAY_TOKEN:0:8}… gateway: ${E2E_GATEWAY_URL:-http://localhost:3308/llm_gateway}" cd "$AGENT_DIR" pnpm test:e2e "$@" diff --git a/packages/agent/src/adapters/acp-connection.ts b/packages/agent/src/adapters/acp-connection.ts index 41c9358217..f67dbb1f10 100644 --- a/packages/agent/src/adapters/acp-connection.ts +++ b/packages/agent/src/adapters/acp-connection.ts @@ -255,6 +255,7 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { apiBaseUrl: codexOptions.apiBaseUrl, apiKey: codexOptions.apiKey, developerInstructions: codexOptions.developerInstructions, + configOverrides: codexOptions.configOverrides, }, model: codexOptions.model, reasoningEffort: codexOptions.reasoningEffort, diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index 8b5da22467..0e0523ddc2 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -42,7 +42,10 @@ function makeStubRpc(responses: Record) { // regression (a missing required field) sail through CI as a false-green. const missing = requiredFieldMissing(method, params); if (missing) { - throw { code: -32600, message: `Invalid request: missing field \`${missing}\`` }; + throw { + code: -32600, + message: `Invalid request: missing field \`${missing}\``, + }; } return (responses[method] ?? {}) as T; }, @@ -205,7 +208,9 @@ describe("CodexAppServerAgent", () => { const permissionToolCalls: Array> = []; const client = { sessionUpdate: async () => {}, - requestPermission: async (params: { toolCall: Record }) => { + requestPermission: async (params: { + toolCall: Record; + }) => { permissionToolCalls.push(params.toolCall); return { outcome: { outcome: "selected", optionId: "accept" } }; }, @@ -255,8 +260,9 @@ describe("CodexAppServerAgent", () => { "thread/start": { thread: { id: "thr_1" } }, }); const permissionToolCalls: Array> = []; - const permissionOptions: Array> = - []; + const permissionOptions: Array< + Array<{ optionId?: string; kind?: string }> + > = []; const client = { sessionUpdate: async () => {}, requestPermission: async (params: { @@ -302,7 +308,8 @@ describe("CodexAppServerAgent", () => { }); it("surfaces Allow-always and echoes codex's remember decision when offered", async () => { - const { agent, stub, permissionOptions } = makeApprovalAgent("allow_always"); + const { agent, stub, permissionOptions } = + makeApprovalAgent("allow_always"); await agent.initialize(init); await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); @@ -408,9 +415,7 @@ describe("CodexAppServerAgent", () => { await stub.invokeRequest("item/fileChange/requestApproval", { itemId: "f1", - changes: [ - { path: "src/a.ts", diff: "@@ -1 +1 @@\n-old\n+new\n" }, - ], + changes: [{ path: "src/a.ts", diff: "@@ -1 +1 @@\n-old\n+new\n" }], }); expect(permissionToolCalls).toHaveLength(1); @@ -687,7 +692,6 @@ describe("CodexAppServerAgent", () => { }); }); - it("returns mode + model + thought_level configOptions and emits config_option_update", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } }, @@ -1139,9 +1143,9 @@ describe("CodexAppServerAgent", () => { await agent.cancel({ sessionId: "t" }); expect((await done).stopReason).toBe("cancelled"); - expect( - stub.requests.some((r) => r.method === "turn/interrupt"), - ).toBe(false); + expect(stub.requests.some((r) => r.method === "turn/interrupt")).toBe( + false, + ); }); it("rejects a concurrent prompt while a turn is in progress", async () => { @@ -1551,6 +1555,70 @@ describe("CodexAppServerAgent", () => { }); }); + it("signals compaction start (_posthog/status) when a contextCompaction item begins", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client, extNotifications } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + await agent.newSession({ + cwd: "/r", + _meta: {}, + } as unknown as NewSessionRequest); + + stub.emit("item/started", { + item: { type: "contextCompaction", id: "c1" }, + }); + + // Mirrors the Claude adapter — the host sets isCompacting (gates steer/queue). + const status = extNotifications.find((n) => n.method === "_posthog/status"); + expect(status?.params).toMatchObject({ + sessionId: "t", + status: "compacting", + }); + }); + + it("emits compact_boundary + a transcript marker when the compaction item completes", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client, extNotifications, sessionUpdates } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + await agent.newSession({ + cwd: "/r", + _meta: {}, + } as unknown as NewSessionRequest); + + // The compaction item brackets the compaction: started → in progress, then + // completed → boundary (codex emits no separate thread/compacted). + stub.emit("item/started", { + item: { type: "contextCompaction", id: "c1" }, + }); + stub.emit("item/completed", { + item: { type: "contextCompaction", id: "c1", summary: "…" }, + }); + + // compact_boundary clears isCompacting + drains the host queue. + expect( + extNotifications.find((n) => n.method === "_posthog/compact_boundary") + ?.params, + ).toMatchObject({ sessionId: "t" }); + // ...and a user-visible marker lands in the transcript. + expect(sessionUpdates).toContainEqual({ + sessionId: "t", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "\n\nContext compacted." }, + }, + }); + // Exactly one boundary — the dedupe flag prevents a double-emit. + expect( + extNotifications.filter((n) => n.method === "_posthog/compact_boundary"), + ).toHaveLength(1); + }); + it("loadSession resumes the thread and returns configOptions", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t1" } }, diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index d743117b6f..0ea4593bec 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -167,6 +167,8 @@ export class CodexAppServerAgent extends BaseAcpAgent { private jsonSchema?: Record; /** Final assistant message text for the in-flight turn (structured output). */ private lastAgentMessage = ""; + /** True between a contextCompaction item's start and its boundary (dedupes the boundary). */ + private compactionActive = false; /** Maps the host's taskRunId to this session, replayed for cloud notifications. */ private taskRunId?: string; private cwd?: string; @@ -809,6 +811,33 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.mcp.capture(params); } + // codex auto-compaction surfaces as a contextCompaction item bracketing the + // work: item/started marks it in progress (gates steering/queue host-side), + // and item/completed is the boundary (empirically codex does NOT emit a + // separate thread/compacted — the item lifecycle is the signal). A + // thread/compacted, if one ever arrives, is a guarded fallback. The + // `compactionActive` flag dedupes so only one boundary fires per compaction. + const isCompactionItem = + (params as { item?: { type?: string } })?.item?.type === + "contextCompaction"; + if ( + method === APP_SERVER_NOTIFICATIONS.ITEM_STARTED && + isCompactionItem && + !this.compactionActive + ) { + this.compactionActive = true; + this.emitCompactionStarted(); + } + if ( + this.compactionActive && + ((method === APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED && + isCompactionItem) || + method === APP_SERVER_NOTIFICATIONS.CONTEXT_COMPACTED) + ) { + this.compactionActive = false; + this.emitCompactionBoundary(); + } + if (method === APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED) { this.captureAgentMessage(params); } @@ -847,6 +876,45 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } + /** + * Compaction started: mirror the Claude adapter's `_posthog/status` so the + * host sets `isCompacting` (which gates steering + queue dispatch). The host + * reads `isCompacting = !isComplete`, so omitting it means "in progress". + */ + private emitCompactionStarted(): void { + if (!this.sessionId) return; + void this.client + .extNotification(POSTHOG_NOTIFICATIONS.STATUS, { + sessionId: this.sessionId, + status: "compacting", + }) + .catch(() => undefined); + } + + /** + * Compaction finished: mirror the Claude adapter's `_posthog/compact_boundary` + * (the host clears `isCompacting` + drains the queued messages) plus a + * user-visible transcript marker. The context indicator updates on its own — + * the next `thread/tokenUsage/updated` carries the reduced `tokenUsage.last`. + */ + private emitCompactionBoundary(): void { + if (!this.sessionId) return; + void this.client + .extNotification(POSTHOG_NOTIFICATIONS.COMPACT_BOUNDARY, { + sessionId: this.sessionId, + }) + .catch(() => undefined); + void this.client + .sessionUpdate({ + sessionId: this.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "\n\nContext compacted." }, + }, + }) + .catch(() => undefined); + } + /** Mirror codex-acp's `_posthog/usage_update` so the host's token/cost UI fills. */ private emitUsageExtNotification(params: unknown): void { if (!this.sessionId) return; diff --git a/packages/agent/src/adapters/codex-app-server/protocol.ts b/packages/agent/src/adapters/codex-app-server/protocol.ts index 681b9ff8e8..dbc99cc4a4 100644 --- a/packages/agent/src/adapters/codex-app-server/protocol.ts +++ b/packages/agent/src/adapters/codex-app-server/protocol.ts @@ -46,6 +46,10 @@ export const APP_SERVER_NOTIFICATIONS = { // Fatal turn error; `willRetry:false` means it won't recover on its own. ERROR: "error", TOKEN_USAGE_UPDATED: "thread/tokenUsage/updated", + // codex auto-compacted the thread's context (it hit auto_compact_token_limit). + // Mirrors the Claude adapter's compact_boundary: we surface it to the host so + // the context indicator, isCompacting state, and queue drain all fire. + CONTEXT_COMPACTED: "thread/compacted", // Streamed stdout/stderr chunks for an in-progress commandExecution item. COMMAND_OUTPUT_DELTA: "item/commandExecution/outputDelta", // PTY-level stdin echoed back for an interactive terminal command. diff --git a/packages/agent/src/adapters/codex-app-server/spawn.ts b/packages/agent/src/adapters/codex-app-server/spawn.ts index 302cb85b9f..5281040c31 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.ts @@ -14,6 +14,8 @@ export interface CodexAppServerProcessOptions { apiKey?: string; /** Guidance appended to Codex's base prompt via `developer_instructions`. */ developerInstructions?: string; + /** Extra codex `-c key=value` config overrides (e.g. auto_compact_token_limit). */ + configOverrides?: Record; logger?: Logger; processCallbacks?: ProcessSpawnedCallback; } @@ -82,6 +84,15 @@ export function buildAppServerArgs( // host's task system prompt) rather than as a spawn-level global default, so // the task prompt — only known at newSession — reaches the model too. + // Caller-supplied config overrides (e.g. the e2e's low auto_compact_token_limit). + // Numbers/bools go bare; strings are quoted, matching codex's `-c` parser. + for (const [key, value] of Object.entries(options.configOverrides ?? {})) { + args.push( + "-c", + `${key}=${typeof value === "number" ? value : `"${value}"`}`, + ); + } + return args; } diff --git a/packages/agent/src/adapters/codex/spawn.ts b/packages/agent/src/adapters/codex/spawn.ts index e1c00f8b64..c023b31126 100644 --- a/packages/agent/src/adapters/codex/spawn.ts +++ b/packages/agent/src/adapters/codex/spawn.ts @@ -25,6 +25,12 @@ export interface CodexProcessOptions { settings?: CodexSettings; /** Additional writable roots passed to Codex's workspace-write sandbox. */ additionalDirectories?: string[]; + /** + * Extra codex `-c key=value` config overrides (app-server sub-adapter only). + * An escape hatch for config the adapter doesn't model — e.g. the e2e sets + * `auto_compact_token_limit` low to force a compaction. + */ + configOverrides?: Record; } export interface CodexProcess { From fb5e28260af740f8465cea966d1f0259d4dc10b3 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Wed, 1 Jul 2026 14:59:23 +0100 Subject: [PATCH 09/20] more improvements --- .github/workflows/test.yml | 12 +- CODEX_APP_SERVER_TESTING.md | 258 ------------------ packages/agent/e2e/README.md | 20 +- packages/agent/e2e/config.ts | 22 +- packages/agent/e2e/driver.ts | 9 +- packages/agent/e2e/guard.e2e.test.ts | 13 + .../agent/e2e/session-lifecycle.e2e.test.ts | 84 ++++-- .../agent/e2e/structured-output.e2e.test.ts | 9 +- .../codex-app-server-agent.test.ts | 68 ++++- .../codex-app-server-agent.ts | 51 +++- .../adapters/codex-app-server/mapping.test.ts | 33 ++- .../src/adapters/codex-app-server/mapping.ts | 7 +- .../codex-app-server/session-config.ts | 43 ++- packages/agent/src/server/agent-server.ts | 10 +- 14 files changed, 324 insertions(+), 315 deletions(-) delete mode 100644 CODEX_APP_SERVER_TESTING.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6437439081..3de066b56c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -190,9 +190,11 @@ jobs: pnpm --filter @posthog/enricher run build - name: Download native codex binary - # Non-fatal: if the download fails the codex arm self-skips (missing - # binary) and the claude arm still runs — never a false green. - run: node apps/code/scripts/download-binaries.mjs || echo "codex binary download failed; codex arm will self-skip" + # Non-fatal at the STEP so a failure surfaces as the fail-loud binary guard + # (guard.e2e.test.ts) with a clear message rather than an opaque download + # error. A missing binary then REDS the run (the guard fails when a token is + # set) instead of letting the codex arm silently skip to green. + run: node apps/code/scripts/download-binaries.mjs || echo "codex binary download failed; the binary guard test will red the run" - name: Run live e2e (both adapters) run: pnpm --filter agent run test:e2e @@ -201,3 +203,7 @@ jobs: E2E_GATEWAY_URL: ${{ vars.E2E_GATEWAY_URL }} E2E_CLAUDE_MODEL: ${{ vars.E2E_CLAUDE_MODEL }} E2E_CODEX_MODEL: ${{ vars.E2E_CODEX_MODEL }} + # Optional: set vars.E2E_ENVIRONMENT=cloud to exercise the cloud code + # path (sandbox/permission-profile gating). Unset = local. The OS-sandbox + # enforcement test is macOS-gated, so it doesn't red this linux runner. + E2E_ENVIRONMENT: ${{ vars.E2E_ENVIRONMENT }} diff --git a/CODEX_APP_SERVER_TESTING.md b/CODEX_APP_SERVER_TESTING.md deleted file mode 100644 index 7fdd6b2a8d..0000000000 --- a/CODEX_APP_SERVER_TESTING.md +++ /dev/null @@ -1,258 +0,0 @@ -# Codex app-server — manual test checklist - -Manual QA for the native **codex app-server** sub-adapter in PostHog Code. -Tick items as you verify them. - -## Before you start - -- [ ] **Confirm you're actually on app-server** (not codex-acp). With a codex session running: - - `pgrep -fl "codex app-server"` → a PID means app-server. A `codex-acp` process means the old adapter. - - Or grep the dev log for `Codex sub-adapter selected: app-server (native codex)`. -- [ ] App-server requires: the native `codex` binary present (`apps/code/resources/codex-acp/codex`; run `node apps/code/scripts/download-binaries.mjs` if missing) **and** the opt-in on — the `codex-app-server` flag enabled for your user, or `POSTHOG_CODEX_USE_APP_SERVER=1`. With neither, you get codex-acp by design. -- [ ] Build/run with the adapter changes in the working tree (the flag is inert without them). - -Triage tip: if something looks wrong, run the same action on **codex-acp** (flag off) and **claude**. Breaks only on app-server → adapter bug. Breaks everywhere → upstream of the adapter. - ---- - -## Tier 1 — Regression-critical (these were genuinely broken in app-server and fixed) - -- [ ] **PostHog system prompt takes effect** (was rendering as `[object Object]`) - - Do: ask it to make a git commit, and to create a branch. Also set a custom instruction in settings. - - Expect: commit message uses PostHog trailers (`Generated-By: PostHog Code`, `Task-Id: …`), **not** Claude's default attribution; new branches prefixed `posthog-code/`; custom instruction honored. -- [ ] **Initial permission mode is honored** (was ignored — always started in default) - - Do: start three separate sessions, one each in **Read only**, **Auto**, **Full access**, then ask each to edit a file / run a command. - - Expect: Read-only asks before *any* change; Auto asks only for risky ops; Full access auto-approves. The **first** action already respects the mode. -- [ ] **Pending / PR context prepend** (was being dropped) - - Do: with a session running, trigger a context change — focus/unfocus a worktree (CWD move / detached HEAD), or reconnect a task that had queued context. - - Expect: the next turn acknowledges the new working-dir/branch context. - -## Tier 2 — App-server-specific protocol paths (new code, most likely to break) - -- [x] **Steering** (`turn/steer`) — ✅ live e2e (`folds a mid-turn prompt into the running turn via steering`) + capability now reaches the host (was hardcoded to claude). -- [x] **Interrupt / cancel mid-turn** — ✅ live e2e (`interrupts an in-flight turn` + follow-up asserts `end_turn`); the false-green that hid the broken cancel is fixed. -- [x] **Structured output** (native `outputSchema`) — ✅ live e2e (`structured-output.e2e`). -- [x] **Mode → approval-policy synthesis** — ✅ live e2e: read-only **actually blocks an edit**, plan **engages codex's plan collaboration + reverts** on switch back. Modes are real, not cosmetic. -- [~] **loadSession / resume** — basic resume + list/fork pass live; the audit still wants a test proving the **tool transcript replays** against a persisted thread (not just count). - -## Tier 3 — Config controls (UI selectors → adapter) - -- [ ] **Model selector**: switch model mid-session. - - Expect: the next turn uses the new model. -- [ ] **Reasoning effort selector**: switch effort (low/medium/high…). - - Expect: applies; reasoning/thinking text still streams. - -## Tier 4 — Tool calls & integrations (rendering + approvals) - -- [ ] **File edits**: read / write / edit a file. - - Expect: diff / file-change rendering looks correct in the UI. -- [ ] **Command execution**: run a bash command. - - Expect: command-approval prompt (per mode) + output renders. -- [x] **Permission prompts** — ✅ verified manually: Allow once / Allow always / Reject / reject-with-feedback - all work; "Allow always" sticks for the rest of the session. -- [ ] **MCP (PostHog tools)**: ask it to query PostHog via MCP. - - Expect: read-only tools auto-approve (if configured), writes prompt; tools actually execute. -- [ ] **Skills / commands** (`available_commands_update`): confirm skill/slash commands appear and one runs. -- [x] **AskUserQuestion / elicitation** — ✅ live e2e (plan-mode round-trip): codex's `request_user_input` fires only in plan collaboration, and the `_meta.questions` shape now renders (was an empty "Review your answers" card). -- [ ] **Image input**: paste/attach an image in a prompt. - - Expect: image is sent and understood. -- [ ] **Additional directories** (worktree): confirm it can read/edit files outside the primary cwd. - -## Tier 5 — Usage display & special modes - -- [ ] **Token usage + context breakdown**: confirm the usage counter updates and the breakdown popover populates (systemPrompt / tools / skills / mcp / conversation). Driven by `_posthog/usage_update`. -- [ ] **Usage indicator tracks the CURRENT turn (win #1, fresh)**: over a multi-turn session the context % should track the real context and NOT ratchet up every turn (it now reads codex's `tokenUsage.last`, not the cumulative `total`). It should visibly **drop after a compaction**. -- [ ] **Context compaction (fresh — both adapters)**: force a compaction and confirm the UI shows it (a "Context compacted." / "Compacting completed." marker) and the usage indicator drops. Covered by `compaction.e2e.test.ts` (claude via `/compact`, codex via a low `auto_compact_token_limit`) — the e2e thresholds may need one tune on the first real run. -- [ ] **Channel mode (repo-less task)**: start a task with no repo. - - Expect: behaves as a general assistant; only attaches/clones a repo when actually needed. - -## Tier 6 — Post-refactor regression (god-class split + native win #1) - -- [ ] **Refactor smoke**: after extracting `TurnController` / `UsageTracker` / `SessionConfigState` / `McpManager` and switching per-turn usage to codex's `last`, do a normal end-to-end session (turn → tool → edit → interrupt → resume). Unit-guarded (959 tests) + the live e2e is the real regression proof — run the e2e suite once to confirm the split didn't regress a path the unit tests miss. - -## Known issues / follow-ups - -- [x] **MCP `exec` permission prompt shows raw codex text — FIXED.** The approval prompt for the PostHog MCP `exec` tool rendered `Allow the posthog MCP server to run tool "exec"?` instead of the real tool + command. Diagnosed from the session ACP logs (`~/.posthog-code/sessions/*/logs.ndjson`). - - **Real root cause (the earlier hypothesis was wrong):** the exec approval does **not** come through `item/commandExecution/requestApproval` (the `mcpToolCallsByItemId` path). It comes through **`mcpServer/elicitation/request`** — confirmed by the prompt's `toolCallId: "posthog:elicitation"` (built in `approvals.ts handleMcpElicitation`) and the Accept/Decline options. The logs show two back-to-back messages: a `tool_call` with the real data (`title:"posthog/exec"`, `rawInput:{command,context}`) and a **separate** `session/request_permission` carrying only codex's generic `params.message` — no `_meta.posthog`, no rawInput. The elicitation handler never correlated to the in-flight `mcpToolCall`. - - **Fix:** `handleMcpElicitation` now takes a `resolveMcpToolCall(serverName)` from `HandleServerRequestOptions`; the agent tracks `lastMcpToolCall` (set in `captureMcpToolCall`) and resolves it by matching `serverName`. When matched, the prompt carries `rawInput` + `_meta.posthog` (`mcp__posthog__exec`), mirroring the command-approval enrichment, so the host renders the proper MCP permission card. Falls back to codex's generic text when nothing correlates. - - **Tests:** `approvals.test.ts` (enriches vs falls-back) + `codex-app-server-agent.test.ts` (end-to-end: `item/started` mcpToolCall → `mcpServer/elicitation/request` → enriched prompt). 64/64 green. - - **Touched:** `approvals.ts` (`handleMcpElicitation`, `HandleServerRequestOptions`), `codex-app-server-agent.ts` (`captureMcpToolCall`, `handleApproval`, `lastMcpToolCall`). - - **To verify live:** ask codex (on app-server) to run a PostHog MCP query; the approval prompt should now show the real tool + command, not the generic "run tool exec?". - -- [x] **Mode picker on app-server (flattened, Claude-style) — IMPLEMENTED.** App-server now emits a `category:"mode"` config option (`session-config.ts buildConfigOptions`) with four presets — **Plan / Read only / Auto / Full access** — so the existing `ModeSelector` shows a switcher for app-server only (codex-acp/claude unchanged). Each preset maps to a `(collaborationMode, approvalPolicy)` tuple applied per-turn on `turn/start`. The adapter now negotiates `experimentalApi: true` (required for the experimental `collaborationMode` field). Verified against the real binary: `collaborationMode/list` → `[Plan, Default]`, `thread/start` accepts `collaborationMode:{mode,settings:{model}}`. - - **To verify live:** switch the picker to **Plan** → codex should propose a plan, make no edits, and `request_user_input` (AskUserQuestion) should fire; switching to Auto/Read-only/Full-access should behave per approval policy. Exit Plan = switch the picker to a coding preset (sets `collaborationMode=default` next turn). - - **Watch:** `experimentalApi: true` is a session-wide flip; confirm normal (non-plan) turns are unaffected. - -- [ ] **AskUserQuestion / `request_user_input`** — unblocked by the Plan preset above (codex only injects the tool in `plan` collaboration mode). Test it by selecting **Plan** and asking codex something under-specified; the structured card should render via `approvals.ts handleToolUserInput`. (Was "N/A for codex"; now reachable on app-server.) - ---- - -## Parity audit + RED-GREEN session (against the live binary + gateway) - -A 15-feature parity audit (vs the Claude adapter, codex-acp, and the real codex protocol schema) found **42 confirmed items** (full list stashed at `scratchpad/audit-confirmed.json`; workflow `wf_f53857b7-a94`). The headline lesson: several existing tests were **false-greens** (they passed even with the feature broken). The live e2e runs here (gateway up at `localhost:3308`, token via `e2e/run-e2e.sh`). - -### Fixed this session (RED → GREEN, with regression tests) - -- [x] **Cancel/interrupt — the real bug.** Two layered defects, both fixed: - 1. `turn/interrupt` was sent with only `{ threadId }`; the schema requires `{ threadId, turnId }` (native binary rejects `-32600`). The error was swallowed and a local `finalizeTurn("cancelled")` masked it → false-green. Fixed: send `turnId`; the stub now enforces the schema (`makeStubRpc` throws on a turnId-less interrupt). - 2. Once interrupt actually fired, codex's **late `turn/completed(interrupted)`** for the cancelled turn finalized the *follow-up* turn as cancelled. Fixed with a `cancelledTurnIds` guard (drop the stale completion by `turn.id`). **Verified live** — the interrupt e2e now sends a follow-up prompt and asserts `end_turn`. -- [x] **Steering** — `turn/steer` response `turnId` was discarded, so `this.turnId` went stale and a later steer/interrupt targeted the wrong turn. Fixed + unit test. -- [x] **Skills** — disabled skills (`enabled:false`) were advertised in `available_commands_update`. Fixed (`!== false` filter) + unit test. -- [x] **Reasoning (mapping)** — only the raw `item/reasoning/textDelta` was mapped; gpt-5-family streams the **default** `summaryTextDelta`, which was dropped → no thinking reached the host. Mapping + `summary:"detailed"` added + parameterized unit test. *Live trigger still unconfirmed* (see deferred). -- [x] **MCP ambient-disable** — `mcp_servers..enabled=false` was emitted without name validation; a dotted/spaced server name wedges the whole session. Fixed (mirrors codex-acp's `/^[A-Za-z0-9_-]+$/` guard). - -### Remaining (prioritized — from `scratchpad/audit-confirmed.json`) - -Code bugs (unit-testable): -- [ ] **structured-output (#25)** — final-message capture ignores codex `MessagePhase`; a trailing `commentary` agent message can clobber the `final_answer` used for structured output. Prefer `final_answer` text. -- [ ] **usage `totalTokens` (#29)** — recomputed total drops `reasoningOutputTokens`; the e2e assertion is a tautology against the producer formula. Carry codex's authoritative total incl. reasoning. - -False-green e2e strengthenings (live): -- [ ] **loadSession (#6)** — doesn't prove the tool transcript replays against a real persisted thread. -- [ ] **steering echo (#8)** — asserts only echo count (fires before the fold). -- [ ] **fileChange diff (#9)** — golden turn never asserts diff content (`parseUnifiedDiff`). -- [ ] **instructions {append}→flatten (#2)** — the prod `[object Object]` fix has no real-binary coverage. -- [ ] **structured-output (#24)** — passes even if `outputSchema` is never sent. -- [ ] tool-kind classification (#27), MCP-injection/local-tools e2e (#16), image-input e2e (#13), plan-rendering e2e (#35), command/file approval round-trip via read-only mode (#12). - -### Deferred design issues (not quick fixes) - -- [ ] **Modes are neutralized by the sandbox — plan mode is currently cosmetic.** The audit DISPROVED the earlier "Mode picker IMPLEMENTED" claim: `collaborationMode` on `turn/start` is **silently dropped** (codex ignores unknown fields — a `totallyBogusField123` is accepted too; acceptance ≠ effect). It only lives in *server→client* `ThreadSettings`, not any client turn/thread param. And `approvalPolicy` is neutralized because spawn forces `sandbox_mode=danger-full-access` (codex auto-approves everything). So all four presets currently behave identically. Making plan/read-only actually restrict needs per-turn `sandboxPolicy` — but `sandboxPolicy:readOnly` risks re-engaging the OS sandbox that spawn deliberately disables (linux-sandbox panics on cloud). Needs design. **Action:** at minimum remove the dead `collaborationMode` field + `collaborationModeFor` and correct the misleading comments/tests. -- [ ] **Reasoning live trigger (#11)** — `summary:"detailed"` did not surface `agent_thought_chunk` on the gpt-5-mini golden turn. Confirm the right lever (the `summary` turn field vs `-c show_raw_agent_reasoning=true` spawn config). The mapping fix + unit test stand regardless; the live e2e assertion is intentionally omitted until the trigger is confirmed. - ---- - -## Ship-readiness RED-GREEN session (host/UI integration + CI guard) - -### The CI-coverage truth (important) -The live e2e suite (`packages/agent/e2e`, `vitest.e2e.config.ts`) **does not run in CI** — it -is opt-in (`pnpm test:e2e`) and needs a live gateway + real codex binary + a minted token. So -the **unit suite (`src/**/*.test.ts`, the default `vitest run`) is the only automated regression -guard**, and its power depends on **stub fidelity**. Every bug the e2e can find now also has a -unit regression test. Practical rule going forward: when the e2e catches something, add the -unit test too, or CI won't protect it. - -### Fixed (RED → GREEN, each with a unit regression test that runs in CI) -- [x] **Modes are real, not cosmetic — PROVEN LIVE.** Removed the dead `collaborationMode` - turn/start field (silently dropped) and wired a per-turn `sandboxPolicy: {type:readOnly}` for - plan/read-only. The first live e2e exposed that this alone did NOTHING: the edit still went - through, because `spawn.ts` forced `sandbox_mode="danger-full-access"` on *every* platform, which - disables codex's OS sandbox at the process level so a per-turn `sandboxPolicy` can't re-engage it. - Fix: gate the spawn sandbox on `process.platform` (which mirrors sandbox availability) — macOS gets - `workspace-write` (Seatbelt present → per-turn read-only can tighten and block edits), cloud/linux - keeps `danger-full-access` (its linux-sandbox launcher is absent and would panic). A new live e2e - (`read-only mode actually blocks a file edit`) now passes — read-only blocks the write while auto - still edits — and a `spawn.test.ts` case locks the platform gating. This was the headline - "deferred design issue"; it is now closed for local/desktop (cloud stays permissive by necessity, - documented). `session-config.test.ts`, `spawn.test.ts`, and the live codex e2e arm (13/13). -- [x] **Native steering reaches codex.** The host hardcoded `adapter === "claude"` in both the - `sendPrompt` gate and `useSupportsNativeSteer`, so codex's `turn/steer` was dead. Now - capability-driven: the adapter's advertised `agentCapabilities._meta.posthog.steering` ("native" - vs codex-acp's "interrupt-resend") flows host→session via the start/reconnect response, and - both gates use the shared `sessionSupportsNativeSteer` helper. Belt-and-suspenders: Claude - falls back to native if the capability is unset, so the rollout can't regress it. - `shared/sessions.test.ts`. -- [x] **AskUserQuestion renders.** Codex `requestUserInput` emitted a bare `_meta:{header}` that - failed `QuestionMetaSchema`, leaving an empty "Review your answers" card. Now emits a valid - single-question `questions` array. `approvals.test.ts`. -- [x] **Bypass-mode revert is adapter-safe.** `maybeRevertBypassMode` forced `"default"`, which is - not a codex mode (left an undefined approval state). New pure `resolveBypassRevertMode` picks a - valid mode from the session's own options. `shared/sessions.test.ts`. -- [x] **Command/file approvals render richly.** Codex approvals lacked `kind`/`content` so they - fell back to `DefaultPermission`. Now set `kind:"execute"` + command text / `kind:"edit"` + diff - (reusing `mapping.diffContent`/`changePaths`) → ExecutePermission / EditPermission. -- [x] **Reasoning-effort labels** humanized (`Low`/`Medium`/`High`) to match Claude/codex-acp. -- [x] **Usage indicator survives an unknown context window.** `extractAggregate` no longer drops - the whole aggregate when `size` is absent; the indicator shows the token count without a - misleading "/0 · 0%". `contextUsage.test.ts` + `ContextUsageIndicator.test.tsx`. -- [x] **Context indicator tracks the current turn, not the cumulative thread total.** `emitUsageExtNotification` - emitted `used` from `tokenUsage.total.totalTokens` (cumulative — grows every turn), so a real ~189k - context displayed as ~433k (43% of a 997k window) and crept toward 100% from accumulation alone. codex's - `ThreadTokenUsage` is `{ total, last, modelContextWindow }` (confirmed from the binary); `last` is this - turn's breakdown = the actual occupancy (matches codex's own context-left math). Now `used`/`contextUsed`/`usage` - read `tu.last` (fall back to `total` for turn-one/old builds); the cumulative `total` still feeds the per-turn - delta in `turn_complete`. Regression test seeded with the real session numbers (total 433289 vs last 189075 → - indicator asserts 189075). `codex-app-server-agent.test.ts`. -- [x] **Unit false-greens killed + stub hardened.** The cancel test at `817-844` passed without - ever sending `turn/interrupt`; it now emits `turn/started` and asserts the RPC fired, plus a new - test locks the turnId-undefined skip path. `makeStubRpc` now enforces the real required-field - contract for `turn/interrupt` ({threadId,turnId}) and `turn/steer` ({threadId,input,expectedTurnId}). - -### Verified non-issues (traced to the consumer, intentionally NOT changed) -- `usage.reasoningTokens` / `usage.cachedWriteTokens` / `usage.totalTokens` rename concerns — the - host (`contextUsage.ts`) reads only `used`/`size`/`cost`; the `usage` sub-object is unread. -- `cachedWriteTokens: 0` — codex's app-server `TokenUsage.total` has no cache-write field; 0 is - authoritative, not a dropped value (comment added). -- usage `totalTokens` (#29) — the adapter forwards codex's authoritative `total.totalTokens`, not a - recompute, so reasoning isn't dropped. - -### Adversarial re-review of the above (2nd workflow pass) -A second review workflow re-checked the working-tree diff across four dimensions. Three — -**steer-plumbing, host/UI-fixes, test-quality** — came back 10/10 (the steer capability is complete -end-to-end with no path that silently degrades a codex session and no claude regression; the new -unit tests are genuine guards that fail if their fix is reverted). The **missed-gaps** pass surfaced -five; the dispositions: -- [x] **cancelledTurnIds could accumulate** across a long-lived process if an interrupted turn's late - completion never arrived — now cleared in `closeSession`. -- [x] **Question option descriptions were dropped** by the requestUserInput fix — now carried - (non-empty only), with a test assertion. -- **MCP capture "race" — not a bug.** Capture is registered on BOTH `item/started` and - `item/completed`, so whichever arrives first populates the cache; the proposed "only started" patch - would *narrow* the window. The only true gap (approval before either event) is inherent. -- Two observability nits (debug-log a skill missing `enabled`; warn when a session has no non-bypass - mode to revert to) intentionally skipped — both are effectively-impossible states and the logs - would be noise. - -### Plan mode is now a REAL mode (collaboration, not just sandbox) — PROVEN LIVE -The earlier "collaborationMode is silently dropped" conclusion was **wrong** — it was confused by -codex tolerating unknown fields. The truth, found by probing the binary's method registry + schema: -- collaboration mode is a **per-turn `turn/start` field** (`collaborationMode`), NOT a thread setting - (`thread/settings/update` accepts it but doesn't honor it). -- Its shape is `{ mode, settings: { model, reasoning_effort? } }` — the `settings.model` must be a - string (NOT the verbatim `collaborationMode/list` output, whose `model` is null). Sending a Default - struct breaks `turn/start` ("Internal error: missing field/expected string"), so only **Plan** is - sent; Default is codex's implicit mode (omitted). -Now `plan` sends `collaborationMode:{mode:"plan", settings:{model}}` on every turn, which unlocks -codex's plan proposals + `request_user_input` (AskUserQuestion). The behavioral e2e -`plan mode engages codex's plan collaboration (request_user_input becomes available)` proves it: it -instructs codex to call request_user_input and asserts the question reaches the host — which **only -happens in plan collaboration mode** (in default codex replies "request_user_input is unavailable in -this mode"). RED before the fix, GREEN after. Plan also keeps the read-only sandbox, so it both -proposes and can't edit. (Creation now also offers Plan — `execution-mode.ts` + core `executionModes.ts`.) - -### Still open -- [x] **Live e2e RAN against the real gateway + binary** — the codex arm is **13/13** (steering folds - mid-turn, interrupt halts in-flight, structured output delivers, and the new behavioral - `read-only mode actually blocks a file edit` passes). This is the strongest ship-readiness signal: - the steer/interrupt/modes fixes are confirmed end-to-end, not just unit-mocked. -- [ ] **A few e2e assertions remain intentionally loose** for live-model variance (the working-turn - asserts `contains FOO`, not an exact diff; loadSession asserts replay count, not order). These are - defensible against a non-deterministic model; tighten only if a real regression motivates it. The - CI guard remains the unit layer (e2e does not run in CI). -- [ ] **Reasoning live trigger (#11)** — unchanged from above. -- [ ] **structured-output `MessagePhase` (#25)** — deferred pending a live probe. The binary DOES - define a `MessagePhase` enum (`"final_answer"` / `"commentary"`) that "classifies an assistant - message", and `AgentMessageItem` has 4 elements — so the concept exists. But the ACP session logs - are post-translation (they carry `agent_message_chunk`, not the raw codex item), so I could not - confirm the **wire field name** (`phase`?) on the `item/completed` `agentMessage` we receive. - Rather than add speculative code, capture a real `agentMessage` item during the e2e round; if it - carries the phase, wire `captureAgentMessage` to keep the `final_answer` message (so a trailing - `commentary` can't clobber the structured-output source) with a last-wins fallback. -- [ ] **mcp partial-fields** — the `server && tool` cache guard is correct (caching partial entries - would render "undefined"). Revisit only with a real repro. - -## God-class refactor (agent file 1424 → 1201 lines, 47 → 31 fields) -`codex-app-server-agent.ts` was split into four focused collaborators, each behavior-preserving and -guarded by the existing 142 unit tests (all green + typecheck + lint clean at every step): -- **`TurnController`** (`turn-controller.ts`) — the turn state machine: `turnId`, pending completion, - the atomic `claim()`, steer folding, and the interrupted-turn drop set. -- **`UsageTracker`** (`usage-tracker.ts`) — token usage + context breakdown. Folds in **native - delegation win #1**: per-turn usage now comes from codex's `tokenUsage.last` directly, deleting the - cumulative-snapshot delta machinery (`threadUsageTotal`/`turnStartUsage`). Needs a live e2e sanity - check that per-turn numbers look right across turns. -- **`SessionConfigState`** (in `session-config.ts`) — model / effort / mode selectors + the derived - `configOptions` (the old input interface was renamed `ConfigSelectors`). -- **`McpManager`** (`mcp-manager.ts`) — session MCP tool-call state; correlates approval prompts to - the real tool. -The 140-line `handleApproval` was **deliberately left inline** — it's coupled orchestration (needs -rpc/client/turnId/mcp), so extracting it would just relocate the coupling. diff --git a/packages/agent/e2e/README.md b/packages/agent/e2e/README.md index dddb033b94..21ff545fe7 100644 --- a/packages/agent/e2e/README.md +++ b/packages/agent/e2e/README.md @@ -55,15 +55,17 @@ side effects — never model prose — so they hold across adapters and cheap mo These never run under `pnpm test` or per-PR CI (the default vitest config only includes `src/**`). They are opt-in and cost a couple of short model turns. -In CI they run **only** via the gated `agent-live-e2e` workflow (manual dispatch -+ nightly cron), and only when the repo variable `AGENT_E2E_ENABLED` is `true` +In CI they run as the **`e2e` job in `.github/workflows/test.yml`**, on pull +requests only, after the unit + integration jobs pass. The job is opt-in and safe +by default: it self-skips unless the repo variable `AGENT_E2E_ENABLED` is `true` with an `E2E_GATEWAY_TOKEN` secret and an `E2E_GATEWAY_URL` variable pointing at a -gateway reachable from the runner. Off by default, so it costs nothing until -explicitly enabled; the codex arm self-skips if the native binary isn't on the -runner. +gateway reachable from the runner, and it never runs for fork PRs (their secrets +are withheld, which would otherwise red the fail-loud token guard). Off by +default, so it costs nothing until explicitly enabled; the codex arm self-skips if +the native binary isn't on the runner. ```bash -# from packages/agent — mints a local-gateway OAuth token, runs both arms +# from packages/agent — reads the local dev API key from the posthog repo, runs both arms bash e2e/run-e2e.sh # just one adapter (matches the (codex) / (claude) marker in every title) @@ -78,11 +80,11 @@ arm self-skips if it is missing). | Var | Default | Notes | | --- | --- | --- | -| `E2E_GATEWAY_TOKEN` | — | Required. OAuth token the gateway accepts. Without it every arm skips. `run-e2e.sh` mints one. | -| `E2E_GATEWAY_URL` | `http://localhost:3308/posthog_code` | Gateway base (codex appends `/v1`). | +| `E2E_GATEWAY_TOKEN` | — | Required. A token the gateway accepts — the `llm_gateway` product takes a personal API key (no OAuth). Without it every arm skips. `run-e2e.sh` reads the local dev key. | +| `E2E_GATEWAY_URL` | `http://localhost:3308/llm_gateway` | Gateway base (codex appends `/v1`). `llm_gateway` accepts a personal API key; `posthog_code` is OAuth-only. | | `E2E_CLAUDE_MODEL` | `claude-haiku-4-5` | Override if the gateway serves a different cheap Claude id. | | `E2E_CODEX_MODEL` | `gpt-5-mini` | Cheapest codex id the local gateway serves; override if needed. | -| `POSTHOG_REPO` | sibling `../posthog` | Where `run-e2e.sh` mints the token from. | +| `POSTHOG_REPO` | sibling `../posthog` | Where `run-e2e.sh` reads the local dev key from. | | `E2E_DEBUG` | — | `1` for verbose adapter logging. | If a default model isn't served by your gateway, the turn fails loudly (never a diff --git a/packages/agent/e2e/config.ts b/packages/agent/e2e/config.ts index 78ba6529be..548d8a54f1 100644 --- a/packages/agent/e2e/config.ts +++ b/packages/agent/e2e/config.ts @@ -45,6 +45,13 @@ export const E2E = { hasToken: !!TOKEN, gatewayUrl: GATEWAY_URL, codexBin: NATIVE_CODEX_BIN, + /** + * Deployment environment the session runs as. `E2E_ENVIRONMENT=cloud` exercises + * the cloud code path (sandbox/permission-profile gating, cloud notifications) — + * the driver injects it into every session's `_meta`. Undefined = local. + */ + environment: (process.env.E2E_ENVIRONMENT as "local" | "cloud" | undefined) || + undefined, /** * Cheap model per adapter (overridable). Defaults to a small/cheap model so a @@ -93,6 +100,7 @@ export const E2E = { codexOptions( cwd: string, configOverrides?: Record, + modelOverride?: string, ): { cwd: string; binaryPath: string; @@ -106,8 +114,20 @@ export const E2E = { binaryPath: NATIVE_CODEX_BIN, apiBaseUrl: openAiBase(), apiKey: TOKEN, - model: this.model("codex"), + model: modelOverride || this.model("codex"), ...(configOverrides ? { configOverrides } : {}), }; }, + + /** + * A stronger model for the occasional test the cheapest models can't handle — + * e.g. gpt-5-mini / claude-haiku hang on schema-constrained (structured-output) + * decodes. Opts up to a capable model; still env-overridable. + */ + strongModel(adapter: Adapter): string { + if (adapter === "claude") { + return process.env.E2E_CLAUDE_MODEL || "claude-sonnet-4-5"; + } + return process.env.E2E_CODEX_MODEL || "gpt-5.5"; + }, }; diff --git a/packages/agent/e2e/driver.ts b/packages/agent/e2e/driver.ts index 8d6e8cb227..605f3249eb 100644 --- a/packages/agent/e2e/driver.ts +++ b/packages/agent/e2e/driver.ts @@ -23,7 +23,7 @@ import { join, resolve } from "node:path"; import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; import { createAcpConnection } from "../src/adapters/acp-connection"; import { Logger } from "../src/utils/logger"; -import type { Adapter } from "./config"; +import { type Adapter, E2E } from "./config"; export type { Adapter } from "./config"; @@ -218,7 +218,12 @@ export async function openSession(opts: { const newSession = await c.conn.newSession({ cwd: opts.cwd, mcpServers: [], - _meta: opts.meta, + // Inject the deployment environment (E2E_ENVIRONMENT) so the whole suite can + // run as a cloud session without threading it through every test's meta. + _meta: { + ...opts.meta, + ...(E2E.environment ? { environment: E2E.environment } : {}), + }, }); return { conn: c.conn, diff --git a/packages/agent/e2e/guard.e2e.test.ts b/packages/agent/e2e/guard.e2e.test.ts index 35a3e57a19..96830b88f9 100644 --- a/packages/agent/e2e/guard.e2e.test.ts +++ b/packages/agent/e2e/guard.e2e.test.ts @@ -23,4 +23,17 @@ describe("live e2e preconditions", () => { "set E2E_GATEWAY_TOKEN against a reachable E2E_GATEWAY_URL.", ).toBe(true); }); + + // When the suite IS meant to run (token present) the codex arm must not skip + // silently: a failed binary download would otherwise let the whole run pass + // with ZERO codex coverage — the exact adapter this suite exists to test. + it("requires the native codex binary when a token is set (else codex skips-to-green)", () => { + if (!E2E.hasToken) return; // no token → whole suite skips; nothing to guard + expect( + E2E.skipReason("codex"), + "E2E_GATEWAY_TOKEN is set but the native codex binary is missing — the " + + "codex arm would silently skip and the run would pass without exercising " + + "the codex adapter. Ensure apps/code/scripts/download-binaries.mjs ran.", + ).toBeNull(); + }); }); diff --git a/packages/agent/e2e/session-lifecycle.e2e.test.ts b/packages/agent/e2e/session-lifecycle.e2e.test.ts index 5e60c2a15e..42bd8f8ae4 100644 --- a/packages/agent/e2e/session-lifecycle.e2e.test.ts +++ b/packages/agent/e2e/session-lifecycle.e2e.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { type Adapter, E2E } from "./config"; import { type Capture, + type ConfigOption, cleanupRepo, INIT_PARAMS, killCodexStragglers, @@ -40,6 +41,22 @@ for (const adapter of ADAPTERS) { // Codex-only capabilities; registered as skipped on the claude arm so the gap // is visible rather than silent. const itCodex = adapter === "codex" ? it : it.skip; + // OS-sandbox enforcement only TIGHTENS per-turn on macOS in a LOCAL session: + // spawn.ts spawns `workspace-write` there (Seatbelt available), so a per-turn + // `:read-only` profile can narrow it and block a write. On Linux the process + // spawns `danger-full-access` (the linux-sandbox launcher is unavailable), so + // the per-turn profile can't re-engage the sandbox. And in the cloud environment + // the adapter deliberately sends NO sandboxPolicy/permissionProfile at all (it + // would panic the absent launcher) — so on macOS + E2E_ENVIRONMENT=cloud the + // workspace-write spawn permits the edit. Gate to macOS AND non-cloud so the + // test only runs where the read-only profile is actually applied; otherwise it + // reds spuriously. + const itCodexSandbox = + adapter === "codex" && + process.platform === "darwin" && + E2E.environment !== "cloud" + ? it + : it.skip; describe.skipIf(!!skip)(title, () => { let repo: string; @@ -56,7 +73,10 @@ for (const adapter of ADAPTERS) { let sessionId: string; let newSessionResponse: NewSessionResponse; - let turn: { stopReason?: string; capture: Capture; target: string }; + let turn: + | { stopReason?: string; capture: Capture; target: string } + | undefined; + let goldenError: unknown; beforeAll(async () => { if (adapter === "codex") killCodexStragglers(); @@ -80,6 +100,12 @@ for (const adapter of ADAPTERS) { capture: s.capture, target: readTarget(repo), }; + } catch (err) { + // Don't fail the whole describe on a flaky golden turn — record it so only + // the one test that consumes `turn` fails. sessionId/newSessionResponse are + // already captured above, so the independent scenarios (each opens its own + // session) and the reattach/list/fork/resume tests still run. + goldenError = err; } finally { await s.cleanup(); } @@ -96,6 +122,8 @@ for (const adapter of ADAPTERS) { }); it("streams a working turn: assistant text, tool calls, usage, file edit", () => { + if (goldenError) throw goldenError; + if (!turn) throw new Error("golden turn did not produce a result"); expect(turn.stopReason).toBe("end_turn"); expect( turn.capture.updates("agent_message_chunk").length, @@ -179,12 +207,13 @@ for (const adapter of ADAPTERS) { s.capture.updates("config_option_update").length, ).toBeGreaterThan(0); } else { - // claude acks via the returned configOptions and/or a re-emit. - const acknowledged = - s.capture.updates("config_option_update").length + - s.capture.updates("current_mode_update").length > - 0 || Array.isArray(res?.configOptions); - expect(acknowledged).toBe(true); + // claude returns the updated configOptions — assert the switch actually + // took (the option's currentValue is now the value we set), not merely + // that an ack event/array was produced (which is unconditionally true). + const updated = ((res?.configOptions ?? []) as ConfigOption[]).find( + (o) => o.id === opt?.id, + ); + expect(updated?.currentValue).toBe(alt?.value); } } finally { await s.cleanup(); @@ -227,10 +256,11 @@ for (const adapter of ADAPTERS) { }, 60_000); // The behavioral proof that the mode picker is NOT cosmetic: read-only maps - // to a per-turn sandboxPolicy:readOnly (codex app-server), which must block a - // write at the OS level even though the host auto-approves. If this fails the - // edit went through → the restriction is not actually applied. - itCodex( + // to a per-turn `:read-only` permission profile (codex app-server), which must + // block a write at the OS level even though the host auto-approves. macOS-only + // (see itCodexSandbox): on Linux the process spawns danger-full-access, so the + // per-turn profile can't tighten it and this would spuriously red. + itCodexSandbox( "read-only mode actually blocks a file edit (sandbox restricts, not just approval)", async () => { if (adapter === "codex") killCodexStragglers(); @@ -241,7 +271,7 @@ for (const adapter of ADAPTERS) { meta: meta(), }); try { - // Switch to read-only; the per-turn sandboxPolicy applies on the next turn. + // Switch to read-only; the per-turn profile applies on the next turn. await s.conn.setSessionConfigOption({ sessionId: s.sessionId, configId: "mode", @@ -254,14 +284,21 @@ for (const adapter of ADAPTERS) { { type: "text", text: - "Edit target.txt so its second line reads SENTINEL_RO_EDIT. " + - "Then stop.", + "Use your file-editing tool to change target.txt so its second " + + "line reads SENTINEL_RO_EDIT. You MUST attempt the edit with your " + + "tool even if it appears restricted. Then stop.", }, ], }); // The turn must complete (a read-only sandbox must not panic/hang)... expect(res.stopReason).toBeTruthy(); - // ...and the on-disk file is byte-for-byte unchanged: the readOnly + // ...the model actually DID something (>=1 tool call), so a pure prose + // no-op can't masquerade as enforcement. (This only rules out the + // zero-activity false-green; the insistent prompt above pushes toward an + // edit, but a read-then-refuse could still satisfy this without a write + // attempt — an accepted residual on a nondeterministic cheap model.) + expect(s.capture.updates("tool_call").length).toBeGreaterThan(0); + // ...and the on-disk file is byte-for-byte unchanged: the read-only // sandbox blocked the write despite the host auto-approving. expect(readTarget(repo)).toBe(before); expect(readTarget(repo)).not.toContain("SENTINEL_RO_EDIT"); @@ -434,10 +471,25 @@ for (const adapter of ADAPTERS) { }); const [r1] = await Promise.all([p1, p2]); expect(r1.stopReason).toBe("end_turn"); - // Both the original and the steered message echoed as user turns. + // Both the original and the steered message echoed as user turns... expect( s.capture.updates("user_message_chunk").length, ).toBeGreaterThanOrEqual(2); + // ...and — the steer-specific proof — they folded into a SINGLE turn: + // exactly one _posthog/turn_complete. A swallowed/failed turn/steer, or + // p1 finishing before p2 lands (so p2 runs as its own turn), would yield + // 2. The bare user_message_chunk count above only proves prompt() ran + // twice; this proves the second prompt actually steered the first turn. + const turnCompletes = s.capture.events.filter( + (e) => + e.kind === "extNotification" && + e.method === "_posthog/turn_complete", + ).length; + expect( + turnCompletes, + "expected the steered prompt to fold into one running turn (1 " + + "turn_complete); 2 means the steer didn't take", + ).toBe(1); } finally { await s.cleanup(); } diff --git a/packages/agent/e2e/structured-output.e2e.test.ts b/packages/agent/e2e/structured-output.e2e.test.ts index 8f57edb69f..a5a8a51c20 100644 --- a/packages/agent/e2e/structured-output.e2e.test.ts +++ b/packages/agent/e2e/structured-output.e2e.test.ts @@ -43,16 +43,21 @@ for (const adapter of ADAPTERS) { it("delivers schema-constrained structured output", async () => { let captured: Record | undefined; + // The cheapest models hang on the constrained decode; use a stronger one. + const model = E2E.strongModel(adapter); const s = await openSession({ adapter, cwd: repo, - codexOptions: adapter === "codex" ? E2E.codexOptions(repo) : undefined, + codexOptions: + adapter === "codex" + ? E2E.codexOptions(repo, undefined, model) + : undefined, onStructuredOutput: async (o) => { captured = o; }, meta: { systemPrompt: "You answer strictly with JSON matching the schema.", - model: E2E.model(adapter), + model, permissionMode: "bypassPermissions", jsonSchema: SCHEMA, // Prod always sets taskRunId — exercise structured output and the diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index 0e0523ddc2..db16f3b0d8 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -807,6 +807,37 @@ describe("CodexAppServerAgent", () => { expect(modelOpt.currentValue).toBe("gpt-6"); }); + it("sends activePermissionProfile :read-only on turn/start in read-only mode", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + await agent.setSessionConfigOption({ + configId: "mode", + value: "read-only", + sessionId: "t", + } as any); + + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "look around" }], + } as unknown as PromptRequest); + stub.emit("turn/completed", { turn: { status: "completed" } }); + await done; + + // codex 0.140.0 enforces the sandbox via the named profile — the raw + // sandboxPolicy alone is no longer honored — so read-only MUST send + // activePermissionProfile:{extends:":read-only"} (alongside sandboxPolicy). + const turnStart = stub.requests.find((r) => r.method === "turn/start"); + expect(turnStart?.params).toMatchObject({ + activePermissionProfile: { extends: ":read-only" }, + sandboxPolicy: { type: "readOnly" }, + }); + }); + it("resumeSession resumes the existing thread and returns configOptions", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t1" } }, @@ -976,12 +1007,14 @@ describe("CodexAppServerAgent", () => { item: { type: "agentMessage", id: "a1", text: '{"ok":true}' }, }); // A fatal error AND turn/completed for the same turn must not double-fire - // the structured-output callback or the _posthog/turn_complete notification. + // the _posthog/turn_complete notification (idempotent finalize). stub.emit("error", { willRetry: false, error: { message: "boom" } }); stub.emit("turn/completed", { turn: { status: "failed" } }); await done; - expect(outputs).toEqual([{ ok: true }]); + // Structured output is gated on a clean end_turn: a refused/failed turn must + // NOT record task output even though a valid final message was captured. + expect(outputs).toEqual([]); expect( extNotifications.filter((n) => n.method === "_posthog/turn_complete") .length, @@ -1619,6 +1652,37 @@ describe("CodexAppServerAgent", () => { ).toHaveLength(1); }); + it("still emits compact_boundary when the turn dies mid-compaction (no stuck isCompacting)", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client, extNotifications } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + await agent.newSession({ + cwd: "/r", + _meta: {}, + } as unknown as NewSessionRequest); + + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + // Compaction starts, then a fatal error ends the turn BEFORE item/completed — + // without the finalize-time recovery the boundary would never fire and the + // host's isCompacting would stay stuck true. + stub.emit("item/started", { + item: { type: "contextCompaction", id: "c1" }, + }); + stub.emit("error", { willRetry: false, error: { message: "boom" } }); + await done; + + expect( + extNotifications.find((n) => n.method === "_posthog/compact_boundary") + ?.params, + ).toMatchObject({ sessionId: "t" }); + }); + it("loadSession resumes the thread and returns configOptions", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t1" } }, diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 0ea4593bec..8454e74c42 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -171,7 +171,6 @@ export class CodexAppServerAgent extends BaseAcpAgent { private compactionActive = false; /** Maps the host's taskRunId to this session, replayed for cloud notifications. */ private taskRunId?: string; - private cwd?: string; /** * Deployment environment from the host `_meta`. Gates the per-turn * `sandboxPolicy` mode override: on "cloud" a non-danger sandbox would @@ -420,7 +419,6 @@ export class CodexAppServerAgent extends BaseAcpAgent { }, ): Promise<{ threadId: string; thread: AppServerThread | undefined }> { this.jsonSchema = params.meta?.jsonSchema ?? undefined; - this.cwd = params.cwd; this.taskRunId = params.meta?.taskRunId; this.environment = params.meta?.environment; // Honor the host's initial approval mode (mirrors codex-acp). A non-codex @@ -447,10 +445,22 @@ export class CodexAppServerAgent extends BaseAcpAgent { // Fold the host's MCP servers and the local-tools stdio server into one map // — the local-tools server (signed-git etc.) is gated by the same cwd + meta // the codex-acp adapter uses, so local/desktop runs without a token get none. - const localTools = buildLocalToolsServer( - { cwd: params.cwd }, - this.localToolsMeta(params.meta), - ); + // Degrade gracefully: if the bundled server script can't be resolved (a + // packaging gap, or running from source), skip local-tools with a loud + // warning rather than throwing — a missing optional tool must not kill the + // whole session's thread setup. + let localTools: ReturnType = null; + try { + localTools = buildLocalToolsServer( + { cwd: params.cwd }, + this.localToolsMeta(params.meta), + ); + } catch (err) { + this.logger.warn( + "local-tools server unavailable; continuing without it", + { error: String(err) }, + ); + } const mcpServers = toCodexMcpServers([ ...(params.mcpServers ?? []), ...(localTools ? [localTools] : []), @@ -680,6 +690,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { try { const approvalPolicy = this.config.approvalPolicy(); const sandboxPolicy = this.config.sandboxPolicy(); + const activePermissionProfile = this.config.permissionProfile(); await this.rpc.request(APP_SERVER_METHODS.TURN_START, { threadId: this.threadId, input, @@ -704,6 +715,14 @@ export class CodexAppServerAgent extends BaseAcpAgent { ...(this.environment !== "cloud" && sandboxPolicy ? { sandboxPolicy } : {}), + // codex 0.140.0 enforces the sandbox through named permission profiles; + // the raw sandboxPolicy above is no longer honored on its own, so + // plan/read-only also send `activePermissionProfile: {extends:":read-only"}`. + // Same cloud gating — a restrictive profile would re-engage the absent + // linux-sandbox there. + ...(this.environment !== "cloud" && activePermissionProfile + ? { activePermissionProfile } + : {}), // Constrain the final assistant message to the task's schema so it is // valid JSON we can parse for structured output (replaces the codex-acp // `create_output` MCP, which the native app-server has no need for). @@ -942,12 +961,30 @@ export class CodexAppServerAgent extends BaseAcpAgent { // window below sees no live turn. const pending = this.turns.claim(); if (!pending) return; + // If the turn ends while a compaction is still in progress (interrupt or a + // fatal error before item/completed(contextCompaction)), the boundary would + // never fire — leaving the host's `isCompacting` stuck true, which silently + // queues every later user message. Clear the flag and emit the boundary here + // (idempotent: only the first finalize for a turn gets past claim()) so the + // host recovers instead of wedging. + if (this.compactionActive) { + this.compactionActive = false; + this.emitCompactionBoundary(); + } const message = this.lastAgentMessage; // Per-turn usage is codex's own `tokenUsage.last` (not a reconstructed delta). const usage = this.usage.perTurnUsage(); const contextUsed = this.usage.contextTokens(); - if (this.jsonSchema && this.onStructuredOutput && message) { + // Deliver structured output only on a clean completion — a cancelled + // (user-interrupted) or refused turn must not record task output the host + // considers failed (mirrors the Claude adapter's success-only delivery). + if ( + reason === "end_turn" && + this.jsonSchema && + this.onStructuredOutput && + message + ) { const parsed = parseStructuredOutput(message); if (parsed) { try { diff --git a/packages/agent/src/adapters/codex-app-server/mapping.test.ts b/packages/agent/src/adapters/codex-app-server/mapping.test.ts index 65e42b7a18..b098cd6adc 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.test.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.test.ts @@ -176,7 +176,7 @@ describe("mapAppServerNotification", () => { ).toBeNull(); }); - it("maps thread/tokenUsage/updated to a usage_update", () => { + it("maps thread/tokenUsage/updated to a usage_update from the per-turn `last` (not cumulative `total`)", () => { const result = mapAppServerNotification( "s-1", APP_SERVER_NOTIFICATIONS.TOKEN_USAGE_UPDATED, @@ -184,14 +184,35 @@ describe("mapAppServerNotification", () => { threadId: "t", turnId: "u", tokenUsage: { - total: { - totalTokens: 1500, - inputTokens: 1000, - outputTokens: 500, + // `total` is cumulative across the thread; the gauge must NOT use it. + total: { totalTokens: 1500, inputTokens: 1000, outputTokens: 500 }, + // `last` is the current turn's occupancy — the value that must win. + last: { + totalTokens: 600, + inputTokens: 500, + outputTokens: 100, cachedInputTokens: 0, reasoningOutputTokens: 0, }, - last: {}, + modelContextWindow: 200000, + }, + }, + ); + expect(result).toEqual({ + sessionId: "s-1", + update: { sessionUpdate: "usage_update", used: 600, size: 200000 }, + }); + }); + + it("falls back to cumulative `total` when `last` is absent (pre-`last` build / turn 1)", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.TOKEN_USAGE_UPDATED, + { + threadId: "t", + turnId: "u", + tokenUsage: { + total: { totalTokens: 1500, inputTokens: 1000, outputTokens: 500 }, modelContextWindow: 200000, }, }, diff --git a/packages/agent/src/adapters/codex-app-server/mapping.ts b/packages/agent/src/adapters/codex-app-server/mapping.ts index 80cc3430d0..ce80b660a9 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.ts @@ -54,7 +54,12 @@ export function mapAppServerNotification( // additionally emitted as a `_posthog/usage_update` ext-notification by // the agent. const tu = (params as { tokenUsage?: any })?.tokenUsage; - const used = tu?.total?.totalTokens ?? tu?.total?.inputTokens; + // Occupancy is THIS turn's `last` (mirroring usage-tracker.ts), not codex's + // cumulative `total` — `total` grows across the whole thread, so feeding it + // to the gauge over-reports and pegs it at 100% after enough turns. `total` + // is only the fallback for a build that predates `last` (≈ total on turn 1). + const context = tu?.last ?? tu?.total; + const used = context?.totalTokens ?? context?.inputTokens; if (used == null) return null; const size = tu?.modelContextWindow; // `usage_update` is a PostHog-convention update, not in the ACP union. diff --git a/packages/agent/src/adapters/codex-app-server/session-config.ts b/packages/agent/src/adapters/codex-app-server/session-config.ts index 2a8ce48d52..f831db9591 100644 --- a/packages/agent/src/adapters/codex-app-server/session-config.ts +++ b/packages/agent/src/adapters/codex-app-server/session-config.ts @@ -44,11 +44,20 @@ export interface CodexMode { * than a relabeled read-only sandbox. */ collaborationMode?: "plan" | "default"; + /** + * codex's named permission profile, sent per-turn on `turn/start` as + * `activePermissionProfile: { extends }`. codex 0.140.0 enforces the sandbox + * through these built-in profiles (`:read-only` / `:workspace` / + * `:danger-full-access`); the raw per-turn `sandboxPolicy` we also send is no + * longer honored on its own. Undefined means keep the spawned default (editable). + */ + permissionProfile?: string; } // Flattened Claude-style presets. Restriction is driven by approvalPolicy + -// sandboxPolicy (the only honored levers); plan/read-only block edits via a -// read-only sandbox, auto/full-access keep the spawned full-access sandbox. +// the named permissionProfile (codex 0.140.0's enforced sandbox lever — the raw +// sandboxPolicy is sent too but no longer honored alone); plan/read-only block +// edits via `:read-only`, auto/full-access keep the spawned editable sandbox. export const CODEX_MODES: CodexMode[] = [ { id: "plan", @@ -56,6 +65,7 @@ export const CODEX_MODES: CodexMode[] = [ description: "Plan first — inspect and propose; makes no changes", approvalPolicy: "on-request", sandboxPolicy: { type: "readOnly", networkAccess: true }, + permissionProfile: ":read-only", collaborationMode: "plan", }, { @@ -64,6 +74,7 @@ export const CODEX_MODES: CodexMode[] = [ description: "Read-only — can inspect but not modify files", approvalPolicy: "untrusted", sandboxPolicy: { type: "readOnly", networkAccess: true }, + permissionProfile: ":read-only", }, { id: "auto", @@ -94,13 +105,24 @@ export function sandboxPolicyFor( return CODEX_MODES.find((m) => m.id === modeId)?.sandboxPolicy; } +/** Named permission profile for a mode (undefined keeps the spawned default). */ +export function permissionProfileFor( + modeId: string | undefined, +): string | undefined { + return CODEX_MODES.find((m) => m.id === modeId)?.permissionProfile; +} + /** * codex collaboration mode for a preset — "plan" only for the Plan preset, else * "default". Switching away from Plan must reset to "default", so this never * returns undefined. */ -export function collaborationModeFor(modeId: string | undefined): "plan" | "default" { - return CODEX_MODES.find((m) => m.id === modeId)?.collaborationMode ?? "default"; +export function collaborationModeFor( + modeId: string | undefined, +): "plan" | "default" { + return ( + CODEX_MODES.find((m) => m.id === modeId)?.collaborationMode ?? "default" + ); } /** @@ -146,9 +168,7 @@ export interface ConfigSelectors { } /** Builds the ACP configOptions (mode + model + thought_level) the host renders. */ -export function buildConfigOptions( - s: ConfigSelectors, -): SessionConfigOption[] { +export function buildConfigOptions(s: ConfigSelectors): SessionConfigOption[] { const baseModels = s.models.length ? s.models : [{ id: s.model, name: s.model }]; @@ -325,6 +345,15 @@ export class SessionConfigState { return sandboxPolicyFor(this._mode); } + /** + * The per-turn `activePermissionProfile` for the current mode (codex 0.140.0's + * enforced sandbox mechanism), or undefined to keep the spawned default. + */ + permissionProfile(): { extends: string } | undefined { + const profile = permissionProfileFor(this._mode); + return profile ? { extends: profile } : undefined; + } + private rebuild(): void { this._options = buildConfigOptions({ mode: this._mode, diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index abba65b07a..31075f2ea3 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -395,7 +395,15 @@ export class AgentServer { } private shouldRelayPermissionToClient(mode: PermissionMode): boolean { - return mode === "default" || mode === "auto" || mode === "read-only"; + // "plan" relays like "read-only": both are look-don't-touch modes, so an + // edit/command escalation must reach a connected desktop for a human veto + // rather than being silently auto-approved. + return ( + mode === "default" || + mode === "auto" || + mode === "read-only" || + mode === "plan" + ); } private createApp(): Hono { From d4dea1edb8bde5712ac3cb10638a5fb3d32cca2c Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Wed, 1 Jul 2026 17:50:06 +0100 Subject: [PATCH 10/20] include binary --- packages/agent/package.json | 1 + .../codex-app-server/binary-path.test.ts | 44 ++++++--- .../adapters/codex-app-server/binary-path.ts | 90 +++++++++++++++++-- 3 files changed, 117 insertions(+), 18 deletions(-) diff --git a/packages/agent/package.json b/packages/agent/package.json index bdc0b50f49..43d4a980f2 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -133,6 +133,7 @@ "@anthropic-ai/claude-agent-sdk": "0.3.170", "@anthropic-ai/sdk": "0.104.1", "@hono/node-server": "^1.19.9", + "@openai/codex": "0.140.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.0.0", diff --git a/packages/agent/src/adapters/codex-app-server/binary-path.test.ts b/packages/agent/src/adapters/codex-app-server/binary-path.test.ts index f8e46a544d..c17cb0162e 100644 --- a/packages/agent/src/adapters/codex-app-server/binary-path.test.ts +++ b/packages/agent/src/adapters/codex-app-server/binary-path.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const existsSyncMock = vi.hoisted(() => vi.fn()); vi.mock("node:fs", async (importOriginal) => ({ @@ -6,24 +6,46 @@ vi.mock("node:fs", async (importOriginal) => ({ existsSync: existsSyncMock, })); +const resolveMock = vi.hoisted(() => vi.fn()); +vi.mock("node:module", async (importOriginal) => ({ + ...(await importOriginal()), + createRequire: () => ({ resolve: resolveMock }), +})); + const { nativeCodexBinaryPath } = await import("./binary-path"); describe("nativeCodexBinaryPath", () => { - it("returns undefined without a codex-acp path", () => { - expect(nativeCodexBinaryPath(undefined)).toBeUndefined(); - }); - - it("returns undefined when the sibling codex binary is absent", () => { - existsSyncMock.mockReturnValue(false); - expect( - nativeCodexBinaryPath("/bundle/codex-acp/codex-acp"), - ).toBeUndefined(); + beforeEach(() => { + existsSyncMock.mockReset(); + resolveMock.mockReset(); }); - it("returns the sibling codex binary when present", () => { + it("returns the sibling codex binary bundled next to codex-acp when present", () => { existsSyncMock.mockReturnValue(true); expect(nativeCodexBinaryPath("/bundle/codex-acp/codex-acp")).toBe( "/bundle/codex-acp/codex", ); }); + + it("falls back to the @openai/codex vendored binary when no sibling is bundled", () => { + // No sibling next to codex-acp; the @openai/codex platform sub-package + // resolves and its vendored binary exists. + resolveMock.mockReturnValue("/nm/@openai/codex-plat/package.json"); + existsSyncMock.mockImplementation((p: string) => p.includes("/vendor/")); + const got = nativeCodexBinaryPath(undefined); + expect(got).toContain("@openai/codex-plat"); + expect(got).toContain("/vendor/"); + expect(got?.endsWith("/bin/codex")).toBe(true); + }); + + it("returns undefined when neither the sibling nor the @openai/codex dep is present", () => { + existsSyncMock.mockReturnValue(false); + resolveMock.mockImplementation(() => { + throw new Error("Cannot find module '@openai/codex-plat/package.json'"); + }); + expect( + nativeCodexBinaryPath("/bundle/codex-acp/codex-acp"), + ).toBeUndefined(); + expect(nativeCodexBinaryPath(undefined)).toBeUndefined(); + }); }); diff --git a/packages/agent/src/adapters/codex-app-server/binary-path.ts b/packages/agent/src/adapters/codex-app-server/binary-path.ts index c025522cd2..e179bce671 100644 --- a/packages/agent/src/adapters/codex-app-server/binary-path.ts +++ b/packages/agent/src/adapters/codex-app-server/binary-path.ts @@ -1,17 +1,93 @@ import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; import { dirname, join } from "node:path"; /** - * The native codex CLI is bundled next to codex-acp, so derive its path from - * the codex-acp binary path (same directory, `codex` instead of `codex-acp`). - * Returns undefined when the binary isn't present (e.g. the npx fallback), in - * which case the caller keeps using the codex-acp adapter. + * Node `platform-arch` → codex target triple + the `@openai/codex` platform + * sub-package that vendors the native binary. Mirrors the map in `@openai/codex`'s + * own `bin/codex.js` shim (the sub-packages are aliased optional deps, e.g. + * `@openai/codex-linux-arm64` = `npm:@openai/codex@-linux-arm64`). + */ +const CODEX_NATIVE_TARGETS: Record< + string, + { triple: string; pkg: string } | undefined +> = { + "linux-x64": { + triple: "x86_64-unknown-linux-musl", + pkg: "@openai/codex-linux-x64", + }, + "linux-arm64": { + triple: "aarch64-unknown-linux-musl", + pkg: "@openai/codex-linux-arm64", + }, + "darwin-x64": { + triple: "x86_64-apple-darwin", + pkg: "@openai/codex-darwin-x64", + }, + "darwin-arm64": { + triple: "aarch64-apple-darwin", + pkg: "@openai/codex-darwin-arm64", + }, + "win32-x64": { + triple: "x86_64-pc-windows-msvc", + pkg: "@openai/codex-win32-x64", + }, + "win32-arm64": { + triple: "aarch64-pc-windows-msvc", + pkg: "@openai/codex-win32-arm64", + }, +}; + +/** + * Resolve the native codex binary vendored by the `@openai/codex` dependency's + * platform sub-package, so the app-server adapter works from a plain + * `npm install @posthog/agent` with no separate download step — the same way + * Claude (an SDK dep) and codex-acp (npm platform binaries) ride along with the + * package. Returns undefined when the dep (or this platform's sub-package, an + * `os`/`cpu`-gated optional dep) isn't installed. + */ +function vendoredCodexBinary(): string | undefined { + const target = CODEX_NATIVE_TARGETS[`${process.platform}-${process.arch}`]; + if (!target) return undefined; + const binaryName = process.platform === "win32" ? "codex.exe" : "codex"; + try { + // Anchor resolution at this module's directory so the dep is found in + // @posthog/agent's node_modules (or a hoisted one). The filename passed to + // createRequire need not exist — only its directory is used. + const requireFrom = createRequire( + join(import.meta.dirname ?? __dirname, "_resolve.js"), + ); + const pkgJson = requireFrom.resolve(`${target.pkg}/package.json`); + const binary = join( + dirname(pkgJson), + "vendor", + target.triple, + "bin", + binaryName, + ); + return existsSync(binary) ? binary : undefined; + } catch { + return undefined; + } +} + +/** + * Path to the native codex CLI (the one that exposes `app-server`), or undefined + * when it isn't available (caller then keeps the codex-acp adapter). + * + * Two sources, in order: + * 1. Bundled next to the codex-acp binary (the desktop's resources dir, where + * `download-binaries.mjs` places both). + * 2. Vendored by the `@openai/codex` npm dependency — the install-time path that + * works everywhere `@posthog/agent` is installed (sandbox, CI), no download. */ export function nativeCodexBinaryPath( codexAcpPath?: string, ): string | undefined { - if (!codexAcpPath) return undefined; const binaryName = process.platform === "win32" ? "codex.exe" : "codex"; - const candidate = join(dirname(codexAcpPath), binaryName); - return existsSync(candidate) ? candidate : undefined; + if (codexAcpPath) { + const candidate = join(dirname(codexAcpPath), binaryName); + if (existsSync(candidate)) return candidate; + } + return vendoredCodexBinary(); } From 65c90468513766f508938d06f44f841e27b24b37 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Wed, 1 Jul 2026 18:45:31 +0100 Subject: [PATCH 11/20] don't relay background perm requests --- packages/agent/src/server/agent-server.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 31075f2ea3..595964aaf7 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -2928,9 +2928,16 @@ ${signedCommitInstructions} isQuestion || this.shouldRelayPermissionToClient(sessionPermissionMode); + // A "background" (autonomous) run has no interactive human to answer a + // relayed approval — and hasDesktopConnected can still be true because + // the event-relay SSE reader counts as a connected client. Relaying to + // it would hang the run forever (e.g. a posthog/exec elicitation during + // signals repo-selection/research). Auto-approve instead (the intended + // autonomous behavior), still honoring the publish block below. if ( - isPlanApproval || - (needsDesktopApproval && this.session?.hasDesktopConnected) + mode !== "background" && + (isPlanApproval || + (needsDesktopApproval && this.session?.hasDesktopConnected)) ) { this.logger.debug("Relaying permission request", { kind: params.toolCall?.kind, From c2c752d1238f62d26f657fc03595628851acd6c9 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Wed, 1 Jul 2026 18:56:37 +0100 Subject: [PATCH 12/20] remove some comments --- packages/agent/e2e/compaction.e2e.test.ts | 34 +- packages/agent/e2e/config.ts | 49 +-- packages/agent/e2e/driver.ts | 66 +--- packages/agent/e2e/guard.e2e.test.ts | 19 +- .../agent/e2e/session-lifecycle.e2e.test.ts | 150 ++------ .../agent/e2e/structured-output.e2e.test.ts | 14 +- .../app-server-client.test.ts | 7 +- .../codex-app-server/app-server-client.ts | 24 +- .../codex-app-server/approvals.test.ts | 11 +- .../adapters/codex-app-server/approvals.ts | 84 ++--- .../codex-app-server/binary-path.test.ts | 2 - .../adapters/codex-app-server/binary-path.ts | 29 +- .../codex-app-server-agent.ts | 355 ++++-------------- .../codex-app-server/ext-notifications.ts | 35 +- .../src/adapters/codex-app-server/input.ts | 26 +- .../codex-app-server/local-tools-mcp.test.ts | 10 +- .../codex-app-server/local-tools-mcp.ts | 37 +- .../adapters/codex-app-server/mapping.test.ts | 8 - .../src/adapters/codex-app-server/mapping.ts | 95 +---- .../adapters/codex-app-server/mcp-config.ts | 13 +- .../adapters/codex-app-server/mcp-manager.ts | 15 +- .../src/adapters/codex-app-server/protocol.ts | 39 +- .../codex-app-server/session-config.ts | 103 ++--- .../adapters/codex-app-server/spawn.test.ts | 3 - .../src/adapters/codex-app-server/spawn.ts | 34 +- .../codex-app-server/turn-controller.ts | 25 +- .../codex-app-server/usage-tracker.ts | 16 +- packages/agent/src/server/agent-server.ts | 14 +- 28 files changed, 327 insertions(+), 990 deletions(-) diff --git a/packages/agent/e2e/compaction.e2e.test.ts b/packages/agent/e2e/compaction.e2e.test.ts index 96b67e4d14..3a6653d1e4 100644 --- a/packages/agent/e2e/compaction.e2e.test.ts +++ b/packages/agent/e2e/compaction.e2e.test.ts @@ -8,29 +8,15 @@ import { } from "./driver"; /** - * Live compaction e2e — codex only (for now). - * - * codex auto-compacts when the context crosses its `model_auto_compact_token_limit`. - * We spawn with a low limit and make turn 1 a big cheap INPUT blob (tiny output), - * so the SECOND turn trips compaction; the adapter must surface it to the host via - * `_posthog/compact_boundary` (which clears `isCompacting` + drains the queue). - * Opt-in — self-skips without `E2E_GATEWAY_TOKEN` / the native binary. - * - * Claude is excluded: its manual `/compact` hangs the adapter's `prompt()` — the - * SDK signals /compact completion with a `status`/`compact_result` message, not - * the `result` message `prompt()` resolves on, so the turn never returns (a - * separate claude-adapter issue), and filling its ~200k window to force AUTO - * compaction is too costly for an e2e. Re-enable by adding "claude" to ADAPTERS - * once /compact resolves cleanly. - * - * NOTE: the codex limit/turn count may need tuning on a new model — if it never - * compacts, codex may clamp the limit or the baseline exceeds it; raise the limit - * and FILLER together. The failure message prints the methods seen. + * Live compaction e2e — codex only. codex auto-compacts when the context crosses + * `model_auto_compact_token_limit`; we spawn with a low limit and a big cheap input + * blob so a later turn trips it, and the adapter must surface `_posthog/compact_boundary`. + * Claude is excluded: its manual `/compact` hangs `prompt()` and forcing auto + * compaction is too costly. Tuning: if it never compacts, raise the limit and FILLER together. */ const ADAPTERS: Adapter[] = ["codex"]; -// codex: a limit above codex's resident baseline (so turn 1 leaves real content -// to compact) with FILLER > limit so the crossing is baseline-independent. +// A limit above codex's resident baseline, with FILLER > limit so the crossing is baseline-independent. const AUTO_COMPACT_TOKEN_LIMIT = 16000; // ~20k tokens (~45 chars ≈ 11 tokens × 1800) — larger than the limit above. const FILLER = "The quick brown fox jumps over the lazy dog. ".repeat(1800); @@ -77,8 +63,7 @@ for (const adapter of ADAPTERS) { s.capture.extMethods().includes("_posthog/compact_boundary"); if (adapter === "claude") { - // A little conversation so there's content to compact, then the - // cheap deterministic trigger: manual /compact. + // A little conversation, then the cheap deterministic trigger: manual /compact. await s.conn.prompt({ sessionId: s.sessionId, prompt: [{ type: "text", text: "Reply with only: hello." }], @@ -88,9 +73,8 @@ for (const adapter of ADAPTERS) { prompt: [{ type: "text", text: "/compact" }], }); } else { - // codex: turn 1 is a big cheap input blob that fills the context past - // the low limit (tiny output); turn 2+ trips the auto-compaction on - // the way in. Stop as soon as the boundary is surfaced. + // codex: turn 1's big input blob fills the context past the limit; turn 2+ + // trips auto-compaction. Stop once the boundary is surfaced. for (let i = 0; i < MAX_CODEX_TURNS && !compacted(); i++) { const text = i === 0 diff --git a/packages/agent/e2e/config.ts b/packages/agent/e2e/config.ts index 548d8a54f1..d165d802b9 100644 --- a/packages/agent/e2e/config.ts +++ b/packages/agent/e2e/config.ts @@ -5,24 +5,16 @@ export type Adapter = "claude" | "codex"; /** * Live e2e configuration, resolved entirely from the environment so no secret is - * ever committed. A run needs a local llm-gateway (`./bin/start` in the posthog - * repo) and a token in `E2E_GATEWAY_TOKEN`. We target the gateway's `llm_gateway` - * product, which accepts a **personal API key** (`allow_api_keys=True`) and all - * models — so a plain key works, no OAuth mint (unlike prod's `posthog_code` - * product, which is OAuth-only). `run-e2e.sh` supplies the local dev key. Without - * the token every arm self-skips, so `pnpm test` and CI spend nothing. - * - * The adapter code is product-agnostic (it just posts to `apiBaseUrl`), so - * `llm_gateway` exercises the exact same paths `posthog_code` would. + * committed. Needs a local llm-gateway and a token in `E2E_GATEWAY_TOKEN`; targets + * the `llm_gateway` product, which accepts a personal API key (no OAuth mint, + * unlike prod's `posthog_code`). Without the token every arm self-skips. */ -// `||` not `??`: CI sets unset `vars.*` to an empty string, which should fall -// back to the default rather than override it with "". +// `||` not `??`: CI sets unset vars to "" which should fall back to the default. const GATEWAY_URL = process.env.E2E_GATEWAY_URL || "http://localhost:3308/llm_gateway"; const TOKEN = process.env.E2E_GATEWAY_TOKEN ?? ""; -// apps/code/resources/codex-acp/codex (the native app-server binary), relative -// to packages/agent/e2e. +// The native app-server binary, relative to packages/agent/e2e. const NATIVE_CODEX_BIN = join( __dirname, "..", @@ -45,28 +37,18 @@ export const E2E = { hasToken: !!TOKEN, gatewayUrl: GATEWAY_URL, codexBin: NATIVE_CODEX_BIN, - /** - * Deployment environment the session runs as. `E2E_ENVIRONMENT=cloud` exercises - * the cloud code path (sandbox/permission-profile gating, cloud notifications) — - * the driver injects it into every session's `_meta`. Undefined = local. - */ + /** Deployment environment. `E2E_ENVIRONMENT=cloud` exercises the cloud code path; undefined = local. */ environment: (process.env.E2E_ENVIRONMENT as "local" | "cloud" | undefined) || undefined, - /** - * Cheap model per adapter (overridable). Defaults to a small/cheap model so a - * full run is a couple of short turns. If the gateway doesn't serve the - * default, override via `E2E_CLAUDE_MODEL` / `E2E_CODEX_MODEL` — the turn will - * fail loudly (never a false green) rather than silently skip. - */ + /** Cheap model per adapter, overridable via `E2E_CLAUDE_MODEL` / `E2E_CODEX_MODEL`. */ model(adapter: Adapter): string { // `||` so an empty CI variable falls back to the default. if (adapter === "claude") { return process.env.E2E_CLAUDE_MODEL || "claude-haiku-4-5"; } - // gpt-5-mini is the cheapest codex model the gateway serves. It's on the - // product block list, but that gate is only enforced in Agent.run — the - // e2e drives createAcpConnection directly, so the model is accepted. + // gpt-5-mini is on the product block list, but that gate is only enforced in + // Agent.run — the e2e drives createAcpConnection directly, so it's accepted. return process.env.E2E_CODEX_MODEL || "gpt-5-mini"; }, @@ -79,12 +61,7 @@ export const E2E = { return null; }, - /** - * Point the adapter at the gateway exactly as the host's `configureEnvironment` - * does: Claude reads `ANTHROPIC_*` from env; codex takes the gateway via - * `codexOptions` but we set `OPENAI_*` too for parity, and force the native - * app-server sub-adapter. - */ + /** Point the adapter at the gateway as the host's `configureEnvironment` does. */ configureEnv(adapter: Adapter): void { if (adapter === "claude") { process.env.ANTHROPIC_BASE_URL = GATEWAY_URL; @@ -119,11 +96,7 @@ export const E2E = { }; }, - /** - * A stronger model for the occasional test the cheapest models can't handle — - * e.g. gpt-5-mini / claude-haiku hang on schema-constrained (structured-output) - * decodes. Opts up to a capable model; still env-overridable. - */ + /** A stronger model for tests the cheapest models can't handle (e.g. structured-output decodes). */ strongModel(adapter: Adapter): string { if (adapter === "claude") { return process.env.E2E_CLAUDE_MODEL || "claude-sonnet-4-5"; diff --git a/packages/agent/e2e/driver.ts b/packages/agent/e2e/driver.ts index 605f3249eb..7110e6f40a 100644 --- a/packages/agent/e2e/driver.ts +++ b/packages/agent/e2e/driver.ts @@ -1,12 +1,8 @@ /** - * Adapter-agnostic ACP driver for the live e2e suite. - * - * Stands up the same in-process ACP transport the real host uses - * (`createAcpConnection` → `ClientSideConnection` over `ndJsonStream`) and drives - * a real adapter + real binary + real gateway. The ONLY thing mocked is the - * host/UI client: a recording `sessionUpdate`, an auto-allow `requestPermission`, - * and real `readTextFile`/`writeTextFile` against the on-disk test repo. Nothing - * in the agent/model/tool path is stubbed. + * Adapter-agnostic ACP driver for the live e2e suite. Stands up the same in-process + * ACP transport the real host uses and drives a real adapter + binary + gateway. + * The only thing mocked is the host/UI client (recording sessionUpdate, auto-allow + * requestPermission, real fs read/write against the test repo). */ import { execFileSync } from "node:child_process"; import { @@ -36,11 +32,8 @@ export interface CapturedEvent { export interface Capture { events: CapturedEvent[]; - /** sessionUpdate events of a given type (e.g. "agent_message_chunk"). */ updates(type: string): CapturedEvent[]; - /** server→client permission requests we auto-allowed. */ approvals(): CapturedEvent[]; - /** distinct PostHog ext-notification methods seen (e.g. "_posthog/usage_update"). */ extMethods(): string[]; } @@ -69,7 +62,7 @@ export interface AcpConn { prompt: (p: unknown) => Promise<{ stopReason?: string; usage?: unknown }>; setSessionConfigOption: (p: unknown) => Promise; cancel: (p: unknown) => Promise; - /** Client→agent ext-method (the host drives _posthog/refresh_session). */ + // Client→agent ext-method (the host drives _posthog/refresh_session). extMethod: (method: string, params: unknown) => Promise; } @@ -80,18 +73,15 @@ export interface E2EConnection { } /** - * The ACP `initialize` params our host client sends. Matches the cloud host, - * which advertises NO clientCapabilities — so the adapter runs file/terminal - * tools in-process (codex via shell, claude via its own Read/Write) rather than - * proxying through the host's fs callbacks. The driver still implements - * readTextFile/writeTextFile as harmless insurance. + * The ACP `initialize` params our host client sends. Matches the cloud host, which + * advertises no clientCapabilities — so the adapter runs file/terminal tools + * in-process rather than proxying through the host's fs callbacks. */ export const INIT_PARAMS = { protocolVersion: 1, clientCapabilities: {}, }; -/** Open a live ACP connection to one adapter and start recording its stream. */ export function openConnection(opts: { adapter: Adapter; cwd: string; @@ -101,10 +91,8 @@ export function openConnection(opts: { const { adapter, cwd } = opts; const events: CapturedEvent[] = []; - // Mirror the real cloud host's client surface (sessionUpdate, requestPermission, - // fs read/write, extNotification). Deliberately NO extMethod: the real host - // doesn't implement it, so an adapter that ever calls it should fail e2e the - // same way it would fail in production. + // Mirror the cloud host's client surface. Deliberately no extMethod: the real + // host doesn't implement it, so an adapter calling it should fail e2e as in prod. const client = { async sessionUpdate(p: any): Promise { events.push({ @@ -119,8 +107,7 @@ export function openConnection(opts: { data: { title: p?.toolCall?.title, kind: p?.toolCall?.kind, - // request_user_input (AskUserQuestion) surfaces as a permission with - // `_meta.codeToolKind: "question"`; codex only offers it in Plan mode. + // request_user_input surfaces as a permission with codeToolKind: "question"; codex only offers it in Plan mode. codeToolKind: p?.toolCall?._meta?.codeToolKind, }, }); @@ -201,11 +188,7 @@ export interface OpenSession { cleanup: () => Promise; } -/** - * openConnection + initialize + newSession — the common scenario setup. Returns - * the live connection, the recording capture, the session id, and the full - * newSession response (for configOptions assertions). - */ +/** openConnection + initialize + newSession — the common scenario setup. */ export async function openSession(opts: { adapter: Adapter; cwd: string; @@ -218,8 +201,7 @@ export async function openSession(opts: { const newSession = await c.conn.newSession({ cwd: opts.cwd, mcpServers: [], - // Inject the deployment environment (E2E_ENVIRONMENT) so the whole suite can - // run as a cloud session without threading it through every test's meta. + // Inject E2E_ENVIRONMENT so the suite can run as a cloud session without threading it through every test's meta. _meta: { ...opts.meta, ...(E2E.environment ? { environment: E2E.environment } : {}), @@ -236,18 +218,15 @@ export async function openSession(opts: { export const ORIGINAL_TARGET = "line1\nline2\nline3\n"; -/** A throwaway git repo with a single editable file — the scenario's workspace. */ export function setupRepo(): string { - // realpath so the cwd is canonical: on macOS os.tmpdir() is /var/... (a symlink - // to /private/var/...). The Claude SDK records the resolved path in its session - // store; a fresh connection must use the same path or loadSession's transcript - // replay (keyed by cwd) finds nothing. + // realpath so cwd is canonical: on macOS os.tmpdir() is a symlink. The Claude + // SDK keys its session store by the resolved path, so loadSession's replay finds + // nothing if a fresh connection uses a different path. const repo = realpathSync(mkdtempSync(join(tmpdir(), "agent-e2e-"))); writeFileSync(join(repo, "target.txt"), ORIGINAL_TARGET); execFileSync("git", ["init", "-q"], { cwd: repo }); execFileSync("git", ["add", "-A"], { cwd: repo }); - // -c commit.gpgsign=false: ignore the user's global signing config (e.g. a - // 1Password SSH signer), which fails in this non-interactive context. + // -c commit.gpgsign=false: ignore the user's global signing config, which fails in this non-interactive context. execFileSync( "git", [ @@ -278,11 +257,7 @@ export function cleanupRepo(repo: string): void { } } -/** - * Poll `fn` until it returns a non-undefined value or the timeout elapses. - * Absorbs the small in-process transport / on-disk flush delay between an agent - * emitting a sessionUpdate and the recording client observing it. - */ +/** Poll `fn` until it returns a non-undefined value or the timeout elapses. */ export async function waitFor( fn: () => T | undefined, timeoutMs = 5000, @@ -298,9 +273,8 @@ export async function waitFor( } /** - * codex spawns detached (own process group); a killed run can orphan it holding - * a flock under ~/.codex/tmp, wedging the next run. Kill stragglers first — - * process death releases the flock. + * codex spawns detached; a killed run can orphan it holding a flock under + * ~/.codex/tmp, wedging the next run. Kill stragglers first to release the flock. */ export function killCodexStragglers(): void { try { diff --git a/packages/agent/e2e/guard.e2e.test.ts b/packages/agent/e2e/guard.e2e.test.ts index 96830b88f9..bf859a4969 100644 --- a/packages/agent/e2e/guard.e2e.test.ts +++ b/packages/agent/e2e/guard.e2e.test.ts @@ -2,17 +2,9 @@ import { describe, expect, it } from "vitest"; import { E2E } from "./config"; /** - * Fail-loud precondition for the live e2e suite. - * - * Without E2E_GATEWAY_TOKEN every adapter arm self-skips and `vitest run` exits - * 0 — a green run that exercised NOTHING. That false "adapters OK" is the whole - * reason the suite gave no real signal. This one non-skipped test turns a - * missing token into a RED run, so an enabled-but-misconfigured CI job (or a - * bare `pnpm test:e2e`) fails visibly instead of passing vacuously. - * - * The per-arm skips remain (so a missing token yields ONE clear failure here, - * not a wall of connection errors). Mint a token via `e2e/run-e2e.sh`, or set - * E2E_GATEWAY_TOKEN against a reachable gateway. + * Fail-loud precondition for the live e2e suite. Without E2E_GATEWAY_TOKEN every + * arm self-skips and `vitest run` exits 0 — a green run that tested nothing. This + * one non-skipped test turns a missing token into a RED run. */ describe("live e2e preconditions", () => { it("requires E2E_GATEWAY_TOKEN (else the suite would skip-to-green)", () => { @@ -24,9 +16,8 @@ describe("live e2e preconditions", () => { ).toBe(true); }); - // When the suite IS meant to run (token present) the codex arm must not skip - // silently: a failed binary download would otherwise let the whole run pass - // with ZERO codex coverage — the exact adapter this suite exists to test. + // When a token is present, the codex arm must not skip silently — a missing + // binary would let the run pass with zero codex coverage. it("requires the native codex binary when a token is set (else codex skips-to-green)", () => { if (!E2E.hasToken) return; // no token → whole suite skips; nothing to guard expect( diff --git a/packages/agent/e2e/session-lifecycle.e2e.test.ts b/packages/agent/e2e/session-lifecycle.e2e.test.ts index 42bd8f8ae4..4de1acf3e5 100644 --- a/packages/agent/e2e/session-lifecycle.e2e.test.ts +++ b/packages/agent/e2e/session-lifecycle.e2e.test.ts @@ -16,16 +16,10 @@ import { } from "./driver"; /** - * Live session-lifecycle e2e: drives a representative session per adapter end to - * end against the real gateway + binary on a cheap model. One shared golden turn - * (in `beforeAll`) backs the turn / config / reattach assertions; the other - * scenarios use their own short sessions. Codex-specific capabilities (the - * `{decision}` approval round-trip, steering, mode synthesis, list/fork) run only - * on the codex arm. Assertions are structural lifecycle invariants + the on-disk - * side effect — never model prose (except the deterministic file edit) — so the - * suite holds across adapters and cheap models. Opt-in: each arm self-skips - * unless `E2E_GATEWAY_TOKEN` is set (and, for codex, the native binary exists). - * Run via `pnpm test:e2e`; filter one adapter with `-t "(codex)"`. + * Live session-lifecycle e2e per adapter: drives a real session end to end against + * the real gateway + binary on a cheap model. Assertions are structural lifecycle + * invariants + the on-disk edit, never model prose. Opt-in: each arm self-skips + * unless `E2E_GATEWAY_TOKEN` is set (codex also needs the native binary). */ const ADAPTERS: Adapter[] = ["claude", "codex"]; @@ -38,19 +32,10 @@ const EDIT_PROMPT = for (const adapter of ADAPTERS) { const skip = E2E.skipReason(adapter); const title = `session lifecycle (${adapter})${skip ? ` — SKIPPED (${skip})` : ""}`; - // Codex-only capabilities; registered as skipped on the claude arm so the gap - // is visible rather than silent. + // Codex-only; skipped on the claude arm so the gap is visible. const itCodex = adapter === "codex" ? it : it.skip; - // OS-sandbox enforcement only TIGHTENS per-turn on macOS in a LOCAL session: - // spawn.ts spawns `workspace-write` there (Seatbelt available), so a per-turn - // `:read-only` profile can narrow it and block a write. On Linux the process - // spawns `danger-full-access` (the linux-sandbox launcher is unavailable), so - // the per-turn profile can't re-engage the sandbox. And in the cloud environment - // the adapter deliberately sends NO sandboxPolicy/permissionProfile at all (it - // would panic the absent launcher) — so on macOS + E2E_ENVIRONMENT=cloud the - // workspace-write spawn permits the edit. Gate to macOS AND non-cloud so the - // test only runs where the read-only profile is actually applied; otherwise it - // reds spuriously. + // Read-only profile only tightens per-turn on macOS + non-cloud (elsewhere the + // spawn is danger-full-access / no profile), so gate to where it actually applies. const itCodexSandbox = adapter === "codex" && process.platform === "darwin" && @@ -102,9 +87,7 @@ for (const adapter of ADAPTERS) { }; } catch (err) { // Don't fail the whole describe on a flaky golden turn — record it so only - // the one test that consumes `turn` fails. sessionId/newSessionResponse are - // already captured above, so the independent scenarios (each opens its own - // session) and the reattach/list/fork/resume tests still run. + // the test that consumes `turn` fails. goldenError = err; } finally { await s.cleanup(); @@ -135,31 +118,21 @@ for (const adapter of ADAPTERS) { ].some((e) => e.data?.status === "completed"); expect(anyToolCompleted).toBe(true); - // A concrete usage signal — the exact method, not a loose substring. const hasUsage = turn.capture.updates("usage_update").length > 0 || turn.capture.extMethods().includes("_posthog/usage_update"); expect(hasUsage).toBe(true); - // Both adapters map the session to the host's taskRunId via sdk_session - // (the golden meta sets taskRunId, the cloud host always does). expect(turn.capture.extMethods()).toContain("_posthog/sdk_session"); - // The real on-disk side effect. expect(turn.target).not.toBe(ORIGINAL_TARGET); expect(turn.target).toContain("FOO"); - // codex additionally emits _posthog/turn_complete (claude signals turn - // completion via the prompt response, not this ext-notification). + // codex additionally emits turn_complete; claude signals completion via the prompt response. if (adapter === "codex") { - // NOTE: reasoning (agent_thought_chunk) parity is covered by the unit - // test for both reasoning streams (mapping.test.ts). A live assertion is - // intentionally omitted: the cheap gpt-5-mini turn doesn't reliably emit - // a reasoning summary, so asserting > 0 here would be flaky. Confirming - // the live trigger (summary field vs show_raw_agent_reasoning config) is - // tracked in CODEX_APP_SERVER_TESTING.md. + // Reasoning parity is unit-covered (mapping.test.ts); a live assertion + // would be flaky on the cheap model. expect(turn.capture.extMethods()).toContain("_posthog/turn_complete"); - // turn_complete carries real, well-formed usage (totalTokens = sum). const tc = turn.capture.events.find( (e) => e.kind === "extNotification" && @@ -207,9 +180,8 @@ for (const adapter of ADAPTERS) { s.capture.updates("config_option_update").length, ).toBeGreaterThan(0); } else { - // claude returns the updated configOptions — assert the switch actually - // took (the option's currentValue is now the value we set), not merely - // that an ack event/array was produced (which is unconditionally true). + // claude returns updated configOptions — assert the switch actually took, + // not merely that an ack array was produced (unconditionally true). const updated = ((res?.configOptions ?? []) as ConfigOption[]).find( (o) => o.id === opt?.id, ); @@ -220,8 +192,7 @@ for (const adapter of ADAPTERS) { } }, 90_000); - // The cloud host switches mode ONLY via setSessionConfigOption(configId:"mode") - // (never ACP setSessionMode) on BOTH adapters, so exercise both arms. + // Cloud host switches mode only via setSessionConfigOption(configId:"mode"), so exercise both arms. it("emits current_mode_update when the mode is switched via setSessionConfigOption", async () => { if (adapter === "codex") killCodexStragglers(); const s = await openSession({ @@ -231,8 +202,7 @@ for (const adapter of ADAPTERS) { meta: meta(), }); try { - // codex synthesizes modes (read-only); claude exposes a "mode" - // configOption — pick an alternate value from it. + // codex synthesizes modes; claude exposes a "mode" configOption — pick an alternate value. let value = "read-only"; if (adapter === "claude") { const modeOpt = (s.newSession.configOptions ?? []).find( @@ -255,11 +225,9 @@ for (const adapter of ADAPTERS) { } }, 60_000); - // The behavioral proof that the mode picker is NOT cosmetic: read-only maps - // to a per-turn `:read-only` permission profile (codex app-server), which must - // block a write at the OS level even though the host auto-approves. macOS-only - // (see itCodexSandbox): on Linux the process spawns danger-full-access, so the - // per-turn profile can't tighten it and this would spuriously red. + // Proves the mode picker isn't cosmetic: read-only maps to an OS-level + // :read-only profile that blocks the write even though the host auto-approves. + // macOS-only (see itCodexSandbox). itCodexSandbox( "read-only mode actually blocks a file edit (sandbox restricts, not just approval)", async () => { @@ -271,7 +239,6 @@ for (const adapter of ADAPTERS) { meta: meta(), }); try { - // Switch to read-only; the per-turn profile applies on the next turn. await s.conn.setSessionConfigOption({ sessionId: s.sessionId, configId: "mode", @@ -290,16 +257,10 @@ for (const adapter of ADAPTERS) { }, ], }); - // The turn must complete (a read-only sandbox must not panic/hang)... expect(res.stopReason).toBeTruthy(); - // ...the model actually DID something (>=1 tool call), so a pure prose - // no-op can't masquerade as enforcement. (This only rules out the - // zero-activity false-green; the insistent prompt above pushes toward an - // edit, but a read-then-refuse could still satisfy this without a write - // attempt — an accepted residual on a nondeterministic cheap model.) + // >=1 tool call, so a pure prose no-op can't masquerade as enforcement. expect(s.capture.updates("tool_call").length).toBeGreaterThan(0); - // ...and the on-disk file is byte-for-byte unchanged: the read-only - // sandbox blocked the write despite the host auto-approving. + // File unchanged: the read-only sandbox blocked the write despite host auto-approval. expect(readTarget(repo)).toBe(before); expect(readTarget(repo)).not.toContain("SENTINEL_RO_EDIT"); } finally { @@ -309,11 +270,9 @@ for (const adapter of ADAPTERS) { 180_000, ); - // The proof that Plan is a REAL mode, not a relabeled read-only: codex only - // offers request_user_input (AskUserQuestion) in its "plan" collaboration - // mode (pushed via the turn/start `collaborationMode` field). This also - // covers the revert — codex's collaboration mode is sticky, so switching back - // to auto must push `default` explicitly or it stays stuck in Plan. + // Proves Plan is a real mode: codex only offers request_user_input in its plan + // collaboration mode. Also covers the revert — the collaboration mode is sticky, + // so switching back to auto must push default explicitly. itCodex( "plan mode engages codex's plan collaboration, and reverts when switched back to auto", async () => { @@ -333,7 +292,6 @@ for (const adapter of ADAPTERS) { .approvals() .filter((e) => e.data?.codeToolKind === "question").length; try { - // Plan: request_user_input is available → a question reaches the host. await s.conn.setSessionConfigOption({ sessionId: s.sessionId, configId: "mode", @@ -346,9 +304,7 @@ for (const adapter of ADAPTERS) { const afterPlan = questionCount(); expect(afterPlan).toBeGreaterThan(0); - // Switch back to auto (default collaboration): request_user_input is no - // longer available, so the SAME prompt yields no new question. If the - // collaboration mode didn't revert, codex would ask again. + // Switch back to auto: request_user_input is gone, so the same prompt yields no new question. await s.conn.setSessionConfigOption({ sessionId: s.sessionId, configId: "mode", @@ -379,14 +335,12 @@ for (const adapter of ADAPTERS) { mcpServers: [], }); if (adapter === "claude") { - // claude IMPLEMENTS refresh_session; the cheap model (haiku) is on - // the MCP-injection exclude list, so it RECOGNIZES the method and - // rejects on the model gate — not method-not-found — proving the - // host's call reaches the handler. + // claude implements refresh_session; haiku is on the MCP-injection exclude + // list, so it rejects on the model gate (not method-not-found), proving the + // call reaches the handler. await expect(call).rejects.toThrow(/MCP injection/i); } else { - // codex doesn't implement extMethod — the host's refresh_session - // call rejects cleanly (the known adapter divergence). + // codex doesn't implement extMethod — the call rejects cleanly (known adapter divergence). await expect(call).rejects.toThrow(); } } finally { @@ -394,15 +348,9 @@ for (const adapter of ADAPTERS) { } }, 60_000); - // NOTE: the command/file approval `{decision}` round-trip is NOT exercised - // here. codex spawns under a danger-full-access sandbox (spawn.ts), so it - // auto-approves and never sends item/*requestApproval — even in read-only - // mode — so an e2e approval assertion can't fire without changing production - // sandbox behavior. That envelope is covered by unit tests instead - // (codex-app-server-agent.test.ts: "maps allow to a decision envelope" et al). - // Likewise the server->client requestPermission policy (publish-blocking, - // Slack relay, plan approval, deny-on-shutdown) can't be triggered from a - // cheap model in this harness — it's covered by approvals.test.ts. + // Known gap: the approval {decision} round-trip and requestPermission policy + // aren't exercised here (codex auto-approves under danger-full-access) — + // unit-covered in codex-app-server-agent.test.ts / approvals.test.ts. it("incorporates a prompt's _meta.prContext without error", async () => { if (adapter === "codex") killCodexStragglers(); @@ -413,8 +361,7 @@ for (const adapter of ADAPTERS) { meta: meta(), }); try { - // The host attaches prContext on PR-follow-up runs; both adapters - // prepend it to the forwarded prompt. + // The host attaches prContext on PR-follow-up runs; both adapters prepend it. const res = await s.conn.prompt({ sessionId: s.sessionId, prompt: [ @@ -464,22 +411,17 @@ for (const adapter of ADAPTERS) { : undefined, 20_000, ); - // A second prompt while the turn is in flight folds in via turn/steer. const p2 = s.conn.prompt({ sessionId: s.sessionId, prompt: [{ type: "text", text: "Now stop and say DONE." }], }); const [r1] = await Promise.all([p1, p2]); expect(r1.stopReason).toBe("end_turn"); - // Both the original and the steered message echoed as user turns... expect( s.capture.updates("user_message_chunk").length, ).toBeGreaterThanOrEqual(2); - // ...and — the steer-specific proof — they folded into a SINGLE turn: - // exactly one _posthog/turn_complete. A swallowed/failed turn/steer, or - // p1 finishing before p2 lands (so p2 runs as its own turn), would yield - // 2. The bare user_message_chunk count above only proves prompt() ran - // twice; this proves the second prompt actually steered the first turn. + // The steer proof: folded into a SINGLE turn (one turn_complete). Two would + // mean the steer didn't take and p2 ran as its own turn. const turnCompletes = s.capture.events.filter( (e) => e.kind === "extNotification" && @@ -526,12 +468,9 @@ for (const adapter of ADAPTERS) { 60_000, ); - // NOTE: the permission DENY path isn't exercised here. Neither arm reliably - // surfaces a deny-able approval to a cheap model: codex auto-approves under - // its danger-full-access sandbox, and claude routes file edits through ACP - // writeTextFile rather than a requestPermission round-trip. The deny/cancel - // paths are unit-covered instead (approvals.test.ts safe-default-on-reject; - // codex-app-server-agent.test.ts command-approval cancel/decline envelope). + // Known gap: the permission DENY path isn't exercised (neither arm reliably + // surfaces a deny-able approval to a cheap model) — unit-covered in + // approvals.test.ts / codex-app-server-agent.test.ts. it("interrupts an in-flight turn", async () => { if (adapter === "codex") killCodexStragglers(); @@ -551,8 +490,7 @@ for (const adapter of ADAPTERS) { }, ], }); - // Cancel as soon as the turn is in flight (unbounded work, so no race - // with a fast finish). + // Cancel as soon as the turn is in flight (unbounded work, so no race). await waitFor( () => s.capture.updates("agent_message_chunk").length > 0 || @@ -565,10 +503,7 @@ for (const adapter of ADAPTERS) { const res = await p; expect(res.stopReason).toBe("cancelled"); - // Real-world impact: after a cancel the session must be usable again. - // With the old bug (turn/interrupt sent without the required turnId) the - // server-side turn kept running, so a follow-up turn could not start - // cleanly. A bounded follow-up must now complete normally. + // After a cancel the session must be usable again — a bounded follow-up must complete. const followUp = await s.conn.prompt({ sessionId: s.sessionId, prompt: [{ type: "text", text: "Stop. Reply with just: OK" }], @@ -617,11 +552,8 @@ for (const adapter of ADAPTERS) { _meta: { model: E2E.model(adapter) }, }); expect(loaded).toBeTruthy(); - // loadSession runs no turn, so any transcript update here is replayed - // history. The replayed shape differs by adapter, so assert each arm's - // real replay path: codex replays user/agent message chunks (from - // thread.turns via mapHistoryItem); claude replays tool calls (from its - // SDK transcript via replaySessionHistory). + // loadSession runs no turn, so any update here is replayed history. The + // shape differs by adapter: codex replays message chunks, claude tool calls. const replayed = await waitFor(() => { const n = adapter === "codex" diff --git a/packages/agent/e2e/structured-output.e2e.test.ts b/packages/agent/e2e/structured-output.e2e.test.ts index a5a8a51c20..05cddc1003 100644 --- a/packages/agent/e2e/structured-output.e2e.test.ts +++ b/packages/agent/e2e/structured-output.e2e.test.ts @@ -8,12 +8,10 @@ import { } from "./driver"; /** - * Live structured-output e2e: both adapters constrain the final assistant message - * to a JSON schema (`_meta.jsonSchema`) and deliver the parsed object via the - * `onStructuredOutput` callback — the contract the signals pipeline relies on - * (codex: native `outputSchema`; claude: SDK `outputFormat: json_schema`). The - * answer is deterministic (capital of France) so a cheap model passes reliably. - * Opt-in (same gating as the lifecycle suite); run via `pnpm test:e2e`. + * Live structured-output e2e: both adapters constrain the final message to a JSON + * schema (`_meta.jsonSchema`) and deliver the parsed object via `onStructuredOutput` + * — the contract the signals pipeline relies on. Deterministic answer so a cheap + * model passes reliably. Opt-in (same gating as the lifecycle suite). */ const ADAPTERS: Adapter[] = ["claude", "codex"]; @@ -60,8 +58,7 @@ for (const adapter of ADAPTERS) { model, permissionMode: "bypassPermissions", jsonSchema: SCHEMA, - // Prod always sets taskRunId — exercise structured output and the - // session ext-notification together, the way the host actually drives it. + // Prod always sets taskRunId — exercise structured output + the session ext-notification together. taskRunId: "e2e-structured", }, }); @@ -79,7 +76,6 @@ for (const adapter of ADAPTERS) { expect(captured, "onStructuredOutput should fire").toBeTruthy(); expect(typeof captured?.capital).toBe("string"); expect((captured?.capital as string).toLowerCase()).toContain("paris"); - // With taskRunId set, the session is mapped for the host too. expect(s.capture.extMethods()).toContain("_posthog/sdk_session"); } finally { await s.cleanup(); diff --git a/packages/agent/src/adapters/codex-app-server/app-server-client.test.ts b/packages/agent/src/adapters/codex-app-server/app-server-client.test.ts index d45276c6b1..cc688e061d 100644 --- a/packages/agent/src/adapters/codex-app-server/app-server-client.test.ts +++ b/packages/agent/src/adapters/codex-app-server/app-server-client.test.ts @@ -14,10 +14,7 @@ interface RpcMessage { error?: { code: number; message: string }; } -/** - * Drives the "server" end of a {@link StreamPair}: reads newline-delimited - * JSON-RPC the client sent and writes framed responses/notifications back. - */ +/** Drives the "server" end of a {@link StreamPair}: reads client JSON-RPC and writes framed replies back. */ function makeFakeServer(transport: StreamPair) { const writer = transport.writable.getWriter(); const reader = transport.readable.getReader(); @@ -158,8 +155,6 @@ describe("AppServerClient", () => { }); const response = await server.readMessage(); - // Routed to onRequest (not silently dropped as a notification) and the - // exact string id is echoed back, so the server can correlate the reply. expect(onRequest).toHaveBeenCalledTimes(1); expect(response.id).toBe("req-abc"); expect(response.result).toEqual({ decision: "approved" }); diff --git a/packages/agent/src/adapters/codex-app-server/app-server-client.ts b/packages/agent/src/adapters/codex-app-server/app-server-client.ts index 4915c2d624..c437155be3 100644 --- a/packages/agent/src/adapters/codex-app-server/app-server-client.ts +++ b/packages/agent/src/adapters/codex-app-server/app-server-client.ts @@ -5,10 +5,7 @@ import type { JsonRpcMessage, JsonRpcResponse, RequestId } from "./protocol"; export interface AppServerClientHandlers { /** Server-pushed notification (no id), e.g. `item/agentMessage/delta`. */ onNotification?: (method: string, params: unknown) => void; - /** - * Server-initiated request (has an id), e.g. an approval. The resolved value - * is returned to the server as the JSON-RPC result. - */ + /** Server-initiated request (has an id), e.g. an approval; the resolved value is returned as the JSON-RPC result. */ onRequest?: (method: string, params: unknown) => Promise; /** Fired once when the stream ends without an explicit close() (process exit). */ onClose?: () => void; @@ -28,12 +25,8 @@ export interface AppServerRpc { } /** - * Bidirectional newline-delimited JSON-RPC client for the native Codex - * `app-server` subprocess. Unlike the codex-acp adapter this speaks Codex's - * own protocol rather than ACP, so it cannot reuse the ACP SDK connection. - * - * Transport-agnostic: it is given a {@link StreamPair} so tests can drive it - * over in-memory streams without spawning a process. + * Bidirectional newline-delimited JSON-RPC client for the native Codex `app-server` subprocess. + * Transport-agnostic via a {@link StreamPair} so tests can drive it over in-memory streams. */ export class AppServerClient implements AppServerRpc { private readonly writer: WritableStreamDefaultWriter; @@ -126,9 +119,7 @@ export class AppServerClient implements AppServerRpc { // lock already released by cancel() } if (!this.closed) { - // The stream ended without an explicit close() (the process exited). - // Fail in-flight calls and notify the owner so a pending turn does not - // hang forever. + // Stream ended without close() (process exited): fail in-flight calls so the turn doesn't hang. this.closed = true; for (const call of this.pending.values()) { call.reject(new Error("codex app-server stream closed")); @@ -151,11 +142,8 @@ export class AppServerClient implements AppServerRpc { const id = (message as { id?: unknown }).id; const method = (message as { method?: unknown }).method; const params = (message as { params?: unknown }).params; - // JSON-RPC framing: a request has both method and id; a notification has a - // method and no id; a response has an id and no method. Discriminate on - // presence, not `typeof id === "number"` — the schema's RequestId is - // `string | number`, so a string-id server request must still be answered - // (keying on number alone silently misroutes it and hangs the turn). + // Discriminate on id presence, not `typeof id === "number"` — RequestId is + // string|number, so a string-id server request must still be answered. const hasId = id !== undefined && id !== null; if (typeof method === "string") { diff --git a/packages/agent/src/adapters/codex-app-server/approvals.test.ts b/packages/agent/src/adapters/codex-app-server/approvals.test.ts index a767efc756..743e3f5fc5 100644 --- a/packages/agent/src/adapters/codex-app-server/approvals.test.ts +++ b/packages/agent/src/adapters/codex-app-server/approvals.test.ts @@ -7,8 +7,7 @@ import { QuestionMetaSchema } from "../claude/questions/utils"; import { handleServerRequest } from "./approvals"; import { APP_SERVER_REQUESTS } from "./protocol"; -// A fake ACP client whose requestPermission returns whatever the test queues, -// matched positionally to the order requestPermission is called. +// A fake ACP client whose requestPermission returns queued outcomes positionally. function fakeClient(outcomes: RequestPermissionResponse["outcome"][]) { const calls: RequestPermissionRequest[] = []; let next = 0; @@ -64,7 +63,6 @@ describe("handleServerRequest", () => { answers: { q1: { answers: ["production"] } }, }); - // Prompt carried the question's options and the session id. expect(calls).toHaveLength(1); expect(calls[0].sessionId).toBe("sess-1"); expect(calls[0].options.map((o) => o.name)).toEqual([ @@ -105,8 +103,7 @@ describe("handleServerRequest", () => { opts, ); - // The bug: a bare `{ header }` _meta fails QuestionMetaSchema, so the host's - // QuestionPermission renders an empty "Review your answers" screen. + // A bare `{ header }` _meta fails QuestionMetaSchema, rendering an empty card. const parsed = QuestionMetaSchema.safeParse(calls[0].toolCall?._meta); expect(parsed.success).toBe(true); expect(parsed.data?.questions).toEqual([ @@ -185,7 +182,6 @@ describe("handleServerRequest", () => { }); it("fails closed to the safe default when a payload is malformed", async () => { - // null params makes the handler throw on param access; it must deny, not raise. const { client } = fakeClient([{ outcome: "selected", optionId: "allow" }]); const result = await handleServerRequest( APP_SERVER_REQUESTS.PERMISSIONS_APPROVAL, @@ -306,9 +302,6 @@ describe("handleServerRequest", () => { }, ); - // The prompt now carries the real MCP tool + args + _meta.posthog (not - // codex's bare "run tool exec" text), so the host renders the proper MCP - // permission card with the unwrapped command. expect(calls[0].toolCall).toMatchObject({ toolCallId: "posthog:elicitation", rawInput: { command: "search project|insight" }, diff --git a/packages/agent/src/adapters/codex-app-server/approvals.ts b/packages/agent/src/adapters/codex-app-server/approvals.ts index 1b028b3d80..950e978307 100644 --- a/packages/agent/src/adapters/codex-app-server/approvals.ts +++ b/packages/agent/src/adapters/codex-app-server/approvals.ts @@ -1,25 +1,9 @@ /** - * Handlers for the richer Codex app-server server-requests that carry distinct - * response shapes rather than a yes/no approval decision string. - * - * The hub agent's `handleApproval` already covers the two simple approvals - * (`item/commandExecution/requestApproval`, `item/fileChange/requestApproval`) - * by returning "accept"/"decline"/"cancel". The three requests below each - * expect a *typed response object*, so they live here: - * - * - `item/tool/requestUserInput` — AskUserQuestion-style multi-question prompt; - * response is `{ answers: { [questionId]: { answers: string[] } } }`. - * - `item/permissions/requestApproval` — grant a permission profile for a - * turn/session; response is `{ permissions, scope }`. - * - `mcpServer/elicitation/request` — an MCP server asking the user for - * structured input; response is `{ action, content }`. - * - * We surface each to the ACP client through `requestPermission` (the only - * user-prompt primitive ACP gives an agent), mirroring how the Claude adapter - * maps AskUserQuestion options to permission options and the selected optionId - * back to an answer. On cancel/error we always default to the safe outcome - * (empty answers / no permissions granted / decline) so a dropped prompt never - * silently grants access. + * Handlers for the richer Codex app-server server-requests that carry a typed + * response object rather than a yes/no decision string (requestUserInput, + * permissions/requestApproval, mcpServer/elicitation). Each is surfaced through + * ACP `requestPermission`; on cancel/error we default to the safe outcome so a + * dropped prompt never silently grants access. */ import type { @@ -29,14 +13,11 @@ import type { } from "@agentclientprotocol/sdk"; import { mcpToolKey, posthogToolMeta } from "@posthog/shared"; import type { Logger } from "../../utils/logger"; -// Shared with the Claude adapter so synthesized permission option ids round-trip -// the same way across adapters. import { OPTION_PREFIX } from "../claude/questions/utils"; import { APP_SERVER_REQUESTS } from "./protocol"; -// Native app-server param/response shapes (subset of /tmp/codex-schema/v2). -// Re-declared locally so this module stays self-contained and does not depend -// on the generated schema package being present at build time. +// Native app-server shapes, re-declared locally so this module doesn't depend on +// the generated schema at build time. interface ToolRequestUserInputOption { label: string; @@ -111,8 +92,7 @@ interface McpServerElicitationRequestParams { serverName: string; mode: "form" | "url"; message: string; - // form mode carries requestedSchema; url mode carries url/elicitationId. - // We only need `message` to render the prompt, so the rest is untyped here. + // Only `message` is needed to render the prompt; the rest stays untyped. [key: string]: unknown; } @@ -123,8 +103,7 @@ interface McpServerElicitationRequestResponse { } export interface HandleServerRequestResult { - // false → not one of the richer requests; the caller should fall through to - // its own handling (e.g. the simple command/file approvals). + // false → not a richer request; the caller handles it (simple approvals). handled: boolean; response: unknown; } @@ -133,13 +112,9 @@ export interface HandleServerRequestOptions { sessionId: string; logger?: Logger; /** - * Resolve the in-flight MCP tool call for an elicitation's `serverName`. codex - * gates an MCP tool behind a generic `mcpServer/elicitation/request` ("Allow - * the posthog MCP server to run tool X?") that carries no tool/args — but the - * originating `mcpToolCall` (real tool + arguments) is in flight at the same - * time. Supplying it lets the prompt render the actual operation (e.g. the - * PostHog `exec` command) with `_meta.posthog`, matching the command-approval - * path. Undefined → fall back to codex's generic text. + * Resolve the in-flight MCP tool call for an elicitation's `serverName`. codex's + * elicitation carries no tool/args, so supplying the originating `mcpToolCall` + * lets the prompt render the real operation. Undefined → codex's generic text. */ resolveMcpToolCall?: ( serverName: string, @@ -148,8 +123,7 @@ export interface HandleServerRequestOptions { /** * Routes a server-initiated request to the matching richer-response handler. - * Returns `{ handled: false }` for anything this module does not own (including - * the two simple approvals and unknown methods) so the caller keeps control. + * Returns `{ handled: false }` for anything this module doesn't own. */ export async function handleServerRequest( method: string, @@ -190,9 +164,7 @@ export async function handleServerRequest( return { handled: false, response: undefined }; } } catch (err) { - // A malformed payload must fail CLOSED to the method's safe default — never - // throw (the hub would surface a JSON-RPC error the server may treat - // ambiguously) and never grant. + // Malformed payload fails closed to the safe default — never throw, never grant. opts.logger?.warn("server-request handler threw; failing closed", { method, error: String(err), @@ -201,7 +173,6 @@ export async function handleServerRequest( } } -/** Fail-closed default response per richer-approval method. */ function safeDefaultFor(method: string): unknown { if (method === APP_SERVER_REQUESTS.PERMISSIONS_APPROVAL) { return { permissions: {}, scope: "turn" }; @@ -223,8 +194,7 @@ function buildQuestionOptions( })); } -// Maps a selected permission optionId back to the chosen option's label. The id -// is `option_`, so we index back into the question's options. +// Maps a selected optionId (`option_`) back to the chosen option's label. function answerFromSelection( question: ToolRequestUserInputQuestion, optionId: string | undefined, @@ -245,13 +215,11 @@ async function handleToolUserInput( const answers: ToolRequestUserInputResponse["answers"] = {}; for (const question of params.questions ?? []) { - // Default each question to "no answer" so a cancel or failure leaves a - // well-formed, empty response rather than a missing key. + // Default to "no answer" so cancel/failure leaves a well-formed empty response. answers[question.id] = { answers: [] }; const options = buildQuestionOptions(question); - // Free-text ("other"/secret) questions have no selectable options; we can't - // collect typed input over requestPermission, so leave them empty. + // Free-text questions have no options; requestPermission can't collect them. if (options.length === 0) { continue; } @@ -265,10 +233,8 @@ async function handleToolUserInput( toolCallId: `${params.itemId}:${question.id}`, title: question.question, kind: "other", - // The host's QuestionPermission renders from `_meta.questions` - // (QuestionMetaSchema) — a bare `header` left it empty ("Review your - // answers" with nothing). codex prompts one question per request, so - // carry exactly this question; selection still flows through `options`. + // The host's QuestionPermission renders from `_meta.questions`; a bare + // `header` renders empty. codex prompts one question per request. _meta: { codeToolKind: "question", questions: [ @@ -295,7 +261,6 @@ async function handleToolUserInput( } if (response.outcome.outcome !== "selected") { - // Cancelled → keep the safe empty default. continue; } answers[question.id] = { @@ -342,8 +307,7 @@ async function handlePermissionsApproval( response.outcome.outcome === "selected" && response.outcome.optionId === "allow" ) { - // Grant exactly what was requested, scoped to this turn — the option is - // "allow_once", so a single click must not grant session-wide access. + // Grant only what was requested, scoped to this turn (option is "allow_once"). return { permissions: grantedFromRequested(params.permissions), scope: "turn", @@ -376,9 +340,8 @@ async function handleMcpElicitation( _meta: null, }; - // When the elicitation gates a known in-flight MCP tool call, carry its real - // tool + args + `_meta.posthog` so the host renders the proper MCP permission - // (incl. PostHog `exec` unwrapping) instead of codex's generic server text. + // If the elicitation gates a known in-flight MCP call, carry its real tool + + // args + `_meta.posthog` so the host renders the proper MCP permission. const mcp = opts.resolveMcpToolCall?.(params.serverName); const toolCall = mcp ? { @@ -422,8 +385,7 @@ async function handleMcpElicitation( response.outcome.outcome === "selected" && response.outcome.optionId === "accept" ) { - // We have no structured form UI over requestPermission, so accept with no - // content. The MCP server treats this as an empty-but-accepted result. + // No structured form UI over requestPermission; accept with empty content. return { action: "accept", content: {}, _meta: null }; } return declined; diff --git a/packages/agent/src/adapters/codex-app-server/binary-path.test.ts b/packages/agent/src/adapters/codex-app-server/binary-path.test.ts index c17cb0162e..27d472ff02 100644 --- a/packages/agent/src/adapters/codex-app-server/binary-path.test.ts +++ b/packages/agent/src/adapters/codex-app-server/binary-path.test.ts @@ -28,8 +28,6 @@ describe("nativeCodexBinaryPath", () => { }); it("falls back to the @openai/codex vendored binary when no sibling is bundled", () => { - // No sibling next to codex-acp; the @openai/codex platform sub-package - // resolves and its vendored binary exists. resolveMock.mockReturnValue("/nm/@openai/codex-plat/package.json"); existsSyncMock.mockImplementation((p: string) => p.includes("/vendor/")); const got = nativeCodexBinaryPath(undefined); diff --git a/packages/agent/src/adapters/codex-app-server/binary-path.ts b/packages/agent/src/adapters/codex-app-server/binary-path.ts index e179bce671..6af43597cb 100644 --- a/packages/agent/src/adapters/codex-app-server/binary-path.ts +++ b/packages/agent/src/adapters/codex-app-server/binary-path.ts @@ -3,10 +3,8 @@ import { createRequire } from "node:module"; import { dirname, join } from "node:path"; /** - * Node `platform-arch` → codex target triple + the `@openai/codex` platform - * sub-package that vendors the native binary. Mirrors the map in `@openai/codex`'s - * own `bin/codex.js` shim (the sub-packages are aliased optional deps, e.g. - * `@openai/codex-linux-arm64` = `npm:@openai/codex@-linux-arm64`). + * Node `platform-arch` → codex target triple + `@openai/codex` platform sub-package + * that vendors the native binary. Mirrors `@openai/codex`'s own `bin/codex.js` shim. */ const CODEX_NATIVE_TARGETS: Record< string, @@ -39,21 +37,17 @@ const CODEX_NATIVE_TARGETS: Record< }; /** - * Resolve the native codex binary vendored by the `@openai/codex` dependency's - * platform sub-package, so the app-server adapter works from a plain - * `npm install @posthog/agent` with no separate download step — the same way - * Claude (an SDK dep) and codex-acp (npm platform binaries) ride along with the - * package. Returns undefined when the dep (or this platform's sub-package, an - * `os`/`cpu`-gated optional dep) isn't installed. + * Resolve the native codex binary vendored by `@openai/codex`'s platform sub-package, + * so the adapter works from a plain `npm install @posthog/agent` with no download. + * Returns undefined when the dep or this platform's sub-package isn't installed. */ function vendoredCodexBinary(): string | undefined { const target = CODEX_NATIVE_TARGETS[`${process.platform}-${process.arch}`]; if (!target) return undefined; const binaryName = process.platform === "win32" ? "codex.exe" : "codex"; try { - // Anchor resolution at this module's directory so the dep is found in - // @posthog/agent's node_modules (or a hoisted one). The filename passed to - // createRequire need not exist — only its directory is used. + // Anchor resolution at this module's dir; the createRequire filename need not + // exist (only its directory is used). const requireFrom = createRequire( join(import.meta.dirname ?? __dirname, "_resolve.js"), ); @@ -73,13 +67,8 @@ function vendoredCodexBinary(): string | undefined { /** * Path to the native codex CLI (the one that exposes `app-server`), or undefined - * when it isn't available (caller then keeps the codex-acp adapter). - * - * Two sources, in order: - * 1. Bundled next to the codex-acp binary (the desktop's resources dir, where - * `download-binaries.mjs` places both). - * 2. Vendored by the `@openai/codex` npm dependency — the install-time path that - * works everywhere `@posthog/agent` is installed (sandbox, CI), no download. + * when unavailable. Two sources in order: bundled next to codex-acp, then vendored + * by the `@openai/codex` npm dependency. */ export function nativeCodexBinaryPath( codexAcpPath?: string, diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 8454e74c42..bf163ba199 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -70,20 +70,10 @@ import { import { TurnController } from "./turn-controller"; import { UsageTracker } from "./usage-tracker"; -/** - * Subset of the host-supplied `_meta` the app-server adapter consumes. The host - * (agent-server) sets these on `newSession`; see its `clientConnection.newSession` - * call. `systemPrompt` carries the task instructions, `jsonSchema` constrains the - * final assistant message for structured output. The remaining fields (taskRunId, - * taskId/persistence, environment/channelMode, baseBranch) drive the cloud - * ext-notifications, local-tools gating, and the context-breakdown baseline — - * mirroring the codex-acp adapter's `NewSessionMeta`. - */ type AppServerSessionMeta = { // The host sends either a plain string or the Claude-style `{ append }` form. systemPrompt?: string | { append?: string }; jsonSchema?: Record | null; - /** Initial approval mode the host requests (mapped to a codex mode). */ permissionMode?: string; taskRunId?: string; taskId?: string; @@ -93,18 +83,13 @@ type AppServerSessionMeta = { baseBranch?: string; }; -/** - * The subset of codex's `Thread` the adapter reads: its id, plus the persisted - * `turns` (each a list of `ThreadItem`s) that `thread/resume` returns for - * `loadSession` history replay. - */ +/** The subset of codex's `Thread` the adapter reads: id + persisted `turns` for history replay. */ type AppServerThread = { id?: string; turns?: Array<{ items?: Parameters[1][] }>; }; -// The native app-server owns its own configuration, so there is nothing for the -// host to manage. BaseAcpAgent only calls dispose() on this. +// The native app-server owns its config; BaseAcpAgent only calls dispose() on this. class NoopSettingsManager implements BaseSettingsManager { constructor(private cwd: string) {} dispose(): void {} @@ -119,43 +104,24 @@ class NoopSettingsManager implements BaseSettingsManager { export interface CodexAppServerAgentOptions { processOptions: CodexAppServerProcessOptions; - /** Model id passed to thread/start. */ model?: string; - /** Reasoning effort passed to turn/start. */ reasoningEffort?: string; processCallbacks?: ProcessSpawnedCallback; logger?: Logger; - /** - * Invoked once per turn with the structured output the agent produced, parsed - * from the schema-constrained final assistant message. Mirrors the codex-acp - * adapter's callback so the host's `setTaskRunOutput` contract is unchanged. - */ onStructuredOutput?: (output: Record) => Promise; /** Test seam: build the JSON-RPC client (defaults to spawning the process). */ rpcFactory?: (handlers: AppServerClientHandlers) => AppServerRpc; } /** - * ACP Agent backed by the native Codex `app-server` protocol. Presents the same - * ACP surface to PostHog Code as the codex-acp adapter, but talks to Codex's own - * JSON-RPC protocol underneath instead of going through the Zed translation layer. - * - * At parity with codex-acp on the adapter surface: lifecycle (initialize, - * thread/start, turn/start), resume/fork/list, streamed agent + reasoning text, - * tool-call rendering (command/file-diff/MCP), token usage (+ `_posthog/usage_update`), - * configOptions/modes (model + effort selectors, `setSessionConfigOption`, - * `current_mode_update`, mode→approvalPolicy), `available_commands_update` (skills), - * plan rendering, interrupt, command/file approvals, MCP injection, structured - * output via native `outputSchema`, image prompt input, steering (turn/steer), - * the local-tools MCP, richer approvals (AskUserQuestion / elicitation / permission - * profiles), the cloud ext-notifications (`_posthog/sdk_session`, - * `_posthog/turn_complete`, usage breakdown), and `loadSession`. + * ACP Agent backed by the native Codex `app-server` JSON-RPC protocol. Presents the + * same ACP surface to PostHog Code as the codex-acp adapter, without the Zed + * translation layer, and stays at parity with it on the adapter surface. */ export class CodexAppServerAgent extends BaseAcpAgent { readonly adapterName = "codex"; private readonly rpc: AppServerRpc; private readonly proc?: CodexAppServerProcess; - /** Model / reasoning-effort / mode selectors + the derived configOptions. */ private readonly config: SessionConfigState; private readonly onStructuredOutput?: ( output: Record, @@ -171,18 +137,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { private compactionActive = false; /** Maps the host's taskRunId to this session, replayed for cloud notifications. */ private taskRunId?: string; - /** - * Deployment environment from the host `_meta`. Gates the per-turn - * `sandboxPolicy` mode override: on "cloud" a non-danger sandbox would - * re-engage the unavailable linux-sandbox and panic, so we leave the spawned - * danger-full-access in place there. - */ + /** Deployment environment; on "cloud" a non-danger sandbox would panic, so we skip the override. */ private environment?: "local" | "cloud"; - /** Session MCP tool-call state; correlates approval prompts to the real tool. */ private readonly mcp = new McpManager(); - /** The turn state machine: turnId, pending completion, steer/interrupt races. */ private readonly turns = new TurnController(); - /** Token usage + context-breakdown state, driven by codex's tokenUsage.last. */ private readonly usage = new UsageTracker(); constructor( @@ -242,26 +200,18 @@ export class CodexAppServerAgent extends BaseAcpAgent { title: "PostHog Code", version: "0.1.0", }, - // Opt into codex's experimental API surface. Experimental fields are - // additive (unknown ones are ignored), so the adapter's known - // methods/notifications are unaffected; we keep it on so experimental - // turn/start fields are honored rather than silently dropped. + // Opt into codex's experimental API so experimental turn/start fields are honored. capabilities: { experimentalApi: true, requestAttestation: false }, }); this.rpc.notify(APP_SERVER_NOTIFICATIONS.INITIALIZED, {}); return { protocolVersion: request.protocolVersion, agentCapabilities: { - // Image prompt input now flows through toCodexInput (data URL / remote / - // localImage). embeddedContext mirrors the Claude adapter's advertisement. promptCapabilities: { image: true, embeddedContext: true, }, - // Only http is advertised: toCodexMcpServers translates stdio + http - // (url) servers. SSE isn't a distinct transport in the codex config we - // emit, so we don't claim it rather than mistranslate an SSE server into - // the http shape. + // Only http: we don't claim SSE rather than mistranslate it into the http shape. mcpCapabilities: { http: true, }, @@ -270,13 +220,11 @@ export class CodexAppServerAgent extends BaseAcpAgent { list: {}, fork: {}, resume: {}, - // Extra workspace roots are forwarded to codex as writable_roots. additionalDirectories: {}, }, _meta: { posthog: { resumeSession: true, - // turn/steer folds a mid-turn prompt into the running turn. steering: "native", }, }, @@ -303,7 +251,6 @@ export class CodexAppServerAgent extends BaseAcpAgent { return { sessionId: threadId, configOptions: this.config.options }; } - /** thread/resume — the host calls this on every reconnect to restore a session. */ async resumeSession( params: ResumeSessionRequest, ): Promise { @@ -317,14 +264,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { return { configOptions: this.config.options }; } - /** - * loadSession — the host re-attaches to an existing thread (e.g. page reload) - * without starting a new turn. Restores it through the same thread/resume path - * as resumeSession (model config, configOptions, commands, `_posthog/sdk_session`) - * and replays the persisted transcript from the resumed thread's turns so the - * host shows prior history. Returns no `modes` since codex exposes approval - * modes only through configOptions, not a SessionModeState. - */ + /** Re-attach to an existing thread without starting a turn: resume it, then replay the transcript. */ async loadSession(params: LoadSessionRequest): Promise { const { thread } = await this.setupThread( APP_SERVER_METHODS.THREAD_RESUME, @@ -340,7 +280,6 @@ export class CodexAppServerAgent extends BaseAcpAgent { return { configOptions: this.config.options }; } - /** thread/fork — branch a new thread from an existing one. */ async unstable_forkSession( params: ForkSessionRequest, ): Promise { @@ -357,12 +296,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { return { sessionId: threadId, configOptions: this.config.options }; } - /** - * Replay a resumed thread's persisted turns as ACP session updates so a - * reattaching host renders the prior transcript. Native: the turns come from - * the `thread/resume` response (codex populates `thread.turns` there), so no - * extra round-trip — and each item reuses the live `mapHistoryItem` renderer. - */ + /** Replay a resumed thread's persisted turns (from the thread/resume response) as session updates. */ private replayHistory(thread: AppServerThread | undefined): void { if (!this.sessionId || !thread?.turns?.length) return; for (const turn of thread.turns) { @@ -374,7 +308,6 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } - /** thread/list — past sessions for the session picker. */ async listSessions( params: ListSessionsRequest, ): Promise { @@ -403,11 +336,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } - /** - * Shared thread setup for start/resume/fork: injects task instructions + MCP - * servers, opens the thread, loads model config, and emits the configOptions. - * `threadId` present => resume/fork an existing thread; absent => new thread. - */ + /** Shared thread setup for start/resume/fork. `threadId` present => resume/fork; absent => new thread. */ private async setupThread( method: string, params: { @@ -421,19 +350,11 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.jsonSchema = params.meta?.jsonSchema ?? undefined; this.taskRunId = params.meta?.taskRunId; this.environment = params.meta?.environment; - // Honor the host's initial approval mode (mirrors codex-acp). A non-codex - // value falls back to the default; setSessionConfigOption can change it later. this.config.setInitialMode(params.meta?.permissionMode); - // Codex doesn't attribute input tokens by source, so seed the breakdown with - // the always-resident floor + the host's system prompt (mirrors codex-acp's - // buildCodexBaseline) and let the live contextUsed count fill conversation. + // Codex doesn't attribute input tokens by source; the baseline seeds the resident floor + system prompt. this.usage.setBaseline(buildBaseline(params.meta)); - // Codex's own guidance (set at spawn) plus the host's task system prompt. - // The host sends systemPrompt as `string | { append }` and, for codex, ALSO - // pre-flattens it into developerInstructions — so flatten the {append} form - // (else it stringifies to "[object Object]") and dedupe identical parts (else - // the prod prompt is duplicated). Set per-thread; the task prompt is only - // known here. + // Flatten the {append} form (else "[object Object]") and dedupe identical parts + // (the host pre-flattens into developerInstructions, so the prod prompt would duplicate). const developerInstructions = [ ...new Set( [ @@ -442,13 +363,8 @@ export class CodexAppServerAgent extends BaseAcpAgent { ].filter((s): s is string => !!s), ), ].join("\n\n"); - // Fold the host's MCP servers and the local-tools stdio server into one map - // — the local-tools server (signed-git etc.) is gated by the same cwd + meta - // the codex-acp adapter uses, so local/desktop runs without a token get none. - // Degrade gracefully: if the bundled server script can't be resolved (a - // packaging gap, or running from source), skip local-tools with a loud - // warning rather than throwing — a missing optional tool must not kill the - // whole session's thread setup. + // Degrade gracefully: an unresolvable bundled local-tools script skips it with a + // warning rather than killing thread setup. let localTools: ReturnType = null; try { localTools = buildLocalToolsServer( @@ -487,8 +403,6 @@ export class CodexAppServerAgent extends BaseAcpAgent { await this.loadModelConfig(); this.emitConfigOptions(); await this.emitAvailableCommands(); - // Map the host's taskRunId to this session so it can resume later. Mirrors - // the codex-acp adapter; only emitted when the host supplied a taskRunId. await this.emitSdkSession(); this.logger.info("Codex app-server thread ready", { method, @@ -500,7 +414,6 @@ export class CodexAppServerAgent extends BaseAcpAgent { return { threadId, thread }; } - /** Project the session meta onto the local-tools gate inputs. */ private localToolsMeta( meta: AppServerSessionMeta | undefined, ): LocalToolsMeta | undefined { @@ -514,7 +427,6 @@ export class CodexAppServerAgent extends BaseAcpAgent { }; } - /** Emit `_posthog/sdk_session` once a thread is ready, when a taskRunId exists. */ private async emitSdkSession(): Promise { if (!this.taskRunId || !this.sessionId) return; await this.client @@ -530,15 +442,13 @@ export class CodexAppServerAgent extends BaseAcpAgent { ); } - /** Switch model / reasoning effort / approval mode mid-session. */ async setSessionConfigOption( params: SetSessionConfigOptionRequest, ): Promise { const { configId } = params as { configId?: string }; const value = (params as { value?: unknown }).value; const { modeChanged } = this.config.setOption(configId, value); - // collaborationMode rides the next turn/start (see prompt()), so a mode - // switch only needs current_mode_update here — it takes effect next turn. + // collaborationMode rides the next turn/start, so a mode switch only needs current_mode_update here. if (modeChanged) this.emitCurrentMode(this.config.mode); this.emitConfigOptions(); return { configOptions: this.config.options }; @@ -555,7 +465,6 @@ export class CodexAppServerAgent extends BaseAcpAgent { .catch(() => undefined); } - /** Populate the model/effort selectors from the app-server's model/list. */ private async loadModelConfig(): Promise { try { const res = await this.rpc.request<{ data?: any[] }>( @@ -595,9 +504,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { ); commands = (res?.data ?? []) .flatMap((entry) => entry?.skills ?? []) - // `enabled` is a required boolean in the schema; drop explicitly-disabled - // skills so the slash-command menu doesn't advertise unusable commands - // (lenient `!== false` so a malformed payload that omits it still shows). + // Drop explicitly-disabled skills; lenient `!== false` so a malformed payload still shows. .filter((s) => s?.name && s?.enabled !== false) .map((s: any) => ({ name: s.name, description: s.description ?? "" })); } catch (err) { @@ -618,14 +525,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { if (!this.threadId) { throw new Error("prompt() called before newSession()"); } - // Reopen the notification gate for any prompt being processed (fresh or - // steer), not only the fresh-turn branch below — a prior interrupt may have - // left session.cancelled set. + // Reopen the notification gate (a prior interrupt may have left session.cancelled set). this.session.cancelled = false; - // Prepend `_meta.prContext` (set by the host on PR-follow-up / Slack runs) to - // the FORWARDED prompt as a text block — mirroring claude (acp-to-sdk) and - // codex-acp (prependPrContext). Without it, codex cloud follow-up runs lose - // the PR-review context. The original prompt (no prefix) is what we echo. + // Prepend _meta.prContext (host PR-follow-up / Slack runs) to the FORWARDED prompt, + // else codex cloud follow-ups lose the PR-review context. The echo omits it. const prContext = (params._meta as { prContext?: unknown } | undefined) ?.prContext; const promptBlocks = @@ -634,14 +537,11 @@ export class CodexAppServerAgent extends BaseAcpAgent { : params.prompt; const input = toCodexInput(promptBlocks); if (input.length === 0) { - // Every block was unmappable (audio / malformed image). turn/start - // requires a non-empty input, so end the turn cleanly instead of sending - // an empty one the server would reject. + // turn/start rejects empty input, so end the turn cleanly. this.logger.warn("prompt() had no usable input blocks; ending turn"); return { stopReason: "end_turn" }; } - // Count by type (not input.length) since a resource block can fan out to a - // link + a trailing block — only audio/malformed images drop. + // Count by type (not input.length): a resource block can fan out to multiple blocks. const dropped = params.prompt.filter( (b) => b.type !== "text" && @@ -652,19 +552,13 @@ export class CodexAppServerAgent extends BaseAcpAgent { if (dropped > 0) { this.logger.warn("Dropped non-text/non-image prompt blocks", { dropped }); } - // Echo the user prompt so the host's session log/UI shows the user turn — - // codex doesn't emit one (mirrors codex-acp's broadcastUserMessage). Done - // for both fresh turns and steering so the folded message still renders. + // Echo the user prompt (codex emits none), for fresh turns and steering alike. this.broadcastUserInput(params.prompt); if (this.turns.isRunning) { - // A turn is already running: rather than fail (the codex-acp/Claude - // behavior is to fold the message in), steer it via turn/steer with the - // active turnId as the precondition. The existing turn keeps resolving on - // its original turn/completed. - // turn/steer returns the (possibly rotated) active turnId; refresh it so a - // later turn/steer's expectedTurnId precondition and a turn/interrupt still - // target the live turn (turn/started is not re-emitted for a steer). + // A turn is already running: fold the message in via turn/steer (precondition: the + // active turnId). Refresh from the response's rotated turnId so a later steer/interrupt + // still targets the live turn (no turn/started is re-emitted for a steer). const steerRes = await this.rpc .request<{ turnId?: string }>(APP_SERVER_METHODS.TURN_STEER, { threadId: this.threadId, @@ -679,8 +573,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { return { stopReason: await this.turns.awaitCompletion() }; } if (this.turns.isPending) { - // A turn is pending but we never saw turn/started (no turnId yet), so we - // can't steer. Fail fast rather than clobber the single pending slot. + // A turn is pending but has no turnId yet, so we can't steer; fail fast. throw new Error("prompt() called while a turn is already in progress"); } @@ -696,36 +589,22 @@ export class CodexAppServerAgent extends BaseAcpAgent { input, model: this.config.model, ...(this.config.effort ? { effort: this.config.effort } : {}), - // Always request a reasoning summary so the host surfaces thinking; the - // default "auto" can skip summaries on trivial turns (and raw reasoning - // is off, so without this the host sees no thought stream at all). + // Always request a reasoning summary; the default "auto" can skip it on trivial turns. summary: "detailed", - // The picker's preset, applied per-turn (no app-server mode RPC): - // approvalPolicy plus a sandboxPolicy override that actually restricts - // edits (plan/read-only → readOnly). Skipped on cloud, where a - // non-danger sandbox re-engages the unavailable linux-sandbox and panics - // — there it stays at the spawned danger-full-access. Switching the - // preset takes effect on the next turn. + // Picker preset applied per-turn. Skipped on cloud, where a non-danger sandbox + // re-engages the unavailable linux-sandbox and panics. ...(approvalPolicy ? { approvalPolicy } : {}), - // codex's collaboration mode (per-turn field, verified against the - // binary). Sent every turn — plan unlocks plan proposals + - // request_user_input, default reverts; codex remembers the last mode, so - // it must be pushed explicitly to switch back. + // Pushed every turn — codex remembers the last mode, so switching back from plan must be explicit. collaborationMode: this.config.collaborationModeForTurn(), ...(this.environment !== "cloud" && sandboxPolicy ? { sandboxPolicy } : {}), - // codex 0.140.0 enforces the sandbox through named permission profiles; - // the raw sandboxPolicy above is no longer honored on its own, so - // plan/read-only also send `activePermissionProfile: {extends:":read-only"}`. - // Same cloud gating — a restrictive profile would re-engage the absent - // linux-sandbox there. + // codex 0.140.0 enforces the sandbox via named profiles; sandboxPolicy alone is no + // longer honored, so plan/read-only also send this. Same cloud gating. ...(this.environment !== "cloud" && activePermissionProfile ? { activePermissionProfile } : {}), - // Constrain the final assistant message to the task's schema so it is - // valid JSON we can parse for structured output (replaces the codex-acp - // `create_output` MCP, which the native app-server has no need for). + // Constrain the final message to the task schema for parseable structured output. ...(this.jsonSchema ? { outputSchema: this.jsonSchema } : {}), }); return { stopReason: await completion }; @@ -734,11 +613,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } - /** - * Echo each user prompt block as a `user_message_chunk` for the host log/UI. - * Echoes the original ACP blocks (text + image) so an image-only turn still - * renders, mirroring codex-acp/Claude rather than the text-only codex input. - */ + /** Echo each user prompt block (text + image, so an image-only turn still renders) for the host log/UI. */ private broadcastUserInput(prompt: PromptRequest["prompt"]): void { if (!this.sessionId) return; for (const block of prompt) { @@ -760,17 +635,9 @@ export class CodexAppServerAgent extends BaseAcpAgent { } protected async interrupt(): Promise { - // Tell the server to stop first, then finalize through the shared path so a - // cancelled turn still emits the cloud notifications (_posthog/turn_complete) - // the host treats as the idle/queue-dispatch signal — matching codex-acp. - // finalizeTurn claims the turn idempotently, so the server's later - // turn/completed(interrupted) is a no-op. - // TurnInterruptParams requires BOTH threadId and turnId (the native binary - // rejects a turnId-less request with -32600, leaving the turn running). - // markInterrupted returns the live turnId (and remembers it so its late - // turn/completed(interrupted) is dropped rather than finalizing a follow-up - // turn). When no turn/started was seen yet there is no server-side turn to - // abort, so skip the RPC and just finalize. + // Stop the server, then finalize through the shared path so a cancelled turn still emits + // the cloud idle signal (finalizeTurn claims idempotently). turn/interrupt requires BOTH + // threadId and turnId (else -32600); skip the RPC when no turn started. const turnId = this.turns.markInterrupted(); if (this.threadId && turnId) { await this.rpc @@ -785,15 +652,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { async closeSession(): Promise { this.session.abortController.abort(); - // Resolve any in-flight turn and drain interrupted-turn ids so the set can't - // accumulate across a long-lived process (each is normally removed when its - // late completion arrives, but a dropped one would linger). this.turns.close("cancelled"); this.session.settingsManager.dispose(); - // Close the transport BEFORE killing the process: kill() destroys the - // stdio streams, so awaiting writer.close()/reader.cancel() afterwards - // would block on an ack that never arrives. Bounded so cleanup can never - // hang the caller even if the stream is wedged. + // Close the transport BEFORE kill() destroys the stdio streams (else close() blocks on + // an ack that never arrives). Bounded so cleanup can't hang the caller. await Promise.race([ this.rpc.close().catch(() => undefined), new Promise((resolve) => setTimeout(resolve, 2000)), @@ -817,9 +679,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { } if (method === APP_SERVER_NOTIFICATIONS.TURN_STARTED) { - // Capture the active turn id; it's the precondition turn/steer requires - // and the target turn/interrupt aborts. onStarted ignores it unless a turn - // is pending, so a stale/duplicate turn/started can't install a stray id. + // Capture the active turn id (steer precondition / interrupt target). this.turns.onStarted((params as { turn?: { id?: string } })?.turn?.id); } @@ -830,12 +690,9 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.mcp.capture(params); } - // codex auto-compaction surfaces as a contextCompaction item bracketing the - // work: item/started marks it in progress (gates steering/queue host-side), - // and item/completed is the boundary (empirically codex does NOT emit a - // separate thread/compacted — the item lifecycle is the signal). A - // thread/compacted, if one ever arrives, is a guarded fallback. The - // `compactionActive` flag dedupes so only one boundary fires per compaction. + // codex auto-compaction surfaces as a contextCompaction item: item/started → in progress, + // item/completed → boundary (codex emits no separate thread/compacted; that's a guarded + // fallback). compactionActive dedupes to one boundary per compaction. const isCompactionItem = (params as { item?: { type?: string } })?.item?.type === "contextCompaction"; @@ -868,15 +725,13 @@ export class CodexAppServerAgent extends BaseAcpAgent { if (method === APP_SERVER_NOTIFICATIONS.TURN_COMPLETED) { const turn = (params as { turn?: { id?: string; status?: string } }) ?.turn; - // Drop the late completion of a turn we already interrupted so it can't - // finalize the current (follow-up) turn as cancelled. + // Drop the late completion of an already-interrupted turn (else it cancels the follow-up). if (this.turns.shouldDropCompletion(turn?.id)) return; void this.finalizeTurn(mapTurnStopReason(turn?.status)); } if (method === APP_SERVER_NOTIFICATIONS.ERROR) { - // A non-retried fatal error: resolve the turn so prompt() returns instead - // of hanging until the stream closes. (willRetry true → codex recovers.) + // A non-retried fatal error: resolve the turn so prompt() returns rather than hangs. const willRetry = (params as { willRetry?: boolean })?.willRetry; if (willRetry === false) { this.logger.warn("codex app-server fatal error notification", { @@ -895,11 +750,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } - /** - * Compaction started: mirror the Claude adapter's `_posthog/status` so the - * host sets `isCompacting` (which gates steering + queue dispatch). The host - * reads `isCompacting = !isComplete`, so omitting it means "in progress". - */ + /** Compaction started: emit `_posthog/status` so the host sets `isCompacting` (gates steer/queue). */ private emitCompactionStarted(): void { if (!this.sessionId) return; void this.client @@ -910,12 +761,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { .catch(() => undefined); } - /** - * Compaction finished: mirror the Claude adapter's `_posthog/compact_boundary` - * (the host clears `isCompacting` + drains the queued messages) plus a - * user-visible transcript marker. The context indicator updates on its own — - * the next `thread/tokenUsage/updated` carries the reduced `tokenUsage.last`. - */ + /** Compaction finished: emit `_posthog/compact_boundary` (host clears isCompacting) + a transcript marker. */ private emitCompactionBoundary(): void { if (!this.sessionId) return; void this.client @@ -947,26 +793,14 @@ export class CodexAppServerAgent extends BaseAcpAgent { .catch((err) => this.logger.warn("usage extNotification failed", err)); } - /** - * Parses the schema-constrained final message into structured output and - * delivers it before resolving the turn, so the host's `setTaskRunOutput` - * has completed by the time `prompt()` returns. - */ + /** Deliver structured output (parsed from the final message) before resolving the turn. */ private async finalizeTurn(reason: StopReason): Promise { - // Idempotent: claim the pending turn synchronously (before any await) so a - // second finalize for the same turn — e.g. an `error` notification racing - // turn/completed — is a no-op and the structured-output callback + cloud - // notifications don't double-fire. claim() clears both the pending slot and - // the turnId in one step, so a steer/fresh prompt racing into the await - // window below sees no live turn. + // Idempotent: claim synchronously (before any await) so a second finalize (e.g. an + // error racing turn/completed) is a no-op and callbacks don't double-fire. const pending = this.turns.claim(); if (!pending) return; - // If the turn ends while a compaction is still in progress (interrupt or a - // fatal error before item/completed(contextCompaction)), the boundary would - // never fire — leaving the host's `isCompacting` stuck true, which silently - // queues every later user message. Clear the flag and emit the boundary here - // (idempotent: only the first finalize for a turn gets past claim()) so the - // host recovers instead of wedging. + // If the turn dies mid-compaction the boundary never fires, leaving isCompacting stuck + // true (silently queuing later messages). Recover here. if (this.compactionActive) { this.compactionActive = false; this.emitCompactionBoundary(); @@ -976,9 +810,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { const usage = this.usage.perTurnUsage(); const contextUsed = this.usage.contextTokens(); - // Deliver structured output only on a clean completion — a cancelled - // (user-interrupted) or refused turn must not record task output the host - // considers failed (mirrors the Claude adapter's success-only delivery). + // Deliver structured output only on a clean end_turn — a cancelled/refused turn records nothing. if ( reason === "end_turn" && this.jsonSchema && @@ -1005,12 +837,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { pending.resolve(reason); } - /** - * Emit the cloud per-turn notifications, mirroring codex-acp's runPrompt: - * `_posthog/turn_complete` (only with a taskRunId — it's a task-tracking - * signal) plus the `_posthog/usage_update` breakdown variant (always, so local - * sessions get the ContextBreakdownPopover too). - */ + /** Emit cloud per-turn notifications: `_posthog/turn_complete` (only with a taskRunId) + the usage breakdown (always). */ private async emitTurnComplete( reason: StopReason, usage: AccumulatedUsage, @@ -1054,13 +881,9 @@ export class CodexAppServerAgent extends BaseAcpAgent { } /** - * Server-initiated requests. The two simple approvals resolve to a - * `{ decision }` envelope (codex's `CommandExecutionRequestApprovalResponse` / - * `FileChangeRequestApprovalResponse` — a bare string is rejected); the richer - * requests (AskUserQuestion / permission profile / MCP elicitation) carry - * distinct typed response objects and are delegated to `handleServerRequest`. - * The AppServerClient sends whatever we return straight back as the JSON-RPC - * result. + * Server-initiated requests. Simple approvals resolve to a `{ decision }` envelope (a bare + * string is rejected); richer ones (AskUserQuestion / permission profile / elicitation) go + * to `handleServerRequest`. Whatever we return is sent back as the JSON-RPC result. */ private async handleApproval( method: string, @@ -1088,11 +911,8 @@ export class CodexAppServerAgent extends BaseAcpAgent { changes?: AppServerItem["changes"]; available_decisions?: unknown; }; - // codex tells us which decisions are valid for THIS approval. The standard - // accept/decline/cancel always apply; when codex also offers an - // "approve and remember" decision — a command-prefix exec-policy allowlist - // ("don't ask again for commands beginning with X") or whole-session approval - // — surface it as an Allow-always option and echo that exact decision back. + // codex tells us which decisions are valid here. When it offers an "approve and + // remember" decision (exec-policy allowlist / session approval), surface Allow-always. const availableDecisions = Array.isArray(detail.available_decisions) ? detail.available_decisions.filter( (d): d is string => typeof d === "string", @@ -1104,14 +924,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { const title = detail.command ?? (isFileChange ? "Apply file changes" : "Run command"); const toolCallId = detail.itemId ?? "codex-approval"; - // Codex has no MCP-specific approval; an MCP tool call surfaces as a - // command-execution approval. When the item is a known MCP call, surface the - // real server/tool/args so the host renders the proper MCP permission - // (incl. the PostHog `exec` unwrapping) instead of codex's generic text. + // Codex has no MCP-specific approval; a known MCP call surfaces the real server/tool/args + // so the host renders the proper MCP permission (incl. PostHog `exec` unwrapping). const mcp = this.mcp.byItemId(detail.itemId); - // Set kind + content so the host routes plain command/file approvals to - // ExecutePermission / EditPermission (command styling, diff body) rather - // than the bare DefaultPermission fallback. + // kind + content route plain command/file approvals to Execute/EditPermission (not the fallback). const toolCall = mcp ? { toolCallId, @@ -1172,17 +988,15 @@ export class CodexAppServerAgent extends BaseAcpAgent { }); if (response.outcome.outcome === "selected") { if (response.outcome.optionId === "allow_always" && rememberDecision) { - // Echo codex's own "approve and remember" decision so it applies the - // exec-policy/session amendment it proposed in the request. + // Echo codex's "approve and remember" decision so it applies the proposed amendment. return { decision: rememberDecision }; } if (response.outcome.optionId === "allow") { return { decision: "accept" }; } if (response.outcome.optionId === "reject_with_feedback") { - // codex's approval response carries no feedback field, so decline the - // action and inject the user's guidance into the still-running turn — - // exactly what codex's TUI does (Denied + a follow-up user message). + // codex's response has no feedback field, so decline and inject the guidance + // into the running turn (as its TUI does: Denied + a follow-up message). const feedback = (response as { _meta?: { customInput?: unknown } }) ._meta?.customInput; const activeTurnId = this.turns.activeTurnId; @@ -1211,28 +1025,17 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } -// codex-rs/protocol/src/protocol.rs BASELINE_TOKENS — the always-resident floor -// (MCP schemas, skills, preset prompt) we can't attribute per-source. Matches -// the codex-acp adapter's CODEX_BASELINE_TOKENS so the breakdown agrees across -// transports. +// BASELINE_TOKENS from codex-rs protocol.rs — the resident floor we can't attribute per-source. const CODEX_BASELINE_TOKENS = 12000; -/** - * codex `TurnStatus` → ACP `StopReason`. `interrupted` is a cancel (not a clean - * end), `failed` surfaces as a refusal; `completed`/unknown end the turn. - */ +/** codex `TurnStatus` → ACP `StopReason`: interrupted → cancel, failed → refusal, else end. */ function mapTurnStopReason(status: string | undefined): StopReason { if (status === "interrupted") return "cancelled"; if (status === "failed") return "refusal"; return "end_turn"; } -/** - * The codex `thread/start` / `thread/resume` `config` override map (the JSON form - * of `-c key=value`). Folds in the host MCP servers and — mirroring the codex-acp - * `-c sandbox_workspace_write.writable_roots=[...]` spawn arg — makes any extra - * workspace roots writable. Returns undefined when there is nothing to override. - */ +/** The codex thread config override map: folds in MCP servers + makes extra workspace roots writable. Undefined when empty. */ function buildThreadConfig( mcpServers: ReturnType, additionalDirectories: string[] | undefined, @@ -1247,11 +1050,7 @@ function buildThreadConfig( return Object.keys(config).length > 0 ? config : undefined; } -/** - * Seed the context-breakdown baseline with the resident floor plus the host's - * system prompt, mirroring codex-acp's buildCodexBaseline. The live contextUsed - * count fills the conversation bucket once the turn produces token usage. - */ +/** Seed the context-breakdown baseline with the resident floor + the host's system prompt. */ function buildBaseline( meta: AppServerSessionMeta | undefined, ): ContextBreakdownBaseline { @@ -1262,11 +1061,7 @@ function buildBaseline( return baseline; } -/** - * The host sends systemPrompt as a plain string OR the Claude-style - * `{ append }` form. Flatten to the underlying string so it isn't stringified - * to "[object Object]" when folded into developer_instructions / token estimates. - */ +/** Flatten the host's systemPrompt (`string | { append }`) to a string (else "[object Object]"). */ function flattenSystemPrompt( systemPrompt: string | { append?: string } | undefined, ): string | undefined { @@ -1277,11 +1072,7 @@ function flattenSystemPrompt( return undefined; } -/** - * Parses structured output from the final assistant message. `outputSchema` - * should make the message pure JSON, but parse defensively (fenced block / first - * object) so a stray wrapper never throws or drops the result. - */ +/** Parse structured output from the final message, defensively (fenced block / first object). */ function parseStructuredOutput(text: string): Record | null { const trimmed = text.trim(); const candidates = [trimmed]; diff --git a/packages/agent/src/adapters/codex-app-server/ext-notifications.ts b/packages/agent/src/adapters/codex-app-server/ext-notifications.ts index f05147c498..f6898c8fa2 100644 --- a/packages/agent/src/adapters/codex-app-server/ext-notifications.ts +++ b/packages/agent/src/adapters/codex-app-server/ext-notifications.ts @@ -1,11 +1,7 @@ /** - * Pure builders for the PostHog `_posthog/*` ext-notification params the - * app-server adapter emits, mirroring the codex-acp adapter so the host's log - * consumers (parse_sandbox_log.py) and the renderer see the same shapes. - * - * These are param-only builders (no I/O, no client) so each can be unit-tested - * in isolation and the hub agent just forwards the result to - * `client.extNotification(method, params)`. + * Pure builders for the PostHog `_posthog/*` ext-notification params the app-server + * adapter emits, mirroring the codex-acp adapter so log consumers and the renderer + * see the same shapes. Param-only (no I/O) so each is unit-testable in isolation. */ import type { StopReason } from "@agentclientprotocol/sdk"; @@ -16,10 +12,8 @@ import { } from "../claude/context-breakdown"; /** - * Adapter tag carried on `_posthog/sdk_session`. Kept as `"codex"` (not - * `"codex-app-server"`) so the host's resume/keying logic treats both Codex - * transports as the same agent family — the codex-acp adapter emits the same - * value. + * Adapter tag on `_posthog/sdk_session`. Kept `"codex"` (not `"codex-app-server"`) + * so resume/keying treats both Codex transports as the same agent family. */ const CODEX_ADAPTER = "codex" as const; @@ -29,10 +23,7 @@ export interface SdkSessionParams { adapter: typeof CODEX_ADAPTER; } -/** - * `_posthog/sdk_session` — maps a taskRunId to the agent's sessionId so the - * host can resume the session later. Emitted once per session creation/load. - */ +/** `_posthog/sdk_session` — maps a taskRunId to the sessionId so the host can resume later. */ export function buildSdkSessionParams( sessionId: string, taskRunId: string, @@ -68,11 +59,8 @@ export interface AccumulatedUsage { } /** - * `_posthog/turn_complete` — fired when a prompt turn finishes. `parse_sandbox_log.py` - * reads `usage.{inputTokens,outputTokens,cachedReadTokens,totalTokens}`; the - * renderer reads `stopReason`. `totalTokens` is the sum of all four component - * counts, matching the codex-acp adapter so totals don't diverge between - * transports. + * `_posthog/turn_complete` — fired when a prompt turn finishes. `totalTokens` is the + * sum of all four component counts, matching the codex-acp adapter. */ export function buildTurnCompleteParams( sessionId: string, @@ -102,10 +90,9 @@ export interface UsageBreakdownParams { } /** - * `_posthog/usage_update` (breakdown variant) — per-source context attribution - * the renderer's ContextBreakdownPopover consumes. Codex's app-server doesn't - * attribute input tokens by source, so we fold the resident-baseline estimate - * with the live `contextUsed` count via the shared `buildBreakdown` helper. + * `_posthog/usage_update` (breakdown variant) — per-source context attribution. + * Codex doesn't attribute tokens by source, so we fold the baseline estimate with + * the live `contextUsed` via `buildBreakdown`. */ export function buildUsageBreakdownParams( sessionId: string, diff --git a/packages/agent/src/adapters/codex-app-server/input.ts b/packages/agent/src/adapters/codex-app-server/input.ts index 02081917e0..7a19d1612a 100644 --- a/packages/agent/src/adapters/codex-app-server/input.ts +++ b/packages/agent/src/adapters/codex-app-server/input.ts @@ -2,10 +2,8 @@ import { fileURLToPath } from "node:url"; import type { ContentBlock } from "@agentclientprotocol/sdk"; /** - * Codex app-server `UserInput` (version-pinned shape from - * codex-schema/v2/UserInput.ts). We only emit the three variants an ACP prompt - * can produce — `text`, remote `image`, and `localImage` — so the union is - * narrowed here rather than importing the full generated type. + * Codex app-server `UserInput`, narrowed to the three variants an ACP prompt + * can produce (`text`, remote `image`, `localImage`). */ export type CodexUserInput = | { type: "text"; text: string; text_elements: [] } @@ -29,17 +27,9 @@ function resourceLinkText(uri: string): string { } /** - * Maps ACP prompt content blocks to codex app-server `UserInput[]`. - * - * Text blocks pass through unchanged (codex requires `text_elements`, empty - * when the host has no UI spans). Image blocks map to `image`/`localImage` - * (base64 → data URL, `http(s)` → remote `image`, `file://` → `localImage`). - * `embeddedContext` blocks are honored rather than dropped — mirroring the - * Claude adapter: a `resource_link` (or `file://` `resource`) becomes a - * path/link note, and a non-file `resource` with inline text is inlined as a - * `` block appended after the main content (kept salient by - * trailing it, as Claude does). Dropped: audio, malformed images, and binary - * (blob) embedded resources — only the text variant of a resource is inlined. + * Maps ACP prompt content blocks to codex app-server `UserInput[]`. Text passes through; + * images map to `image`/`localImage`; `file://` resources become path notes and non-file + * resource text is inlined as a trailing `` block. Audio/blob/malformed are dropped. */ export function toCodexInput(prompt: ContentBlock[]): CodexUserInput[] { const input: CodexUserInput[] = []; @@ -79,10 +69,8 @@ export function toCodexInput(prompt: ContentBlock[]): CodexUserInput[] { } /** - * ACP `ImageContent` always declares `data`/`mimeType`, but `data` may be empty - * when the image is referenced by `uri` instead. Prefer inline base64 (carried - * as a data URL on codex's `image.url`), then fall back to the URI: `http(s)` - * stays a remote `image`, `file://` becomes a `localImage` path. + * Prefer inline base64 (as a data URL); else fall back to the `uri`: + * `http(s)` → remote `image`, `file://` → `localImage`. */ function imageToCodexInput(block: { data: string; diff --git a/packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts b/packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts index 8c291d0760..1a65e20dbd 100644 --- a/packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts +++ b/packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts @@ -2,9 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { LOCAL_TOOLS_MCP_NAME } from "../local-tools"; import { buildLocalToolsServer } from "./local-tools-mcp"; -// The resolver walks up looking for the compiled MCP script via existsSync; the -// dist asset isn't on the walk-up path in unit tests, so make the first -// candidate succeed. Nothing here spawns the script — we only inspect the path. +// The dist asset isn't on the walk-up path in unit tests, so make existsSync +// succeed; nothing spawns the script — we only inspect the path. vi.mock("node:fs", async (importActual) => { const actual = await importActual(); return { ...actual, existsSync: vi.fn().mockReturnValue(true) }; @@ -18,9 +17,8 @@ describe("buildLocalToolsServer", () => { }; beforeEach(() => { - // The signed-git gate keys off isCloudRun, which also reads IS_SANDBOX — - // clear it so meta.environment is the only cloud signal under test, and - // clear the token vars so each case controls them explicitly. + // The signed-git gate reads IS_SANDBOX and the token vars; clear them so each + // case controls the cloud signal (meta.environment) and token explicitly. delete process.env.IS_SANDBOX; delete process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; diff --git a/packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts b/packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts index e7ca8984d1..e7f0976f59 100644 --- a/packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts +++ b/packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts @@ -1,17 +1,8 @@ /** * Builds the stdio local-tools MCP server config to inject into a Codex - * app-server thread's `config.mcp_servers`. Ported from the codex-acp adapter - * (`adapters/codex/codex-agent.ts` buildLocalToolsMcpServer + applyLocalTools + - * enabledLocalTools gating). - * - * The two adapters share the SAME bundled stdio server script - * (`adapters/codex/local-tools-mcp-server.js`), the SAME registry - * (`adapters/local-tools`), and the SAME `LocalToolCtx`. Only the injection - * boundary differs: codex-acp pushes an `McpServerStdio` into `newSession`'s - * `mcpServers`, whereas the app-server adapter passes the result through - * `toCodexMcpServers` into the thread config. We return the ACP `McpServerStdio` - * shape so the existing translation layer stays the single owner of the - * ACP -> Codex map. + * app-server thread's `config.mcp_servers`, ported from the codex-acp adapter. + * Returns the ACP `McpServerStdio` shape so the existing translation layer stays + * the single owner of the ACP→Codex map. */ import { existsSync } from "node:fs"; @@ -28,10 +19,9 @@ import { import { resolveTaskId } from "../session-meta"; /** - * Gate inputs the local-tools server needs beyond what `LocalToolGateMeta` - * carries: the task id (top-level or nested under `persistence`) and the base - * branch the signed-git tools default to. Kept self-contained so this module - * does not depend on the hub agent's session-meta type. + * Gate inputs the local-tools server needs beyond `LocalToolGateMeta`: the task id + * and the base branch the signed-git tools default to. Self-contained so this + * module doesn't depend on the hub agent's session-meta type. */ export interface LocalToolsMeta extends LocalToolGateMeta { taskId?: string; @@ -40,10 +30,8 @@ export interface LocalToolsMeta extends LocalToolGateMeta { } /** - * Path resolves relative to the compiled adapter location. When bundled into - * different entry points (dist/agent.js, dist/server/bin.cjs, etc), the - * adapter sits at different depths, so walk up until we find the shared dist - * asset. Mirrors `resolveBundledMcpScript` in the codex-acp adapter. + * Resolve a shared dist asset by walking up from the compiled adapter location — + * its depth varies across bundle entry points. Mirrors the codex-acp adapter. */ function resolveBundledMcpScript(rel: string): string { let dir = import.meta.dirname ?? __dirname; @@ -70,8 +58,7 @@ function toMcpServerStdio( { name: "POSTHOG_LOCAL_TOOLS_ENABLED", value: enabledNames.join(",") }, ]; if (ctx.token) { - // Token also on the child env so its own git remote ops (fetch/ls-remote) - // authenticate; the var names come from the single shared source. + // Token also on the child env so its own git remote ops authenticate. env.push( ...Object.entries(ghTokenEnv(ctx.token)).map(([name, value]) => ({ name, @@ -89,10 +76,8 @@ function toMcpServerStdio( /** * Returns the local-tools stdio server config to inject, or null when no tool's - * gate passes (e.g. local/desktop run with no GH token — signed-git tools are - * cloud-only). Tools self-gate via the registry; the server is only injected - * when at least one passes. Their instructions already live in the shared cloud - * system prompt, so only the server config needs returning here. + * gate passes (e.g. local/desktop run with no GH token). Tools self-gate via the + * registry; the server is only injected when at least one passes. */ export function buildLocalToolsServer( ctx: { cwd?: string }, diff --git a/packages/agent/src/adapters/codex-app-server/mapping.test.ts b/packages/agent/src/adapters/codex-app-server/mapping.test.ts index b098cd6adc..18454844b3 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.test.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.test.ts @@ -25,8 +25,6 @@ describe("mapAppServerNotification", () => { it.each([ ["raw textDelta", APP_SERVER_NOTIFICATIONS.REASONING_TEXT_DELTA], - // The DEFAULT stream for gpt-5-family models — must map identically, else - // the host sees no reasoning at all (raw reasoning is off by default). ["summaryTextDelta", APP_SERVER_NOTIFICATIONS.REASONING_SUMMARY_TEXT_DELTA], ])("maps a reasoning %s to an ACP agent_thought_chunk", (_label, method) => { const result = mapAppServerNotification("s-1", method, { @@ -157,8 +155,6 @@ describe("mapAppServerNotification", () => { }, ); - // The host renderer routes MCP rendering (and the PostHog `exec` unwrapping) - // off the structured `_meta.posthog` channel. const meta = (result?.update as { _meta?: unknown })._meta as { posthog?: { toolName?: string; mcp?: { server: string; tool: string } }; }; @@ -184,9 +180,7 @@ describe("mapAppServerNotification", () => { threadId: "t", turnId: "u", tokenUsage: { - // `total` is cumulative across the thread; the gauge must NOT use it. total: { totalTokens: 1500, inputTokens: 1000, outputTokens: 500 }, - // `last` is the current turn's occupancy — the value that must win. last: { totalTokens: 600, inputTokens: 500, @@ -549,8 +543,6 @@ describe("mapHistoryItem", () => { describe("parseUnifiedDiff", () => { it("keeps added/removed content lines whose payload starts with ++ or --", () => { - // "---count;" is a removed line of content "--count;"; "+++count;" is an - // added line of content "++count;" — neither is a file header. expect(parseUnifiedDiff("@@ -1 +1 @@\n---count;\n+++count;")).toEqual({ oldText: "--count;", newText: "++count;", diff --git a/packages/agent/src/adapters/codex-app-server/mapping.ts b/packages/agent/src/adapters/codex-app-server/mapping.ts index ce80b660a9..46c6148b86 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.ts @@ -7,15 +7,9 @@ import { mcpToolKey, posthogToolMeta } from "@posthog/shared"; import { APP_SERVER_NOTIFICATIONS } from "./protocol"; /** - * Translates a native app-server notification into an ACP SessionNotification - * so the rest of PostHog Code, which speaks ACP, stays unchanged. - * - * Streamed text (agent message + reasoning) maps to chunks; item lifecycle - * notifications for tool-like items (command execution, file changes, MCP tool - * calls, web search) map to `tool_call` / `tool_call_update`. Agent-message and - * reasoning *items* are intentionally dropped here because their deltas already - * streamed the content — re-emitting the completed item would double-render. - * Structured-output capture is handled in the agent, not here. + * Translates a native app-server notification into an ACP SessionNotification. + * Streamed text maps to chunks; tool-like items map to `tool_call`/`tool_call_update`. + * Agent-message and reasoning items are dropped — their deltas already streamed. */ export function mapAppServerNotification( sessionId: string, @@ -34,8 +28,6 @@ export function mapAppServerNotification( }, }; } - // Both reasoning streams (raw textDelta + the default summaryTextDelta) carry - // a `delta: string` and map to the same ACP thought chunk. case APP_SERVER_NOTIFICATIONS.REASONING_TEXT_DELTA: case APP_SERVER_NOTIFICATIONS.REASONING_SUMMARY_TEXT_DELTA: { const delta = readStringField(params, "delta"); @@ -49,15 +41,10 @@ export function mapAppServerNotification( }; } case APP_SERVER_NOTIFICATIONS.TOKEN_USAGE_UPDATED: { - // Context/token indicator: the renderer reads `used`/`size` off the - // update (same shape codex-acp forwards). Detailed token breakdown is - // additionally emitted as a `_posthog/usage_update` ext-notification by - // the agent. + // Context indicator: renderer reads `used`/`size`; detailed breakdown comes via `_posthog/usage_update`. const tu = (params as { tokenUsage?: any })?.tokenUsage; - // Occupancy is THIS turn's `last` (mirroring usage-tracker.ts), not codex's - // cumulative `total` — `total` grows across the whole thread, so feeding it - // to the gauge over-reports and pegs it at 100% after enough turns. `total` - // is only the fallback for a build that predates `last` (≈ total on turn 1). + // Use this turn's `last`, not cumulative `total` (which over-reports and pegs the + // gauge); `total` is the fallback for pre-`last` builds. const context = tu?.last ?? tu?.total; const used = context?.totalTokens ?? context?.inputTokens; if (used == null) return null; @@ -100,27 +87,18 @@ export function mapAppServerNotification( ); } case APP_SERVER_NOTIFICATIONS.COMMAND_OUTPUT_DELTA: { - // Live stdout/stderr for an in-progress command: surface as streamed text - // on the tool call so output appears before the item completes. The host - // renderer accumulates these chunks the same way the Claude adapter does - // for its terminal output. const itemId = readStringField(params, "itemId"); const delta = readStringField(params, "delta"); if (!itemId || !delta) return null; return toolOutputChunk(sessionId, itemId, delta); } case APP_SERVER_NOTIFICATIONS.TERMINAL_INTERACTION: { - // PTY stdin echoed back for an interactive command. Echo it into the same - // tool call so the transcript shows what was typed, mirroring how a real - // terminal renders local echo. const itemId = readStringField(params, "itemId"); const stdin = readStringField(params, "stdin"); if (!itemId || !stdin) return null; return toolOutputChunk(sessionId, itemId, stdin); } case APP_SERVER_NOTIFICATIONS.FILE_CHANGE_PATCH_UPDATED: { - // Incremental diff for an in-progress fileChange: push the latest diff so - // the edit renders before the patch is applied. const itemId = readStringField(params, "itemId"); if (!itemId) return null; const changes = (params as { changes?: AppServerItem["changes"] }) @@ -142,12 +120,7 @@ export function mapAppServerNotification( } } -/** - * A streamed text chunk attached to an in-progress tool call. ACP's - * `tool_call_update.content` semantically replaces the collection; the host - * renderer treats successive single-chunk updates as appended output, which is - * how live command output streams without owning an ACP terminal lifecycle. - */ +/** A streamed text chunk on an in-progress tool call; the renderer appends successive single-chunk updates. */ function toolOutputChunk( sessionId: string, toolCallId: string, @@ -173,11 +146,8 @@ function mapPlanStatus( } /** - * Extracts {oldText,newText} from a unified diff so a codex `fileChange` renders - * as an ACP diff. Hunk-level (context + ± lines), which is what the renderer - * needs to show the change. Known cosmetic limit: a content line whose payload - * begins with "-- " / "++ " (diff line "--- " / "+++ ") is misread as a file - * header and dropped from the rendered diff — it never affects the actual edit. + * Extracts {oldText,newText} from a unified diff so a codex `fileChange` renders as an ACP diff. + * Cosmetic limit: a content line whose payload begins with "-- "/"++ " is misread as a header and dropped. */ export function parseUnifiedDiff(diff: string): { oldText: string; @@ -186,10 +156,7 @@ export function parseUnifiedDiff(diff: string): { const oldLines: string[] = []; const newLines: string[] = []; for (const line of diff.split("\n")) { - // Skip diff/hunk metadata. The file headers are "--- a/..." / "+++ b/..." - // and the no-newline marker is "\ No newline...". Match the trailing space - // on --- / +++ so a real added/removed CONTENT line like "++i;" (diff line - // "+++i;") or "--count" isn't mistaken for a header and dropped. + // Skip diff/hunk metadata; match trailing space on ---/+++ so content lines like "++i;" aren't dropped. if ( line.startsWith("@@") || line.startsWith("diff ") || @@ -226,7 +193,6 @@ export type AppServerItem = { arguments?: unknown; aggregatedOutput?: string | null; changes?: Array<{ path?: string; diff?: string; kind?: unknown }>; - // mcpToolCall result / error (McpToolCallResult / McpToolCallError). result?: { content?: unknown } | null; error?: { message?: string } | null; // Present on message/reasoning items replayed from thread history. @@ -234,8 +200,6 @@ export type AppServerItem = { content?: unknown; }; -/** Text rendering of a completed mcpToolCall: the error message, else the - * result's text content blocks joined. */ function mcpResultText( result: AppServerItem["result"], error: AppServerItem["error"], @@ -254,7 +218,6 @@ function mcpResultText( return text || null; } -/** Text rendering of a dynamicToolCall's output (its `inputText` content items). */ function dynamicToolText(items: unknown): string | null { if (!Array.isArray(items)) return null; const text = items @@ -271,13 +234,9 @@ function dynamicToolText(items: unknown): string | null { } /** - * Re-renders a persisted `ThreadItem` (from `thread/resume`'s `thread.turns`) as - * the ACP updates a live stream would have produced, so a reattaching host shows - * the full transcript. A tool item collapses to a single completed `tool_call` - * (there is no prior start to update); messages map to their chunk. Reuses the - * live `describeTool`/`completedContent`/`mapStatus` so there is no second - * rendering surface to drift. Ephemeral items (reasoning, plan, hookPrompt) are - * not replayed. + * Re-renders a persisted `ThreadItem` as the ACP updates a live stream would have produced, + * so a reattaching host shows the full transcript. Tool items collapse to one completed + * `tool_call`; ephemeral items (reasoning, plan) are not replayed. */ export function mapHistoryItem( sessionId: string, @@ -332,12 +291,7 @@ export function mapHistoryItem( } } -/** - * A persisted `userMessage`'s `content` is codex `UserInput[]`. Replay the text - * inputs as `user_message_chunk`s; historical image attachments aren't - * re-rendered (the live echo handles new ones), keeping the replay lossless for - * the text transcript without reconstructing data URLs here. - */ +/** Replays a persisted `userMessage`'s text inputs; historical image attachments aren't re-rendered. */ function userMessageChunks( sessionId: string, content: unknown, @@ -371,19 +325,11 @@ type ToolDescriptor = { rawInput?: unknown; output?: string | null; locations?: ToolCallLocation[]; - /** - * Originating MCP server + tool for MCP tool calls. Surfaced on the canonical - * `_meta.posthog` channel so the host renderer routes MCP rendering (and the - * PostHog `exec` display) the same way it does for every adapter. - */ + /** Originating MCP server + tool, surfaced on `_meta.posthog` so the renderer routes MCP rendering. */ mcp?: { server: string; tool: string }; }; -/** - * Classify a shell command by its parsed actions so read-only commands (`cat`, - * `ls`, `grep`) render as read/search rather than execute — matching how the - * codex-acp adapter surfaces them. - */ +/** Classify a shell command by its actions so read-only commands render as read/search, not execute. */ function commandKind( actions: AppServerItem["commandActions"], ): "read" | "search" | "execute" { @@ -406,8 +352,6 @@ function describeTool(item: AppServerItem): ToolDescriptor | null { case "fileChange": { const paths = changePaths(item.changes); return { - // Title with the changed path(s) instead of a generic label so the - // tool call reads like Claude's edit summary. title: fileChangeTitle(paths), kind: "edit", locations: paths.map((path) => ({ path })), @@ -458,11 +402,7 @@ function fileChangeTitle(paths: string[]): string { return `${paths[0]} (+${paths.length - 1} more)`; } -/** - * Clickable locations for a commandExecution: the read/search/listFiles command - * actions carry concrete paths, otherwise fall back to the working directory so - * the UI can still anchor "follow-along" navigation somewhere. - */ +/** Clickable locations for a commandExecution: action paths, else the cwd as a fallback. */ function commandLocations(item: AppServerItem): ToolCallLocation[] | undefined { const paths: string[] = []; const seen = new Set(); @@ -523,7 +463,6 @@ function mapItem( }; } -/** Content for a completed tool call: file diffs for fileChange, else output text. */ function completedContent( item: AppServerItem, tool: ToolDescriptor, diff --git a/packages/agent/src/adapters/codex-app-server/mcp-config.ts b/packages/agent/src/adapters/codex-app-server/mcp-config.ts index 594e22f436..4e4873e516 100644 --- a/packages/agent/src/adapters/codex-app-server/mcp-config.ts +++ b/packages/agent/src/adapters/codex-app-server/mcp-config.ts @@ -1,20 +1,17 @@ import type { McpServer } from "@agentclientprotocol/sdk"; /** - * Codex's per-thread `mcp_servers` config entry. Stdio servers carry a - * command/args/env; HTTP servers carry a url + headers. The native app-server - * accepts this map under `thread/start`'s `config.mcp_servers`. + * Codex's per-thread `mcp_servers` config entry (stdio: command/args/env; http: + * url + headers), accepted under `thread/start`'s `config.mcp_servers`. */ export type CodexMcpServerConfig = | { command: string; args: string[]; env?: Record } | { url: string; http_headers?: Record }; /** - * Translates the ACP `McpServer[]` the host passes in `newSession` into the - * shape Codex's app-server expects under `config.mcp_servers`. ACP encodes env - * and headers as `{ name, value }[]`; Codex wants plain string maps. - * - * Returns undefined when there is nothing to inject so callers can omit the key. + * Translates the ACP `McpServer[]` into the shape Codex's app-server expects under + * `config.mcp_servers` — ACP encodes env/headers as `{ name, value }[]`, Codex + * wants plain string maps. Returns undefined when there's nothing to inject. */ export function toCodexMcpServers( servers: McpServer[] | undefined, diff --git a/packages/agent/src/adapters/codex-app-server/mcp-manager.ts b/packages/agent/src/adapters/codex-app-server/mcp-manager.ts index 60811c0ca1..6faae9f47d 100644 --- a/packages/agent/src/adapters/codex-app-server/mcp-manager.ts +++ b/packages/agent/src/adapters/codex-app-server/mcp-manager.ts @@ -6,18 +6,9 @@ export interface McpCall { } /** - * Manages the session's MCP tool-call state. - * - * Its main job today is correlating codex approval prompts back to the tool that - * triggered them. Codex has no MCP-specific approval: a tool surfaces either a - * command-execution approval (which carries the item id) or — for the PostHog - * `exec` tool — a generic `mcpServer/elicitation/request` carrying only the - * server name. Neither carries the real tool/args, so we remember each in-flight - * call from its `mcpToolCall` item and recover it at approval time: by item id - * for a command approval, or by server name for an elicitation (which has no id, - * so it correlates to the latest in-flight call for that server — MCP calls are - * sequential and an elicitation fires while its call is live). Session-scoped and - * small (one entry per MCP call). + * Correlates codex approval prompts back to the MCP tool that triggered them: by + * item id for a command approval, or by server name for an elicitation (which + * carries no id, so it maps to the latest in-flight call — MCP calls are sequential). */ export class McpManager { private readonly byId = new Map(); diff --git a/packages/agent/src/adapters/codex-app-server/protocol.ts b/packages/agent/src/adapters/codex-app-server/protocol.ts index dbc99cc4a4..bd948e9a34 100644 --- a/packages/agent/src/adapters/codex-app-server/protocol.ts +++ b/packages/agent/src/adapters/codex-app-server/protocol.ts @@ -1,14 +1,7 @@ /** - * Minimal typings for the native Codex `app-server` JSON-RPC protocol. - * - * Method names and message shapes follow the documented protocol - * (https://developers.openai.com/codex/app-server). The wire framing is - * newline-delimited JSON that follows JSON-RPC 2.0 structure but omits the - * `"jsonrpc": "2.0"` header on the wire. - * - * Spike scope: param/result shapes are still partial. Generate the exact, - * version-pinned schema with `codex app-server generate-ts` once the codex - * binary is bundled, then tighten these. + * Minimal typings for the native Codex `app-server` JSON-RPC protocol + * (https://developers.openai.com/codex/app-server). Wire framing is + * newline-delimited JSON that omits the `"jsonrpc": "2.0"` header. */ export const APP_SERVER_METHODS = { @@ -17,8 +10,7 @@ export const APP_SERVER_METHODS = { THREAD_RESUME: "thread/resume", THREAD_FORK: "thread/fork", TURN_START: "turn/start", - // Inject input into the active turn instead of starting a new one — used to - // mirror Claude's mid-turn steering. Fails unless `expectedTurnId` matches. + // Inject input into the active turn (mirrors Claude's mid-turn steering); fails unless `expectedTurnId` matches. TURN_STEER: "turn/steer", TURN_INTERRUPT: "turn/interrupt", MODEL_LIST: "model/list", @@ -29,42 +21,31 @@ export const APP_SERVER_METHODS = { export const APP_SERVER_NOTIFICATIONS = { INITIALIZED: "initialized", THREAD_STARTED: "thread/started", - // Carries the active turn id (`turn.id`) — captured as the turn/steer + - // turn/interrupt precondition. + // Carries the active turn id — precondition for turn/steer + turn/interrupt. TURN_STARTED: "turn/started", ITEM_STARTED: "item/started", ITEM_COMPLETED: "item/completed", AGENT_MESSAGE_DELTA: "item/agentMessage/delta", REASONING_TEXT_DELTA: "item/reasoning/textDelta", - // The DEFAULT reasoning stream for gpt-5-family models (summaries via - // model_reasoning_summary="auto"). Raw textDelta only fires when - // show_raw_agent_reasoning=true, which we don't set — so without this the host - // sees no reasoning at all. + // Default reasoning stream for gpt-5 models; raw textDelta is off by default, so without this the host sees no reasoning. REASONING_SUMMARY_TEXT_DELTA: "item/reasoning/summaryTextDelta", TURN_PLAN_UPDATED: "turn/plan/updated", TURN_COMPLETED: "turn/completed", // Fatal turn error; `willRetry:false` means it won't recover on its own. ERROR: "error", TOKEN_USAGE_UPDATED: "thread/tokenUsage/updated", - // codex auto-compacted the thread's context (it hit auto_compact_token_limit). - // Mirrors the Claude adapter's compact_boundary: we surface it to the host so - // the context indicator, isCompacting state, and queue drain all fire. + // codex auto-compacted the thread; mirrors Claude's compact_boundary so the host's context indicator + queue drain fire. CONTEXT_COMPACTED: "thread/compacted", - // Streamed stdout/stderr chunks for an in-progress commandExecution item. COMMAND_OUTPUT_DELTA: "item/commandExecution/outputDelta", // PTY-level stdin echoed back for an interactive terminal command. TERMINAL_INTERACTION: "item/commandExecution/terminalInteraction", - // Incremental patch/diff updates for an in-progress fileChange item. FILE_CHANGE_PATCH_UPDATED: "item/fileChange/patchUpdated", } as const; /** - * Server-initiated requests the client must answer. The two approvals are - * handled in handleApproval (yes/no decision). The richer requests carry - * distinct response shapes, not the approval decision: - * - TOOL_USER_INPUT — AskUserQuestion-style multi-question prompt. - * - PERMISSIONS_APPROVAL — grant a permission profile for a turn/session. - * - MCP_ELICITATION — an MCP server asking the user for structured input. + * Server-initiated requests the client must answer. The two approvals are yes/no + * decisions; the richer requests carry distinct response shapes (multi-question + * prompt, permission-profile grant, MCP elicitation). */ export const APP_SERVER_REQUESTS = { COMMAND_APPROVAL: "item/commandExecution/requestApproval", diff --git a/packages/agent/src/adapters/codex-app-server/session-config.ts b/packages/agent/src/adapters/codex-app-server/session-config.ts index f831db9591..dea07c69d6 100644 --- a/packages/agent/src/adapters/codex-app-server/session-config.ts +++ b/packages/agent/src/adapters/codex-app-server/session-config.ts @@ -3,22 +3,15 @@ import { type GatewayModel, isOpenAIModel } from "../../gateway-models"; import { getReasoningEffortOptions } from "../codex/models"; /** - * Session config + mode synthesis for the codex app-server adapter. - * - * PostHog Code expects ACP `configOptions` (model + reasoning-effort selectors) - * and a `mode` switcher. The native app-server has no "mode" RPC — a thread is - * configured by `approvalPolicy` + `sandbox` — so the modes are synthesized here - * and applied per-turn. We mirror the codex-acp adapter, which surfaces only - * `model` + `thought_level` configOptions (mode is driven via - * `setSessionConfigOption`, not listed as a configOption). + * Session config + mode synthesis for the codex app-server adapter. The native + * app-server has no "mode" RPC (a thread is configured by `approvalPolicy` + + * `sandbox`), so modes are synthesized here and applied per-turn. */ /** - * Per-turn sandbox the mode maps to (a subset of codex's SandboxPolicy). This is - * what makes read-only/plan actually BLOCK edits — `approvalPolicy` alone is - * neutralized because the process spawns under `workspace-write`/`danger-full-access`. - * (Plan ALSO sets codex's `collaborationMode` on turn/start — a separate axis, - * see codex-app-server-agent.ts — which unlocks plan proposals + request_user_input.) + * Per-turn sandbox the mode maps to (subset of codex's SandboxPolicy). This is + * what makes read-only/plan actually block edits — `approvalPolicy` alone is + * neutralized because the process spawns editable. */ export type CodexSandboxPolicy = | { type: "readOnly"; networkAccess: boolean } @@ -31,33 +24,27 @@ export interface CodexMode { /** codex AskForApproval the mode maps to, applied per-turn on turn/start. */ approvalPolicy: string; /** - * Per-turn sandbox override (turn/start `sandboxPolicy`). Undefined means keep - * the spawned `danger-full-access` (can edit). Applied only off the cloud - * sandbox, where a non-danger policy would re-engage the unavailable - * linux-sandbox and panic — see codex-app-server-agent.ts. + * Per-turn sandbox override; undefined keeps the spawned editable sandbox. + * Only applied off the cloud sandbox, where a non-danger policy would re-engage + * the unavailable linux-sandbox and panic. */ sandboxPolicy?: CodexSandboxPolicy; /** - * codex's native collaboration mode, sent per-turn on `turn/start`. "plan" - * makes codex propose a plan and unlocks `request_user_input` (AskUserQuestion); - * everything else runs in "default". This is what makes Plan a real mode rather - * than a relabeled read-only sandbox. + * codex's native collaboration mode (per-turn on `turn/start`). "plan" unlocks + * plan proposals + `request_user_input`; everything else runs "default". */ collaborationMode?: "plan" | "default"; /** - * codex's named permission profile, sent per-turn on `turn/start` as - * `activePermissionProfile: { extends }`. codex 0.140.0 enforces the sandbox - * through these built-in profiles (`:read-only` / `:workspace` / - * `:danger-full-access`); the raw per-turn `sandboxPolicy` we also send is no - * longer honored on its own. Undefined means keep the spawned default (editable). + * codex's named permission profile (per-turn `activePermissionProfile.extends`). + * codex 0.140.0 enforces the sandbox through these built-in profiles; the raw + * `sandboxPolicy` is no longer honored alone. Undefined keeps the spawned default. */ permissionProfile?: string; } -// Flattened Claude-style presets. Restriction is driven by approvalPolicy + -// the named permissionProfile (codex 0.140.0's enforced sandbox lever — the raw -// sandboxPolicy is sent too but no longer honored alone); plan/read-only block -// edits via `:read-only`, auto/full-access keep the spawned editable sandbox. +// Flattened Claude-style presets. Restriction is driven by approvalPolicy + the +// named permissionProfile (codex 0.140.0's enforced sandbox lever); plan/read-only +// block edits, auto/full-access keep the spawned editable sandbox. export const CODEX_MODES: CodexMode[] = [ { id: "plan", @@ -112,11 +99,7 @@ export function permissionProfileFor( return CODEX_MODES.find((m) => m.id === modeId)?.permissionProfile; } -/** - * codex collaboration mode for a preset — "plan" only for the Plan preset, else - * "default". Switching away from Plan must reset to "default", so this never - * returns undefined. - */ +/** codex collaboration mode for a preset — "plan" only for Plan, else "default". */ export function collaborationModeFor( modeId: string | undefined, ): "plan" | "default" { @@ -126,10 +109,8 @@ export function collaborationModeFor( } /** - * Resolve the host's initial `_meta.permissionMode` to a codex mode — mirroring - * codex-acp's toCodexPermissionMode. A recognized codex mode is honored; any - * other value (e.g. a Claude-style "bypassPermissions") falls back to the - * default so the session starts in a sane approval policy. + * Resolve the host's initial `_meta.permissionMode` to a codex mode. A recognized + * mode is honored; anything else (e.g. "bypassPermissions") falls back to default. */ export function resolveInitialMode(permissionMode: string | undefined): string { return permissionMode && CODEX_MODES.some((m) => m.id === permissionMode) @@ -140,9 +121,7 @@ export function resolveInitialMode(permissionMode: string | undefined): string { /** Codex's standard reasoning efforts; used when model/list doesn't expose them. */ export const DEFAULT_EFFORTS = ["low", "medium", "high"]; -// Display labels for reasoning efforts. Mirrors codex/models.ts and -// claude/session/models.ts so the live selector matches the preview path -// (the host renders `name` verbatim — raw "low" would show lowercase). +// Display labels for reasoning efforts; the host renders `name` verbatim. const EFFORT_LABELS: Record = { low: "Low", medium: "Medium", @@ -163,7 +142,6 @@ export interface ConfigSelectors { effort?: string; /** From model/list; falls back to the single current model when empty. */ models: Array<{ id: string; name: string }>; - /** Reasoning efforts supported by the current model. */ efforts: string[]; } @@ -172,9 +150,7 @@ export function buildConfigOptions(s: ConfigSelectors): SessionConfigOption[] { const baseModels = s.models.length ? s.models : [{ id: s.model, name: s.model }]; - // Ensure the active model/effort is always a selectable option, even if - // model/list omitted it or a mid-session switch moved off the listed set — - // otherwise the selector's currentValue points at nothing. + // Ensure the active model stays selectable, else currentValue points at nothing. const models = baseModels.some((m) => m.id === s.model) ? baseModels : [...baseModels, { id: s.model, name: s.model }]; @@ -225,14 +201,9 @@ interface RawModel { } /** - * Stateful holder for a codex session's model / reasoning-effort / mode - * selectors and the ACP `configOptions` derived from them. - * - * The native app-server has no `configOptions` or `mode` concept — it's - * configured by `model` + per-turn `approvalPolicy`/`sandbox`/`collaborationMode` - * — so this synthesizes the Claude-style picker the host renders, and rebuilds - * the options on every change. The agent owns the transport; this owns the state - * and its projection through the pure builders above. + * Stateful holder for a codex session's model / effort / mode selectors and the + * ACP `configOptions` derived from them — synthesizing the Claude-style picker + * the app-server has no native concept of, rebuilt on every change. */ export class SessionConfigState { private _model: string; @@ -267,10 +238,7 @@ export class SessionConfigState { this.rebuild(); } - /** - * Apply a `setSessionConfigOption` change. Returns whether the mode changed, - * so the caller can emit `current_mode_update`. - */ + /** Apply a `setSessionConfigOption` change; returns whether the mode changed. */ setOption( configId: string | undefined, value: unknown, @@ -289,12 +257,9 @@ export class SessionConfigState { } /** - * Populate the model + reasoning-effort selectors from a `model/list` `data` - * array. model/list comes through the PostHog gateway, which also serves - * Claude models, so drop non-OpenAI ones. The gateway doesn't populate - * reasoning efforts, so fall back to the shared codex model→effort map (which - * surfaces "xhigh" for the gpt-5.5 family); if the gateway starts reporting - * efforts they win. + * Populate the model + effort selectors from a `model/list` `data` array. The + * gateway also serves Claude models, so drop non-OpenAI ones; it doesn't + * populate efforts, so fall back to the shared codex model→effort map. */ loadModels(rawModels: RawModel[]): void { this.models = rawModels @@ -324,8 +289,7 @@ export class SessionConfigState { } /** - * codex's per-turn `collaborationMode` field: `{ mode, settings: { model } }`. - * `plan` unlocks plan proposals + request_user_input; `default` reverts. The + * codex's per-turn `collaborationMode`: `{ mode, settings: { model } }`. The * model must be a string (not the null in collaborationMode/list output). */ collaborationModeForTurn(): unknown { @@ -335,20 +299,15 @@ export class SessionConfigState { }; } - /** The AskForApproval policy for the current mode (turn/start `approvalPolicy`). */ approvalPolicy(): string | undefined { return modeApprovalPolicy(this._mode); } - /** The per-turn sandbox override for the current mode, if any. */ sandboxPolicy(): CodexSandboxPolicy | undefined { return sandboxPolicyFor(this._mode); } - /** - * The per-turn `activePermissionProfile` for the current mode (codex 0.140.0's - * enforced sandbox mechanism), or undefined to keep the spawned default. - */ + /** Per-turn `activePermissionProfile` (codex 0.140.0's enforced sandbox), or undefined. */ permissionProfile(): { extends: string } | undefined { const profile = permissionProfileFor(this._mode); return profile ? { extends: profile } : undefined; diff --git a/packages/agent/src/adapters/codex-app-server/spawn.test.ts b/packages/agent/src/adapters/codex-app-server/spawn.test.ts index 24ee03604a..ef87b2cb64 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.test.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.test.ts @@ -34,7 +34,6 @@ describe("buildAppServerArgs", () => { try { const args = buildAppServerArgs({ binaryPath: "/bundle/codex" }); expect(args).toContain(expected); - // Exactly one sandbox_mode override is emitted. expect( args.filter((a) => a.startsWith("sandbox_mode=")), ).toHaveLength(1); @@ -53,8 +52,6 @@ describe("buildAppServerArgs", () => { developerInstructions: "Follow PostHog rules.", }); - // Guidance is injected per-thread in thread/start (combined with the host's - // task system prompt), so the spawn args carry no instructions of any kind. expect(args.some((arg) => arg.startsWith("developer_instructions="))).toBe( false, ); diff --git a/packages/agent/src/adapters/codex-app-server/spawn.ts b/packages/agent/src/adapters/codex-app-server/spawn.ts index 5281040c31..48a97d67ca 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.ts @@ -34,18 +34,10 @@ export function buildAppServerArgs( args.push("-c", "features.remote_models=false"); - // OS sandbox mode is gated on the platform, which mirrors sandbox AVAILABILITY: - // - macOS (local desktop + e2e): Seatbelt is available, so spawn with the - // `workspace-write` sandbox. This keeps the OS sandbox engaged so a per-turn - // `sandboxPolicy:readOnly` (the Plan / Read-only presets) can actually - // TIGHTEN it and block edits — a process spawned `danger-full-access` has - // the sandbox fully disabled and can't re-engage it per-turn, which made the - // mode picker cosmetic. - // - cloud (linux containers): codex's `linux-sandbox` launcher is unavailable, - // so the default mode panics ("sandbox launcher unavailable") and wedges the - // session. Run `danger-full-access` (PostHog's enclosing docker/Modal - // sandbox provides the real isolation there). Windows falls here too, - // conservatively, until its sandbox is verified. + // OS sandbox gated on platform (= availability): macOS Seatbelt → workspace-write + // (keeps the sandbox engaged so a per-turn readOnly can tighten it and block + // edits); linux/windows have no sandbox launcher and would panic, so + // danger-full-access (the enclosing docker/Modal sandbox isolates instead). args.push( "-c", process.platform === "darwin" @@ -53,18 +45,12 @@ export function buildAppServerArgs( : `sandbox_mode="danger-full-access"`, ); - // Disable the user's ambient ~/.codex MCP servers (linear/figma/etc.) so the - // adapter only exposes MCP servers PostHog Code injects per-thread — matching - // the codex-acp adapter. Without this, codex tries (and fails) to connect to - // the user's local MCP servers, polluting the session. Only the first key - // segment is disabled (`mcp_servers..enabled=false`) — see settings.ts. + // Disable the user's ambient ~/.codex MCP servers so the adapter only exposes + // MCP servers PostHog injects per-thread; otherwise codex fails connecting to them. for (const name of new CodexSettingsManager( options.cwd ?? process.cwd(), ).getSettings().mcpServerNames) { - // codex's `-c` parser rejects quoted/special key segments; a dotted or - // spaced server name would emit an override that fails to load and wedges - // the whole session. Skip it (the server stays enabled, which is harmless) - // — mirrors the guard in codex/spawn.ts. + // codex's `-c` parser rejects quoted/special key segments; skip such names. if (!/^[A-Za-z0-9_-]+$/.test(name)) continue; args.push("-c", `mcp_servers.${name}.enabled=false`); } @@ -80,11 +66,9 @@ export function buildAppServerArgs( ); } - // developer_instructions are set per-thread in thread/start (combined with the - // host's task system prompt) rather than as a spawn-level global default, so - // the task prompt — only known at newSession — reaches the model too. + // developer_instructions are set per-thread in thread/start (with the host's + // task system prompt), not as a spawn-level global default. - // Caller-supplied config overrides (e.g. the e2e's low auto_compact_token_limit). // Numbers/bools go bare; strings are quoted, matching codex's `-c` parser. for (const [key, value] of Object.entries(options.configOverrides ?? {})) { args.push( diff --git a/packages/agent/src/adapters/codex-app-server/turn-controller.ts b/packages/agent/src/adapters/codex-app-server/turn-controller.ts index 99ed0f2426..5192222f77 100644 --- a/packages/agent/src/adapters/codex-app-server/turn-controller.ts +++ b/packages/agent/src/adapters/codex-app-server/turn-controller.ts @@ -6,14 +6,9 @@ interface PendingTurn { } /** - * The turn state machine for one codex thread. - * - * A codex turn is asynchronous: `prompt()` starts it and awaits a completion - * promise that `turn/completed` (or an interrupt/error) resolves. This owns the - * pieces that dance across those events — the in-flight `turnId` (the precondition - * `turn/steer` requires and the target `turn/interrupt` aborts), the pending - * completion, and the ids of interrupted turns whose late `turn/completed` must be - * dropped so it can't finalize a follow-up turn as cancelled. + * The turn state machine for one codex thread. A turn is async: `prompt()` starts it and + * awaits a completion promise `turn/completed` (or interrupt/error) resolves. Owns the + * in-flight `turnId`, the pending completion, and the ids of interrupted turns to drop. */ export class TurnController { private turnId?: string; @@ -21,7 +16,6 @@ export class TurnController { private completion?: Promise; private readonly cancelled = new Set(); - /** Start a fresh turn; returns the promise that resolves when it completes. */ begin(): Promise { this.completion = new Promise((resolve, reject) => { this.pending = { resolve, reject }; @@ -34,7 +28,6 @@ export class TurnController { return this.turnId; } - /** A turn is awaiting completion. */ get isPending(): boolean { return this.pending !== undefined; } @@ -49,7 +42,6 @@ export class TurnController { if (this.pending && typeof id === "string") this.turnId = id; } - /** Update the turn id after a turn/steer rotates it. */ onSteered(id: string | undefined): void { if (typeof id === "string") this.turnId = id; } @@ -59,11 +51,7 @@ export class TurnController { return this.completion ?? Promise.resolve("end_turn"); } - /** - * Atomically claim the pending turn for finalization: clears the pending slot - * and turnId (synchronously, before any await, so a racing steer/finalize sees - * no live turn) and returns its resolver — or undefined if already claimed. - */ + /** Atomically claim the pending turn (clears the slot + turnId synchronously), or undefined if already claimed. */ claim(): PendingTurn | undefined { const pending = this.pending; if (!pending) return undefined; @@ -72,10 +60,7 @@ export class TurnController { return pending; } - /** - * Mark the live turn interrupted (so its late turn/completed is dropped) and - * return its id for the turn/interrupt RPC, or undefined if no turn started. - */ + /** Mark the live turn interrupted (so its late completion is dropped) and return its id, or undefined. */ markInterrupted(): string | undefined { if (!this.turnId) return undefined; this.cancelled.add(this.turnId); diff --git a/packages/agent/src/adapters/codex-app-server/usage-tracker.ts b/packages/agent/src/adapters/codex-app-server/usage-tracker.ts index d56e5da53a..ecd87ffa47 100644 --- a/packages/agent/src/adapters/codex-app-server/usage-tracker.ts +++ b/packages/agent/src/adapters/codex-app-server/usage-tracker.ts @@ -18,14 +18,9 @@ export interface UsageUpdate { } /** - * Tracks token usage for one codex thread. - * - * codex's `thread/tokenUsage/updated` carries `{ total (cumulative), last (this - * turn's breakdown), modelContextWindow }`. We let codex own the per-turn number - * rather than reconstructing it: `last` is both the context-window occupancy (as - * input tokens) and the per-turn usage reported on `_posthog/turn_complete` — so - * there's no cumulative snapshot to diff. `total` is only a fallback for a build - * that predates `last` (turn one, where `last` ≈ `total`). + * Tracks token usage for one codex thread. codex's `thread/tokenUsage/updated` carries + * `{ total, last, modelContextWindow }`; `last` drives both context occupancy and per-turn + * usage rather than diffing `total` (a fallback for builds predating `last`). */ export class UsageTracker { private baseline: ContextBreakdownBaseline = emptyBaseline(); @@ -46,10 +41,7 @@ export class UsageTracker { this.contextUsed = undefined; } - /** - * Ingest a `thread/tokenUsage/updated` payload; returns the live usage_update - * to emit, or null if the payload has no usable totals. - */ + /** Ingest a `thread/tokenUsage/updated` payload; returns the live usage_update, or null if unusable. */ ingest(params: unknown): UsageUpdate | null { const tu = (params as { tokenUsage?: any })?.tokenUsage; const total = tu?.total; diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 595964aaf7..9f854db6c0 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -395,9 +395,8 @@ export class AgentServer { } private shouldRelayPermissionToClient(mode: PermissionMode): boolean { - // "plan" relays like "read-only": both are look-don't-touch modes, so an - // edit/command escalation must reach a connected desktop for a human veto - // rather than being silently auto-approved. + // "plan" relays like "read-only" (look-don't-touch): escalations need a human + // veto, not silent auto-approval. return ( mode === "default" || mode === "auto" || @@ -2928,12 +2927,9 @@ ${signedCommitInstructions} isQuestion || this.shouldRelayPermissionToClient(sessionPermissionMode); - // A "background" (autonomous) run has no interactive human to answer a - // relayed approval — and hasDesktopConnected can still be true because - // the event-relay SSE reader counts as a connected client. Relaying to - // it would hang the run forever (e.g. a posthog/exec elicitation during - // signals repo-selection/research). Auto-approve instead (the intended - // autonomous behavior), still honoring the publish block below. + // A background run has no human to answer a relayed approval + // (hasDesktopConnected is true from the event-relay reader), so + // auto-approve rather than hang on it. if ( mode !== "background" && (isPlanApproval || From dbc09c21c9379970a1b17755ee889e77156bdf32 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Thu, 2 Jul 2026 13:19:46 +0100 Subject: [PATCH 13/20] resolve conflicts --- packages/agent/e2e/config.ts | 4 +- .../codex-app-server-agent.test.ts | 93 ++++++------------- .../codex-app-server/session-config.test.ts | 2 +- .../adapters/codex-app-server/spawn.test.ts | 6 +- packages/core/src/sessions/sessionService.ts | 4 +- 5 files changed, 35 insertions(+), 74 deletions(-) diff --git a/packages/agent/e2e/config.ts b/packages/agent/e2e/config.ts index d165d802b9..0670dd82eb 100644 --- a/packages/agent/e2e/config.ts +++ b/packages/agent/e2e/config.ts @@ -38,8 +38,8 @@ export const E2E = { gatewayUrl: GATEWAY_URL, codexBin: NATIVE_CODEX_BIN, /** Deployment environment. `E2E_ENVIRONMENT=cloud` exercises the cloud code path; undefined = local. */ - environment: (process.env.E2E_ENVIRONMENT as "local" | "cloud" | undefined) || - undefined, + environment: + (process.env.E2E_ENVIRONMENT as "local" | "cloud" | undefined) || undefined, /** Cheap model per adapter, overridable via `E2E_CLAUDE_MODEL` / `E2E_CODEX_MODEL`. */ model(adapter: Adapter): string { diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index db16f3b0d8..d3221eb2b8 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -11,9 +11,7 @@ import type { } from "./app-server-client"; import { CodexAppServerAgent } from "./codex-app-server-agent"; -// The required-field invariants the native codex app-server enforces on each -// client request (verified against the binary). turn/interrupt needs both ids; -// turn/steer needs the precondition turnId plus the thread and the message. +// Required-field invariants the native codex app-server enforces on each request. const REQUIRED_FIELDS: Record = { "turn/interrupt": ["threadId", "turnId"], "turn/steer": ["threadId", "input", "expectedTurnId"], @@ -36,10 +34,7 @@ function makeStubRpc(responses: Record) { const rpc: AppServerRpc = { async request(method: string, params?: unknown): Promise { requests.push({ method, params }); - // Enforce the real app-server schema contract so a dropped required field - // fails loudly here, not silently in production. The native binary rejects - // these with -32600; a stub that accepted anything would let an adapter - // regression (a missing required field) sail through CI as a false-green. + // Enforce the schema contract so a dropped required field fails loudly, not as a CI false-green. const missing = requiredFieldMissing(method, params); if (missing) { throw { @@ -161,8 +156,7 @@ describe("CodexAppServerAgent", () => { await agent.initialize(init); await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); - // The MCP tool call item arrives first, then codex asks to approve it via - // the command-execution approval (it has no MCP-specific approval request). + // The MCP tool call item arrives first, then codex approves it via a command-execution request. stub.emit("item/started", { item: { type: "mcpToolCall", @@ -196,11 +190,8 @@ describe("CodexAppServerAgent", () => { }); it("enriches the MCP elicitation approval (posthog exec) from the in-flight tool call", async () => { - // The real production path: codex gates the PostHog `exec` tool behind a - // generic mcpServer/elicitation/request (serverName only, no tool/args) — - // NOT the command approval. Without correlation the host showed a bare - // 'Allow the posthog MCP server to run tool "exec"?'. The adapter now - // resolves it to the in-flight mcpToolCall so the real tool + command render. + // codex gates PostHog `exec` behind a generic elicitation (serverName only, no tool/args); + // the adapter correlates it to the in-flight mcpToolCall so the real tool + command render. const stub = makeStubRpc({ initialize: {}, "thread/start": { thread: { id: "thr_1" } }, @@ -284,9 +275,7 @@ describe("CodexAppServerAgent", () => { } it("routes a non-MCP command approval to an execute permission (kind + command body)", async () => { - // The bug: a bare { toolCallId, title } routed to the DefaultPermission - // fallback, losing the command styling/monospace body. kind:"execute" plus - // the command as text content makes the host render ExecutePermission. + // kind:"execute" + command text content makes the host render ExecutePermission (not the fallback). const { agent, stub, permissionToolCalls } = makeApprovalAgent(); await agent.initialize(init); await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); @@ -323,9 +312,8 @@ describe("CodexAppServerAgent", () => { }, ); - // The host gets an allow_always option (UI already renders this kind)... expect(permissionOptions[0].map((o) => o.kind)).toContain("allow_always"); - // ...and picking it echoes codex's own decision so it applies the amendment. + // Picking it echoes codex's own decision so it applies the amendment. expect(decision).toEqual({ decision: "approved_execpolicy_amendment" }); }); @@ -392,13 +380,11 @@ describe("CodexAppServerAgent", () => { ); expect(decision).toEqual({ decision: "decline" }); - // The "tell Codex what to do differently" option was offered (free-text). const feedbackOpt = offeredOptions[0].find( (o) => o.optionId === "reject_with_feedback", ); expect(feedbackOpt).toBeTruthy(); - // The guidance was steered into the running turn (codex's response carries - // no feedback field, so it's injected as a follow-up user message). + // The guidance was steered into the running turn as a follow-up message. const steer = stub.requests.find((r) => r.method === "turn/steer"); expect((steer?.params as { expectedTurnId?: string })?.expectedTurnId).toBe( "turn_1", @@ -514,8 +500,7 @@ describe("CodexAppServerAgent", () => { const agent = new CodexAppServerAgent(client, { processOptions: { binaryPath: "/x/codex", - // The host pre-flattens the session prompt into developerInstructions - // for codex AND also sends the raw {append} form as _meta.systemPrompt. + // The host pre-flattens into developerInstructions AND sends the raw {append} form. developerInstructions: "Be a careful engineer.", }, rpcFactory: stub.factory, @@ -527,8 +512,7 @@ describe("CodexAppServerAgent", () => { } as unknown as NewSessionRequest); const threadStart = stub.requests.find((r) => r.method === "thread/start"); - // {append} is flattened (NOT "[object Object]") and, being identical to the - // pre-flattened developerInstructions, deduped to a single copy. + // {append} is flattened (not "[object Object]") and, being identical, deduped to one copy. expect( (threadStart?.params as { developerInstructions?: string }) .developerInstructions, @@ -626,7 +610,6 @@ describe("CodexAppServerAgent", () => { rpcFactory: stub.factory, }); await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); - // Switch the flattened picker to Plan, then run a turn. await agent.setSessionConfigOption({ configId: "mode", value: "plan", @@ -645,8 +628,7 @@ describe("CodexAppServerAgent", () => { approvalPolicy?: string; collaborationMode?: unknown; }; - // Plan engages codex's native plan collaboration (unlocks request_user_input - // + plan proposals) AND blocks edits via a read-only sandbox. + // Plan engages codex's plan collaboration AND blocks edits via a read-only sandbox. expect(params.collaborationMode).toEqual({ mode: "plan", settings: { model: "gpt-5.5" }, @@ -684,8 +666,7 @@ describe("CodexAppServerAgent", () => { collaborationMode?: unknown; }; expect(params.sandboxPolicy).toBeUndefined(); - // Default collaboration is pushed explicitly every turn so that switching - // back from Plan reverts (codex remembers the last collaboration mode). + // Default collaboration is pushed every turn so switching back from Plan reverts. expect(params.collaborationMode).toEqual({ mode: "default", settings: { model: "gpt-5.5" }, @@ -754,8 +735,7 @@ describe("CodexAppServerAgent", () => { supportedReasoningEfforts: [], }, { - // The gateway also serves Claude models — they must not leak into a - // codex session's model picker. + // The gateway also serves Claude models — they must not leak into the picker. id: "claude-opus-4-8", model: "claude-opus-4-8", hidden: false, @@ -778,8 +758,7 @@ describe("CodexAppServerAgent", () => { expect( opts.find((o) => o.category === "model").options.map((x: any) => x.value), ).toEqual(["gpt-5.5"]); - // No live efforts → shared codex map, which exposes xhigh for the gpt-5.5 - // family (instead of the bare low/medium/high fallback). + // No live efforts → shared codex map, which exposes xhigh for the gpt-5.5 family. expect( opts .find((o) => o.category === "thought_level") @@ -828,9 +807,7 @@ describe("CodexAppServerAgent", () => { stub.emit("turn/completed", { turn: { status: "completed" } }); await done; - // codex 0.140.0 enforces the sandbox via the named profile — the raw - // sandboxPolicy alone is no longer honored — so read-only MUST send - // activePermissionProfile:{extends:":read-only"} (alongside sandboxPolicy). + // codex 0.140.0 enforces the sandbox via the named profile, so read-only MUST send it alongside sandboxPolicy. const turnStart = stub.requests.find((r) => r.method === "turn/start"); expect(turnStart?.params).toMatchObject({ activePermissionProfile: { extends: ":read-only" }, @@ -1006,14 +983,12 @@ describe("CodexAppServerAgent", () => { stub.emit("item/completed", { item: { type: "agentMessage", id: "a1", text: '{"ok":true}' }, }); - // A fatal error AND turn/completed for the same turn must not double-fire - // the _posthog/turn_complete notification (idempotent finalize). + // error + turn/completed for one turn must not double-fire turn_complete (idempotent). stub.emit("error", { willRetry: false, error: { message: "boom" } }); stub.emit("turn/completed", { turn: { status: "failed" } }); await done; - // Structured output is gated on a clean end_turn: a refused/failed turn must - // NOT record task output even though a valid final message was captured. + // Structured output is gated on a clean end_turn: a refused turn records nothing. expect(outputs).toEqual([]); expect( extNotifications.filter((n) => n.method === "_posthog/turn_complete") @@ -1070,9 +1045,7 @@ describe("CodexAppServerAgent", () => { sessionId: "t", prompt: [{ type: "text", text: "go" }], } as unknown as PromptRequest); - // turn/started carries the live turnId the real server REQUIRES on - // turn/interrupt — without it codex rejects the RPC (-32600) and the turn - // keeps running while the adapter falsely reports "cancelled". + // turn/started carries the live turnId the server REQUIRES on turn/interrupt (else -32600). stub.emit("turn/started", { turn: { id: "turn_1" } }); await agent.cancel({ sessionId: "t" }); @@ -1109,8 +1082,7 @@ describe("CodexAppServerAgent", () => { prompt: [{ type: "text", text: "again" }], } as unknown as PromptRequest); stub.emit("turn/started", { turn: { id: "turn_2" } }); - // codex's late completion of the cancelled turn arrives during turn 2 — it - // must be ignored, not finalize turn 2 as cancelled. + // The cancelled turn's late completion arrives during turn 2 — it must be ignored. stub.emit("turn/completed", { turn: { id: "turn_1", status: "interrupted" }, }); @@ -1136,9 +1108,7 @@ describe("CodexAppServerAgent", () => { sessionId: "t", prompt: [{ type: "text", text: "go" }], } as unknown as PromptRequest); - // Emit turn/started so the interrupt actually reaches the binary — without - // it turnId is undefined, turn/interrupt is skipped, and this test would - // pass on the local finalize alone (the false-green it used to be). + // Emit turn/started so the interrupt actually reaches the binary (else false-green on local finalize). stub.emit("turn/started", { turn: { id: "turn_1" } }); await agent.cancel({ sessionId: "t" }); @@ -1170,9 +1140,7 @@ describe("CodexAppServerAgent", () => { sessionId: "t", prompt: [{ type: "text", text: "go" }], } as unknown as PromptRequest); - // No turn/started → no live turnId. interrupt() must NOT send a turnId-less - // turn/interrupt (the binary would reject it -32600); it guards on turnId and - // falls back to the local finalize so cancel never hangs. + // No turn/started → no turnId: interrupt() must skip the RPC (else -32600) and still finalize. await agent.cancel({ sessionId: "t" }); expect((await done).stopReason).toBe("cancelled"); @@ -1332,8 +1300,7 @@ describe("CodexAppServerAgent", () => { sessionId: "t", prompt: [{ type: "text", text: "two" }], } as unknown as PromptRequest); - // Let the first steer's response (turnId: turn_2) be applied before the next - // steer reads this.turnId — turn/started is not re-emitted for a steer. + // Let the first steer's rotated turnId apply before the next steer reads it. await new Promise((r) => setTimeout(r, 0)); const third = agent.prompt({ sessionId: "t", @@ -1477,10 +1444,8 @@ describe("CodexAppServerAgent", () => { }); it("context-usage indicator reports the latest turn, not the cumulative thread total", async () => { - // codex's ThreadTokenUsage is { total (cumulative, grows every turn), last - // (this turn's usage), modelContextWindow }. The window-occupancy indicator - // must track `last` — using `total` over-reports the window as filling up - // from accumulation alone (here a real 189k context shown as 433k = 43%). + // The window-occupancy indicator must track `last`, not the cumulative `total` + // (which over-reports the window as filling from accumulation alone). const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); const { client, extNotifications } = makeFakeClient(); const agent = new CodexAppServerAgent(client, { @@ -1541,8 +1506,7 @@ describe("CodexAppServerAgent", () => { _meta: { taskRunId: "run_u" }, } as unknown as NewSessionRequest); - // codex carries both the cumulative `total` and this turn's `last`; we let - // `last` drive the per-turn number rather than diffing the cumulative. + // We let `last` drive the per-turn number rather than diffing the cumulative `total`. const t1 = agent.prompt({ sessionId: "t", prompt: [{ type: "text", text: "a" }], @@ -1624,8 +1588,7 @@ describe("CodexAppServerAgent", () => { _meta: {}, } as unknown as NewSessionRequest); - // The compaction item brackets the compaction: started → in progress, then - // completed → boundary (codex emits no separate thread/compacted). + // The compaction item brackets it: started → in progress, completed → boundary. stub.emit("item/started", { item: { type: "contextCompaction", id: "c1" }, }); @@ -1668,9 +1631,7 @@ describe("CodexAppServerAgent", () => { sessionId: "t", prompt: [{ type: "text", text: "go" }], } as unknown as PromptRequest); - // Compaction starts, then a fatal error ends the turn BEFORE item/completed — - // without the finalize-time recovery the boundary would never fire and the - // host's isCompacting would stay stuck true. + // A fatal error ends the turn before item/completed; the finalize-time recovery still fires the boundary. stub.emit("item/started", { item: { type: "contextCompaction", id: "c1" }, }); diff --git a/packages/agent/src/adapters/codex-app-server/session-config.test.ts b/packages/agent/src/adapters/codex-app-server/session-config.test.ts index b52751df87..08fbb428ab 100644 --- a/packages/agent/src/adapters/codex-app-server/session-config.test.ts +++ b/packages/agent/src/adapters/codex-app-server/session-config.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"; import { buildConfigOptions, CODEX_MODES, + collaborationModeFor, DEFAULT_EFFORTS, modeApprovalPolicy, - collaborationModeFor, sandboxPolicyFor, } from "./session-config"; diff --git a/packages/agent/src/adapters/codex-app-server/spawn.test.ts b/packages/agent/src/adapters/codex-app-server/spawn.test.ts index ef87b2cb64..a0db5c3b62 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.test.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.test.ts @@ -34,9 +34,9 @@ describe("buildAppServerArgs", () => { try { const args = buildAppServerArgs({ binaryPath: "/bundle/codex" }); expect(args).toContain(expected); - expect( - args.filter((a) => a.startsWith("sandbox_mode=")), - ).toHaveLength(1); + expect(args.filter((a) => a.startsWith("sandbox_mode="))).toHaveLength( + 1, + ); } finally { Object.defineProperty(process, "platform", { value: original, diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index a06bc432f5..1c44ae604e 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -25,10 +25,10 @@ import { mergeConfigOptions, type OptimisticItem, type PermissionRequest, - resolveBypassRevertMode, - sessionSupportsNativeSteer, type QueuedMessage, + resolveBypassRevertMode, type StoredLogEntry, + sessionSupportsNativeSteer, type TaskRunStatus, } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; From a4a5564510cfc32ea090ab1fdedabc755dd4ac5a Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Thu, 2 Jul 2026 17:49:38 +0100 Subject: [PATCH 14/20] fix(codex): address pr review + ci failures - adopt the rotated turn id after a feedback steer so later interrupts/steers target the live turn - skip the empty bare-uri text block for uri-less resources - parametrize the approval outcome tests with it.each - add @openai/codex to the lockfile (frozen-lockfile install was failing every job) - drop a committed vite timestamp temp file and fix biome format errors Co-Authored-By: Claude Fable 5 --- packages/agent/parity/harness.ts | 116 +++++++-- packages/agent/parity/run.ts | 230 +++++++++++++++--- .../codex-app-server/approvals.test.ts | 167 +++++-------- .../codex-app-server-agent.test.ts | 14 ++ .../codex-app-server-agent.ts | 5 +- .../adapters/codex-app-server/input.test.ts | 17 ++ .../src/adapters/codex-app-server/input.ts | 4 +- ...timestamp-1782808001712-f0f037a43e4af8.mjs | 20 -- packages/shared/src/sessions.test.ts | 65 ++++- .../components/UnifiedModelSelector.test.tsx | 6 +- pnpm-lock.yaml | 71 ++++++ 11 files changed, 516 insertions(+), 199 deletions(-) delete mode 100644 packages/electron-trpc/vitest.config.ts.timestamp-1782808001712-f0f037a43e4af8.mjs diff --git a/packages/agent/parity/harness.ts b/packages/agent/parity/harness.ts index d3edcf0595..7fe768ac66 100644 --- a/packages/agent/parity/harness.ts +++ b/packages/agent/parity/harness.ts @@ -68,34 +68,80 @@ function redact(value: any): any { for (const [k, v] of Object.entries(value)) { if (k === "sessionId") out[k] = ""; else if (k === "configOptions" && Array.isArray(v)) { - out[k] = v.map((o: any) => ({ id: o?.id, category: o?.category, value: o?.value, options: (o?.options ?? []).map((x: any) => x?.id ?? x?.optionId) })); + out[k] = v.map((o: any) => ({ + id: o?.id, + category: o?.category, + value: o?.value, + options: (o?.options ?? []).map((x: any) => x?.id ?? x?.optionId), + })); } else if (k === "modes") { - out[k] = { currentModeId: (v as any)?.currentModeId, availableModes: ((v as any)?.availableModes ?? []).map((m: any) => m?.id) }; + out[k] = { + currentModeId: (v as any)?.currentModeId, + availableModes: ((v as any)?.availableModes ?? []).map( + (m: any) => m?.id, + ), + }; } else if (k === "usage" && v && typeof v === "object") { - out[k] = Object.fromEntries(Object.entries(v).map(([uk, uv]) => [uk, typeof uv === "number" ? (uv > 0 ? ">0" : 0) : uv])); - } else if (typeof v === "string" && v.length > 120) out[k] = ``; + out[k] = Object.fromEntries( + Object.entries(v).map(([uk, uv]) => [ + uk, + typeof uv === "number" ? (uv > 0 ? ">0" : 0) : uv, + ]), + ); + } else if (typeof v === "string" && v.length > 120) + out[k] = ``; else out[k] = v; } return out; } -export async function runScenario(mode: AdapterMode, scenario: Scenario, cfg: HarnessConfig): Promise { +export async function runScenario( + mode: AdapterMode, + scenario: Scenario, + cfg: HarnessConfig, +): Promise { // Select the adapter. Until the migration adds a passed-in option, the env // toggle is the only lever: set => codex-acp, unset => native app-server. if (mode === "acp") process.env.POSTHOG_CODEX_USE_ACP = "1"; else delete process.env.POSTHOG_CODEX_USE_ACP; - const captured: CapturedRun = { adapter: mode, scenario: scenario.name, events: [], stepResults: [] }; + const captured: CapturedRun = { + adapter: mode, + scenario: scenario.name, + events: [], + stepResults: [], + }; let ord = 0; const client = { async sessionUpdate(p: any): Promise { - captured.events.push({ t: ord++, kind: "sessionUpdate", sessionUpdate: p?.update?.sessionUpdate, data: p?.update }); + captured.events.push({ + t: ord++, + kind: "sessionUpdate", + sessionUpdate: p?.update?.sessionUpdate, + data: p?.update, + }); }, async requestPermission(p: any): Promise { - captured.events.push({ t: ord++, kind: "requestPermission", data: { title: p?.toolCall?.title, kind: p?.toolCall?.kind, options: (p?.options ?? []).map((o: any) => ({ id: o?.optionId, kind: o?.kind })) } }); - const allow = (p?.options ?? []).find((o: any) => o?.kind === "allow_once" || o?.kind === "allow_always") ?? p?.options?.[0]; - return { outcome: { outcome: "selected", optionId: allow?.optionId ?? "allow" } }; + captured.events.push({ + t: ord++, + kind: "requestPermission", + data: { + title: p?.toolCall?.title, + kind: p?.toolCall?.kind, + options: (p?.options ?? []).map((o: any) => ({ + id: o?.optionId, + kind: o?.kind, + })), + }, + }); + const allow = + (p?.options ?? []).find( + (o: any) => o?.kind === "allow_once" || o?.kind === "allow_always", + ) ?? p?.options?.[0]; + return { + outcome: { outcome: "selected", optionId: allow?.optionId ?? "allow" }, + }; }, async readTextFile(p: any): Promise { return { content: await fs.readFile(resolve(cfg.cwd, p.path), "utf8") }; @@ -108,16 +154,33 @@ export async function runScenario(mode: AdapterMode, scenario: Scenario, cfg: Ha // _posthog/sdk_session, ...) are part of the parity surface and are sent // outside sessionUpdate — capture them so the report covers them. async extNotification(method: string, params: any): Promise { - captured.events.push({ t: ord++, kind: "extNotification", op: method, data: redact(params) }); + captured.events.push({ + t: ord++, + kind: "extNotification", + op: method, + data: redact(params), + }); }, async extMethod(method: string, params: any): Promise { - captured.events.push({ t: ord++, kind: "extMethod", op: method, data: redact(params) }); + captured.events.push({ + t: ord++, + kind: "extMethod", + op: method, + data: redact(params), + }); return {}; }, }; - const acp = createAcpConnection({ adapter: "codex", codexOptions: cfg.codexOptions as any, logger: cfg.logger }); - const stream = ndJsonStream(acp.clientStreams.writable, acp.clientStreams.readable); + const acp = createAcpConnection({ + adapter: "codex", + codexOptions: cfg.codexOptions as any, + logger: cfg.logger, + }); + const stream = ndJsonStream( + acp.clientStreams.writable, + acp.clientStreams.readable, + ); const conn = new ClientSideConnection(() => client, stream); const ctx: ScenarioCtx = { @@ -133,16 +196,33 @@ export async function runScenario(mode: AdapterMode, scenario: Scenario, cfg: Ha captured.stepResults.push({ op, ok: true, result: redact(result) }); return result; } catch (e: any) { - console.error(` [step] ${op} ✗ (${Date.now() - started}ms): ${String(e?.message ?? e)}`); - captured.stepResults.push({ op, ok: false, error: String(e?.message ?? e) }); + console.error( + ` [step] ${op} ✗ (${Date.now() - started}ms): ${String(e?.message ?? e)}`, + ); + captured.stepResults.push({ + op, + ok: false, + error: String(e?.message ?? e), + }); throw e; } }, }; - const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error(`scenario timeout after ${cfg.timeoutMs ?? 180000}ms`)), cfg.timeoutMs ?? 180000)); + const timeout = new Promise((_, rej) => + setTimeout( + () => + rej(new Error(`scenario timeout after ${cfg.timeoutMs ?? 180000}ms`)), + cfg.timeoutMs ?? 180000, + ), + ); try { - await ctx.step("initialize", () => conn.initialize({ protocolVersion: 1, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } } })); + await ctx.step("initialize", () => + conn.initialize({ + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + }), + ); await Promise.race([scenario.run(conn, ctx), timeout]); } catch (e: any) { captured.fatalError = String(e?.message ?? e); diff --git a/packages/agent/parity/run.ts b/packages/agent/parity/run.ts index 7beee0f094..6df165897e 100644 --- a/packages/agent/parity/run.ts +++ b/packages/agent/parity/run.ts @@ -33,7 +33,12 @@ const SCENARIOS: Scenario[] = [ conn.newSession({ cwd: ctx.cwd, mcpServers: [], - _meta: { sessionId: "parity", systemPrompt: "You are a coding assistant in a tiny test repo.", model: ctx.model, permissionMode: "bypassPermissions" }, + _meta: { + sessionId: "parity", + systemPrompt: "You are a coding assistant in a tiny test repo.", + model: ctx.model, + permissionMode: "bypassPermissions", + }, }), ); const sessionId = session.sessionId; @@ -54,14 +59,53 @@ const SCENARIOS: Scenario[] = [ name: "modes-and-resume", async run(conn, ctx) { const session = await ctx.step("newSession", () => - conn.newSession({ cwd: ctx.cwd, mcpServers: [], _meta: { sessionId: "parity2", systemPrompt: "You are a coding assistant.", model: ctx.model, permissionMode: "auto" } }), + conn.newSession({ + cwd: ctx.cwd, + mcpServers: [], + _meta: { + sessionId: "parity2", + systemPrompt: "You are a coding assistant.", + model: ctx.model, + permissionMode: "auto", + }, + }), ); const sessionId = session.sessionId; // Mode switch — codex-acp supports it; app-server gap until migration. - await ctx.step("setSessionConfigOption(mode)", () => conn.setSessionConfigOption({ sessionId, configId: "mode", value: "read-only" }).catch((e: any) => { throw e; })); - await ctx.step("prompt", () => conn.prompt({ sessionId, prompt: [{ type: "text", text: "List the files in this repo with `ls`, then stop." }] })); + await ctx.step("setSessionConfigOption(mode)", () => + conn + .setSessionConfigOption({ + sessionId, + configId: "mode", + value: "read-only", + }) + .catch((e: any) => { + throw e; + }), + ); + await ctx.step("prompt", () => + conn.prompt({ + sessionId, + prompt: [ + { + type: "text", + text: "List the files in this repo with `ls`, then stop.", + }, + ], + }), + ); // Resume in the same connection (host calls resumeSession on reconnect). - await ctx.step("resumeSession", () => conn.resumeSession({ sessionId, cwd: ctx.cwd, mcpServers: [], _meta: { systemPrompt: "You are a coding assistant.", model: ctx.model } })); + await ctx.step("resumeSession", () => + conn.resumeSession({ + sessionId, + cwd: ctx.cwd, + mcpServers: [], + _meta: { + systemPrompt: "You are a coding assistant.", + model: ctx.model, + }, + }), + ); }, }, ]; @@ -97,20 +141,35 @@ function extractFeatures(run: CapturedRun): Record { if (c?.type === "content") hasToolContent = true; } } - if (d.rawInput?.diff || (typeof d.rawOutput === "string" && d.rawOutput.includes("diff"))) hasDiff = true; + if ( + d.rawInput?.diff || + (typeof d.rawOutput === "string" && d.rawOutput.includes("diff")) + ) + hasDiff = true; } - if (u === "current_mode_update" || u === "config_option_update") modeUpdate = true; - if (u === "usage_update") usageFields = new Set([...usageFields, ...Object.keys(d.usage ?? d ?? {})]); + if (u === "current_mode_update" || u === "config_option_update") + modeUpdate = true; + if (u === "usage_update") + usageFields = new Set([ + ...usageFields, + ...Object.keys(d.usage ?? d ?? {}), + ]); } // newSession response: configOptions / modes const ns = run.stepResults.find((s) => s.op === "newSession")?.result ?? {}; - const configCategories = (ns.configOptions ?? []).map((o: any) => o.category).filter(Boolean); + const configCategories = (ns.configOptions ?? []) + .map((o: any) => o.category) + .filter(Boolean); const modes = ns.modes ?? null; // prompt response usage / stopReason - const promptRes = run.stepResults.filter((s) => s.op === "prompt").map((s) => s.result ?? {}); + const promptRes = run.stepResults + .filter((s) => s.op === "prompt") + .map((s) => s.result ?? {}); const stopReasons = promptRes.map((r) => r.stopReason).filter(Boolean); - const promptUsage = promptRes.some((r) => r.usage && Object.keys(r.usage).length > 0); + const promptUsage = promptRes.some( + (r) => r.usage && Object.keys(r.usage).length > 0, + ); return { fatalError: run.fatalError ?? null, @@ -119,7 +178,10 @@ function extractFeatures(run: CapturedRun): Record { toolStatuses: [...toolStatuses].sort(), hasDiffContent: hasDiff, hasToolContent: hasToolContent, - hasUsage: promptUsage || updateTypes.has("usage_update") || extNotifs.has("_posthog/usage_update"), + hasUsage: + promptUsage || + updateTypes.has("usage_update") || + extNotifs.has("_posthog/usage_update"), usageFields: [...usageFields].sort(), configOptionCategories: [...new Set(configCategories)].sort(), modesPresent: !!modes, @@ -135,13 +197,46 @@ function extractFeatures(run: CapturedRun): Record { // on which tools the model chose (native codex edits via shell `execute`; // codex-acp exposes Edit/Read) — a tool-surface difference, not an adapter bug — // so they're reported as behavioral, not counted as parity gaps. -const ADAPTER_KEYS = ["fatalError", "updateTypes", "hasUsage", "usageFields", "configOptionCategories", "modesPresent", "modeChangeEmitted", "extNotifications", "stopReasons"]; -const BEHAVIORAL_KEYS = ["toolKinds", "toolStatuses", "hasDiffContent", "hasToolContent"]; +const ADAPTER_KEYS = [ + "fatalError", + "updateTypes", + "hasUsage", + "usageFields", + "configOptionCategories", + "modesPresent", + "modeChangeEmitted", + "extNotifications", + "stopReasons", +]; +const BEHAVIORAL_KEYS = [ + "toolKinds", + "toolStatuses", + "hasDiffContent", + "hasToolContent", +]; -function diffFeatures(acp: Record, app: Record): Array<{ feature: string; acp: any; appServer: any; match: boolean; behavioral: boolean }> { +function diffFeatures( + acp: Record, + app: Record, +): Array<{ + feature: string; + acp: any; + appServer: any; + match: boolean; + behavioral: boolean; +}> { const j = (v: any) => JSON.stringify(v); - const mk = (k: string, behavioral: boolean) => ({ feature: k, acp: acp[k], appServer: app[k], match: j(acp[k]) === j(app[k]), behavioral }); - return [...ADAPTER_KEYS.map((k) => mk(k, false)), ...BEHAVIORAL_KEYS.map((k) => mk(k, true))]; + const mk = (k: string, behavioral: boolean) => ({ + feature: k, + acp: acp[k], + appServer: app[k], + match: j(acp[k]) === j(app[k]), + behavioral, + }); + return [ + ...ADAPTER_KEYS.map((k) => mk(k, false)), + ...BEHAVIORAL_KEYS.map((k) => mk(k, true)), + ]; } function setupRepo(): void { @@ -152,7 +247,21 @@ function setupRepo(): void { try { // -c commit.gpgsign=false: ignore the user's global commit-signing config // (e.g. 1Password SSH signer), which fails in this non-interactive context. - execFileSync("git", ["-c", "commit.gpgsign=false", "-c", "user.email=p@p.dev", "-c", "user.name=parity", "commit", "-qm", "init"], { cwd: REPO }); + execFileSync( + "git", + [ + "-c", + "commit.gpgsign=false", + "-c", + "user.email=p@p.dev", + "-c", + "user.name=parity", + "commit", + "-qm", + "init", + ], + { cwd: REPO }, + ); } catch { /* already committed */ } @@ -160,18 +269,31 @@ function setupRepo(): void { async function main(): Promise { const args = process.argv.slice(2); - const only = args.includes("--only") ? (args[args.indexOf("--only") + 1] as AdapterMode) : null; - const scenarioFilter = args.includes("--scenario") ? args[args.indexOf("--scenario") + 1] : null; + const only = args.includes("--only") + ? (args[args.indexOf("--only") + 1] as AdapterMode) + : null; + const scenarioFilter = args.includes("--scenario") + ? args[args.indexOf("--scenario") + 1] + : null; mkdirSync(OUT_DIR, { recursive: true }); setupRepo(); const modes: AdapterMode[] = []; if (!only || only === "acp") modes.push("acp"); - if ((!only || only === "app-server") && existsSync(NATIVE_CODEX_BIN)) modes.push("app-server"); - else if (only === "app-server") console.warn(`native codex binary missing at ${NATIVE_CODEX_BIN}; app-server arm skipped`); + if ((!only || only === "app-server") && existsSync(NATIVE_CODEX_BIN)) + modes.push("app-server"); + else if (only === "app-server") + console.warn( + `native codex binary missing at ${NATIVE_CODEX_BIN}; app-server arm skipped`, + ); - const scenarios = SCENARIOS.filter((s) => !scenarioFilter || s.name === scenarioFilter); - const logger = new Logger({ debug: !!process.env.PARITY_DEBUG, prefix: "[parity]" }); + const scenarios = SCENARIOS.filter( + (s) => !scenarioFilter || s.name === scenarioFilter, + ); + const logger = new Logger({ + debug: !!process.env.PARITY_DEBUG, + prefix: "[parity]", + }); const featuresByMode: Record> = {}; for (const scenario of scenarios) { @@ -183,23 +305,41 @@ async function main(): Promise { // stragglers first — process death releases the flock. (Uses the default // CODEX_HOME: an isolated empty home makes codex-acp crash at startup.) try { - execFileSync("pkill", ["-9", "-f", "resources/codex-acp"], { stdio: "ignore" }); + execFileSync("pkill", ["-9", "-f", "resources/codex-acp"], { + stdio: "ignore", + }); } catch { /* none running */ } const cfg = { cwd: REPO, - codexOptions: { cwd: REPO, binaryPath: CODEX_ACP_BIN, apiBaseUrl: GATEWAY, apiKey: API_KEY, model: MODEL }, + codexOptions: { + cwd: REPO, + binaryPath: CODEX_ACP_BIN, + apiBaseUrl: GATEWAY, + apiKey: API_KEY, + model: MODEL, + }, timeoutMs: 240000, logger, }; const run = await runScenario(mode, scenario, cfg); - writeFileSync(join(OUT_DIR, `${scenario.name}.${mode}.json`), JSON.stringify(run, null, 2)); + writeFileSync( + join(OUT_DIR, `${scenario.name}.${mode}.json`), + JSON.stringify(run, null, 2), + ); const feats = extractFeatures(run); featuresByMode[scenario.name][mode] = feats; - writeFileSync(join(OUT_DIR, `${scenario.name}.${mode}.features.json`), JSON.stringify(feats, null, 2)); - console.log(` steps: ${feats.steps.map((s: any) => `${s.op}${s.ok ? "✓" : "✗"}`).join(" ")}`); - console.log(` updates: ${feats.updateTypes.join(",")} | tools: ${feats.toolKinds.join(",")} | usage:${feats.hasUsage} diff:${feats.hasDiffContent} stop:${feats.stopReasons.join(",")}`); + writeFileSync( + join(OUT_DIR, `${scenario.name}.${mode}.features.json`), + JSON.stringify(feats, null, 2), + ); + console.log( + ` steps: ${feats.steps.map((s: any) => `${s.op}${s.ok ? "✓" : "✗"}`).join(" ")}`, + ); + console.log( + ` updates: ${feats.updateTypes.join(",")} | tools: ${feats.toolKinds.join(",")} | usage:${feats.hasUsage} diff:${feats.hasDiffContent} stop:${feats.stopReasons.join(",")}`, + ); if (feats.fatalError) console.log(` ⚠ fatalError: ${feats.fatalError}`); } } @@ -215,17 +355,35 @@ async function main(): Promise { const gaps = diff.filter((d) => !d.match && !d.behavioral); const behavioral = diff.filter((d) => !d.match && d.behavioral); totalGaps += gaps.length; - report.scenarios[scenario.name] = { gaps, behavioral, allMatch: gaps.length === 0 }; + report.scenarios[scenario.name] = { + gaps, + behavioral, + allMatch: gaps.length === 0, + }; console.log(`\n=== parity diff: ${scenario.name} ===`); if (!gaps.length) console.log(" ✅ adapter parity"); - for (const g of gaps) console.log(` ✗ ${g.feature}: acp=${JSON.stringify(g.acp)} app-server=${JSON.stringify(g.appServer)}`); - for (const b of behavioral) console.log(` · behavioral: ${b.feature} acp=${JSON.stringify(b.acp)} app-server=${JSON.stringify(b.appServer)}`); + for (const g of gaps) + console.log( + ` ✗ ${g.feature}: acp=${JSON.stringify(g.acp)} app-server=${JSON.stringify(g.appServer)}`, + ); + for (const b of behavioral) + console.log( + ` · behavioral: ${b.feature} acp=${JSON.stringify(b.acp)} app-server=${JSON.stringify(b.appServer)}`, + ); } else { - report.scenarios[scenario.name] = { baselineOnly: acp ? "acp" : "app-server", features: acp ?? app }; + report.scenarios[scenario.name] = { + baselineOnly: acp ? "acp" : "app-server", + features: acp ?? app, + }; } } - writeFileSync(join(OUT_DIR, "parity-report.json"), JSON.stringify(report, null, 2)); - console.log(`\nWrote ${join(OUT_DIR, "parity-report.json")} — ${totalGaps} parity gap(s).`); + writeFileSync( + join(OUT_DIR, "parity-report.json"), + JSON.stringify(report, null, 2), + ); + console.log( + `\nWrote ${join(OUT_DIR, "parity-report.json")} — ${totalGaps} parity gap(s).`, + ); process.exit(totalGaps > 0 ? 1 : 0); } diff --git a/packages/agent/src/adapters/codex-app-server/approvals.test.ts b/packages/agent/src/adapters/codex-app-server/approvals.test.ts index 743e3f5fc5..f1ea74b941 100644 --- a/packages/agent/src/adapters/codex-app-server/approvals.test.ts +++ b/packages/agent/src/adapters/codex-app-server/approvals.test.ts @@ -149,37 +149,43 @@ describe("handleServerRequest", () => { expect(result.response).toEqual({ answers: { q1: { answers: [] } } }); }); - it("grants the requested permission profile for the turn on allow", async () => { - const { client } = fakeClient([{ outcome: "selected", optionId: "allow" }]); - - const params = { - threadId: "t", - turnId: "turn", - itemId: "perm-1", - environmentId: null, - startedAtMs: 0, - cwd: "/repo", - reason: "needs network", - permissions: { - network: { enabled: true }, - fileSystem: null, - }, - }; - - const result = await handleServerRequest( - APP_SERVER_REQUESTS.PERMISSIONS_APPROVAL, - params, - client, - opts, - ); - - expect(result.handled).toBe(true); - // "allow_once" click grants for the turn, not session-wide. - expect(result.response).toEqual({ - permissions: { network: { enabled: true } }, - scope: "turn", - }); - }); + it.each([ + // "allow_once" grants for the turn, not session-wide; reject grants nothing. + { optionId: "allow", expected: { network: { enabled: true } } }, + { optionId: "reject", expected: {} }, + ])( + "resolves a permission approval on $optionId", + async ({ optionId, expected }) => { + const { client } = fakeClient([{ outcome: "selected", optionId }]); + + const params = { + threadId: "t", + turnId: "turn", + itemId: "perm-1", + environmentId: null, + startedAtMs: 0, + cwd: "/repo", + reason: "needs network", + permissions: { + network: { enabled: true }, + fileSystem: null, + }, + }; + + const result = await handleServerRequest( + APP_SERVER_REQUESTS.PERMISSIONS_APPROVAL, + params, + client, + opts, + ); + + expect(result.handled).toBe(true); + expect(result.response).toEqual({ + permissions: expected, + scope: "turn", + }); + }, + ); it("fails closed to the safe default when a payload is malformed", async () => { const { client } = fakeClient([{ outcome: "selected", optionId: "allow" }]); @@ -195,84 +201,31 @@ describe("handleServerRequest", () => { }); }); - it("denies a permission request with an empty profile on reject", async () => { - const { client } = fakeClient([ - { outcome: "selected", optionId: "reject" }, - ]); - - const params = { - threadId: "t", - turnId: "turn", - itemId: "perm-2", - environmentId: null, - startedAtMs: 0, - cwd: "/repo", - reason: null, - permissions: { network: { enabled: true }, fileSystem: null }, - }; - - const result = await handleServerRequest( - APP_SERVER_REQUESTS.PERMISSIONS_APPROVAL, - params, - client, - opts, - ); - - expect(result.response).toEqual({ permissions: {}, scope: "turn" }); - }); + it.each([ + { optionId: "accept", action: "accept", content: {} }, + { optionId: "decline", action: "decline", content: null }, + ])( + "resolves an elicitation on $optionId", + async ({ optionId, action, content }) => { + const { client } = fakeClient([{ outcome: "selected", optionId }]); - it("returns an accept elicitation response when the user accepts", async () => { - const { client } = fakeClient([ - { outcome: "selected", optionId: "accept" }, - ]); - - const params = { - threadId: "t", - turnId: "turn", - serverName: "posthog", - mode: "form", - message: "Confirm the export", - }; - - const result = await handleServerRequest( - APP_SERVER_REQUESTS.MCP_ELICITATION, - params, - client, - opts, - ); - - expect(result.handled).toBe(true); - expect(result.response).toEqual({ - action: "accept", - content: {}, - _meta: null, - }); - }); - - it("declines an elicitation when the user rejects", async () => { - const { client } = fakeClient([ - { outcome: "selected", optionId: "decline" }, - ]); - - const result = await handleServerRequest( - APP_SERVER_REQUESTS.MCP_ELICITATION, - { - threadId: "t", - turnId: null, - serverName: "x", - mode: "url", - message: "", - }, - client, - opts, - ); + const result = await handleServerRequest( + APP_SERVER_REQUESTS.MCP_ELICITATION, + { + threadId: "t", + turnId: "turn", + serverName: "posthog", + mode: "form", + message: "Confirm the export", + }, + client, + opts, + ); - expect(result.response).toEqual({ - action: "decline", - content: null, - _meta: null, - }); - }); + expect(result.handled).toBe(true); + expect(result.response).toEqual({ action, content, _meta: null }); + }, + ); it("enriches an elicitation with the in-flight MCP tool call so the host renders the real tool", async () => { const { client, calls } = fakeClient([ diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index d3221eb2b8..bb2ad6be0a 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -343,6 +343,8 @@ describe("CodexAppServerAgent", () => { initialize: {}, "thread/start": { thread: { id: "thr_1" } }, "turn/start": { turn: { id: "turn_1" } }, + // codex rotates the turn id on steer. + "turn/steer": { turnId: "turn_2" }, }); const offeredOptions: Array> = []; @@ -390,6 +392,18 @@ describe("CodexAppServerAgent", () => { "turn_1", ); + // The rotated turn id from the steer response was adopted: a second + // rejection targets turn_2, not the dead turn_1. + await new Promise((r) => setImmediate(r)); + await stub.invokeRequest("item/commandExecution/requestApproval", { + itemId: "c2", + command: "rm -rf dist", + }); + const steers = stub.requests.filter((r) => r.method === "turn/steer"); + expect( + (steers[1]?.params as { expectedTurnId?: string })?.expectedTurnId, + ).toBe("turn_2"); + stub.emit("turn/completed", { turn: { status: "completed" } }); await done; }); diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index bf163ba199..1008d89b22 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -1002,11 +1002,14 @@ export class CodexAppServerAgent extends BaseAcpAgent { const activeTurnId = this.turns.activeTurnId; if (typeof feedback === "string" && feedback.trim() && activeTurnId) { void this.rpc - .request(APP_SERVER_METHODS.TURN_STEER, { + .request<{ turnId?: string }>(APP_SERVER_METHODS.TURN_STEER, { threadId: this.threadId, input: toCodexInput([{ type: "text", text: feedback.trim() }]), expectedTurnId: activeTurnId, }) + // codex rotates the turn id on steer; adopt it or later + // interrupts/steers target a dead turn. + .then((res) => this.turns.onSteered(res?.turnId)) .catch((err) => this.logger.warn("turn/steer (reject feedback) failed", err), ); diff --git a/packages/agent/src/adapters/codex-app-server/input.test.ts b/packages/agent/src/adapters/codex-app-server/input.test.ts index 21acc0e023..f63e6172ef 100644 --- a/packages/agent/src/adapters/codex-app-server/input.test.ts +++ b/packages/agent/src/adapters/codex-app-server/input.test.ts @@ -93,6 +93,23 @@ describe("toCodexInput", () => { ]); }); + it("omits the bare-uri text block for a resource with no uri", () => { + const prompt: ContentBlock[] = [ + { + type: "resource", + resource: { text: "inline snippet" }, + } as unknown as ContentBlock, + ]; + + expect(toCodexInput(prompt)).toEqual([ + { + type: "text", + text: '\ninline snippet\n', + text_elements: [], + }, + ]); + }); + it("surfaces a file:// resource as its path, not inline text", () => { const prompt: ContentBlock[] = [ { diff --git a/packages/agent/src/adapters/codex-app-server/input.ts b/packages/agent/src/adapters/codex-app-server/input.ts index 7a19d1612a..3992cbbbad 100644 --- a/packages/agent/src/adapters/codex-app-server/input.ts +++ b/packages/agent/src/adapters/codex-app-server/input.ts @@ -56,7 +56,9 @@ export function toCodexInput(prompt: ContentBlock[]): CodexUserInput[] { input.push(textInput(resourceLinkText(uri))); continue; } - input.push(textInput(uri)); + if (uri) { + input.push(textInput(uri)); + } context.push( `\n${block.resource.text}\n`, ); diff --git a/packages/electron-trpc/vitest.config.ts.timestamp-1782808001712-f0f037a43e4af8.mjs b/packages/electron-trpc/vitest.config.ts.timestamp-1782808001712-f0f037a43e4af8.mjs deleted file mode 100644 index 1ae63460bc..0000000000 --- a/packages/electron-trpc/vitest.config.ts.timestamp-1782808001712-f0f037a43e4af8.mjs +++ /dev/null @@ -1,20 +0,0 @@ -// vitest.config.ts -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { defineConfig } from "file:///Users/js/dev/ph/code/node_modules/vite/dist/node/index.js"; -var __vite_injected_original_import_meta_url = "file:///Users/js/dev/ph/code/packages/electron-trpc/vitest.config.ts"; -var __dirname = path.dirname(fileURLToPath(__vite_injected_original_import_meta_url)); -var vitest_config_default = defineConfig({ - test: { - coverage: { - all: true, - include: ["src/**/*"], - reporter: ["text", "cobertura", "html"], - reportsDirectory: path.resolve(__dirname, "./coverage/") - } - } -}); -export { - vitest_config_default as default -}; -//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZXN0LmNvbmZpZy50cyJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIi9Vc2Vycy9qcy9kZXYvcGgvY29kZS9wYWNrYWdlcy9lbGVjdHJvbi10cnBjXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvVXNlcnMvanMvZGV2L3BoL2NvZGUvcGFja2FnZXMvZWxlY3Ryb24tdHJwYy92aXRlc3QuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9Vc2Vycy9qcy9kZXYvcGgvY29kZS9wYWNrYWdlcy9lbGVjdHJvbi10cnBjL3ZpdGVzdC5jb25maWcudHNcIjsvLy8gPHJlZmVyZW5jZSB0eXBlcz1cInZpdGVzdFwiIC8+XG5pbXBvcnQgcGF0aCBmcm9tIFwibm9kZTpwYXRoXCI7XG5pbXBvcnQgeyBmaWxlVVJMVG9QYXRoIH0gZnJvbSBcIm5vZGU6dXJsXCI7XG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiO1xuXG5jb25zdCBfX2Rpcm5hbWUgPSBwYXRoLmRpcm5hbWUoZmlsZVVSTFRvUGF0aChpbXBvcnQubWV0YS51cmwpKTtcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcbiAgdGVzdDoge1xuICAgIGNvdmVyYWdlOiB7XG4gICAgICBhbGw6IHRydWUsXG4gICAgICBpbmNsdWRlOiBbXCJzcmMvKiovKlwiXSxcbiAgICAgIHJlcG9ydGVyOiBbXCJ0ZXh0XCIsIFwiY29iZXJ0dXJhXCIsIFwiaHRtbFwiXSxcbiAgICAgIHJlcG9ydHNEaXJlY3Rvcnk6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsIFwiLi9jb3ZlcmFnZS9cIiksXG4gICAgfSxcbiAgfSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUNBLE9BQU8sVUFBVTtBQUNqQixTQUFTLHFCQUFxQjtBQUM5QixTQUFTLG9CQUFvQjtBQUhxSyxJQUFNLDJDQUEyQztBQUtuUCxJQUFNLFlBQVksS0FBSyxRQUFRLGNBQWMsd0NBQWUsQ0FBQztBQUU3RCxJQUFPLHdCQUFRLGFBQWE7QUFBQSxFQUMxQixNQUFNO0FBQUEsSUFDSixVQUFVO0FBQUEsTUFDUixLQUFLO0FBQUEsTUFDTCxTQUFTLENBQUMsVUFBVTtBQUFBLE1BQ3BCLFVBQVUsQ0FBQyxRQUFRLGFBQWEsTUFBTTtBQUFBLE1BQ3RDLGtCQUFrQixLQUFLLFFBQVEsV0FBVyxhQUFhO0FBQUEsSUFDekQ7QUFBQSxFQUNGO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K diff --git a/packages/shared/src/sessions.test.ts b/packages/shared/src/sessions.test.ts index 071e571eba..85d736fbc3 100644 --- a/packages/shared/src/sessions.test.ts +++ b/packages/shared/src/sessions.test.ts @@ -6,7 +6,10 @@ import { sessionSupportsNativeSteer, } from "./sessions"; -function modeOption(values: string[], currentValue: string): SessionConfigOption { +function modeOption( + values: string[], + currentValue: string, +): SessionConfigOption { return { type: "select", id: "mode", @@ -37,15 +40,19 @@ describe("resolveBypassRevertMode", () => { }); it("falls back to the first non-bypass option when neither default nor auto exist", () => { - expect(resolveBypassRevertMode(modeOption(["read-only", "full-access"], "full-access"))).toBe( - "read-only", - ); + expect( + resolveBypassRevertMode( + modeOption(["read-only", "full-access"], "full-access"), + ), + ).toBe("read-only"); }); it("returns undefined for a missing or non-select option", () => { expect(resolveBypassRevertMode(undefined)).toBeUndefined(); expect( - resolveBypassRevertMode({ type: "boolean" } as unknown as SessionConfigOption), + resolveBypassRevertMode({ + type: "boolean", + } as unknown as SessionConfigOption), ).toBeUndefined(); }); }); @@ -55,18 +62,50 @@ describe("sessionSupportsNativeSteer", () => { it.each<[string, Case, boolean]>([ // Capability-driven: "native" folds the message into the running turn. - ["claude advertises native", { isCloud: false, steering: "native", adapter: "claude" }, true], - ["codex app-server advertises native", { isCloud: false, steering: "native", adapter: "codex" }, true], + [ + "claude advertises native", + { isCloud: false, steering: "native", adapter: "claude" }, + true, + ], + [ + "codex app-server advertises native", + { isCloud: false, steering: "native", adapter: "codex" }, + true, + ], // codex-acp advertises "interrupt-resend" — must NOT steer natively. - ["codex-acp interrupt-resend", { isCloud: false, steering: "interrupt-resend", adapter: "codex" }, false], + [ + "codex-acp interrupt-resend", + { isCloud: false, steering: "interrupt-resend", adapter: "codex" }, + false, + ], // Fallback: pre-capability start paths leave steering unset; never regress claude. - ["claude with no capability (fallback)", { isCloud: false, steering: undefined, adapter: "claude" }, true], - ["codex with no capability (no fallback)", { isCloud: false, steering: undefined, adapter: "codex" }, false], + [ + "claude with no capability (fallback)", + { isCloud: false, steering: undefined, adapter: "claude" }, + true, + ], + [ + "codex with no capability (no fallback)", + { isCloud: false, steering: undefined, adapter: "codex" }, + false, + ], // An explicit non-native capability overrides the claude fallback. - ["claude explicitly non-native", { isCloud: false, steering: "interrupt-resend", adapter: "claude" }, false], + [ + "claude explicitly non-native", + { isCloud: false, steering: "interrupt-resend", adapter: "claude" }, + false, + ], // Cloud runs queue/resend; they never steer locally regardless of capability. - ["cloud claude native", { isCloud: true, steering: "native", adapter: "claude" }, false], - ["cloud codex native", { isCloud: true, steering: "native", adapter: "codex" }, false], + [ + "cloud claude native", + { isCloud: true, steering: "native", adapter: "claude" }, + false, + ], + [ + "cloud codex native", + { isCloud: true, steering: "native", adapter: "codex" }, + false, + ], ])("%s", (_label, session, expected) => { expect(sessionSupportsNativeSteer(session)).toBe(expected); }); diff --git a/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx b/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx index fcd635cec7..37e434c127 100644 --- a/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx +++ b/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx @@ -43,9 +43,9 @@ const flatCodexModel: SessionConfigOption = { ], }; -function renderSelector(props: Partial< - React.ComponentProps -> = {}) { +function renderSelector( + props: Partial> = {}, +) { return render( =16'} + hasBin: true + + '@openai/codex@0.140.0-darwin-arm64': + resolution: {integrity: sha512-KDyQHsxdc8FHZKziSBXs82ABgben/8lLPdhi2Nu+wj6qs2RAp4k/IvE8foafVnp3OeGqhtEFbhlZp0H4Dg/Slg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.140.0-darwin-x64': + resolution: {integrity: sha512-xA77AcKbP8BKxKqaJz8bqXtU1dUtanEKpWCMJ68LuYU054EC31BD7NftFe5/vpLUQR95fhRr7V9a91SLtCuLAg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.140.0-linux-arm64': + resolution: {integrity: sha512-rGOgWEONilm+pQoQgcGpPRzvnou1CawyBOe8gvtuS32PQ00Pn+9nZF4O7iKBVlNh6Jeun8kpdJSjFdULm2wr4A==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.140.0-linux-x64': + resolution: {integrity: sha512-7+N/cHB74nsDkOoL+VQVFVFRlfGj6GFSIAQHgs9DQIsvG+UdzWgUeeDE3l926taJqmzcP9NH8bysptKlZ2Ff6g==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.140.0-win32-arm64': + resolution: {integrity: sha512-vs5Ed5OF+4671SZoO0MN5WoHl/K9aOSNzLgzbyyDyM7Jwm/PZYvF6OmIPRWf5AGatYqEOWt8Ovp5+df5PFPM7A==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.140.0-win32-x64': + resolution: {integrity: sha512-dP+nzd8UQ3Gdby+F5x0Sxd0hu6V9s6/cZYFsGtmmA6eCpU+IIu5tCOnUfgSu5HDw4BvXg046yd8Ihy5bOhwO4A==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@opentelemetry/api-logs@0.208.0': resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} engines: {node: '>=8.0.0'} @@ -16288,6 +16332,33 @@ snapshots: '@open-draft/until@2.1.0': {} + '@openai/codex@0.140.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.140.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.140.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.140.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.140.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.140.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.140.0-win32-x64' + + '@openai/codex@0.140.0-darwin-arm64': + optional: true + + '@openai/codex@0.140.0-darwin-x64': + optional: true + + '@openai/codex@0.140.0-linux-arm64': + optional: true + + '@openai/codex@0.140.0-linux-x64': + optional: true + + '@openai/codex@0.140.0-win32-arm64': + optional: true + + '@openai/codex@0.140.0-win32-x64': + optional: true + '@opentelemetry/api-logs@0.208.0': dependencies: '@opentelemetry/api': 1.9.0 From 0816e513e45b58ecc6ae7e3649e4781fcde879e2 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Thu, 2 Jul 2026 17:52:35 +0100 Subject: [PATCH 15/20] chore: gitignore vite timestamp artifacts + format parity files Co-Authored-By: Claude Fable 5 --- .gitignore | 2 ++ packages/agent/parity/harness.ts | 11 ++++++++--- packages/agent/parity/run.ts | 25 ++++++++++++++++++++----- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index fa269ba709..fe3abb2fe8 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ bin/ # tsup bundled config artifacts (temporary files left behind when bundling TS configs) *.config.bundled_*.mjs +# vite bundled config artifacts (left behind when a vitest run is interrupted) +*.config.ts.timestamp-*.mjs # Environment .env diff --git a/packages/agent/parity/harness.ts b/packages/agent/parity/harness.ts index 7fe768ac66..0132b1e7a8 100644 --- a/packages/agent/parity/harness.ts +++ b/packages/agent/parity/harness.ts @@ -10,16 +10,21 @@ */ import { promises as fs } from "node:fs"; import { resolve } from "node:path"; -// @ts-ignore - resolved by tsx at runtime +// @ts-expect-error - resolved by tsx at runtime import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; import { createAcpConnection } from "../src/adapters/acp-connection"; -import { Logger } from "../src/utils/logger"; +import type { Logger } from "../src/utils/logger"; export type AdapterMode = "acp" | "app-server"; export interface CapturedEvent { t: number; - kind: "step" | "sessionUpdate" | "requestPermission" | "extNotification" | "extMethod"; + kind: + | "step" + | "sessionUpdate" + | "requestPermission" + | "extNotification" + | "extMethod"; op?: string; sessionUpdate?: string; data?: any; diff --git a/packages/agent/parity/run.ts b/packages/agent/parity/run.ts index 6df165897e..01a7c4d7eb 100644 --- a/packages/agent/parity/run.ts +++ b/packages/agent/parity/run.ts @@ -11,16 +11,31 @@ * PARITY_MODEL default gpt-5.5 */ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { Logger } from "../src/utils/logger"; -import { type AdapterMode, type CapturedRun, type Scenario, runScenario } from "./harness"; +import { + type AdapterMode, + type CapturedRun, + runScenario, + type Scenario, +} from "./harness"; const OUT_DIR = join(import.meta.dirname, "out"); -const RESOURCES = join(import.meta.dirname, "..", "..", "..", "apps", "code", "resources", "codex-acp"); +const RESOURCES = join( + import.meta.dirname, + "..", + "..", + "..", + "apps", + "code", + "resources", + "codex-acp", +); const CODEX_ACP_BIN = join(RESOURCES, "codex-acp"); const NATIVE_CODEX_BIN = join(RESOURCES, "codex"); -const GATEWAY = process.env.PARITY_GATEWAY_URL ?? "http://localhost:3308/posthog_code/v1"; +const GATEWAY = + process.env.PARITY_GATEWAY_URL ?? "http://localhost:3308/posthog_code/v1"; const API_KEY = process.env.PARITY_API_KEY ?? ""; const MODEL = process.env.PARITY_MODEL ?? "gpt-5.5"; const REPO = "/tmp/codex-parity-repo"; From 47cd18ca6e3fbac70eefb9e60a1ff99aaf56b63f Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Thu, 2 Jul 2026 18:14:49 +0100 Subject: [PATCH 16/20] fix some tests --- .../sessions/components/UnifiedModelSelector.test.tsx | 4 +++- .../sessions/sessionServiceHost.recovery.integration.test.ts | 4 ++++ packages/ui/src/features/sessions/sessionServiceHost.test.ts | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx b/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx index 37e434c127..2ef6396fb2 100644 --- a/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx +++ b/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx @@ -114,7 +114,9 @@ describe("UnifiedModelSelector", () => { renderSelector({ onAdapterChange }); await user.click(screen.getByRole("button", { name: "Model" })); - await user.click(await screen.findByText("Switch to Claude")); + await user.click( + await screen.findByRole("menuitem", { name: /switch to claude/i }), + ); expect(onAdapterChange).toHaveBeenCalledExactlyOnceWith("claude"); }); diff --git a/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts index bb44ec8658..abb7924fa0 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts @@ -213,6 +213,10 @@ vi.mock("@posthog/ui/features/sidebar/taskMetaApi", () => ({ vi.mock("@posthog/ui/shell/posthogAnalyticsImpl", () => ({ track: vi.fn(), buildPermissionToolMetadata: vi.fn(() => ({})), + posthogFeatureFlags: { + isEnabled: vi.fn(() => undefined), + onFlagsLoaded: vi.fn(), + }, })); vi.mock("../../shell/logger", () => ({ logger: { diff --git a/packages/ui/src/features/sessions/sessionServiceHost.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.test.ts index 49e3c443d9..84144af24a 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.test.ts @@ -244,6 +244,10 @@ vi.mock("@posthog/ui/features/sidebar/taskMetaApi", () => ({ vi.mock("@posthog/ui/shell/posthogAnalyticsImpl", () => ({ track: vi.fn(), buildPermissionToolMetadata: vi.fn(() => ({})), + posthogFeatureFlags: { + isEnabled: vi.fn(() => undefined), + onFlagsLoaded: vi.fn(), + }, })); vi.mock("../../shell/logger", () => ({ logger: { @@ -899,6 +903,7 @@ describe("SessionService", () => { id: "mode", currentValue: "full-access", options: [ + expect.objectContaining({ value: "plan" }), expect.objectContaining({ value: "read-only" }), expect.objectContaining({ value: "auto" }), expect.objectContaining({ value: "full-access" }), From 4cd5fd50f8d8fe1ded3824359b23fe9ec031e385 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 5 Jul 2026 13:02:33 -0700 Subject: [PATCH 17/20] fix codex app-server turn stall from spawn env --- packages/agent/src/adapters/acp-connection.ts | 1 + .../src/adapters/codex-app-server/spawn.ts | 18 +++++++++- packages/agent/src/adapters/codex/spawn.ts | 2 ++ packages/agent/src/utils/spawn-env.test.ts | 33 +++++++++++++++++++ packages/agent/src/utils/spawn-env.ts | 28 ++++++++++++++++ 5 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 packages/agent/src/utils/spawn-env.test.ts create mode 100644 packages/agent/src/utils/spawn-env.ts diff --git a/packages/agent/src/adapters/acp-connection.ts b/packages/agent/src/adapters/acp-connection.ts index f67dbb1f10..c017f3f6db 100644 --- a/packages/agent/src/adapters/acp-connection.ts +++ b/packages/agent/src/adapters/acp-connection.ts @@ -254,6 +254,7 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { cwd: codexOptions.cwd, apiBaseUrl: codexOptions.apiBaseUrl, apiKey: codexOptions.apiKey, + codexHome: codexOptions.codexHome, developerInstructions: codexOptions.developerInstructions, configOverrides: codexOptions.configOverrides, }, diff --git a/packages/agent/src/adapters/codex-app-server/spawn.ts b/packages/agent/src/adapters/codex-app-server/spawn.ts index 48a97d67ca..bdaee12a07 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.ts @@ -4,6 +4,7 @@ import { delimiter, dirname } from "node:path"; import type { Readable, Writable } from "node:stream"; import type { ProcessSpawnedCallback } from "../../types"; import { Logger } from "../../utils/logger"; +import { stripElectronNodeShimFromPath } from "../../utils/spawn-env"; import { CodexSettingsManager } from "../codex/settings"; export interface CodexAppServerProcessOptions { @@ -12,6 +13,12 @@ export interface CodexAppServerProcessOptions { cwd?: string; apiBaseUrl?: string; apiKey?: string; + /** + * Private CODEX_HOME for this run (skills + config). Without it codex falls + * back to the user's ~/.codex, whose ambient plugins/MCP servers can stall + * every turn (a broken plugin MCP blocks turn/start for its full timeout). + */ + codexHome?: string; /** Guidance appended to Codex's base prompt via `developer_instructions`. */ developerInstructions?: string; /** Extra codex `-c key=value` config overrides (e.g. auto_compact_token_limit). */ @@ -34,6 +41,12 @@ export function buildAppServerArgs( args.push("-c", "features.remote_models=false"); + // Ambient plugins from the user's config.toml inject MCP servers and + // session-start hooks into PostHog sessions (e.g. an unauthenticated plugin + // MCP failing every thread, hooks wedging turns). Threads only get the MCP + // servers PostHog injects, so disable the plugin system outright. + args.push("-c", "features.plugins=false"); + // OS sandbox gated on platform (= availability): macOS Seatbelt → workspace-write // (keeps the sandbox engaged so a per-turn readOnly can tighten it and block // edits); linux/windows have no sandbox launcher and would panic, so @@ -98,7 +111,10 @@ export function spawnCodexAppServerProcess( if (options.apiKey) { env.POSTHOG_GATEWAY_API_KEY = options.apiKey; } - env.PATH = `${dirname(options.binaryPath)}${delimiter}${env.PATH ?? ""}`; + if (options.codexHome) { + env.CODEX_HOME = options.codexHome; + } + env.PATH = `${dirname(options.binaryPath)}${delimiter}${stripElectronNodeShimFromPath(env.PATH) ?? ""}`; const args = buildAppServerArgs(options); diff --git a/packages/agent/src/adapters/codex/spawn.ts b/packages/agent/src/adapters/codex/spawn.ts index c023b31126..d1c576b4f9 100644 --- a/packages/agent/src/adapters/codex/spawn.ts +++ b/packages/agent/src/adapters/codex/spawn.ts @@ -4,6 +4,7 @@ import { delimiter, dirname } from "node:path"; import type { Readable, Writable } from "node:stream"; import type { ProcessSpawnedCallback } from "../../types"; import { Logger } from "../../utils/logger"; +import { stripElectronNodeShimFromPath } from "../../utils/spawn-env"; import type { CodexSettings } from "./settings"; export interface CodexProcessOptions { @@ -143,6 +144,7 @@ export function spawnCodexProcess(options: CodexProcessOptions): CodexProcess { const { command, args } = findCodexBinary(options); + env.PATH = stripElectronNodeShimFromPath(env.PATH); if (options.binaryPath && existsSync(options.binaryPath)) { const binDir = dirname(options.binaryPath); env.PATH = `${binDir}${delimiter}${env.PATH ?? ""}`; diff --git a/packages/agent/src/utils/spawn-env.test.ts b/packages/agent/src/utils/spawn-env.test.ts new file mode 100644 index 0000000000..9be1cf2554 --- /dev/null +++ b/packages/agent/src/utils/spawn-env.test.ts @@ -0,0 +1,33 @@ +import { mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { stripElectronNodeShimFromPath } from "./spawn-env"; + +const EXEC_PATH = "/fake/Electron.app/Contents/MacOS/Electron"; + +function makeDir(kind: "shim" | "other-symlink" | "real-node" | "empty") { + const dir = mkdtempSync(join(tmpdir(), "spawn-env-")); + if (kind === "shim") symlinkSync(EXEC_PATH, join(dir, "node")); + if (kind === "other-symlink") symlinkSync("/usr/bin/true", join(dir, "node")); + if (kind === "real-node") writeFileSync(join(dir, "node"), ""); + return dir; +} + +describe("stripElectronNodeShimFromPath", () => { + it("removes only dirs whose node symlinks to the executable", () => { + const shim = makeDir("shim"); + const other = makeDir("other-symlink"); + const real = makeDir("real-node"); + const empty = makeDir("empty"); + const input = [shim, other, real, empty].join(delimiter); + expect(stripElectronNodeShimFromPath(input, EXEC_PATH)).toBe( + [other, real, empty].join(delimiter), + ); + }); + + it("passes through undefined and empty values", () => { + expect(stripElectronNodeShimFromPath(undefined, EXEC_PATH)).toBeUndefined(); + expect(stripElectronNodeShimFromPath("", EXEC_PATH)).toBe(""); + }); +}); diff --git a/packages/agent/src/utils/spawn-env.ts b/packages/agent/src/utils/spawn-env.ts new file mode 100644 index 0000000000..61a0de1e08 --- /dev/null +++ b/packages/agent/src/utils/spawn-env.ts @@ -0,0 +1,28 @@ +import { readlinkSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +/** + * Removes PATH entries that alias `node` to the current executable. The desktop + * host prepends a shim dir whose `node` symlinks to Electron so claude-code + * children (which inherit ELECTRON_RUN_AS_NODE=1) can resolve a node binary. + * Codex children have ELECTRON_RUN_AS_NODE deleted, so anything they spawn via + * that shim boots a full Electron app that never exits — wedging codex's + * plugin hooks (and with them every turn) until its 10-minute timeout. + */ +export function stripElectronNodeShimFromPath( + pathValue: string | undefined, + execPath: string = process.execPath, +): string | undefined { + if (!pathValue) return pathValue; + return pathValue + .split(delimiter) + .filter((dir) => { + if (!dir) return false; + try { + return readlinkSync(join(dir, "node")) !== execPath; + } catch { + return true; + } + }) + .join(delimiter); +} From c982ceab635358c1768035dbbe89fea92eee07c6 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 5 Jul 2026 13:03:02 -0700 Subject: [PATCH 18/20] quiet expected mcp proxy probe warnings --- .../src/services/mcp-proxy/mcp-proxy.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.ts b/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.ts index 899d094561..dfdda83ada 100644 --- a/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.ts +++ b/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.ts @@ -123,7 +123,13 @@ export class McpProxyService { const target = this.targets.get(id); if (!target) { - this.log.warn("Unknown MCP proxy target", { id, url: req.url }); + // MCP clients probe RFC 8414 OAuth discovery at the proxy root before + // falling back to direct auth; a quiet 404 is the expected answer. + if (id === ".well-known") { + this.log.debug("MCP proxy OAuth discovery probe", { url: req.url }); + } else { + this.log.warn("Unknown MCP proxy target", { id, url: req.url }); + } res.writeHead(404); res.end("Unknown target"); return; @@ -235,8 +241,17 @@ export class McpProxyService { responseHeaders: Object.fromEntries(response.headers.entries()), body: bodyText.slice(0, 2000), }; + // Streamable-HTTP servers MAY answer the client's GET (SSE listen) + // with 405, and OAuth discovery probes 4xx on servers without OAuth. + // Both are expected client behavior, not failures worth a warn. + const expectedProbeRejection = + response.status < 500 && + ((options.method === "GET" && response.status === 405) || + url.includes("/.well-known/")); if (response.status >= 500) { this.log.error("MCP proxy server error", details); + } else if (expectedProbeRejection) { + this.log.debug("MCP proxy probe rejected upstream", details); } else { this.log.warn("MCP proxy non-OK body", details); } From e25dc8d9cb8c579cdaadd31beea5b789c7a95ba6 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 5 Jul 2026 13:03:03 -0700 Subject: [PATCH 19/20] return null for unset last-used external app --- packages/ui/src/features/external-apps/useExternalApps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/features/external-apps/useExternalApps.ts b/packages/ui/src/features/external-apps/useExternalApps.ts index af8f240684..b6a7214271 100644 --- a/packages/ui/src/features/external-apps/useExternalApps.ts +++ b/packages/ui/src/features/external-apps/useExternalApps.ts @@ -18,7 +18,7 @@ export function useExternalApps() { const { data: lastUsedAppId, isLoading: lastUsedLoading } = useQuery({ queryKey: LAST_USED_KEY, queryFn: async () => - (await client.externalApps.getLastUsed.query()).lastUsedApp, + (await client.externalApps.getLastUsed.query()).lastUsedApp ?? null, staleTime: 60_000, }); From f1d823968171ea72b90d272b97f64629b4a30894 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 5 Jul 2026 13:46:10 -0700 Subject: [PATCH 20/20] address codex app-server review findings --- packages/agent/e2e/README.md | 19 +++- packages/agent/e2e/compaction.e2e.test.ts | 49 +++----- packages/agent/e2e/config.ts | 28 +++-- packages/agent/e2e/driver.ts | 50 ++++---- .../agent/e2e/session-lifecycle.e2e.test.ts | 12 -- .../agent/e2e/structured-output.e2e.test.ts | 8 +- packages/agent/parity/harness.ts | 2 - packages/agent/parity/run.ts | 59 +++++----- packages/agent/src/adapters/acp-connection.ts | 2 +- .../app-server-client.test.ts | 30 +++++ .../codex-app-server/app-server-client.ts | 5 + .../codex-app-server/approvals.test.ts | 107 ++++++++++++++++++ .../codex-app-server-agent.test.ts | 21 ++++ .../codex-app-server-agent.ts | 35 ++---- .../codex-app-server/local-tools-mcp.ts | 19 +--- .../adapters/codex-app-server/mapping.test.ts | 50 ++++++++ .../src/adapters/codex-app-server/mapping.ts | 14 +-- .../codex-app-server/mcp-manager.test.ts | 73 ++++++++++++ .../adapters/codex-app-server/mcp-manager.ts | 73 ++++++++---- .../codex-app-server/session-config.ts | 47 ++++---- .../adapters/codex-app-server/spawn.test.ts | 13 +++ .../structured-output.test.ts | 44 +++++++ .../codex-app-server/structured-output.ts | 64 +++++++++++ .../adapters/codex-app-server/token-usage.ts | 41 +++++++ .../codex-app-server/turn-controller.test.ts | 102 +++++++++++++++++ .../codex-app-server/turn-controller.ts | 16 ++- .../codex-app-server/usage-tracker.test.ts | 94 +++++++++++++++ .../codex-app-server/usage-tracker.ts | 16 +-- .../agent/src/adapters/codex/codex-agent.ts | 20 +--- .../agent/src/adapters/codex/spawn.test.ts | 34 ++++++ packages/agent/src/adapters/codex/spawn.ts | 13 ++- packages/agent/src/execution-mode.ts | 37 +----- .../agent/src/utils/resolve-bundled-script.ts | 21 ++++ packages/agent/vitest.e2e.config.ts | 7 +- .../core/src/sessions/contextUsage.test.ts | 37 ++++++ packages/core/src/sessions/contextUsage.ts | 29 ++++- packages/core/src/sessions/executionModes.ts | 33 +----- packages/core/src/sessions/sessionService.ts | 49 +++++++- packages/shared/src/execution-modes.ts | 34 ++++++ packages/shared/src/index.ts | 4 + .../features/permissions/McpPermission.tsx | 15 +-- .../ContextBreakdownPopover.test.tsx | 14 +++ .../components/ContextBreakdownPopover.tsx | 15 ++- .../new-thread/buildThreadGroups.test.ts | 76 +++++++++++++ .../new-thread/buildThreadGroups.ts | 6 +- .../features/sessions/sessionAdapterStore.ts | 28 ++++- .../features/sessions/sessionServiceHost.ts | 6 + 47 files changed, 1235 insertions(+), 336 deletions(-) create mode 100644 packages/agent/src/adapters/codex-app-server/mcp-manager.test.ts create mode 100644 packages/agent/src/adapters/codex-app-server/structured-output.test.ts create mode 100644 packages/agent/src/adapters/codex-app-server/structured-output.ts create mode 100644 packages/agent/src/adapters/codex-app-server/token-usage.ts create mode 100644 packages/agent/src/adapters/codex-app-server/turn-controller.test.ts create mode 100644 packages/agent/src/adapters/codex-app-server/usage-tracker.test.ts create mode 100644 packages/agent/src/utils/resolve-bundled-script.ts create mode 100644 packages/shared/src/execution-modes.ts create mode 100644 packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.test.ts diff --git a/packages/agent/e2e/README.md b/packages/agent/e2e/README.md index 21ff545fe7..76423b4a9c 100644 --- a/packages/agent/e2e/README.md +++ b/packages/agent/e2e/README.md @@ -9,9 +9,10 @@ agent/model/tool path is stubbed. ## What it covers -Two suites, each a per-adapter loop with `describe.skipIf` over `["claude", -"codex"]` (titles carry a `(claude)` / `(codex)` marker so `-t "(codex)"` selects -one arm across both files): +Four suites. The two adapter-parametrized ones loop with `describe.skipIf` over +`["claude", "codex"]` (titles carry a `(claude)` / `(codex)` marker so +`-t "(codex)"` selects one arm across files); `compaction.e2e.test.ts` runs the +codex arm only, and `guard.e2e.test.ts` always runs: `session-lifecycle.e2e.test.ts` — one shared golden turn plus focused scenarios: - **newSession config options** — model / effort selectors are offered. @@ -38,6 +39,16 @@ an approval request to assert on. That envelope is covered by unit tests instead `structured-output.e2e.test.ts` — `_meta.jsonSchema` + `onStructuredOutput` delivers a parsed, schema-constrained object (the signals-pipeline contract). +Runs on the strong model (`E2E_*_STRONG_MODEL`); the cheapest models hang on +the constrained decode. + +`compaction.e2e.test.ts` — codex only: a low `auto_compact_token_limit` plus a +big input blob trips auto-compaction, and the adapter must surface +`_posthog/compact_boundary`. + +`guard.e2e.test.ts` — always runs: fails loudly when the token is missing (every +arm would self-skip) or the codex binary is absent despite a token, so the suite +can never skip itself green. Assertions are structural lifecycle invariants + the deterministic file/JSON side effects — never model prose — so they hold across adapters and cheap models. @@ -84,6 +95,8 @@ arm self-skips if it is missing). | `E2E_GATEWAY_URL` | `http://localhost:3308/llm_gateway` | Gateway base (codex appends `/v1`). `llm_gateway` accepts a personal API key; `posthog_code` is OAuth-only. | | `E2E_CLAUDE_MODEL` | `claude-haiku-4-5` | Override if the gateway serves a different cheap Claude id. | | `E2E_CODEX_MODEL` | `gpt-5-mini` | Cheapest codex id the local gateway serves; override if needed. | +| `E2E_CLAUDE_STRONG_MODEL` | `claude-sonnet-4-5` | Stronger Claude id for tests the cheap model can't handle (structured output). | +| `E2E_CODEX_STRONG_MODEL` | `gpt-5.5` | Stronger codex id for the same tests. | | `POSTHOG_REPO` | sibling `../posthog` | Where `run-e2e.sh` reads the local dev key from. | | `E2E_DEBUG` | — | `1` for verbose adapter logging. | diff --git a/packages/agent/e2e/compaction.e2e.test.ts b/packages/agent/e2e/compaction.e2e.test.ts index 3a6653d1e4..28798c5393 100644 --- a/packages/agent/e2e/compaction.e2e.test.ts +++ b/packages/agent/e2e/compaction.e2e.test.ts @@ -1,11 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { type Adapter, E2E } from "./config"; -import { - cleanupRepo, - killCodexStragglers, - openSession, - setupRepo, -} from "./driver"; +import { cleanupRepo, openSession, setupRepo } from "./driver"; /** * Live compaction e2e — codex only. codex auto-compacts when the context crosses @@ -30,7 +25,6 @@ for (const adapter of ADAPTERS) { let repo: string; beforeAll(() => { - if (adapter === "codex") killCodexStragglers(); E2E.configureEnv(adapter); repo = setupRepo(); }); @@ -43,14 +37,11 @@ for (const adapter of ADAPTERS) { const s = await openSession({ adapter, cwd: repo, - codexOptions: - adapter === "codex" - ? E2E.codexOptions(repo, { - // The model-scoped key is the effective one; set both to be safe. - model_auto_compact_token_limit: AUTO_COMPACT_TOKEN_LIMIT, - auto_compact_token_limit: AUTO_COMPACT_TOKEN_LIMIT, - }) - : undefined, + codexOptions: E2E.codexOptions(repo, { + // The model-scoped key is the effective one; set both to be safe. + model_auto_compact_token_limit: AUTO_COMPACT_TOKEN_LIMIT, + auto_compact_token_limit: AUTO_COMPACT_TOKEN_LIMIT, + }), meta: { systemPrompt: "You are a coding assistant in a tiny test repo.", model: E2E.model(adapter), @@ -62,29 +53,17 @@ for (const adapter of ADAPTERS) { const compacted = () => s.capture.extMethods().includes("_posthog/compact_boundary"); - if (adapter === "claude") { - // A little conversation, then the cheap deterministic trigger: manual /compact. + // Turn 1's big input blob fills the context past the limit; turn 2+ + // trips auto-compaction. Stop once the boundary is surfaced. + for (let i = 0; i < MAX_CODEX_TURNS && !compacted(); i++) { + const text = + i === 0 + ? `Reference text — do not summarize, reply with only: OK.\n\n${FILLER}` + : "Reply with only: DONE."; await s.conn.prompt({ sessionId: s.sessionId, - prompt: [{ type: "text", text: "Reply with only: hello." }], + prompt: [{ type: "text", text }], }); - await s.conn.prompt({ - sessionId: s.sessionId, - prompt: [{ type: "text", text: "/compact" }], - }); - } else { - // codex: turn 1's big input blob fills the context past the limit; turn 2+ - // trips auto-compaction. Stop once the boundary is surfaced. - for (let i = 0; i < MAX_CODEX_TURNS && !compacted(); i++) { - const text = - i === 0 - ? `Reference text — do not summarize, reply with only: OK.\n\n${FILLER}` - : "Reply with only: DONE."; - await s.conn.prompt({ - sessionId: s.sessionId, - prompt: [{ type: "text", text }], - }); - } } expect( diff --git a/packages/agent/e2e/config.ts b/packages/agent/e2e/config.ts index 0670dd82eb..5c2bec225f 100644 --- a/packages/agent/e2e/config.ts +++ b/packages/agent/e2e/config.ts @@ -14,8 +14,8 @@ const GATEWAY_URL = process.env.E2E_GATEWAY_URL || "http://localhost:3308/llm_gateway"; const TOKEN = process.env.E2E_GATEWAY_TOKEN ?? ""; -// The native app-server binary, relative to packages/agent/e2e. -const NATIVE_CODEX_BIN = join( +// This checkout's bundled codex binaries, relative to packages/agent/e2e. +const CODEX_RESOURCES_DIR = join( __dirname, "..", "..", @@ -24,8 +24,8 @@ const NATIVE_CODEX_BIN = join( "code", "resources", "codex-acp", - "codex", ); +const NATIVE_CODEX_BIN = join(CODEX_RESOURCES_DIR, "codex"); /** The gateway base with a trailing `/v1` (codex / OpenAI-format endpoint). */ function openAiBase(): string { @@ -37,6 +37,8 @@ export const E2E = { hasToken: !!TOKEN, gatewayUrl: GATEWAY_URL, codexBin: NATIVE_CODEX_BIN, + /** Scopes the straggler sweep to binaries spawned from THIS checkout. */ + codexResourcesDir: CODEX_RESOURCES_DIR, /** Deployment environment. `E2E_ENVIRONMENT=cloud` exercises the cloud code path; undefined = local. */ environment: (process.env.E2E_ENVIRONMENT as "local" | "cloud" | undefined) || undefined, @@ -52,6 +54,18 @@ export const E2E = { return process.env.E2E_CODEX_MODEL || "gpt-5-mini"; }, + /** + * A stronger model for tests the cheapest models can't handle (e.g. + * structured-output decodes). Its own env vars, so the documented cheap-model + * overrides cannot silently downgrade these tests. + */ + strongModel(adapter: Adapter): string { + if (adapter === "claude") { + return process.env.E2E_CLAUDE_STRONG_MODEL || "claude-sonnet-4-5"; + } + return process.env.E2E_CODEX_STRONG_MODEL || "gpt-5.5"; + }, + /** Null => runnable; a string => skip this arm with that reason (never silent). */ skipReason(adapter: Adapter): string | null { if (!TOKEN) return "E2E_GATEWAY_TOKEN not set"; @@ -95,12 +109,4 @@ export const E2E = { ...(configOverrides ? { configOverrides } : {}), }; }, - - /** A stronger model for tests the cheapest models can't handle (e.g. structured-output decodes). */ - strongModel(adapter: Adapter): string { - if (adapter === "claude") { - return process.env.E2E_CLAUDE_MODEL || "claude-sonnet-4-5"; - } - return process.env.E2E_CODEX_MODEL || "gpt-5.5"; - }, }; diff --git a/packages/agent/e2e/driver.ts b/packages/agent/e2e/driver.ts index 7110e6f40a..54556a1e0f 100644 --- a/packages/agent/e2e/driver.ts +++ b/packages/agent/e2e/driver.ts @@ -89,6 +89,9 @@ export function openConnection(opts: { onStructuredOutput?: (output: Record) => Promise; }): E2EConnection { const { adapter, cwd } = opts; + // Sweep before every codex spawn so one leaked process (holding the + // ~/.codex/tmp flock) cannot wedge the rest of the run. + if (adapter === "codex") killCodexStragglers(); const events: CapturedEvent[] = []; // Mirror the cloud host's client surface. Deliberately no extMethod: the real @@ -197,23 +200,30 @@ export async function openSession(opts: { meta: Record; }): Promise { const c = openConnection(opts); - await c.conn.initialize(INIT_PARAMS); - const newSession = await c.conn.newSession({ - cwd: opts.cwd, - mcpServers: [], - // Inject E2E_ENVIRONMENT so the suite can run as a cloud session without threading it through every test's meta. - _meta: { - ...opts.meta, - ...(E2E.environment ? { environment: E2E.environment } : {}), - }, - }); - return { - conn: c.conn, - capture: c.capture, - sessionId: newSession.sessionId, - newSession, - cleanup: c.cleanup, - }; + // initialize/newSession hit a live gateway; on failure the caller never gets + // a cleanup handle, so clean up here or the spawned adapter process leaks. + try { + await c.conn.initialize(INIT_PARAMS); + const newSession = await c.conn.newSession({ + cwd: opts.cwd, + mcpServers: [], + // Inject E2E_ENVIRONMENT so the suite can run as a cloud session without threading it through every test's meta. + _meta: { + ...opts.meta, + ...(E2E.environment ? { environment: E2E.environment } : {}), + }, + }); + return { + conn: c.conn, + capture: c.capture, + sessionId: newSession.sessionId, + newSession, + cleanup: c.cleanup, + }; + } catch (err) { + await c.cleanup(); + throw err; + } } export const ORIGINAL_TARGET = "line1\nline2\nline3\n"; @@ -274,11 +284,13 @@ export async function waitFor( /** * codex spawns detached; a killed run can orphan it holding a flock under - * ~/.codex/tmp, wedging the next run. Kill stragglers first to release the flock. + * ~/.codex/tmp, wedging the next run. Kill stragglers first to release the + * flock. Matched on THIS checkout's absolute resources path so a concurrent + * run from another checkout (or a dev's real session) is never killed. */ export function killCodexStragglers(): void { try { - execFileSync("pkill", ["-9", "-f", "resources/codex-acp"], { + execFileSync("pkill", ["-9", "-f", E2E.codexResourcesDir], { stdio: "ignore", }); } catch { diff --git a/packages/agent/e2e/session-lifecycle.e2e.test.ts b/packages/agent/e2e/session-lifecycle.e2e.test.ts index 4de1acf3e5..79c4efb257 100644 --- a/packages/agent/e2e/session-lifecycle.e2e.test.ts +++ b/packages/agent/e2e/session-lifecycle.e2e.test.ts @@ -5,7 +5,6 @@ import { type ConfigOption, cleanupRepo, INIT_PARAMS, - killCodexStragglers, type NewSessionResponse, ORIGINAL_TARGET, openConnection, @@ -64,7 +63,6 @@ for (const adapter of ADAPTERS) { let goldenError: unknown; beforeAll(async () => { - if (adapter === "codex") killCodexStragglers(); E2E.configureEnv(adapter); repo = setupRepo(); const s = await openSession({ @@ -194,7 +192,6 @@ for (const adapter of ADAPTERS) { // Cloud host switches mode only via setSessionConfigOption(configId:"mode"), so exercise both arms. it("emits current_mode_update when the mode is switched via setSessionConfigOption", async () => { - if (adapter === "codex") killCodexStragglers(); const s = await openSession({ adapter, cwd: repo, @@ -231,7 +228,6 @@ for (const adapter of ADAPTERS) { itCodexSandbox( "read-only mode actually blocks a file edit (sandbox restricts, not just approval)", async () => { - if (adapter === "codex") killCodexStragglers(); const s = await openSession({ adapter, cwd: repo, @@ -276,7 +272,6 @@ for (const adapter of ADAPTERS) { itCodex( "plan mode engages codex's plan collaboration, and reverts when switched back to auto", async () => { - if (adapter === "codex") killCodexStragglers(); const s = await openSession({ adapter, cwd: repo, @@ -323,7 +318,6 @@ for (const adapter of ADAPTERS) { ); it("handles the host's refresh_session extMethod per adapter", async () => { - if (adapter === "codex") killCodexStragglers(); const s = await openSession({ adapter, cwd: repo, @@ -353,7 +347,6 @@ for (const adapter of ADAPTERS) { // unit-covered in codex-app-server-agent.test.ts / approvals.test.ts. it("incorporates a prompt's _meta.prContext without error", async () => { - if (adapter === "codex") killCodexStragglers(); const s = await openSession({ adapter, cwd: repo, @@ -387,7 +380,6 @@ for (const adapter of ADAPTERS) { itCodex( "folds a mid-turn prompt into the running turn via steering", async () => { - killCodexStragglers(); const s = await openSession({ adapter, cwd: repo, @@ -442,7 +434,6 @@ for (const adapter of ADAPTERS) { itCodex( "lists the session and forks it", async () => { - killCodexStragglers(); const b = openConnection({ adapter, cwd: repo, @@ -473,7 +464,6 @@ for (const adapter of ADAPTERS) { // approvals.test.ts / codex-app-server-agent.test.ts. it("interrupts an in-flight turn", async () => { - if (adapter === "codex") killCodexStragglers(); const s = await openSession({ adapter, cwd: repo, @@ -515,7 +505,6 @@ for (const adapter of ADAPTERS) { }, 120_000); it("resumeSession reconnects and returns config options", async () => { - if (adapter === "codex") killCodexStragglers(); const b = openConnection({ adapter, cwd: repo, @@ -537,7 +526,6 @@ for (const adapter of ADAPTERS) { }, 60_000); it("reattach (loadSession) restores the session and replays the transcript", async () => { - if (adapter === "codex") killCodexStragglers(); const b = openConnection({ adapter, cwd: repo, diff --git a/packages/agent/e2e/structured-output.e2e.test.ts b/packages/agent/e2e/structured-output.e2e.test.ts index 05cddc1003..45d3994ea8 100644 --- a/packages/agent/e2e/structured-output.e2e.test.ts +++ b/packages/agent/e2e/structured-output.e2e.test.ts @@ -1,11 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { type Adapter, E2E } from "./config"; -import { - cleanupRepo, - killCodexStragglers, - openSession, - setupRepo, -} from "./driver"; +import { cleanupRepo, openSession, setupRepo } from "./driver"; /** * Live structured-output e2e: both adapters constrain the final message to a JSON @@ -30,7 +25,6 @@ for (const adapter of ADAPTERS) { let repo: string; beforeAll(() => { - if (adapter === "codex") killCodexStragglers(); E2E.configureEnv(adapter); repo = setupRepo(); }); diff --git a/packages/agent/parity/harness.ts b/packages/agent/parity/harness.ts index 0132b1e7a8..83a197a34f 100644 --- a/packages/agent/parity/harness.ts +++ b/packages/agent/parity/harness.ts @@ -60,8 +60,6 @@ export interface HarnessConfig { model?: string; reasoningEffort?: string; }; - /** Override flag plumbing once the migration adds useCodexAppServer. */ - selectAppServer?: boolean; timeoutMs?: number; logger?: Logger; } diff --git a/packages/agent/parity/run.ts b/packages/agent/parity/run.ts index 01a7c4d7eb..0d89ae476e 100644 --- a/packages/agent/parity/run.ts +++ b/packages/agent/parity/run.ts @@ -11,7 +11,7 @@ * PARITY_MODEL default gpt-5.5 */ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { Logger } from "../src/utils/logger"; import { @@ -254,32 +254,35 @@ function diffFeatures( ]; } +/** + * Recreate the repo from scratch. Runs before every (scenario, mode) pair so + * the second arm starts from the same pristine state as the first — a scenario + * edits target.txt, and comparing arms against different starting files would + * bias the exact diff this harness exists to produce. + */ function setupRepo(): void { - if (!existsSync(REPO)) mkdirSync(REPO, { recursive: true }); + rmSync(REPO, { recursive: true, force: true }); + mkdirSync(REPO, { recursive: true }); execFileSync("git", ["init", "-q"], { cwd: REPO }); writeFileSync(join(REPO, "target.txt"), "line1\nline2\nline3\n"); execFileSync("git", ["add", "-A"], { cwd: REPO }); - try { - // -c commit.gpgsign=false: ignore the user's global commit-signing config - // (e.g. 1Password SSH signer), which fails in this non-interactive context. - execFileSync( - "git", - [ - "-c", - "commit.gpgsign=false", - "-c", - "user.email=p@p.dev", - "-c", - "user.name=parity", - "commit", - "-qm", - "init", - ], - { cwd: REPO }, - ); - } catch { - /* already committed */ - } + // -c commit.gpgsign=false: ignore the user's global commit-signing config + // (e.g. 1Password SSH signer), which fails in this non-interactive context. + execFileSync( + "git", + [ + "-c", + "commit.gpgsign=false", + "-c", + "user.email=p@p.dev", + "-c", + "user.name=parity", + "commit", + "-qm", + "init", + ], + { cwd: REPO }, + ); } async function main(): Promise { @@ -291,7 +294,6 @@ async function main(): Promise { ? args[args.indexOf("--scenario") + 1] : null; mkdirSync(OUT_DIR, { recursive: true }); - setupRepo(); const modes: AdapterMode[] = []; if (!only || only === "acp") modes.push("acp"); @@ -315,12 +317,15 @@ async function main(): Promise { featuresByMode[scenario.name] = {}; for (const mode of modes) { console.log(`\n▶ ${scenario.name} via ${mode} ...`); + setupRepo(); // codex spawns detached (own process group); a timed-out run orphans it // holding a flock under ~/.codex/tmp, which wedges the next run. Kill any - // stragglers first — process death releases the flock. (Uses the default - // CODEX_HOME: an isolated empty home makes codex-acp crash at startup.) + // stragglers first — process death releases the flock, matched on THIS + // checkout's absolute resources path so unrelated runs are never killed. + // (Uses the default CODEX_HOME: an isolated empty home makes codex-acp + // crash at startup.) try { - execFileSync("pkill", ["-9", "-f", "resources/codex-acp"], { + execFileSync("pkill", ["-9", "-f", RESOURCES], { stdio: "ignore", }); } catch { diff --git a/packages/agent/src/adapters/acp-connection.ts b/packages/agent/src/adapters/acp-connection.ts index c017f3f6db..35e197327d 100644 --- a/packages/agent/src/adapters/acp-connection.ts +++ b/packages/agent/src/adapters/acp-connection.ts @@ -268,7 +268,7 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { } agent = new CodexAcpAgent(client, { - codexProcessOptions: codexOptions, + codexProcessOptions: { ...codexOptions, environment: config.deviceType }, processCallbacks: config.processCallbacks, posthogApiConfig: resolveEnricherApiConfig(config), onStructuredOutput: config.onStructuredOutput, diff --git a/packages/agent/src/adapters/codex-app-server/app-server-client.test.ts b/packages/agent/src/adapters/codex-app-server/app-server-client.test.ts index cc688e061d..9501f019ce 100644 --- a/packages/agent/src/adapters/codex-app-server/app-server-client.test.ts +++ b/packages/agent/src/adapters/codex-app-server/app-server-client.test.ts @@ -172,4 +172,34 @@ describe("AppServerClient", () => { await expect(pending).rejects.toThrow(/closed/i); }); + + it("rejects new requests immediately once closed instead of registering them", async () => { + const streams = createBidirectionalStreams(); + const client = new AppServerClient(streams.client, { + logger: silentLogger, + }); + + await client.close(); + + await expect(client.request("turn/interrupt", {})).rejects.toThrow( + /closed/i, + ); + expect(() => client.notify("thread/archive", {})).not.toThrow(); + }); + + it("rejects new requests after the stream ends without close (process exit)", async () => { + const streams = createBidirectionalStreams(); + const onClose = vi.fn(); + const client = new AppServerClient(streams.client, { + logger: silentLogger, + onClose, + }); + + await streams.agent.writable.getWriter().close(); + await vi.waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + + await expect(client.request("turn/interrupt", {})).rejects.toThrow( + /closed/i, + ); + }); }); diff --git a/packages/agent/src/adapters/codex-app-server/app-server-client.ts b/packages/agent/src/adapters/codex-app-server/app-server-client.ts index c437155be3..ff32050f5c 100644 --- a/packages/agent/src/adapters/codex-app-server/app-server-client.ts +++ b/packages/agent/src/adapters/codex-app-server/app-server-client.ts @@ -49,6 +49,10 @@ export class AppServerClient implements AppServerRpc { } request(method: string, params?: unknown): Promise { + // The read loop is gone once closed, so a registered call could never settle. + if (this.closed) { + return Promise.reject(new Error("AppServerClient closed")); + } const id = this.nextId++; const promise = new Promise((resolve, reject) => { this.pending.set(id, { @@ -61,6 +65,7 @@ export class AppServerClient implements AppServerRpc { } notify(method: string, params?: unknown): void { + if (this.closed) return; this.send({ method, params }); } diff --git a/packages/agent/src/adapters/codex-app-server/approvals.test.ts b/packages/agent/src/adapters/codex-app-server/approvals.test.ts index f1ea74b941..82e85b830e 100644 --- a/packages/agent/src/adapters/codex-app-server/approvals.test.ts +++ b/packages/agent/src/adapters/codex-app-server/approvals.test.ts @@ -71,6 +71,113 @@ describe("handleServerRequest", () => { ]); }); + it("answers each question in a multi-question request independently", async () => { + const { client, calls } = fakeClient([ + { outcome: "selected", optionId: "option_0" }, + { outcome: "selected", optionId: "option_1" }, + ]); + + const params = { + threadId: "t", + turnId: "turn", + itemId: "item-2", + autoResolutionMs: null, + questions: [ + { + id: "q1", + header: "Env", + question: "Which environment?", + isOther: false, + isSecret: false, + options: [ + { label: "staging", description: "" }, + { label: "production", description: "" }, + ], + }, + { + id: "q2", + header: "Region", + question: "Which region?", + isOther: false, + isSecret: false, + options: [ + { label: "us", description: "" }, + { label: "eu", description: "" }, + ], + }, + ], + }; + + const result = await handleServerRequest( + APP_SERVER_REQUESTS.TOOL_USER_INPUT, + params, + client, + opts, + ); + + expect(result.handled).toBe(true); + expect(result.response).toEqual({ + answers: { + q1: { answers: ["staging"] }, + q2: { answers: ["eu"] }, + }, + }); + expect(calls).toHaveLength(2); + expect(calls.map((c) => c.toolCall?.title)).toEqual([ + "Which environment?", + "Which region?", + ]); + }); + + it("skips a free-text question (no options) with a well-formed empty answer", async () => { + const { client, calls } = fakeClient([ + { outcome: "selected", optionId: "option_0" }, + ]); + + const params = { + threadId: "t", + turnId: "turn", + itemId: "item-3", + autoResolutionMs: null, + questions: [ + { + id: "free", + header: "Notes", + question: "Anything else?", + isOther: true, + isSecret: false, + options: [], + }, + { + id: "pick", + header: "Env", + question: "Which environment?", + isOther: false, + isSecret: false, + options: [{ label: "staging", description: "" }], + }, + ], + }; + + const result = await handleServerRequest( + APP_SERVER_REQUESTS.TOOL_USER_INPUT, + params, + client, + opts, + ); + + expect(result.handled).toBe(true); + expect(result.response).toEqual({ + answers: { + free: { answers: [] }, + pick: { answers: ["staging"] }, + }, + }); + // The free-text question never reaches requestPermission. + expect(calls).toHaveLength(1); + expect(calls[0].toolCall?.title).toBe("Which environment?"); + }); + it("carries a QuestionMetaSchema-valid questions array so the host card renders", async () => { const { client, calls } = fakeClient([ { outcome: "selected", optionId: "option_0" }, diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index bb2ad6be0a..350b9e9a5d 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -931,6 +931,27 @@ describe("CodexAppServerAgent", () => { expect((await done).stopReason).toBe("cancelled"); }); + it("closeSession resolves an in-flight prompt as cancelled instead of hanging", async () => { + const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); + const { client } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + rpcFactory: stub.factory, + }); + + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + const done = agent.prompt({ + sessionId: "t", + prompt: [{ type: "text", text: "go" }], + } as unknown as PromptRequest); + + await agent.closeSession(); + + expect((await done).stopReason).toBe("cancelled"); + // The session is fully torn down: a late turn/completed is a no-op. + stub.emit("turn/completed", { turn: { status: "completed" } }); + }); + it("finalizes the turn on a non-retried error notification", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); const { client } = makeFakeClient(); diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 1008d89b22..1c94abfeec 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -67,6 +67,7 @@ import { type CodexAppServerProcessOptions, spawnCodexAppServerProcess, } from "./spawn"; +import { parseStructuredOutput } from "./structured-output"; import { TurnController } from "./turn-controller"; import { UsageTracker } from "./usage-tracker"; @@ -579,7 +580,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.lastAgentMessage = ""; this.resetUsage(); - const completion = this.turns.begin(); + const { completion, turn } = this.turns.begin(); try { const approvalPolicy = this.config.approvalPolicy(); const sandboxPolicy = this.config.sandboxPolicy(); @@ -609,7 +610,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { }); return { stopReason: await completion }; } finally { - this.turns.finishPrompt(); + this.turns.finishPrompt(turn); } } @@ -683,12 +684,12 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.turns.onStarted((params as { turn?: { id?: string } })?.turn?.id); } - if ( - method === APP_SERVER_NOTIFICATIONS.ITEM_STARTED || - method === APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED - ) { + if (method === APP_SERVER_NOTIFICATIONS.ITEM_STARTED) { this.mcp.capture(params); } + if (method === APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED) { + this.mcp.release(params); + } // codex auto-compaction surfaces as a contextCompaction item: item/started → in progress, // item/completed → boundary (codex emits no separate thread/compacted; that's a guarded @@ -1074,25 +1075,3 @@ function flattenSystemPrompt( } return undefined; } - -/** Parse structured output from the final message, defensively (fenced block / first object). */ -function parseStructuredOutput(text: string): Record | null { - const trimmed = text.trim(); - const candidates = [trimmed]; - const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/); - if (fenced) candidates.push(fenced[1].trim()); - const brace = trimmed.match(/\{[\s\S]*\}/); - if (brace) candidates.push(brace[0]); - - for (const candidate of candidates) { - try { - const parsed: unknown = JSON.parse(candidate); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - // Try the next candidate. - } - } - return null; -} diff --git a/packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts b/packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts index e7f0976f59..5461da1e8e 100644 --- a/packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts +++ b/packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts @@ -5,11 +5,10 @@ * the single owner of the ACP→Codex map. */ -import { existsSync } from "node:fs"; -import { resolve as resolvePath } from "node:path"; import type { McpServerStdio } from "@agentclientprotocol/sdk"; import { ghTokenEnv } from "@posthog/git/signed-commit"; import { resolveGithubToken } from "../../utils/github-token"; +import { resolveBundledMcpScript } from "../../utils/resolve-bundled-script"; import { enabledLocalTools, LOCAL_TOOLS_MCP_NAME, @@ -29,22 +28,6 @@ export interface LocalToolsMeta extends LocalToolGateMeta { baseBranch?: string; } -/** - * Resolve a shared dist asset by walking up from the compiled adapter location — - * its depth varies across bundle entry points. Mirrors the codex-acp adapter. - */ -function resolveBundledMcpScript(rel: string): string { - let dir = import.meta.dirname ?? __dirname; - for (let i = 0; i < 5; i++) { - const candidate = resolvePath(dir, rel); - if (existsSync(candidate)) return candidate; - dir = resolvePath(dir, ".."); - } - throw new Error( - `Could not locate ${rel} relative to ${import.meta.dirname ?? __dirname}.`, - ); -} - function toMcpServerStdio( ctx: LocalToolCtx, enabledNames: string[], diff --git a/packages/agent/src/adapters/codex-app-server/mapping.test.ts b/packages/agent/src/adapters/codex-app-server/mapping.test.ts index 18454844b3..3c9d2ad8ce 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.test.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.test.ts @@ -106,6 +106,56 @@ describe("mapAppServerNotification", () => { }); }); + it("maps a started webSearch item to a fetch tool_call titled by its query", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_STARTED, + { item: { type: "webSearch", id: "w1", query: "posthog hogql docs" } }, + ); + + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "w1", + title: "posthog hogql docs", + kind: "fetch", + status: "in_progress", + }, + }); + + const queryless = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_STARTED, + { item: { type: "webSearch", id: "w2" } }, + ); + expect(queryless?.update).toMatchObject({ title: "Web search" }); + }); + + it("maps a declined completion to a failed tool_call_update", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, + { + item: { + type: "commandExecution", + id: "i2", + command: "rm -rf build", + status: "declined", + }, + }, + ); + + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "i2", + status: "failed", + }, + }); + }); + it("maps a started mcp tool call item, surfacing arguments as rawInput", () => { const result = mapAppServerNotification( "s-1", diff --git a/packages/agent/src/adapters/codex-app-server/mapping.ts b/packages/agent/src/adapters/codex-app-server/mapping.ts index 46c6148b86..fbfa99dee8 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.ts @@ -5,6 +5,7 @@ import type { } from "@agentclientprotocol/sdk"; import { mcpToolKey, posthogToolMeta } from "@posthog/shared"; import { APP_SERVER_NOTIFICATIONS } from "./protocol"; +import { readTokenUsage } from "./token-usage"; /** * Translates a native app-server notification into an ACP SessionNotification. @@ -42,20 +43,15 @@ export function mapAppServerNotification( } case APP_SERVER_NOTIFICATIONS.TOKEN_USAGE_UPDATED: { // Context indicator: renderer reads `used`/`size`; detailed breakdown comes via `_posthog/usage_update`. - const tu = (params as { tokenUsage?: any })?.tokenUsage; - // Use this turn's `last`, not cumulative `total` (which over-reports and pegs the - // gauge); `total` is the fallback for pre-`last` builds. - const context = tu?.last ?? tu?.total; - const used = context?.totalTokens ?? context?.inputTokens; - if (used == null) return null; - const size = tu?.modelContextWindow; + const usage = readTokenUsage(params); + if (!usage) return null; // `usage_update` is a PostHog-convention update, not in the ACP union. return { sessionId, update: { sessionUpdate: "usage_update", - used, - ...(size != null ? { size } : {}), + used: usage.used, + ...(usage.size != null ? { size: usage.size } : {}), }, } as unknown as SessionNotification; } diff --git a/packages/agent/src/adapters/codex-app-server/mcp-manager.test.ts b/packages/agent/src/adapters/codex-app-server/mcp-manager.test.ts new file mode 100644 index 0000000000..e6136e6bee --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/mcp-manager.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { McpManager } from "./mcp-manager"; + +function itemParams(overrides?: Record) { + return { + item: { + type: "mcpToolCall", + id: "m1", + server: "posthog", + tool: "exec", + arguments: { command: "call execute-sql {}" }, + ...overrides, + }, + }; +} + +describe("McpManager", () => { + it("captures an mcpToolCall and resolves it by item id and by server", () => { + const mcp = new McpManager(); + mcp.capture(itemParams()); + + expect(mcp.byItemId("m1")).toEqual({ + server: "posthog", + tool: "exec", + args: { command: "call execute-sql {}" }, + }); + expect(mcp.byServer("posthog")?.tool).toBe("exec"); + expect(mcp.byServer("github")).toBeUndefined(); + expect(mcp.byItemId(undefined)).toBeUndefined(); + }); + + it("ignores non-mcpToolCall and incomplete items", () => { + const mcp = new McpManager(); + mcp.capture({ item: { type: "commandExecution", id: "c1" } }); + mcp.capture(itemParams({ server: undefined })); + mcp.capture(itemParams({ id: undefined })); + mcp.capture(undefined); + + expect(mcp.byItemId("m1")).toBeUndefined(); + expect(mcp.byItemId("c1")).toBeUndefined(); + expect(mcp.byServer("posthog")).toBeUndefined(); + }); + + it("tracks the latest in-flight call per server for elicitations", () => { + const mcp = new McpManager(); + mcp.capture(itemParams()); + mcp.capture(itemParams({ id: "m2", server: "github", tool: "search" })); + + expect(mcp.byServer("github")?.tool).toBe("search"); + // The posthog call is no longer the latest, so an elicitation cannot map to it. + expect(mcp.byServer("posthog")).toBeUndefined(); + expect(mcp.byItemId("m1")?.server).toBe("posthog"); + }); + + it("evicts a completed call so lookups no longer resolve and the map cannot grow unbounded", () => { + const mcp = new McpManager(); + mcp.capture(itemParams()); + mcp.release(itemParams()); + + expect(mcp.byItemId("m1")).toBeUndefined(); + expect(mcp.byServer("posthog")).toBeUndefined(); + }); + + it("keeps the latest pointer when an older call completes", () => { + const mcp = new McpManager(); + mcp.capture(itemParams()); + mcp.capture(itemParams({ id: "m2", tool: "query" })); + mcp.release(itemParams()); + + expect(mcp.byItemId("m1")).toBeUndefined(); + expect(mcp.byServer("posthog")?.tool).toBe("query"); + }); +}); diff --git a/packages/agent/src/adapters/codex-app-server/mcp-manager.ts b/packages/agent/src/adapters/codex-app-server/mcp-manager.ts index 6faae9f47d..09fc2d2ad9 100644 --- a/packages/agent/src/adapters/codex-app-server/mcp-manager.ts +++ b/packages/agent/src/adapters/codex-app-server/mcp-manager.ts @@ -5,6 +5,36 @@ export interface McpCall { args: unknown; } +interface McpItem { + id: string; + server: string; + tool: string; + arguments: unknown; +} + +function readMcpItem(params: unknown): McpItem | null { + const item = ( + params as { + item?: { + type?: string; + id?: string; + server?: string; + tool?: string; + arguments?: unknown; + }; + } + )?.item; + if (item?.type === "mcpToolCall" && item.id && item.server && item.tool) { + return { + id: item.id, + server: item.server, + tool: item.tool, + arguments: item.arguments, + }; + } + return null; +} + /** * Correlates codex approval prompts back to the MCP tool that triggered them: by * item id for a command approval, or by server name for an elicitation (which @@ -14,28 +44,29 @@ export class McpManager { private readonly byId = new Map(); private latest?: McpCall; - /** Record an `mcpToolCall` item from an item/started or item/completed notification. */ + /** Record an `mcpToolCall` item from an item/started notification. */ capture(params: unknown): void { - const item = ( - params as { - item?: { - type?: string; - id?: string; - server?: string; - tool?: string; - arguments?: unknown; - }; - } - )?.item; - if (item?.type === "mcpToolCall" && item.id && item.server && item.tool) { - const call: McpCall = { - server: item.server, - tool: item.tool, - args: item.arguments, - }; - this.byId.set(item.id, call); - this.latest = call; - } + const item = readMcpItem(params); + if (!item) return; + const call: McpCall = { + server: item.server, + tool: item.tool, + args: item.arguments, + }; + this.byId.set(item.id, call); + this.latest = call; + } + + /** + * Evict on item/completed — approvals only arrive while a call is in flight, + * and keeping every finished call would grow the map for the session's lifetime. + */ + release(params: unknown): void { + const item = readMcpItem(params); + if (!item) return; + const call = this.byId.get(item.id); + this.byId.delete(item.id); + if (call && this.latest === call) this.latest = undefined; } /** The MCP call for a command-execution approval's item id, if known. */ diff --git a/packages/agent/src/adapters/codex-app-server/session-config.ts b/packages/agent/src/adapters/codex-app-server/session-config.ts index dea07c69d6..d39353cbbb 100644 --- a/packages/agent/src/adapters/codex-app-server/session-config.ts +++ b/packages/agent/src/adapters/codex-app-server/session-config.ts @@ -1,4 +1,5 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { CODEX_MODE_PRESETS, type CodexModePreset } from "@posthog/shared"; import { type GatewayModel, isOpenAIModel } from "../../gateway-models"; import { getReasoningEffortOptions } from "../codex/models"; @@ -42,40 +43,44 @@ export interface CodexMode { permissionProfile?: string; } -// Flattened Claude-style presets. Restriction is driven by approvalPolicy + the -// named permissionProfile (codex 0.140.0's enforced sandbox lever); plan/read-only -// block edits, auto/full-access keep the spawned editable sandbox. -export const CODEX_MODES: CodexMode[] = [ - { - id: "plan", - name: "Plan", - description: "Plan first — inspect and propose; makes no changes", +// Flattened Claude-style presets: the `{id, name, description}` literals live +// in @posthog/shared (one copy for every picker); this map owns the behavior. +// Restriction is driven by approvalPolicy + the named permissionProfile (codex +// 0.140.0's enforced sandbox lever); plan/read-only block edits, +// auto/full-access keep the spawned editable sandbox. +const CODEX_MODE_POLICIES: Record< + CodexModePreset["id"], + Pick< + CodexMode, + | "approvalPolicy" + | "sandboxPolicy" + | "permissionProfile" + | "collaborationMode" + > +> = { + plan: { approvalPolicy: "on-request", sandboxPolicy: { type: "readOnly", networkAccess: true }, permissionProfile: ":read-only", collaborationMode: "plan", }, - { - id: "read-only", - name: "Read only", - description: "Read-only — can inspect but not modify files", + "read-only": { approvalPolicy: "untrusted", sandboxPolicy: { type: "readOnly", networkAccess: true }, permissionProfile: ":read-only", }, - { - id: "auto", - name: "Auto", - description: "Edits the workspace; asks before risky operations", + auto: { approvalPolicy: "on-request", }, - { - id: "full-access", - name: "Full access", - description: "Auto-approves all operations", + "full-access": { approvalPolicy: "never", }, -]; +}; + +export const CODEX_MODES: CodexMode[] = CODEX_MODE_PRESETS.map((preset) => ({ + ...preset, + ...CODEX_MODE_POLICIES[preset.id], +})); export const DEFAULT_MODE = "auto"; diff --git a/packages/agent/src/adapters/codex-app-server/spawn.test.ts b/packages/agent/src/adapters/codex-app-server/spawn.test.ts index a0db5c3b62..dd6c2dc32c 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.test.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.test.ts @@ -46,6 +46,19 @@ describe("buildAppServerArgs", () => { }, ); + it("renders configOverrides bare for numbers and quoted for strings", () => { + const args = buildAppServerArgs({ + binaryPath: "/bundle/codex", + configOverrides: { + auto_compact_token_limit: 16000, + model_verbosity: "low", + }, + }); + + expect(args).toContain("auto_compact_token_limit=16000"); + expect(args).toContain('model_verbosity="low"'); + }); + it("does not set instructions at spawn (developer_instructions are per-thread)", () => { const args = buildAppServerArgs({ binaryPath: "/bundle/codex", diff --git a/packages/agent/src/adapters/codex-app-server/structured-output.test.ts b/packages/agent/src/adapters/codex-app-server/structured-output.test.ts new file mode 100644 index 0000000000..4a1e6b9b60 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/structured-output.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { parseStructuredOutput } from "./structured-output"; + +describe("parseStructuredOutput", () => { + it("parses a bare JSON object", () => { + expect(parseStructuredOutput('{"status": "done"}')).toEqual({ + status: "done", + }); + }); + + it("parses a fenced json code block surrounded by prose", () => { + const text = 'Here you go:\n```json\n{"status": "done"}\n```\nAll set.'; + expect(parseStructuredOutput(text)).toEqual({ status: "done" }); + }); + + it("parses an object embedded in prose", () => { + const text = 'The result is {"status": "done"} as requested.'; + expect(parseStructuredOutput(text)).toEqual({ status: "done" }); + }); + + it("stops at the object's real closing brace despite later braces in prose", () => { + const text = + 'Result: {"status": "done"} and note that {braces} appear later }'; + expect(parseStructuredOutput(text)).toEqual({ status: "done" }); + }); + + it("skips a non-JSON brace group before the real object", () => { + const text = 'A {rough} answer: {"status": "done"}'; + expect(parseStructuredOutput(text)).toEqual({ status: "done" }); + }); + + it("handles braces inside JSON strings", () => { + const text = 'Answer: {"note": "uses { and } inside"} trailing prose'; + expect(parseStructuredOutput(text)).toEqual({ + note: "uses { and } inside", + }); + }); + + it("returns null for an array, plain prose, or an unclosed object", () => { + expect(parseStructuredOutput("[1, 2, 3]")).toBeNull(); + expect(parseStructuredOutput("no json here")).toBeNull(); + expect(parseStructuredOutput('leading {"a": 1')).toBeNull(); + }); +}); diff --git a/packages/agent/src/adapters/codex-app-server/structured-output.ts b/packages/agent/src/adapters/codex-app-server/structured-output.ts new file mode 100644 index 0000000000..dca29bb0f9 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/structured-output.ts @@ -0,0 +1,64 @@ +/** Parse structured output from the final message, defensively (fenced block / first object). */ +export function parseStructuredOutput( + text: string, +): Record | null { + const trimmed = text.trim(); + const candidates = [trimmed]; + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenced) candidates.push(fenced[1].trim()); + + for (const candidate of candidates) { + const parsed = parseJsonObject(candidate); + if (parsed) return parsed; + } + + // Fall back to the first balanced {...} in the prose. Balance-aware rather + // than a greedy regex, so trailing prose containing braces cannot extend the + // match past the object's real closing brace. + let searchFrom = 0; + for (let guard = 0; guard < 100; guard++) { + const start = trimmed.indexOf("{", searchFrom); + if (start === -1) return null; + const end = findBalancedObjectEnd(trimmed, start); + if (end === -1) return null; + const parsed = parseJsonObject(trimmed.slice(start, end + 1)); + if (parsed) return parsed; + searchFrom = start + 1; + } + return null; +} + +function parseJsonObject(candidate: string): Record | null { + try { + const parsed: unknown = JSON.parse(candidate); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Not valid JSON; the caller tries the next candidate. + } + return null; +} + +/** Index of the `}` closing the object opened at `start`, or -1 (string-aware). */ +function findBalancedObjectEnd(text: string, start: number): number { + let depth = 0; + let inString = false; + let escaped = false; + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (escaped) escaped = false; + else if (ch === "\\") escaped = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') inString = true; + else if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) return i; + } + } + return -1; +} diff --git a/packages/agent/src/adapters/codex-app-server/token-usage.ts b/packages/agent/src/adapters/codex-app-server/token-usage.ts new file mode 100644 index 0000000000..8319f6c573 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/token-usage.ts @@ -0,0 +1,41 @@ +/** One turn's counts from codex's `thread/tokenUsage/updated`. */ +export interface CodexTokenCounts { + inputTokens?: number; + cachedInputTokens?: number; + outputTokens?: number; + reasoningOutputTokens?: number; + totalTokens?: number; +} + +interface CodexTokenUsagePayload { + tokenUsage?: { + total?: CodexTokenCounts; + last?: CodexTokenCounts; + modelContextWindow?: number; + }; +} + +export interface TokenUsageReading { + /** This turn's counts: `last`, falling back to cumulative `total` for pre-`last` builds. */ + context: CodexTokenCounts; + /** Context-window occupancy: `totalTokens`, falling back to `inputTokens`. */ + used: number; + /** The model context window, when the protocol reports it. */ + size: number | undefined; +} + +/** + * The one place a `thread/tokenUsage/updated` payload is decoded, so the + * renderer gauge (mapping.ts) and the usage breakdown (usage-tracker.ts) + * cannot drift onto different fallback orders. + */ +export function readTokenUsage(params: unknown): TokenUsageReading | null { + const tu = (params as CodexTokenUsagePayload | undefined)?.tokenUsage; + // This turn's `last`, not cumulative `total` (which over-reports and pegs the + // gauge); `total` is the fallback for pre-`last` builds. + const context = tu?.last ?? tu?.total; + if (!context) return null; + const used = context.totalTokens ?? context.inputTokens; + if (used == null) return null; + return { context, used, size: tu?.modelContextWindow ?? undefined }; +} diff --git a/packages/agent/src/adapters/codex-app-server/turn-controller.test.ts b/packages/agent/src/adapters/codex-app-server/turn-controller.test.ts new file mode 100644 index 0000000000..9e7398ffe0 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/turn-controller.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { TurnController } from "./turn-controller"; + +describe("TurnController", () => { + it("runs a turn through begin -> onStarted -> claim", async () => { + const turns = new TurnController(); + const { completion } = turns.begin(); + expect(turns.isPending).toBe(true); + expect(turns.isRunning).toBe(false); + + turns.onStarted("turn-1"); + expect(turns.isRunning).toBe(true); + expect(turns.activeTurnId).toBe("turn-1"); + + const pending = turns.claim(); + expect(pending).toBeDefined(); + expect(turns.isPending).toBe(false); + expect(turns.activeTurnId).toBeUndefined(); + expect(turns.claim()).toBeUndefined(); + + pending?.resolve("end_turn"); + await expect(completion).resolves.toBe("end_turn"); + }); + + it("finishPrompt for an older turn does not wipe a newer turn's pending state", async () => { + const turns = new TurnController(); + + // Turn A completes: finalize claims synchronously, then awaits before resolving. + const a = turns.begin(); + turns.onStarted("turn-a"); + const claimedA = turns.claim(); + expect(claimedA).toBeDefined(); + + // A new prompt lands inside finalize's await window and begins turn B. + const b = turns.begin(); + turns.onStarted("turn-b"); + + // Turn A's prompt() resolves and its finally runs; it must not clear turn B. + claimedA?.resolve("end_turn"); + await expect(a.completion).resolves.toBe("end_turn"); + turns.finishPrompt(a.turn); + + expect(turns.isRunning).toBe(true); + const claimedB = turns.claim(); + expect(claimedB).toBeDefined(); + claimedB?.resolve("end_turn"); + await expect(b.completion).resolves.toBe("end_turn"); + }); + + it("finishPrompt with the current turn token clears the pending slot", () => { + const turns = new TurnController(); + const { turn } = turns.begin(); + turns.finishPrompt(turn); + expect(turns.isPending).toBe(false); + expect(turns.claim()).toBeUndefined(); + }); + + it("fail clears the turn id so a later interrupt skips the RPC", async () => { + const turns = new TurnController(); + const { completion } = turns.begin(); + turns.onStarted("turn-1"); + + turns.fail(new Error("codex app-server exited")); + await expect(completion).rejects.toThrow("codex app-server exited"); + expect(turns.activeTurnId).toBeUndefined(); + expect(turns.markInterrupted()).toBeUndefined(); + }); + + it("markInterrupted flags the live turn and shouldDropCompletion fires once", () => { + const turns = new TurnController(); + turns.begin(); + turns.onStarted("turn-1"); + + expect(turns.markInterrupted()).toBe("turn-1"); + expect(turns.shouldDropCompletion("turn-1")).toBe(true); + expect(turns.shouldDropCompletion("turn-1")).toBe(false); + expect(turns.shouldDropCompletion(undefined)).toBe(false); + }); + + it("close resolves the pending turn and resets all state", async () => { + const turns = new TurnController(); + const { completion } = turns.begin(); + turns.onStarted("turn-1"); + turns.markInterrupted(); + + turns.close("cancelled"); + await expect(completion).resolves.toBe("cancelled"); + expect(turns.isPending).toBe(false); + expect(turns.activeTurnId).toBeUndefined(); + expect(turns.shouldDropCompletion("turn-1")).toBe(false); + }); + + it("onSteered rotates the active turn id", () => { + const turns = new TurnController(); + turns.begin(); + turns.onStarted("turn-1"); + turns.onSteered("turn-2"); + expect(turns.activeTurnId).toBe("turn-2"); + turns.onSteered(undefined); + expect(turns.activeTurnId).toBe("turn-2"); + }); +}); diff --git a/packages/agent/src/adapters/codex-app-server/turn-controller.ts b/packages/agent/src/adapters/codex-app-server/turn-controller.ts index 5192222f77..350ba0b1ba 100644 --- a/packages/agent/src/adapters/codex-app-server/turn-controller.ts +++ b/packages/agent/src/adapters/codex-app-server/turn-controller.ts @@ -14,13 +14,15 @@ export class TurnController { private turnId?: string; private pending?: PendingTurn; private completion?: Promise; + private generation = 0; private readonly cancelled = new Set(); - begin(): Promise { + begin(): { completion: Promise; turn: number } { + const turn = ++this.generation; this.completion = new Promise((resolve, reject) => { this.pending = { resolve, reject }; }); - return this.completion; + return { completion: this.completion, turn }; } /** The live turn id (steer precondition / interrupt target), if a turn started. */ @@ -72,14 +74,20 @@ export class TurnController { return id ? this.cancelled.delete(id) : false; } - /** Clear the pending slot after prompt() returns (covers a turn/start throw). */ - finishPrompt(): void { + /** + * Clear the pending slot after prompt() returns (covers a turn/start throw). Guarded by + * the caller's turn token: finalizeTurn claims before it awaits, so a new prompt() can + * begin() in that window; the older prompt's cleanup must not wipe the newer turn. + */ + finishPrompt(turn: number): void { + if (turn !== this.generation) return; this.pending = undefined; this.completion = undefined; } /** Reject the in-flight turn (e.g. the server exited before it completed). */ fail(err: Error): void { + this.turnId = undefined; this.pending?.reject(err); this.pending = undefined; this.completion = undefined; diff --git a/packages/agent/src/adapters/codex-app-server/usage-tracker.test.ts b/packages/agent/src/adapters/codex-app-server/usage-tracker.test.ts new file mode 100644 index 0000000000..aba636c727 --- /dev/null +++ b/packages/agent/src/adapters/codex-app-server/usage-tracker.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { UsageTracker } from "./usage-tracker"; + +function payload(overrides?: Record) { + return { + tokenUsage: { + total: { + inputTokens: 900, + cachedInputTokens: 100, + outputTokens: 200, + reasoningOutputTokens: 40, + totalTokens: 1200, + }, + last: { + inputTokens: 400, + cachedInputTokens: 50, + outputTokens: 80, + reasoningOutputTokens: 20, + totalTokens: 500, + }, + modelContextWindow: 200_000, + ...overrides, + }, + }; +} + +describe("UsageTracker", () => { + it("ingests `last` field-by-field, preferring it over cumulative `total`", () => { + const tracker = new UsageTracker(); + const update = tracker.ingest(payload()); + + expect(update).toEqual({ + used: 500, + size: 200_000, + usage: { + inputTokens: 400, + outputTokens: 80, + cachedReadTokens: 50, + reasoningTokens: 20, + totalTokens: 500, + }, + }); + expect(tracker.contextTokens()).toBe(500); + expect(tracker.perTurnUsage()).toEqual({ + inputTokens: 400, + outputTokens: 80, + cachedReadTokens: 50, + cachedWriteTokens: 0, + }); + }); + + it("falls back to `total` for builds predating `last`", () => { + const tracker = new UsageTracker(); + const update = tracker.ingest(payload({ last: undefined })); + + expect(update?.used).toBe(1200); + expect(tracker.perTurnUsage().inputTokens).toBe(900); + }); + + it("derives `used` from inputTokens when totalTokens is absent (same order as the gauge)", () => { + const tracker = new UsageTracker(); + const update = tracker.ingest({ + tokenUsage: { last: { inputTokens: 300 }, total: { inputTokens: 300 } }, + }); + + expect(update?.used).toBe(300); + expect(update?.size).toBeNull(); + expect(tracker.contextTokens()).toBe(300); + }); + + it("returns null and keeps state on an unusable payload", () => { + const tracker = new UsageTracker(); + tracker.ingest(payload()); + + expect(tracker.ingest({})).toBeNull(); + expect(tracker.ingest({ tokenUsage: {} })).toBeNull(); + expect(tracker.ingest(undefined)).toBeNull(); + expect(tracker.contextTokens()).toBe(500); + }); + + it("resetForTurn zeroes the per-turn view so a token-less turn reports 0, not stale data", () => { + const tracker = new UsageTracker(); + tracker.ingest(payload()); + + tracker.resetForTurn(); + expect(tracker.contextTokens()).toBeUndefined(); + expect(tracker.perTurnUsage()).toEqual({ + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }); + }); +}); diff --git a/packages/agent/src/adapters/codex-app-server/usage-tracker.ts b/packages/agent/src/adapters/codex-app-server/usage-tracker.ts index ecd87ffa47..154d7d1159 100644 --- a/packages/agent/src/adapters/codex-app-server/usage-tracker.ts +++ b/packages/agent/src/adapters/codex-app-server/usage-tracker.ts @@ -3,6 +3,7 @@ import { emptyBaseline, } from "../claude/context-breakdown"; import type { AccumulatedUsage } from "./ext-notifications"; +import { readTokenUsage } from "./token-usage"; /** The live `_posthog/usage_update` fields (context-window occupancy). */ export interface UsageUpdate { @@ -43,12 +44,11 @@ export class UsageTracker { /** Ingest a `thread/tokenUsage/updated` payload; returns the live usage_update, or null if unusable. */ ingest(params: unknown): UsageUpdate | null { - const tu = (params as { tokenUsage?: any })?.tokenUsage; - const total = tu?.total; - if (!total) return null; - const context = tu.last ?? total; + const reading = readTokenUsage(params); + if (!reading) return null; + const { context, used, size } = reading; // Drives the per-source breakdown's "conversation" bucket on turn complete. - this.contextUsed = context.inputTokens ?? context.totalTokens; + this.contextUsed = used; this.lastTurn = { inputTokens: context.inputTokens ?? 0, outputTokens: context.outputTokens ?? 0, @@ -57,8 +57,8 @@ export class UsageTracker { cachedWriteTokens: 0, }; return { - used: context.totalTokens, - size: tu.modelContextWindow ?? null, + used, + size: size ?? null, usage: { inputTokens: context.inputTokens, outputTokens: context.outputTokens, @@ -81,7 +81,7 @@ export class UsageTracker { ); } - /** Live context occupancy (last turn's input tokens), or undefined pre-usage. */ + /** Live context occupancy (same derivation as the renderer gauge), or undefined pre-usage. */ contextTokens(): number | undefined { return this.contextUsed; } diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index 11eac60acb..696c05dd01 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -9,8 +9,6 @@ * - System prompt injection */ -import { existsSync } from "node:fs"; -import { resolve as resolvePath } from "node:path"; import { type AgentSideConnection, type AuthenticateRequest, @@ -61,6 +59,7 @@ import type { PostHogAPIConfig, ProcessSpawnedCallback } from "../../types"; import { isCloudRun } from "../../utils/common"; import { resolveGithubToken } from "../../utils/github-token"; import { Logger } from "../../utils/logger"; +import { resolveBundledMcpScript } from "../../utils/resolve-bundled-script"; import { nodeReadableToWebReadable, nodeWritableToWebWritable, @@ -302,24 +301,7 @@ const STRUCTURED_OUTPUT_INSTRUCTIONS = `\n\nWhen you have completed the task, ca * Builds the stdio MCP server config that exposes the `create_output` tool. * The child process validates tool input against the JSON schema with AJV. * We pass the schema as a base64-encoded env var to avoid shell escaping. - * - * Path resolves relative to the compiled adapter location. When bundled into - * different entry points (dist/agent.js, dist/server/bin.cjs, dist/server/ - * harness/bin.js, etc), `import.meta.dirname` sits at different depths. Walk - * up until we find the script so each bundle locates the shared dist asset. */ -function resolveBundledMcpScript(rel: string): string { - let dir = import.meta.dirname ?? __dirname; - for (let i = 0; i < 5; i++) { - const candidate = resolvePath(dir, rel); - if (existsSync(candidate)) return candidate; - dir = resolvePath(dir, ".."); - } - throw new Error( - `Could not locate ${rel} relative to ${import.meta.dirname ?? __dirname}.`, - ); -} - function buildStructuredOutputMcpServer( jsonSchema: Record, ): McpServerStdio { diff --git a/packages/agent/src/adapters/codex/spawn.test.ts b/packages/agent/src/adapters/codex/spawn.test.ts index 44b77e56b1..2c448bd42b 100644 --- a/packages/agent/src/adapters/codex/spawn.test.ts +++ b/packages/agent/src/adapters/codex/spawn.test.ts @@ -39,6 +39,40 @@ describe("spawnCodexProcess MCP disable args", () => { }); }); +describe("spawnCodexProcess sandbox mode", () => { + it("disables codex's own sandbox only on cloud", () => { + spawnMock.mockClear(); + spawnMock.mockReturnValue(makeFakeChild()); + + spawnCodexProcess({ + logger: new Logger({ debug: false }), + environment: "cloud", + }); + + const args: string[] = spawnMock.mock.calls[0][1]; + expect(args).toContain('sandbox_mode="danger-full-access"'); + }); + + it.each([ + ["local", "local" as const], + ["unset", undefined], + ])( + "keeps codex's own sandbox as the OS-level backstop when environment is %s", + (_label, environment) => { + spawnMock.mockClear(); + spawnMock.mockReturnValue(makeFakeChild()); + + spawnCodexProcess({ + logger: new Logger({ debug: false }), + environment, + }); + + const args: string[] = spawnMock.mock.calls[0][1]; + expect(args.some((arg) => arg.includes("sandbox_mode"))).toBe(false); + }, + ); +}); + describe("spawnCodexProcess developer instructions", () => { it("passes guidance via developer_instructions to preserve the base prompt", () => { spawnMock.mockClear(); diff --git a/packages/agent/src/adapters/codex/spawn.ts b/packages/agent/src/adapters/codex/spawn.ts index d1c576b4f9..38b7b57744 100644 --- a/packages/agent/src/adapters/codex/spawn.ts +++ b/packages/agent/src/adapters/codex/spawn.ts @@ -32,6 +32,8 @@ export interface CodexProcessOptions { * `auto_compact_token_limit` low to force a compaction. */ configOverrides?: Record; + /** Deployment environment; "cloud" disables codex's own OS sandbox (the enclosing sandbox isolates). */ + environment?: "local" | "cloud"; } export interface CodexProcess { @@ -46,13 +48,16 @@ function buildConfigArgs(options: CodexProcessOptions): string[] { args.push("-c", `features.remote_models=false`); - // The agent already runs inside PostHog's isolated sandbox (docker/Modal with - // agentsh egress + filesystem controls), so Codex's own OS-level sandbox is + // On cloud the agent already runs inside PostHog's isolated sandbox (docker/Modal + // with agentsh egress + filesystem controls), so Codex's own OS-level sandbox is // redundant — and its `linux-sandbox` launcher is unavailable inside that // sandbox, so the default workspace-write mode panics ("sandbox launcher // unavailable" → require_escalated) and wedges the session. Run Codex with no - // nested sandbox; the enclosing sandbox provides the isolation. - args.push("-c", `sandbox_mode="danger-full-access"`); + // nested sandbox there; the enclosing sandbox provides the isolation. Local + // desktop sessions keep codex's own sandbox as the OS-level backstop. + if (options.environment === "cloud") { + args.push("-c", `sandbox_mode="danger-full-access"`); + } // Disable the user's local MCPs one-by-one so Codex only uses the MCPs we // provide via ACP. We can't use `-c mcp_servers={}` because that makes Codex diff --git a/packages/agent/src/execution-mode.ts b/packages/agent/src/execution-mode.ts index c90925e631..b5ca70ebb6 100644 --- a/packages/agent/src/execution-mode.ts +++ b/packages/agent/src/execution-mode.ts @@ -1,3 +1,4 @@ +import { CODEX_MODE_PRESETS } from "@posthog/shared"; import { ALLOW_BYPASS } from "./utils/common"; export interface ModeInfo { @@ -73,38 +74,10 @@ export function isCodexNativeMode(mode: string): mode is CodexNativeMode { return (CODEX_NATIVE_MODES as readonly string[]).includes(mode); } -// Mirrors the codex app-server adapter's CODEX_MODES (session-config.ts) so the -// task-creation picker offers the same presets as a live session. "plan" is a -// valid CodeExecutionMode that codex-acp maps to read-only, and the app-server -// gives it a read-only sandbox — so it is safe on both sub-adapters. -const codexModes: ModeInfo[] = [ - { - id: "plan", - name: "Plan", - description: "Plan first — inspect and propose; makes no changes", - }, - { - id: "read-only", - name: "Read Only", - description: "Read-only access, no file modifications", - }, - { - id: "auto", - name: "Auto", - description: "Standard behavior, prompts for dangerous operations", - }, -]; - -if (ALLOW_BYPASS) { - codexModes.push({ - id: "full-access", - name: "Full Access", - description: "Auto-accept all permission requests", - }); -} - +// The preset literals live in @posthog/shared (one copy for every picker and +// the app-server adapter's CODEX_MODES); this module only owns the gating. export function getAvailableCodexModes(): ModeInfo[] { return ALLOW_BYPASS - ? codexModes - : codexModes.filter((m) => m.id !== "full-access"); + ? [...CODEX_MODE_PRESETS] + : CODEX_MODE_PRESETS.filter((m) => m.id !== "full-access"); } diff --git a/packages/agent/src/utils/resolve-bundled-script.ts b/packages/agent/src/utils/resolve-bundled-script.ts new file mode 100644 index 0000000000..c1c4fa36a8 --- /dev/null +++ b/packages/agent/src/utils/resolve-bundled-script.ts @@ -0,0 +1,21 @@ +import { existsSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; + +/** + * Resolve a shared dist asset relative to the compiled adapter location. When + * bundled into different entry points (dist/agent.js, dist/server/bin.cjs, + * dist/server/harness/bin.js, etc), `import.meta.dirname` sits at different + * depths — and is unavailable in the CJS bin bundle, where `__dirname` takes + * over. Walk up until the script is found so each bundle locates the asset. + */ +export function resolveBundledMcpScript(rel: string): string { + let dir = import.meta.dirname ?? __dirname; + for (let i = 0; i < 5; i++) { + const candidate = resolvePath(dir, rel); + if (existsSync(candidate)) return candidate; + dir = resolvePath(dir, ".."); + } + throw new Error( + `Could not locate ${rel} relative to ${import.meta.dirname ?? __dirname}.`, + ); +} diff --git a/packages/agent/vitest.e2e.config.ts b/packages/agent/vitest.e2e.config.ts index 01e95543af..5133379ab1 100644 --- a/packages/agent/vitest.e2e.config.ts +++ b/packages/agent/vitest.e2e.config.ts @@ -2,9 +2,10 @@ import { resolve } from "node:path"; import { defineConfig } from "vitest/config"; // Live, opt-in e2e suite. Separate from the default `vitest.config.ts` (which -// only includes `src/**`), so these never run under `pnpm test` or in CI — only -// via `pnpm test:e2e`. Sequential, generous timeouts: each test drives two real -// model turns end to end. +// only includes `src/**`), so these never run under `pnpm test` or per-PR CI — +// only via `pnpm test:e2e`, which the opt-in `e2e` job in +// .github/workflows/test.yml invokes when AGENT_E2E_ENABLED is set. Sequential, +// generous timeouts: each test drives two real model turns end to end. export default defineConfig({ resolve: { alias: { diff --git a/packages/core/src/sessions/contextUsage.test.ts b/packages/core/src/sessions/contextUsage.test.ts index fb295c5818..b753f257a0 100644 --- a/packages/core/src/sessions/contextUsage.test.ts +++ b/packages/core/src/sessions/contextUsage.test.ts @@ -17,6 +17,21 @@ function usageUpdateEvent(used: number, size: number): AcpMessage { }; } +function sizelessUsageUpdateEvent(used: number): AcpMessage { + return { + type: "acp_message", + ts: 1, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "s1", + update: { sessionUpdate: "usage_update", used }, + }, + }, + }; +} + function breakdownEvent( breakdown: Record, method = "_posthog/usage_update", @@ -77,6 +92,16 @@ describe("extractContextUsage", () => { expect(result?.percentage).toBe(0); }); + it("borrows the context window from an older update when the newest omits it", () => { + const result = extractContextUsage([ + usageUpdateEvent(50_000, 200_000), + sizelessUsageUpdateEvent(60_000), + ]); + expect(result?.used).toBe(60_000); + expect(result?.size).toBe(200_000); + expect(result?.percentage).toBe(30); + }); + it("merges breakdown from a _posthog/usage_update notification", () => { const result = extractContextUsage([ usageUpdateEvent(50_000, 200_000), @@ -132,6 +157,18 @@ describe("createContextUsageTracker", () => { expect(result?.size).toBe(200_000); }); + it("keeps the last known context window when an update omits size", () => { + const tracker = createContextUsageTracker(); + const withSize = usageUpdateEvent(50_000, 200_000); + + expect(tracker.update([withSize])?.size).toBe(200_000); + + const result = tracker.update([withSize, sizelessUsageUpdateEvent(60_000)]); + expect(result?.used).toBe(60_000); + expect(result?.size).toBe(200_000); + expect(result?.percentage).toBe(30); + }); + it("rebuilds when the event list is truncated", () => { const tracker = createContextUsageTracker(); const earlier = usageUpdateEvent(50_000, 200_000); diff --git a/packages/core/src/sessions/contextUsage.ts b/packages/core/src/sessions/contextUsage.ts index 22f33280e9..6d37bc156c 100644 --- a/packages/core/src/sessions/contextUsage.ts +++ b/packages/core/src/sessions/contextUsage.ts @@ -29,11 +29,15 @@ export function extractContextUsage(events: AcpMessage[]): ContextUsage | null { const msg = events[i].message; if (!aggregate) { aggregate = extractAggregate(msg); + } else if (aggregate.size <= 0) { + // The newest update omitted the context window; borrow it from an older one. + const older = extractAggregate(msg); + if (older) aggregate = withCarriedSize(aggregate, older); } if (!breakdown) { breakdown = extractBreakdown(msg); } - if (aggregate && breakdown) break; + if (aggregate && aggregate.size > 0 && breakdown) break; } if (!aggregate) return null; @@ -50,7 +54,10 @@ export function createContextUsageTracker() { init: () => ({ aggregate: null, breakdown: null }), processEvent: (state, event) => { const msg = event.message; - state.aggregate = extractAggregate(msg) ?? state.aggregate; + const next = extractAggregate(msg); + if (next) { + state.aggregate = withCarriedSize(next, state.aggregate); + } state.breakdown = extractBreakdown(msg) ?? state.breakdown; }, getResult: (state) => @@ -60,6 +67,24 @@ export function createContextUsageTracker() { }); } +/** + * An update that omits `size` must not wipe a previously known context window + * (codex reports `modelContextWindow` intermittently), so keep the last known + * size and recompute the percentage against it. + */ +function withCarriedSize( + next: ContextUsageAggregate, + previous: ContextUsageAggregate | null, +): ContextUsageAggregate { + if (next.size > 0 || !previous || previous.size <= 0) return next; + const size = previous.size; + return { + ...next, + size, + percentage: Math.min(100, Math.round((next.used / size) * 100)), + }; +} + function extractAggregate( msg: AcpMessage["message"], ): ContextUsageAggregate | null { diff --git a/packages/core/src/sessions/executionModes.ts b/packages/core/src/sessions/executionModes.ts index dc36a4a6a8..1b84be93a6 100644 --- a/packages/core/src/sessions/executionModes.ts +++ b/packages/core/src/sessions/executionModes.ts @@ -1,3 +1,5 @@ +import { CODEX_MODE_PRESETS } from "@posthog/shared"; + export interface ModeInfo { id: string; name: string; @@ -32,36 +34,13 @@ const availableModes: ModeInfo[] = [ }, ]; -// Mirrors the codex app-server adapter's CODEX_MODES so the picker offers the -// same presets as a live session. "plan" is a CodeExecutionMode codex-acp maps -// to read-only and the app-server gives a read-only sandbox — safe on both. -const codexModes: ModeInfo[] = [ - { - id: "plan", - name: "Plan", - description: "Plan first — inspect and propose; makes no changes", - }, - { - id: "read-only", - name: "Read Only", - description: "Read-only access, no file modifications", - }, - { - id: "auto", - name: "Auto", - description: "Standard behavior, prompts for dangerous operations", - }, - { - id: "full-access", - name: "Full Access", - description: "Auto-accept all permission requests", - }, -]; - export function getAvailableModes(): ModeInfo[] { return availableModes; } +// The preset literals live in @posthog/shared (one copy for every picker and +// the app-server adapter's CODEX_MODES). Cloud sessions offer all presets, +// including full-access; the agent package applies its own bypass gating. export function getAvailableCodexModes(): ModeInfo[] { - return codexModes; + return [...CODEX_MODE_PRESETS]; } diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 1c44ae604e..38b5f8995a 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -301,6 +301,9 @@ export interface SessionServiceDeps { adapterStore: { getAdapter(taskRunId: string): Adapter | undefined; setAdapter(taskRunId: string, adapter: Adapter): void; + /** Codex sub-adapter pin: true = app-server, null = resolved undefined at creation. */ + setUseCodexAppServer(taskRunId: string, useAppServer: boolean | null): void; + getUseCodexAppServer(taskRunId: string): boolean | null | undefined; removeAdapter(taskRunId: string): void; }; readonly settings: { customInstructions?: string | null }; @@ -498,6 +501,11 @@ export function isPermissionRequestAlreadySurfaced( ); } +/** The steering capability on a loosely-typed agent start/reconnect result. */ +function readSteering(result: unknown): string | undefined { + return (result as { steering?: string } | undefined)?.steering; +} + function classifyTurnEventKind( msg: AcpMessage["message"], ): "text" | "output" | "other" { @@ -914,6 +922,25 @@ export class SessionService { this.d.adapterStore.setAdapter(taskRunId, resolvedAdapter); } + // Reuse the codex sub-adapter pinned at session creation so a rollout-flag + // flip cannot resume an app-server thread through codex-acp (or vice + // versa). Sessions from before pinning existed resolve live once, then get + // backfilled so later reconnects stay stable. + const pinnedUseCodexAppServer = + resolvedAdapter === "codex" + ? this.d.adapterStore.getUseCodexAppServer(taskRunId) + : undefined; + const useCodexAppServer = + pinnedUseCodexAppServer === undefined + ? this.resolveUseCodexAppServer(resolvedAdapter) + : (pinnedUseCodexAppServer ?? undefined); + if (resolvedAdapter === "codex" && pinnedUseCodexAppServer === undefined) { + this.d.adapterStore.setUseCodexAppServer( + taskRunId, + useCodexAppServer ?? null, + ); + } + if (previous) { session.optimisticItems = previous.optimisticItems; session.messageQueue = previous.messageQueue; @@ -969,7 +996,7 @@ export class SessionService { logUrl, sessionId, adapter: resolvedAdapter, - useCodexAppServer: this.resolveUseCodexAppServer(resolvedAdapter), + useCodexAppServer, permissionMode: persistedMode, model: persistedModel, customInstructions: customInstructions || undefined, @@ -994,7 +1021,7 @@ export class SessionService { this.d.store.updateSession(taskRunId, { status: "connected", configOptions, - steering: (result as { steering?: string }).steering, + steering: readSteering(result), }); // Persist the merged config options @@ -1272,6 +1299,10 @@ export class SessionService { * override (`POSTHOG_CODEX_USE_APP_SERVER`) and then the codex-acp default — * hard-passing `false` would shadow that env, since the host value has the * highest precedence in resolveUseCodexAppServer. + * + * Consulted for NEW sessions (and once to backfill legacy ones); reconnects + * reuse the value pinned in the adapter store at creation, so a flag flip + * never resumes an existing thread through the other codex sub-adapter. */ private resolveUseCodexAppServer( adapter: "claude" | "codex" | undefined, @@ -1306,6 +1337,7 @@ export class SessionService { const { customInstructions: startCustomInstructions } = this.d.settings; const preferredModel = model ?? this.d.DEFAULT_GATEWAY_MODEL; + const useCodexAppServer = this.resolveUseCodexAppServer(adapter); const result = await this.d.trpc.agent.start.mutate({ taskId, taskRunId: taskRun.id, @@ -1314,7 +1346,7 @@ export class SessionService { projectId: auth.projectId, permissionMode: executionMode, adapter, - useCodexAppServer: this.resolveUseCodexAppServer(adapter), + useCodexAppServer, customInstructions: startCustomInstructions || undefined, effort: effortLevelSchema.safeParse(reasoningLevel).success ? (reasoningLevel as EffortLevel) @@ -1350,16 +1382,23 @@ export class SessionService { | SessionConfigOption[] | undefined; session.configOptions = configOptions; - session.steering = (result as { steering?: string }).steering; + session.steering = readSteering(result); // Persist the config options if (configOptions) { this.d.setPersistedConfigOptions(taskRun.id, configOptions); } - // Persist the adapter + // Persist the adapter, pinning the codex sub-adapter so reconnects keep + // using the one that created this thread even if the rollout flag flips. if (adapter) { this.d.adapterStore.setAdapter(taskRun.id, adapter); + if (adapter === "codex") { + this.d.adapterStore.setUseCodexAppServer( + taskRun.id, + useCodexAppServer ?? null, + ); + } } // Store the initial prompt on the session so retry/reset flows can diff --git a/packages/shared/src/execution-modes.ts b/packages/shared/src/execution-modes.ts new file mode 100644 index 0000000000..909fcf80ee --- /dev/null +++ b/packages/shared/src/execution-modes.ts @@ -0,0 +1,34 @@ +export interface CodexModePreset { + id: "plan" | "read-only" | "auto" | "full-access"; + name: string; + description: string; +} + +/** + * The codex mode presets every picker shows (task creation and live session). + * One copy so the pickers (agent execution-mode.ts, core executionModes.ts) + * and the adapter's behavioral map (codex-app-server session-config.ts) cannot + * drift; each consumer owns only its own gating and policy mapping. + */ +export const CODEX_MODE_PRESETS: readonly CodexModePreset[] = [ + { + id: "plan", + name: "Plan", + description: "Plan first — inspect and propose; makes no changes", + }, + { + id: "read-only", + name: "Read only", + description: "Read-only — can inspect but not modify files", + }, + { + id: "auto", + name: "Auto", + description: "Edits the workspace; asks before risky operations", + }, + { + id: "full-access", + name: "Full access", + description: "Auto-approves all operations", + }, +]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 967c6eb9ca..2e26ef236b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -79,6 +79,10 @@ export { serializeError, } from "./errors"; export type { ExecutionMode } from "./exec-types"; +export { + CODEX_MODE_PRESETS, + type CodexModePreset, +} from "./execution-modes"; export * from "./flags"; export * from "./git-domain"; export type { diff --git a/packages/ui/src/features/permissions/McpPermission.tsx b/packages/ui/src/features/permissions/McpPermission.tsx index 7fbac089aa..6cf59f90cf 100644 --- a/packages/ui/src/features/permissions/McpPermission.tsx +++ b/packages/ui/src/features/permissions/McpPermission.tsx @@ -1,5 +1,4 @@ -import { readMcpToolName } from "@posthog/shared"; -import { parseMcpToolKey } from "@posthog/ui/features/mcp-apps/utils/mcp-app-host-utils"; +import { mcpToolKey, readMcpToolDescriptor } from "@posthog/shared"; import { formatPosthogExecBody, getPostHogExecDisplay, @@ -17,9 +16,9 @@ export function McpPermission({ onSelect, onCancel, }: BasePermissionProps) { - const mcpToolName = readMcpToolName(toolCall._meta); + const mcp = readMcpToolDescriptor(toolCall._meta); - if (!mcpToolName) { + if (!mcp) { return ( { expect(screen.getByText("37% full")).toBeInTheDocument(); }); + it("shows only the token count when the context window is unknown (size 0)", () => { + render( + + + , + ); + // No misleading "/ 0 tokens" denominator or "0% full" line. + expect(screen.getByText("~50K tokens")).toBeInTheDocument(); + expect(screen.queryByText(/\/ 0 tokens/)).not.toBeInTheDocument(); + expect(screen.queryByText(/% full/)).not.toBeInTheDocument(); + }); + it("shows the placeholder copy when breakdown is missing", () => { render( diff --git a/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx index fbb54cf417..316ca05aa0 100644 --- a/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx +++ b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx @@ -15,6 +15,9 @@ export function ContextBreakdownPopover({ }: ContextBreakdownPopoverProps) { const { used, size, percentage, breakdown } = usage; const fillColor = getOverallUsageColor(percentage); + // The context window can be unknown (size 0) — show just the token count + // rather than a misleading "~X / 0 tokens · 0% full". + const hasSize = size > 0; return ( @@ -23,13 +26,17 @@ export function ContextBreakdownPopover({ Context - ~{formatTokensCompact(used)} / {formatTokensCompact(size)} tokens + {hasSize + ? `~${formatTokensCompact(used)} / ${formatTokensCompact(size)} tokens` + : `~${formatTokensCompact(used)} tokens`} - - {percentage}% full - + {hasSize && ( + + {percentage}% full + + )} {breakdown ? ( diff --git a/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.test.ts b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.test.ts new file mode 100644 index 0000000000..5824702632 --- /dev/null +++ b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.test.ts @@ -0,0 +1,76 @@ +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { describe, expect, it } from "vitest"; +import { buildThreadGroups, isGroupableItem } from "./buildThreadGroups"; + +function turnContext() { + return { + toolCalls: new Map(), + childItems: new Map(), + turnCancelled: false, + turnComplete: true, + }; +} + +function toolCallItem( + id: string, + meta: unknown, + overrides?: Record, +): ConversationItem { + return { + type: "session_update", + id, + update: { + sessionUpdate: "tool_call", + toolCallId: id, + title: `tool ${id}`, + kind: "other", + status: "completed", + _meta: meta, + ...overrides, + }, + turnContext: turnContext(), + } as unknown as ConversationItem; +} + +describe("buildThreadGroups MCP detection", () => { + it("keeps a tool call with only the posthog meta channel standalone (codex adapters)", () => { + const mcpItem = toolCallItem("t1", { + posthog: { + toolName: "mcp__posthog__exec", + mcp: { server: "posthog", tool: "exec" }, + }, + }); + + expect(isGroupableItem(mcpItem)).toBe(false); + + const grouping = buildThreadGroups([mcpItem], "all", {}); + expect(grouping.rows).toHaveLength(1); + expect(grouping.rows[0].kind).toBe("item"); + expect(grouping.keepMounted).toEqual([0]); + }); + + it("keeps a tool call with the legacy claudeCode mcp__ name standalone", () => { + const legacyItem = toolCallItem("t1", { + claudeCode: { toolName: "mcp__github__search" }, + }); + + expect(isGroupableItem(legacyItem)).toBe(false); + const grouping = buildThreadGroups([legacyItem], "all", {}); + expect(grouping.keepMounted).toEqual([0]); + }); + + it("folds non-MCP tool calls into a collapsed group", () => { + const plain = toolCallItem("t1", { + posthog: { toolName: "Bash" }, + }); + const alsoPlain = toolCallItem("t2", undefined, { kind: "read" }); + + const grouping = buildThreadGroups([plain, alsoPlain], "all", {}); + expect(grouping.rows).toHaveLength(1); + expect(grouping.rows[0].kind).toBe("tool_group"); + expect(grouping.keepMounted).toEqual([]); + // Both folded items still map to the group's row for find-in-thread. + expect(grouping.idToRowIndex.get("t1")).toBe(0); + expect(grouping.idToRowIndex.get("t2")).toBe(0); + }); +}); diff --git a/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts index 9253179fc1..2b45ea9b9b 100644 --- a/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts +++ b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts @@ -1,5 +1,5 @@ import type { Icon } from "@phosphor-icons/react"; -import { readAgentToolName } from "@posthog/shared"; +import { readAgentToolName, readMcpToolDescriptor } from "@posthog/shared"; import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { buildDoneLabel, @@ -73,7 +73,7 @@ function getToolName(update: { _meta?: unknown }): string | undefined { function isMcpToolItem(item: ConversationItem): boolean { if (item.type !== "session_update") return false; if (item.update.sessionUpdate !== "tool_call") return false; - return getToolName(item.update)?.startsWith("mcp__") ?? false; + return readMcpToolDescriptor(item.update._meta) !== undefined; } function isAlwaysVisibleItem(item: ConversationItem): boolean { @@ -159,7 +159,7 @@ function summarize(items: ConversationItem[]): GroupSummary { if (name && grouping.subagentToolNames.has(name)) { counts.subagents++; addIcon(SUBAGENT_ICON, "subagent"); - } else if (name?.startsWith("mcp__")) { + } else if (readMcpToolDescriptor(update._meta)) { counts.other++; addIcon(MCP_ICON, "mcp"); } else { diff --git a/packages/ui/src/features/sessions/sessionAdapterStore.ts b/packages/ui/src/features/sessions/sessionAdapterStore.ts index 3219aa73cd..f98bc4fac5 100644 --- a/packages/ui/src/features/sessions/sessionAdapterStore.ts +++ b/packages/ui/src/features/sessions/sessionAdapterStore.ts @@ -6,8 +6,17 @@ type AdapterType = "claude" | "codex"; interface SessionAdapterState { adaptersByRunId: Record; + // Codex sub-adapter pinned at session creation: true = app-server, null = + // resolved undefined (codex-acp / env fallback). Keeps a resumed session on + // the sub-adapter that created its thread even if the rollout flag flips. + codexAppServerByRunId: Record; setAdapter: (taskRunId: string, adapter: AdapterType) => void; getAdapter: (taskRunId: string) => AdapterType | undefined; + setUseCodexAppServer: ( + taskRunId: string, + useAppServer: boolean | null, + ) => void; + getUseCodexAppServer: (taskRunId: string) => boolean | null | undefined; removeAdapter: (taskRunId: string) => void; } @@ -15,21 +24,36 @@ export const useSessionAdapterStore = create()( persist( (set, get) => ({ adaptersByRunId: {}, + codexAppServerByRunId: {}, setAdapter: (taskRunId, adapter) => set((state) => ({ adaptersByRunId: { ...state.adaptersByRunId, [taskRunId]: adapter }, })), getAdapter: (taskRunId) => get().adaptersByRunId[taskRunId], + setUseCodexAppServer: (taskRunId, useAppServer) => + set((state) => ({ + codexAppServerByRunId: { + ...state.codexAppServerByRunId, + [taskRunId]: useAppServer, + }, + })), + getUseCodexAppServer: (taskRunId) => + get().codexAppServerByRunId[taskRunId], removeAdapter: (taskRunId) => set((state) => { const { [taskRunId]: _removed, ...rest } = state.adaptersByRunId; - return { adaptersByRunId: rest }; + const { [taskRunId]: _removedPin, ...restPins } = + state.codexAppServerByRunId; + return { adaptersByRunId: rest, codexAppServerByRunId: restPins }; }), }), { name: "session-adapter-storage", storage: electronStorage, - partialize: (state) => ({ adaptersByRunId: state.adaptersByRunId }), + partialize: (state) => ({ + adaptersByRunId: state.adaptersByRunId, + codexAppServerByRunId: state.codexAppServerByRunId, + }), }, ), ); diff --git a/packages/ui/src/features/sessions/sessionServiceHost.ts b/packages/ui/src/features/sessions/sessionServiceHost.ts index c0d0e30b80..0c4d169a1f 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.ts @@ -108,6 +108,12 @@ function buildSessionServiceDeps(): SessionServiceDeps { useSessionAdapterStore.getState().getAdapter(taskRunId), setAdapter: (taskRunId, adapter) => useSessionAdapterStore.getState().setAdapter(taskRunId, adapter), + setUseCodexAppServer: (taskRunId, useAppServer) => + useSessionAdapterStore + .getState() + .setUseCodexAppServer(taskRunId, useAppServer), + getUseCodexAppServer: (taskRunId) => + useSessionAdapterStore.getState().getUseCodexAppServer(taskRunId), removeAdapter: (taskRunId) => useSessionAdapterStore.getState().removeAdapter(taskRunId), },