From c8fc6047f3a091c3e6a01dca748fd6f60131321c Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 12:24:56 -0500 Subject: [PATCH] Normalize optional Codex question descriptions before durable validation Co-Authored-By: Paperclip --- .../crates/runner-core/src/codex_provider.rs | 50 +++++++++++++++++-- .../src/live/runnerd-codex-transport.test.ts | 21 ++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index 549b6d88908..c9363df8b4e 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -3773,11 +3773,17 @@ fn codex_question_set( } let option_id = format!("option-{}", index + 1); labels.insert(option_id.clone(), label.chars().take(240).collect()); - Some(json!({ + let mut canonical_option = json!({ "id": option_id, "label": label.chars().take(240).collect::(), - "description": option.get("description").and_then(Value::as_str).map(|value| value.chars().take(1000).collect::()), - })) + }); + // Native descriptions are optional/nullable. Canonical input + // permits an omitted description or a string, never null. + if let Some(description) = option.get("description").and_then(Value::as_str) { + canonical_option["description"] = + json!(description.chars().take(1000).collect::()); + } + Some(canonical_option) }) .collect::>(); if !options.is_empty() && canonical_options.len() != options.len() { @@ -4593,6 +4599,44 @@ done ); } + #[test] + fn codex_optional_option_descriptions_produce_schema_valid_resolvable_input() { + let (_, question_set, option_labels) = codex_question_set( + &json!(42), + &json!({"questions":[{ + "id":"environment", "question":"Where?", + "options":[ + {"label":"Staging"}, + {"label":"Production", "description":null}, + {"label":"Preview", "description":"Temporary deployment"} + ] + }]}), + ) + .unwrap(); + let options = question_set["questions"][0]["options"].as_array().unwrap(); + assert!(options[0].get("description").is_none()); + assert!(options[1].get("description").is_none()); + assert_eq!(options[2]["description"], "Temporary deployment"); + let pending = PendingRuntimeRequest { + rpc_id: json!(42), + turn_id: "turn-1".to_owned(), + method: "item/tool/requestUserInput".to_owned(), + params: Value::Null, + question_set, + option_labels, + retained_bytes: 0, + }; + for (index, label) in ["Staging", "Production", "Preview"].iter().enumerate() { + // The canonical response validator also validates the retained + // question-set schema, just as durable presentation does. + let native = codex_question_response(&pending, &json!({ + "schema":"paperclip.question_response.v1", + "answers":{"environment":{"selectedOptionIds":[format!("option-{}", index + 1)]}} + })).unwrap(); + assert_eq!(native["answers"]["environment"]["answers"], json!([label])); + } + } + #[test] fn converts_codex_questions_and_responses_without_provider_leakage() { let (request_id, question_set, labels) = codex_question_set( diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 693c6b24bd3..18025ed0a4c 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -33,6 +33,7 @@ import type { } from "../protocol/replay-contract.js"; import { executeNativeSession } from "../native-session-runtime.js"; import { NativeSessionCloseUnrecoverableError } from "../contracts/native-session-backend.js"; +import { parsePaperclipQuestionSet } from "../contracts/question-set.js"; import { DurablePrpControlPlane } from "../control-plane/durable-prp-control-plane.js"; import * as durableControlPlane from "../control-plane/durable-prp-control-plane.js"; @@ -3880,6 +3881,26 @@ it("bridges a runnerd-native question into the server request handler and resolv }, }); expect(methods).toContain("turn/completed"); + // A terminal notification alone does not prove that the question reached + // durable storage or that its answer and provider suffix settled. + await bundle.transport.close(); + const runnerState = JSON.parse(await readFile( + join(stateDirectory, "runner", "runner-state.json"), "utf8", + )); + expect(runnerState.lifecycle).toBe("suspended"); + const controlState = JSON.parse(await readFile( + join(stateDirectory, "control-plane", "control-plane-state.json"), "utf8", + )) as { + committedEvents: Array<{ eventType: string; envelope: { payload: { payload: { request?: { input: unknown } } } } }>; + commands: Array<{ type: string; status: string }>; + }; + const created = controlState.committedEvents.filter(event => event.eventType === "runtime_request.created"); + expect(created).toHaveLength(1); + const input = parsePaperclipQuestionSet(created[0]!.envelope.payload.payload.request!.input); + expect(input.questions[0]!.options![0]!.description).toBe("Deploy safely."); + expect(input.questions[1]!.options![0]).not.toHaveProperty("description"); + expect(controlState.commands.filter(command => command.type === "request.resolve")) + .toEqual([expect.objectContaining({ status: "completed" })]); } finally { await bundle.transport.close(); await rm(stateDirectory, { recursive: true, force: true });