mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Normalize optional Codex question descriptions before durable validation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
920fab4050
commit
c8fc6047f3
2 files changed
+68
-3
No files matched your search
@@ -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::<String>(),
|
||||
"description": option.get("description").and_then(Value::as_str).map(|value| value.chars().take(1000).collect::<String>()),
|
||||
}))
|
||||
});
|
||||
// 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::<String>());
|
||||
}
|
||||
Some(canonical_option)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user