Skip to content

[Bug]: Codex question answers disappear when the question ID contains surrounding whitespace #35850

Closed

Description

@coygeek

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

  1. Use a clean checkout of the commit in Version. No package installation or credentials are required.
  2. 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.
  3. 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

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions