Skip to content

[Bug]: Withdrawn Codex MCP elicitation stays pending after its callback is removed #35804

Closed as not planned

Description

@coygeek

Summary

When Codex withdraws an MCP approval request while its turn continues, T3 removes the response callback but leaves the published approval pending. The approval should become cancelled so users are no longer asked to answer a request the provider has already resolved. This was reproduced through the real Codex adapter and app-server transport with an in-memory provider transcript; a late response failed while a second approval in the same turn remained answerable.

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Use a checkout at the commit below with its locked dependencies installed.
  2. Run the reproduction script below. It creates a detached disposable worktree, links the checkout's existing dependencies, adds one focused regression case to the existing Codex adapter test harness, and runs it alongside the existing successful one-time-form response test.
  3. Observe the first request's published status after its provider-side withdrawal, with the control request still active in the same turn.

The formal reproduction ran once and produced one failed regression assertion and one passed control. An earlier exploratory run of the same inserted regression case produced the same pending versus cancelled mismatch. No real provider turn, login, MCP authorization flow, or live T3 home was used.

Save the following as repro.sh and run sh repro.sh /path/to/t3code. Prerequisites are Git, Python 3, Node with the checkout's supported runtime, and the checkout's already-installed locked dependencies. The script places HOME, TMPDIR, and T3CODE_HOME in disposable scratch, removes its worktree on exit, and leaves the input checkout unchanged. Exit 1 means the defect was reproduced, exit 0 means the expected behavior passed, and exit 2 means the test did not establish either result.

Reproduction script: repro.sh
#!/bin/sh
set -eu
checkout=$(CDPATH= cd -- "${1:?Pass the T3 Code checkout path}" && pwd)
node --version
git --version
python3 --version
"$checkout/node_modules/.bin/vp" --version
scratch=$(mktemp -d)
cleanup() {
  git -C "$checkout" worktree remove --force "$scratch/wt" >/dev/null 2>&1 || :
  rm -rf "$scratch"
}
trap cleanup EXIT HUP INT TERM
mkdir -p "$scratch/home" "$scratch/tmp" "$scratch/t3"
export HOME="$scratch/home" TMPDIR="$scratch/tmp" T3CODE_HOME="$scratch/t3" T3CODE_DEV_SEED=0
git -C "$checkout" worktree add --detach "$scratch/wt" HEAD >/dev/null
cat > "$scratch/probe.txt" <<'PROBE'
  it.effect("g10 withdraws a resolved Codex MCP elicitation while the turn continues", () =>
    Effect.gen(function* () {
      const nativeThreadId = "g10-thread";
      const nativeTurnId = "g10-turn";
      const pendingSeen = yield* Deferred.make<void>();
      const transcript = makeCodexReplayTranscript({
        scenario: "g10-elicitation-withdrawal",
        entries: [
          ...codexReplayPreamble({nativeThreadId, nativeTurnId, prompt: "work"}),
          { type: "emit_inbound", label: "first elicitation", frame: {
            id: 100, method: "mcpServer/elicitation/request", params: {
              threadId: nativeThreadId, turnId: nativeTurnId, serverName: "first-server",
              mode: "form", message: "Confirm first?", requestedSchema: {type: "object", properties: {}},
            },
          }},
          { type: "emit_inbound", label: "withdraw first", frame: {
            method: "serverRequest/resolved", params: {threadId: nativeThreadId, requestId: 100},
          }},
          { type: "emit_inbound", label: "control elicitation", frame: {
            id: 101, method: "mcpServer/elicitation/request", params: {
              threadId: nativeThreadId, turnId: nativeTurnId, serverName: "control-server",
              mode: "form", message: "Confirm control?", requestedSchema: {type: "object", properties: {}},
            },
          }},
          {type: "expect_outbound", label: "control accepted", frame: {id: 101, result: {action: "accept", content: {}}}},
        ],
      });
      const harness = yield* makeCodexReplayHarness(transcript, (event) =>
        event.type === "runtime_request.updated" && event.runtimeRequest.kind === "mcp-elicitation"
          ? Deferred.succeed(pendingSeen, undefined) : Effect.void,
        undefined, undefined,
        (entry) => entry.label === "withdraw first" ? Deferred.await(pendingSeen) : Effect.void,
      );
      yield* harness.runtime.startTurn(makeCodexTestTurnInput({
        threadId: harness.threadId, providerThread: harness.providerThread,
        now: yield* DateTime.now, attemptId: RunAttemptId.make("g10-attempt"), text: "work",
      }));
      const updates = () => harness.events.flatMap((event) =>
        event.type === "runtime_request.updated" ? [event.runtimeRequest] : []);
      yield* harness.until(() => updates().length >= 2);
      const first = updates()[0]!;
      const control = updates().find((request) => request.id !== first.id)!;
      const late = yield* Effect.flip(harness.runtime.respondToRuntimeRequest({requestId: first.id, decision: "accept"}));
      assert.include(String(late.cause), "No pending Codex runtime request");
      yield* harness.runtime.respondToRuntimeRequest({requestId: control.id, decision: "accept"});
      const latestFirst = updates().findLast((request) => request.id === first.id)!;
      console.log("G10: native request withdrawn; late response rejected; control response accepted; first projected status=" + latestFirst.status);
      assert.equal(latestFirst.status, "cancelled");
    }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer, NodeCrypto.layer))),
  );

PROBE
python3 - "$checkout" "$scratch/wt" "$scratch/probe.txt" <<'PYTHON'
from pathlib import Path
import sys
src, dst, probe = map(Path, sys.argv[1:])
for p in [src/'node_modules', *src.glob('apps/*/node_modules'), *src.glob('packages/*/node_modules')]:
    q = dst/p.relative_to(src)
    if p.exists() and not q.exists(): q.symlink_to(p, target_is_directory=True)
p = dst/'apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts'
s = p.read_text()
marker = '  it.effect("keeps native image views available to the clients", () =>'
if s.count(marker) != 1: raise SystemExit('Test insertion point changed')
p.write_text(s.replace(marker, probe.read_text()+marker))
PYTHON
cd "$scratch/wt"
set +e
node_modules/.bin/vp test run apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts apps/server/src/provider/CodexMcpElicitation.test.ts -t 'g10 withdraws|returns the accepted form option' > "$scratch/output" 2>&1
status=$?
set -e
cat "$scratch/output"
if [ "$status" -eq 0 ]; then
  echo 'RESULT: expected behavior'
  exit 0
fi
if python3 - "$scratch/output" <<'CHECK'
from pathlib import Path
import sys
s = Path(sys.argv[1]).read_text()
sys.exit(0 if 'first projected status=pending' in s and '1 passed' in s else 1)
CHECK
then
  echo 'RESULT: defect reproduced'
  exit 1
fi
echo 'RESULT: inconclusive'
exit 2

Expected behavior

When the provider resolves or withdraws an outstanding elicitation, T3 should mark the corresponding request cancelled and stop presenting it as an approval awaiting a decision. Other pending requests in the same turn should remain answerable. This is supported by the upstream app-server's resolve_server_request, which cancels the native request and emits ServerRequestResolved (published Codex source), and by T3's own request-handler cancellation contract in packages/effect-codex-app-server/src/protocol.ts#L356-L365. MCP's response-action model distinguishes an outstanding decision from cancellation; no synthetic user acceptance or refusal should be sent after native withdrawal.

Actual behavior

An in-memory app-server sends a valid mcpServer/elicitation/request with an empty form schema, waits until T3 publishes the pending request, then sends serverRequest/resolved for that request. The same turn remains active and receives a second independent elicitation. T3's first request remains pending; answering it fails with No pending Codex runtime request. Answering the second request succeeds. The observed artifact is the adapter's published runtime-request state, not a browser or native-mobile capture.

Impact

Minor bug or occasional failure

Impact: P2 - A withdrawn MCP prompt stays published as pending and live even though every later response fails. The control demonstrates that the provider turn and another request can continue; this is a consequential stale-approval state, not evidence that every thread is blocked.

The affected path is Codex form-mode elicitation withdrawal within an active turn. Source tracing shows web/desktop and mobile render the shared pending approval state, but their actual rendered behavior was not exercised. Workaround: NOT_ENOUGH_INFO - no recovery or restart workaround was exercised. The test establishes that answering the stale request is not a working recovery.

Version or commit

T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93. This is a test-harness reproduction of that exact checkout, not a released desktop-build observation. The inherited fixture advertises a synthetic Codex app-server version 0.144.0; no installed Codex runtime version is claimed. The notification semantics were also checked against published Codex rust-v0.167.1 source.

Environment

Observed runtime: Node v26.12.1, Python 3.15.2, Git 2.56.0, Vite+ CLI 1.4.0, test runner 5.0.5, Darwin arm64. Locked Effect dependency: 4.1.3. Browser and mobile runtime: NOT_ENOUGH_INFO - no client runtime was launched.

Evidence

The formal script printed the following relevant output; filesystem stack traces and test-run timestamps are omitted:

G10: native request withdrawn; late response rejected; control response accepted; first projected status=pending
AssertionError: expected 'pending' to equal 'cancelled'
Test Files  1 failed | 1 passed (2)
Tests  1 failed | 1 passed | 168 skipped (170)
RESULT: defect reproduced

Source ownership: packages/effect-codex-app-server/src/protocol.ts#L356-L371 interrupts the native request handler on serverRequest/resolved; apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts#L5904-L5914 removes the adapter's pending callback in ensuring but emits no cancelled request/node/item event. The resulting late-response error is at apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts#L8278-L8293. Effect's pinned MCP implementation is not the owner of this provider app-server lifecycle path.

Client source tracing: apps/web/src/components/chat/TimelinePendingApprovalRow.tsx#L43-L46 treats a live pending approval as answerable; apps/mobile/src/features/threads/PendingApprovalCard.tsx#L34-L40 does the same. These source observations establish how the adapter artifact reaches the approval controls, without claiming a GUI reproduction.

Evidence provenance: observed adapter/transport replay plus source tracing. Local verification: reproduced. Reproduction completeness: complete for adapter published state; client rendering was not run.

Interface: MCP through Codex app-server, adapter event stream, and the shared runtime-request approval model.

Earliest failure stage: Cleanup or lifecycle

Target and input context

The two elicitation payloads use mode: "form", requestedSchema: {"type":"object","properties":{}}, distinct JSON-RPC IDs and distinct synthetic server names, and the same active thread and turn. The first is withdrawn by its original JSON-RPC ID. The second prompt provides the control that the turn remains active and response dispatch still works.

The generated Codex form schema explicitly identifies MCP 2025-11-25 in packages/effect-codex-app-server/src/_generated/schema.gen.ts#L46006-L46017. Actual upstream MCP initialize negotiation: NOT_ENOUGH_INFO - this replay exercises the provider app-server boundary, not the provider's MCP client connection. This failure depends on the app-server serverRequest/resolved lifecycle contract and does not assume a negotiated MCP revision from the generated schema citation.

Restoration check

The first runtime request, its approval node and its approval timeline item no longer remain pending/waiting or actionable after the provider withdrawal. The second request remains pending until its own response. A restoration check should observe both state transitions in the same active turn and retain the existing accepted-form response control.

Workaround

NOT_ENOUGH_INFO - no recovery workaround was exercised.

References

Activity

  1. maria-rcks commented on Nov 15, 2026

    @maria-rcks
    Collaborator

    Note

    Written by claude-fable-5-2 on behalf of Maria

    Closing: this account is denounced in .github/VOUCHED.td for filing automated issues faster than maintainers can triage them. A fix PR that links this issue is not affected by this close.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    vouch:denouncedAuthor is explicitly blocked by the VOUCHED list.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions