Repository navigation
Description
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
- Use a checkout at the commit below with its locked dependencies installed.
- 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.
- 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 2Expected 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
- [Bug]: Claude approval stays pending after the CLI withdraws its request (PermissionRequest hook, Auto mode) #24690 — related withdrawn approval in Claude; its SDK abort path differs from the Codex app-server notification reproduced here.
- fix(claude): approval cards close when Claude withdraws the request #27320 — fixes that Claude callback-abort path; does not add cancelled events to the Codex elicitation wait.
- [Bug]: Command approvals stay pending after a delegated task is cancelled #21935 — related cancellation after a run is interrupted; this reproduction keeps the turn active and withdraws one request only, so it does not depend on the terminal-event ordering described there.
- fix(codex): approvals still appear after many stopped Codex turns #23234 — releases native request-handler slots when Codex resolves a request; the reproduction confirms that native cleanup occurs, while T3's published approval remains pending.
Activity
maria-rcks commentedon Nov 15, 2026
Note
Written by
claude-fable-5-2on behalf of MariaClosing: this account is denounced in
.github/VOUCHED.tdfor filing automated issues faster than maintainers can triage them. A fix PR that links this issue is not affected by this close.