Skip to content

feat(agent): Default Codex to the native app-server harness#2723

Merged
joshsny merged 18 commits into
mainfrom
feat/codex-app-server-adapter
Jul 6, 2026
Merged

feat(agent): Default Codex to the native app-server harness#2723
joshsny merged 18 commits into
mainfrom
feat/codex-app-server-adapter

Conversation

@charlesvien

@charlesvien charlesvien commented Jun 17, 2026

Copy link
Copy Markdown
Member

Problem

Codex ran through the Zed codex-acp ACP adapter, an extra translation layer that weakens native Codex capabilities (thread resume/fork, typed item and tool events, detailed usage, structured outputs, approvals).

Changes

  1. Bundle the native codex CLI binary (download-binaries + vite copy)
  2. Add CodexAppServerAgent speaking Codex's native app-server JSON-RPC under an unchanged ACP surface
  3. Add the app-server JSON-RPC client, ACP event mapping, and spawn helper
  4. Default createAcpConnection to the native agent, with POSTHOG_CODEX_USE_ACP=1 to fall back to codex-acp

How did you test this?

Manually

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

This is part 1 of 2 in a stack made with GitButler:

charlesvien commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

React Doctor found 2 issues in 2 files · 2 warnings.

2 warnings

src/features/sessions/components/ContextBreakdownPopover.tsx

src/features/sessions/components/SteerQueueToggle.tsx

Reviewed by React Doctor for commit 4cd9a00.

@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Comments Outside Diff (3)

  1. packages/agent/src/adapters/codex-app-server/spawn.ts, line 1176-1184 (link)

    P2 apiBaseUrl is not escaped in the config arg

    developerInstructions is carefully escaped (backslash, newlines, quotes) before being injected into the -c flag value, but apiBaseUrl and the other string literals (e.g. "posthog", "responses") are interpolated verbatim. If apiBaseUrl ever contains a " character (e.g. from a misconfigured env var), the produced config line will be malformed and silently misbehave. Consider applying the same escaping logic, or at minimum adding an assertion that the URL contains no double-quotes.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/agent/src/adapters/codex-app-server/spawn.ts
    Line: 1176-1184
    
    Comment:
    **`apiBaseUrl` is not escaped in the config arg**
    
    `developerInstructions` is carefully escaped (backslash, newlines, quotes) before being injected into the `-c` flag value, but `apiBaseUrl` and the other string literals (e.g. `"posthog"`, `"responses"`) are interpolated verbatim. If `apiBaseUrl` ever contains a `"` character (e.g. from a misconfigured env var), the produced config line will be malformed and silently misbehave. Consider applying the same escaping logic, or at minimum adding an assertion that the URL contains no double-quotes.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts, line 821-831 (link)

    P2 pendingTurnResolve not cleared when turn/start request rejects

    pendingTurnResolve is assigned before await this.rpc.request(TURN_START, ...). If that request rejects (e.g. the server returns a JSON-RPC error), prompt() throws but pendingTurnResolve is left pointing at the abandoned completion promise's resolver. A subsequent prompt() call overwrites it correctly, but if the server had partially accepted the first turn and later emits turn/completed, handleNotification would call the stale resolver on the new call's promise, prematurely resolving it. Clearing pendingTurnResolve in a catch block around the request keeps the invariant that the field is only set while a live turn is in flight.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts
    Line: 821-831
    
    Comment:
    **`pendingTurnResolve` not cleared when `turn/start` request rejects**
    
    `pendingTurnResolve` is assigned before `await this.rpc.request(TURN_START, ...)`. If that request rejects (e.g. the server returns a JSON-RPC error), `prompt()` throws but `pendingTurnResolve` is left pointing at the abandoned completion promise's resolver. A subsequent `prompt()` call overwrites it correctly, but if the server had partially accepted the first turn and later emits `turn/completed`, `handleNotification` would call the stale resolver on the *new* call's promise, prematurely resolving it. Clearing `pendingTurnResolve` in a `catch` block around the request keeps the invariant that the field is only set while a live turn is in flight.
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts, line 567 (link)

    P2 Test mocks use as unknown as to bypass required interface properties

    init, NewSessionRequest, and PromptRequest stubs are cast via as unknown as InterfaceType rather than supplying all required fields. This sidesteps TypeScript's property checks and can mask breakage when the real interface gains a required field that the agent code reads. The same pattern appears across the three describe blocks. Prefer building the minimal complete objects the agent actually reads, which makes the test contract explicit and keeps it safe under future interface changes.

    Rule Used: When creating mock objects for tests, include all ... (source)

    Learned From
    PostHog/posthog#32521

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts
    Line: 567
    
    Comment:
    **Test mocks use `as unknown as` to bypass required interface properties**
    
    `init`, `NewSessionRequest`, and `PromptRequest` stubs are cast via `as unknown as InterfaceType` rather than supplying all required fields. This sidesteps TypeScript's property checks and can mask breakage when the real interface gains a required field that the agent code reads. The same pattern appears across the three `describe` blocks. Prefer building the minimal complete objects the agent actually reads, which makes the test contract explicit and keeps it safe under future interface changes.
    
    **Rule Used:** When creating mock objects for tests, include all ... ([source](https://app.greptile.com/posthog-org-19734/-/custom-context?memory=693b8339-f300-4cf5-bacb-3f1630384594))
    
    **Learned From**
    [PostHog/posthog#32521](https://github.com/PostHog/posthog/pull/32521)
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
packages/agent/src/adapters/codex-app-server/spawn.ts:1176-1184
**`apiBaseUrl` is not escaped in the config arg**

`developerInstructions` is carefully escaped (backslash, newlines, quotes) before being injected into the `-c` flag value, but `apiBaseUrl` and the other string literals (e.g. `"posthog"`, `"responses"`) are interpolated verbatim. If `apiBaseUrl` ever contains a `"` character (e.g. from a misconfigured env var), the produced config line will be malformed and silently misbehave. Consider applying the same escaping logic, or at minimum adding an assertion that the URL contains no double-quotes.

### Issue 2 of 3
packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts:821-831
**`pendingTurnResolve` not cleared when `turn/start` request rejects**

`pendingTurnResolve` is assigned before `await this.rpc.request(TURN_START, ...)`. If that request rejects (e.g. the server returns a JSON-RPC error), `prompt()` throws but `pendingTurnResolve` is left pointing at the abandoned completion promise's resolver. A subsequent `prompt()` call overwrites it correctly, but if the server had partially accepted the first turn and later emits `turn/completed`, `handleNotification` would call the stale resolver on the *new* call's promise, prematurely resolving it. Clearing `pendingTurnResolve` in a `catch` block around the request keeps the invariant that the field is only set while a live turn is in flight.

### Issue 3 of 3
packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts:567
**Test mocks use `as unknown as` to bypass required interface properties**

`init`, `NewSessionRequest`, and `PromptRequest` stubs are cast via `as unknown as InterfaceType` rather than supplying all required fields. This sidesteps TypeScript's property checks and can mask breakage when the real interface gains a required field that the agent code reads. The same pattern appears across the three `describe` blocks. Prefer building the minimal complete objects the agent actually reads, which makes the test contract explicit and keeps it safe under future interface changes.

Reviews (1): Last reviewed commit: "default codex to native app-server harne..." | Re-trigger Greptile

@charlesvien
charlesvien force-pushed the feat/codex-app-server-adapter branch from f544874 to 2ee0938 Compare June 17, 2026 05:29
@charlesvien charlesvien changed the title add codex app-server json-rpc client feat(agent): Default Codex to the native app-server harness Jun 17, 2026
@charlesvien
charlesvien marked this pull request as ready for review June 17, 2026 05:45
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
packages/agent/src/adapters/codex-app-server/app-server-client.ts:104-106
**Unexpected process exit leaves pending promises hanging forever**

When the codex process crashes, the stdout stream closes and `readLoop` detects `done` and exits silently. At that point `AppServerClient.pending` still holds any in-flight RPC requests, and more critically `CodexAppServerAgent.pendingTurnResolve` is still set (waiting for `turn/completed` that will never arrive). Neither is ever rejected, so `prompt()` hangs indefinitely.

`close()` already does the right thing — rejects all `pending` calls and clears state — but it is never triggered by a transport EOF. The loop should call `void this.close()` (or a dedicated `onClose` callback) when `done` is `true` and `!this.closed`.

Reviews (2): Last reviewed commit: "default codex to native app-server harne..." | Re-trigger Greptile

Comment thread packages/agent/src/adapters/codex-app-server/app-server-client.ts
@charlesvien
charlesvien force-pushed the fix/cloud-claude-reasoning-effort branch from 688d6b1 to ad5cb12 Compare June 17, 2026 06:19
@charlesvien
charlesvien force-pushed the feat/codex-app-server-adapter branch from 2ee0938 to 7abe5de Compare June 17, 2026 06:19
@charlesvien
charlesvien marked this pull request as draft June 17, 2026 06:26
@charlesvien charlesvien added the Stamphog This will request an autostamp by stamphog on small changes label Jun 17, 2026
@charlesvien
charlesvien changed the base branch from fix/cloud-claude-reasoning-effort to graphite-base/2723 June 17, 2026 18:26
@charlesvien
charlesvien force-pushed the feat/codex-app-server-adapter branch from 7abe5de to 2028e4b Compare June 17, 2026 18:38
@graphite-app
graphite-app Bot changed the base branch from graphite-base/2723 to main June 17, 2026 18:39
@charlesvien
charlesvien force-pushed the feat/codex-app-server-adapter branch 2 times, most recently from f30123d to da46d82 Compare June 17, 2026 19:09
@charlesvien charlesvien added Stamphog This will request an autostamp by stamphog on small changes and removed Stamphog This will request an autostamp by stamphog on small changes labels Jun 21, 2026
@charlesvien
charlesvien force-pushed the feat/codex-app-server-adapter branch 2 times, most recently from 1a061f8 to 42759a0 Compare June 29, 2026 03:32
@joshsny
joshsny force-pushed the feat/codex-app-server-adapter branch from 42759a0 to 00acd92 Compare June 29, 2026 09:44
@joshsny
joshsny force-pushed the feat/codex-app-server-adapter branch from 00acd92 to 8899ff0 Compare June 29, 2026 09:47
@joshsny
joshsny force-pushed the feat/codex-app-server-adapter branch from 8899ff0 to 589557c Compare July 2, 2026 12:19
@charlesvien
charlesvien marked this pull request as ready for review July 2, 2026 19:17
@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Comments Outside Diff (1)

  1. packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts, line 1676-1688 (link)

    P1 Unhandled promise rejection when codex crashes during turn/start

    When the codex process exits while turn/start is in-flight, AppServerClient.readLoop's finally block (1) rejects the pending TURN_START call and (2) immediately and synchronously calls onClose()handleServerClosed()pendingTurn.reject(...). Because (1) causes rpc.request(TURN_START) to throw, the await completion line is never reached. The completion promise ends up in a rejected state with no attached handler, which triggers an unhandled promise rejection. In Electron (Node.js ≥ 15), unhandled rejections terminate the process by default, so a codex crash during a session handshake can also crash the host app.

    The test "rejects the pending turn when the app-server stream closes" doesn't reproduce this because the stub's request() resolves synchronously, so await completion is already active when triggerClose() fires. The fix is to attach a no-op .catch() to completion immediately after creating it, so Node.js considers the rejection handled regardless of whether await completion is ever reached.

Reviews (3): Last reviewed commit: "harden codex app-server turn lifecycle" | Re-trigger Greptile

joshsny and others added 10 commits July 6, 2026 09:26
The selector defers onModelChange until the menu-close animation completes, so asserting synchronously after the click races that completion and flakes under CI timing. Wrap the assertion in waitFor.

Generated-By: PostHog Code
Task-Id: 6b58aa32-2ed1-4109-8538-cefba560626b
…e flag

Run the agent live-model e2e job whenever a packages/ file changes (or this workflow changes) and unit + integration tests have passed, rather than requiring the AGENT_E2E_ENABLED variable. Keeps the fork guard since forks don't receive the gateway secret. Gateway env vars still gate the per-arm token guard and will be added separately.

Generated-By: PostHog Code
Task-Id: 6b58aa32-2ed1-4109-8538-cefba560626b
Gate the live e2e job on vars.E2E_GATEWAY_URL so it stays green on packages/ PRs until the gateway is configured, then activates automatically when the URL var and token secret are added. Prevents the fail-loud token guard from redding every packages/ PR before the gateway exists.

Generated-By: PostHog Code
Task-Id: 6b58aa32-2ed1-4109-8538-cefba560626b
…vars

Map the org-level POSTHOG_CODE_E2E_* secret and variables onto the E2E_* env names the suite reads. Token is the secret (POSTHOG_CODE_E2E_GATEWAY_TOKEN); gateway URL and model/environment overrides are variables. The dormancy gate reads vars.POSTHOG_CODE_E2E_GATEWAY_URL, kept as a var because GitHub if: conditions can't read secrets.

Generated-By: PostHog Code
Task-Id: 6b58aa32-2ed1-4109-8538-cefba560626b
Generated-By: PostHog Code
Task-Id: 6b58aa32-2ed1-4109-8538-cefba560626b
…nfigured

Remove the vars.POSTHOG_CODE_E2E_GATEWAY_URL dormancy gate. The e2e job now runs on every packages/ change once unit + integration pass; if the POSTHOG_CODE_E2E_* secret/vars are missing, the fail-loud guard reds the run instead of skipping to green. Fork PRs remain skipped since they never get the secret.

Generated-By: PostHog Code
Task-Id: 6b58aa32-2ed1-4109-8538-cefba560626b
The suite now reads POSTHOG_CODE_E2E_* directly instead of the old E2E_* names, removing the confusing workflow remapping. The gateway token is POSTHOG_CODE_E2E_PERSONAL_API_KEY (org secret); URL, model, and environment overrides are POSTHOG_CODE_E2E_* org vars. Updated config, guard, driver, README, and run-e2e.sh to match, and the workflow passes them through unchanged.

Generated-By: PostHog Code
Task-Id: 6b58aa32-2ed1-4109-8538-cefba560626b
The longer POSTHOG_CODE_E2E_* names pushed several lines past the print width; apply biome's formatting so biome ci passes.

Generated-By: PostHog Code
Task-Id: 6b58aa32-2ed1-4109-8538-cefba560626b
Match the org secret name Josh configured (POSTHOG_CODE_E2E_GATEWAY_PERSONAL_API_KEY). Updated the workflow, config, guard, session-lifecycle, README, and run-e2e.sh accordingly. Paired with the existing POSTHOG_CODE_E2E_GATEWAY_URL variable.

Generated-By: PostHog Code
Task-Id: 6b58aa32-2ed1-4109-8538-cefba560626b
An empty POSTHOG_CODE_E2E_GATEWAY_URL falls back to localhost:3308, which is
unreachable from a runner — every model turn dies with ConnectionRefused as 8
scattered failures. The guard now reds that misconfiguration as one clear
failure naming the org variable to set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@joshsny
joshsny force-pushed the feat/codex-app-server-adapter branch from 0e71608 to af30578 Compare July 6, 2026 10:46
The workflow only read vars.POSTHOG_CODE_E2E_GATEWAY_URL, so a URL stored as
an org secret was invisible and every model turn failed with ConnectionRefused
against the localhost fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@joshsny
joshsny merged commit d4d18f0 into main Jul 6, 2026
25 checks passed
@joshsny
joshsny deleted the feat/codex-app-server-adapter branch July 6, 2026 11:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants