Repository navigation
Description
Summary
A Codex question with whitespace around its identifier is shown under a different identifier, and its submitted answer is silently discarded before the response returns to Codex. Answers must remain associated with the exact native question identifier. This was established by tracing the supported request handler and running the production question mapping and answer conversion in isolation, with an ordinary identifier as a passing control.
Before submitting
- Related issues: no matching report found in focused open and closed issue and pull-request searches; related lifetime work is listed under Additional context.
- Removed credentials, tokens, private paths, and sensitive data.
Area
apps/server
Actual behavior
Source tracing shows that a valid item/tool/requestUserInput server request with a question ID of " choice " publishes a question with ID "choice". A client answering the published question supplies { "choice": ["A"] }. The reply converter then filters against the original ID set containing " choice ", so the returned payload is { "answers": {} } instead of { "answers": { " choice ": { "answers": ["A"] } } }.
The isolated production-code execution reproduced that exact mapping and empty answer object. No app-server process, browser, mobile app, or inference turn was run; the complete request-to-UI interaction is source-traced rather than an end-to-end runtime capture.
Steps to reproduce
- Use a clean checkout of the commit in Version. No package installation or credentials are required.
- Run the script below, which reads the existing adapter source and executes its unchanged
trimText,nonEmptyText,answerValueToStrings,toCodexUserInputAnswers, and question-mapping expressions in a JavaScript VM. - Observe that the ordinary-ID control returns its answer, while the padded-ID case publishes a trimmed ID and returns no answer.
The script ran once: the ordinary-ID control passed and the padded-ID case reproduced the defect. This verifies the production transformations; adapter event transport and rendered clients were not executed.
Save the script as repro-question-id.mjs and run node repro-question-id.mjs /path/to/t3code. Prerequisites are Node with node:module.stripTypeScriptTypes support and Git. It prints the tool versions and checkout revision, puts HOME, TMPDIR, CODEX_HOME, XDG configuration/data, and T3 state under disposable scratch, removes that scratch, and leaves the checkout unchanged. Exit 1 means the defect was reproduced, 0 means the expected answer survived, and 2 means setup or the control did not establish a result.
Reproduction script: repro-question-id.mjs
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import { stripTypeScriptTypes } from 'node:module';
import { execFileSync } from 'node:child_process';
let result = 'inconclusive';
let status = 2;
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-question-id-'));
try {
const checkout = path.resolve(process.argv[2] ?? '');
for (const key of ['HOME', 'TMPDIR', 'CODEX_HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'T3CODE_HOME']) {
const dir = path.join(scratch, key.toLowerCase());
fs.mkdirSync(dir);
process.env[key] = dir;
}
console.log(`Node ${process.version}; ${process.platform} ${process.arch}`);
console.log(execFileSync('git', ['--version'], {encoding: 'utf8'}).trim());
console.log(`T3 Code ${execFileSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], {encoding: 'utf8'}).trim()}`);
const relative = 'apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts';
const source = fs.readFileSync(path.join(checkout, relative), 'utf8');
function extract(start, end) {
const a = source.indexOf(start);
const b = source.indexOf(end, a + start.length);
if (a < 0 || b < 0) throw new Error(`Source boundary changed: ${start}`);
return source.slice(a, b);
}
const helpers = extract('function trimText(', '\nfunction codexPlanStepStatus(')
+ extract('function answerValueToStrings(', '\nfunction compactStrings(');
const mapping = extract(' const questions = input.questions.map(', '\n const nodeId =');
const context = vm.createContext({});
vm.runInContext(stripTypeScriptTypes(helpers + '\nfunction mapQuestions(input) {\n' + mapping + '\nreturn questions;\n}'), context);
const toQuestion = id => ({id, header: 'Choice', question: 'Which option?', options: [{label: 'A', description: 'Option A'}]});
function roundTrip(id) {
const questions = context.mapQuestions({questions: [toQuestion(id)]});
const supplied = {[questions[0].id]: ['A']};
const returned = context.toCodexUserInputAnswers(supplied, new Set([id]));
return {id, projectedId: questions[0].id, supplied, returned};
}
const control = roundTrip('choice');
console.log(`CONTROL ${JSON.stringify(control)}`);
if (JSON.stringify(control.returned) !== JSON.stringify({choice: {answers: ['A']}})) {
throw new Error('Ordinary-ID control failed');
}
const observed = roundTrip(' choice ');
console.log(`PADDED_ID ${JSON.stringify(observed)}`);
if (JSON.stringify(observed.returned) === JSON.stringify({' choice ': {answers: ['A']}})) {
result = 'expected behavior'; status = 0;
} else if (observed.projectedId === 'choice' && Object.keys(observed.returned).length === 0) {
result = 'defect reproduced'; status = 1;
}
} catch (error) {
console.log(`SETUP ${error.message}`);
} finally {
fs.rmSync(scratch, {recursive: true, force: true});
}
// Result lines are RESULT: defect reproduced, RESULT: expected behavior, or RESULT: inconclusive.
console.log(`RESULT: ${result}`);
process.exitCode = status;Expected behavior
Each submitted answer must return under the exact native question ID, even when T3 uses a normalized identifier internally. Codex defines the ID as an unrestricted string and the response as a map keyed by those strings in codex-rs/app-server-protocol/src/protocol/v2/item.rs#L1838-L1912. The same string/map contract exists in the tagged generator and managed-runtime baselines inspected for this investigation.
The padded ID is reachable from the model-facing tool: upstream codex-rs/core/src/tools/handlers/request_user_input_spec.rs#L105-L120 validates that options exist and enables the custom-answer option without trimming IDs. A root-thread question with one nonempty option therefore satisfies that normalization step.
Impact
Minor bug or occasional failure
Impact: P2 - A supported question identifier can cause a submitted answer to be silently lost at the provider boundary. The observed failure is bounded to identifiers changed by T3's normalization; an ordinary identifier worked in the control.
The affected source path is shared by web, desktop, and mobile. Rendered client behavior and the frequency of models producing such identifiers are NOT_ENOUGH_INFO - no clients or real model turns were exercised. Workaround: using an unpadded identifier works in the control, but an end user's ability to force the native model to choose one is NOT_ENOUGH_INFO - that recovery was not exercised.
Version or commit
T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93. Protocol references were compared at Codex tags rust-v0.159.0, rust-v0.166.0, and rust-v0.167.2; no Codex binary or real provider was run for this reproduction.
Environment
Observed isolated runtime: Node v26.12.1, Git 2.56.0, Darwin arm64. No project dependency was installed or imported. Node prints its experimental stripTypeScriptTypes warning after executing the script.
Interface
Codex app-server item/tool/requestUserInput, shared runtime-request question artifacts, and the corresponding answer-map conversion.
Earliest failure stage
Core processing
Evidence
The script printed:
Node v26.12.1; darwin arm64
git version 2.56.0
T3 Code 59e64eef881632a23f89c4138bcf37f23d92bf93
CONTROL {"id":"choice","projectedId":"choice","supplied":{"choice":["A"]},"returned":{"choice":{"answers":["A"]}}}
PADDED_ID {"id":" choice ","projectedId":"choice","supplied":{"choice":["A"]},"returned":{}}
RESULT: defect reproduced
The Node experimental-feature warning is omitted above because it carries no reproduction result. No private paths, account data, credentials, or real conversation identifiers appear in the transcript.
The incoming request is decoded using unrestricted string question IDs at packages/effect-codex-app-server/src/_generated/schema.gen.ts#L19359-L19381. The adapter forwards those questions to its artifact builder at apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts#L6054-L6070, trims their IDs at apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts#L4559-L4568, and filters submitted keys against the original IDs at apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts#L6105-L6110.
The exact filtering operation is apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts#L669-L682. These are the production statements executed by the isolated script, not separately reimplemented normalization rules.
Restoration check
For the padded-ID input, the user-selected answer must survive under " choice "; the ordinary-ID control must continue to pass. An integrated restoration check should send the supported server request through the existing replay harness, answer the published question, and observe the outbound map keyed by the original ID.
References
- fix(server): Codex questions the agent waits on put the thread in waiting for input #26954 — concerns waiting-for-input state for Codex questions; this report concerns answer-key corruption.
- [Bug]: Withdrawn Codex MCP elicitation stays pending after its callback is removed #35804 — concerns provider withdrawal leaving stale approvals; the answer-key failure occurs while responding to a live request.
Activity