feat(runner): preserve editable question defaults through explicit response

Carry bounded text-only initialText through ACP normalization, Rust durable validation, shared contracts and the question UI. Saved edits and explicit clears survive reload. Preserve canonical submitted text while retaining legacy custom-answer normalization and existing validation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-29 12:06:48 -05:00
1 parent 0f04bf454d
commit 012aa52c77
23 files changed
+373 -9

No files matched your search

+2
View File
@@ -611,6 +611,8 @@ export interface PaperclipQuestion {
helpText?: string;
required: boolean;
answerMode: "single_select" | "multi_select" | "text";
/** Editable starting text, never an implicit or submitted answer. Text mode only. */
initialText?: string;
options?: PaperclipQuestionOption[];
customAnswer?: { enabled: true; label?: string; placeholder?: string };
textValidation?: {
@@ -107,6 +107,18 @@ Codex answer objects, OpenCode answer arrays, and ACP typed content exist only
inside their adapters; `origin` may retain the provider method and adapter name
for diagnostics but never provider response data.
Text-mode questions may include `initialText` (at most 100,000 UTF-16 code units,
within the existing 196 KiB question-set bound). It supplies an editable starting
draft, including empty text and whitespace. ACP plain-string `default` values map
without trimming; numeric defaults become editable numeric text. Select questions
do not accept this field. The UI preserves saved drafts and existing responses
ahead of provider defaults. Initial text never submits or resolves a request:
the operator must explicitly submit, and all existing response validation still
applies. Canonical text answers retain submitted whitespace and literal escape
sequences; legacy and select custom answers retain their existing normalization.
Required blank answers remain invalid. Redaction, company scope and durable
replay rules remain in force.
Question and option order is significant, while answers are keyed by stable
question IDs and selections reference stable option IDs. The canonical modes
are `text`, `single_select`, and `multi_select`. Text validation is repeated at
@@ -85,7 +85,7 @@
{
"path": "schemas/question-set.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json",
"sha256": "6aeb2b880a60356cb48acfe26605acd9412a1df65f5308397d3ec521db5ea70f"
"sha256": "85f698e13343319f140e1afa5af16f41abfcba7baae182baffaf45215a31e7a4"
},
{
"path": "schemas/request.schema.json",
@@ -60,6 +60,7 @@
"helpText": { "type": "string", "maxLength": 4000 },
"required": { "type": "boolean" },
"answerMode": { "enum": ["single_select", "multi_select", "text"] },
"initialText": { "type": "string", "maxLength": 100000 },
"options": {
"type": "array",
"maxItems": 128,
@@ -80,6 +81,7 @@
"if": { "properties": { "answerMode": { "enum": ["single_select", "multi_select"] } }, "required": ["answerMode"] },
"then": {
"required": ["options"],
"not": { "required": ["initialText"] },
"properties": { "options": { "type": "array", "minItems": 1 } }
}
}
@@ -380,7 +380,7 @@ fn sanitize_question_set(mut value: Value) -> Result<Value, LocalRunnerError> {
};
// IDs, answer modes, and validation patterns are protocol values:
// changing them would break response correlation or semantics.
redact_object_text(question, &["header", "prompt", "helpText"]);
redact_object_text(question, &["header", "prompt", "helpText", "initialText"]);
if let Some(options) = question.get_mut("options").and_then(Value::as_array_mut) {
for option in options {
if let Some(option) = option.as_object_mut() {
@@ -617,6 +617,15 @@ pub(crate) fn validate_question_set(value: &Value) -> Result<(), LocalRunnerErro
"ACPX input question ids must be unique",
));
}
if question
.get("initialText")
.and_then(Value::as_str)
.is_some_and(|text| text.encode_utf16().count() > 100_000)
{
return Err(LocalRunnerError::invalid(
"ACPX initial text exceeds its UTF-16 bound",
));
}
let mut option_ids = BTreeSet::new();
for option in question
.get("options")
@@ -3145,6 +3145,30 @@ mod tests {
);
}
#[test]
fn durable_question_initial_text_survives_serialized_reload_without_resolution() {
let mut config = config(PathBuf::from("unused"));
config.max_outbox_bytes = 512 * 1024;
config.max_frame_bytes = 256 * 1024;
let mut state = DurableState::new(&config);
let text = " Editable draft 漢字\n".repeat(1_000);
state.enqueue_event(&config, "runtime_request.created", EventPriority::P0, json!({
"request": {"schema":"paperclip.runtime_request.v2", "requestKind":"runtime", "requestId":"edit-1",
"type":"input", "status":"pending", "input":{"schema":"paperclip.question_set.v1", "questions":[{
"id":"draft", "prompt":"Edit", "answerMode":"text", "required":true, "initialText":text
}]}}
})).unwrap();
let reloaded: DurableState =
serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
let request = reloaded.outbox[0]
.envelope
.pointer("/payload/payload/request")
.unwrap();
assert_eq!(request["input"]["questions"][0]["initialText"], json!(text));
assert_eq!(request["status"], "pending");
assert!(request.get("response").is_none());
}
#[test]
fn durable_question_sets_preserve_safe_identity_and_redact_display_text() {
let config = config(PathBuf::from("unused"));
@@ -272,6 +272,15 @@ fn validate_text_constraints(
}
fn validate_persisted_question(question: &Value) -> Result<(), LocalRunnerError> {
if question
.get("initialText")
.and_then(Value::as_str)
.is_some_and(|text| javascript_string_length(text) > MAX_QUESTION_ANSWER_CODE_UNITS)
{
return Err(LocalRunnerError::invalid(
"persisted initial text exceeds its UTF-16 bound",
));
}
let question_id = question
.get("id")
.and_then(Value::as_str)
@@ -406,3 +406,64 @@ fn rejects_payloads_before_decoding_when_scope_or_size_is_invalid() {
let error = decode_acpx_event(&scope, &oversized).unwrap_err();
assert!(error.to_string().contains("256 KiB"));
}
#[test]
fn input_initial_text_survives_wire_admission_with_existing_redaction() {
let scope = active_scope();
let input = |initial: Value, mode: &str| {
json!({"requestId":"request-1","questionSet":{
"schema":"paperclip.question_set.v1","questions":[{"id":"draft","prompt":"Edit", "required":true,
"answerMode":mode,"initialText":initial}]
}})
};
let text = " Draft 漢字\n".repeat(1_000);
let decoded = decode_acpx_event(
&scope,
&event(
GeneratedAcpxSidecarEventType::RuntimeInputRequested,
input(json!(text), "text"),
),
)
.unwrap();
match decoded {
AcpxEventPayload::InputRequested { question_set, .. } => {
assert_eq!(question_set["questions"][0]["initialText"], json!(text))
}
_ => panic!("input request expected"),
}
let decoded = decode_acpx_event(
&scope,
&event(
GeneratedAcpxSidecarEventType::RuntimeInputRequested,
input(json!("token=private-prefill"), "text"),
),
)
.unwrap();
match decoded {
AcpxEventPayload::InputRequested { question_set, .. } => {
assert_eq!(
question_set["questions"][0]["initialText"],
"token=[REDACTED]"
);
assert!(question_set["description"]
.as_str()
.unwrap()
.contains("redacted"));
}
_ => panic!("input request expected"),
}
for invalid in [
input(json!(1), "text"),
input(json!("a".repeat(100_001)), "text"),
input(json!("default"), "single_select"),
] {
assert!(decode_acpx_event(
&scope,
&event(
GeneratedAcpxSidecarEventType::RuntimeInputRequested,
invalid
)
)
.is_err());
}
}
@@ -318,3 +318,18 @@ fn rejects_malformed_or_oversized_response_envelopes() {
code_unit_bounded["answers"]["notes"] = json!({"text":"😀".repeat(50_001)});
assert!(validate_question_response(&unconstrained_set, &code_unit_bounded).is_err());
}
#[test]
fn initial_text_is_never_an_implicit_answer_or_validation_bypass() {
let mut questions = question_set();
questions["questions"][2]["initialText"] = json!("YES");
validate_question_response(&questions, &valid_response()).unwrap();
let mut absent = valid_response();
absent["answers"].as_object_mut().unwrap().remove("notes");
assert!(validate_question_response(&questions, &absent).is_err());
let mut invalid = valid_response();
invalid["answers"]["notes"] = json!({"text":"bad"});
assert!(validate_question_response(&questions, &invalid).is_err());
questions["questions"][2]["initialText"] = json!("😀".repeat(50_001));
assert!(validate_question_response(&questions, &valid_response()).is_err());
}
@@ -51,6 +51,20 @@ describe("Paperclip question-set contract", () => {
});
});
it("preserves bounded initial text only as presentation, including empty text", () => {
for (const initialText of ["", " Draft\n漢字\n", "a".repeat(100_000)]) {
const input = { schema: PAPERCLIP_QUESTION_SET_SCHEMA, questions: [{ id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText }] };
expect(parsePaperclipQuestionSet(input)).toEqual(input);
expect(() => parsePaperclipQuestionResponse(input, { schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, answers: {} })).toThrow(/required/);
}
for (const initialText of [null, 1, "a".repeat(100_001), "😀".repeat(50_001)]) {
expect(() => parsePaperclipQuestionSet({ ...questionSet, questions: [{ ...questionSet.questions[1], initialText }] })).toThrow(/initialText/);
}
expect(() => parsePaperclipQuestionSet({ ...questionSet, questions: [{ ...questionSet.questions[0], initialText: "staging" }] })).toThrow(/only text/);
const input = { ...questionSet, questions: [{ ...questionSet.questions[1], initialText: "21" }] };
expect(() => parsePaperclipQuestionResponse(input, { schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, answers: { replicas: { text: "21" } } })).toThrow(/at most 20/);
});
it("rejects missing, unknown, and provider-shaped answers", () => {
expect(() => parsePaperclipQuestionResponse(questionSet, {
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
@@ -33,6 +33,8 @@ export interface PaperclipQuestion {
helpText?: string;
required: boolean;
answerMode: PaperclipQuestionAnswerMode;
/** Editable starting text, never an implicit or submitted answer. Text mode only. */
initialText?: string;
options?: PaperclipQuestionOption[];
customAnswer?: PaperclipQuestionCustomAnswer;
textValidation?: PaperclipQuestionTextValidation;
@@ -182,6 +184,10 @@ export function parsePaperclipQuestionSet(value: unknown): PaperclipQuestionSet
if (answerMode === "text" && options !== undefined && options.length > 0) {
throw new PaperclipQuestionValidationError(`${path}/options`, "text questions cannot define options");
}
const initialText = optionalText(question.initialText, `${path}/initialText`, 100_000);
if (initialText !== undefined && answerMode !== "text") {
throw new PaperclipQuestionValidationError(`${path}/initialText`, "only text questions can define initial text");
}
const custom = record(question.customAnswer);
const customAnswer = custom === null
? undefined
@@ -252,6 +258,7 @@ export function parsePaperclipQuestionSet(value: unknown): PaperclipQuestionSet
: {}),
required: question.required,
answerMode,
...(initialText !== undefined ? { initialText } : {}),
...(options !== undefined ? { options } : {}),
...(customAnswer !== undefined ? { customAnswer } : {}),
...(textValidation !== undefined ? { textValidation } : {}),
@@ -201,3 +201,24 @@ describe("ACP form question adapter", () => {
expect(JSON.stringify(response.content)).toBe('{"__proto__":"safe"}');
});
});
describe("ACP initial text", () => {
it.each(["", " Draft\n漢字\n"])("keeps exact string defaults through canonical parsing and explicit submission", initialText => {
const form = normalizeAcpFormElicitation({ mode: "form", requestedSchema: { type: "object", properties: { answer: { type: "string", default: initialText } }, required: ["answer"] } })!;
const question = form.questionSet.questions[0]!;
expect(question.initialText).toBe(initialText);
expect(() => form.accept({ schema: "paperclip.question_response.v1", answers: {} })).toThrow(/required/);
const edited = `${initialText}Edited`;
expect(form.accept({ schema: "paperclip.question_response.v1", answers: { [question.id]: { text: edited } } })).toEqual({ action: "accept", content: { answer: edited } });
});
it("bounds defaults, rejects wrong types, and keeps response constraints authoritative", () => {
const request = (property: unknown) => ({ mode: "form", requestedSchema: { type: "object", properties: { answer: property }, required: ["answer"] } });
expect(() => normalizeAcpFormElicitation(request({ type: "string", default: "x".repeat(100_001) }))).toThrow(/initialText/);
expect(() => normalizeAcpFormElicitation(request({ type: "string", default: 3 }))).toThrow(/incompatible/);
const number = normalizeAcpFormElicitation(request({ type: "integer", default: 0, minimum: 1 }))!;
expect(number.questionSet.questions[0]!.initialText).toBe("0");
expect(() => number.accept({ schema: "paperclip.question_response.v1", answers: { [number.questionSet.questions[0]!.id]: { text: "0" } } })).toThrow(/at least 1/);
expect(normalizeAcpFormElicitation(request({ type: "string", enum: ["a", "b"], default: "a" }))!.questionSet.questions[0]).not.toHaveProperty("initialText");
});
});
@@ -148,6 +148,7 @@ function normalizeField(
question: {
...base,
answerMode: "text",
...(property.default !== undefined ? { initialText: initialText(property.default, type) } : {}),
textValidation: {
inputType: "text",
...(minLength !== undefined ? { minLength } : {}),
@@ -168,6 +169,7 @@ function normalizeField(
question: {
...base,
answerMode: "text",
...(property.default !== undefined ? { initialText: initialText(property.default, type) } : {}),
textValidation: {
inputType: type,
...(minimum !== undefined ? { minimum } : {}),
@@ -376,3 +378,10 @@ function finiteNonNegativeInteger(value: unknown): number | undefined {
? Number(value)
: undefined;
}
/** Defaults are editable presentation, not acceptance or native response content. */
function initialText(value: unknown, type: string): string {
if (type === "string" && typeof value === "string") return value;
if ((type === "number" || type === "integer") && typeof value === "number" && Number.isFinite(value)) return String(value);
throw new Error(`ACP ${type} text field default has an incompatible type`);
}
@@ -2843,6 +2843,10 @@ export const questionSetSchema = {
"text"
]
},
"initialText": {
"type": "string",
"maxLength": 100000
},
"options": {
"type": "array",
"maxItems": 128,
@@ -2901,6 +2905,11 @@ export const questionSetSchema = {
"required": [
"options"
],
"not": {
"required": [
"initialText"
]
},
"properties": {
"options": {
"type": "array",
@@ -4,4 +4,4 @@
import equalModule from "ajv/dist/runtime/equal.js";
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
"use strict";export const fixtureValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"enum":[1,2]},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"}]},"commands":{"type":"array","items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/command.schema.json"}]}},"events":{"type":"array","minItems":1,"items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/event.schema.json"}]}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema46 = {"$schema":"https://json-schema.org/draft/2020-1 Line truncated
"use strict";export const fixtureValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"enum":[1,2]},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"}]},"commands":{"type":"array","items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/command.schema.json"}]}},"events":{"type":"array","minItems":1,"items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/event.schema.json"}]}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema46 = {"$schema":"https://json-schema.org/draft/2020-1 Line truncated
+2
View File
@@ -1192,6 +1192,8 @@ export interface PaperclipQuestionSetQuestion {
helpText?: string;
required: boolean;
answerMode: "single_select" | "multi_select" | "text";
/** Editable starting text, never an implicit or submitted answer. Text mode only. */
initialText?: string;
options?: PaperclipQuestionSetOption[];
customAnswer?: {
enabled: true;
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { MAX_ISSUE_REQUEST_DEPTH } from "../index.js";
import {
addIssueCommentSchema,
paperclipQuestionSetPayloadSchema,
issueCommentMetadataSchema,
createIssueSchema,
issueBlockedInboxAttentionSchema,
@@ -632,3 +633,20 @@ describe("issue validators", () => {
expect(parsed.success).toBe(false);
});
});
describe("persisted canonical question initial text", () => {
const question = { id: "draft", prompt: "Edit", required: true, answerMode: "text" };
const input = (patch: Record<string, unknown>) => ({ schema: "paperclip.question_set.v1", questions: [{ ...question, ...patch }] });
it("retains exact editable content through the server payload validator", () => {
for (const initialText of ["", " Draft\n漢字\n", "x".repeat(100_000)]) {
expect(paperclipQuestionSetPayloadSchema.parse(input({ initialText }))).toEqual(input({ initialText }));
}
});
it("rejects non-text modes and unbounded or nonstring defaults", () => {
for (const initialText of [null, 1, "x".repeat(100_001), "😀".repeat(50_001)]) {
expect(paperclipQuestionSetPayloadSchema.safeParse(input({ initialText })).success).toBe(false);
}
expect(paperclipQuestionSetPayloadSchema.safeParse(input({ answerMode: "single_select", options: [{ id: "a", label: "A" }], initialText: "a" })).success).toBe(false);
});
});
+7 -2
View File
@@ -1251,6 +1251,7 @@ const paperclipQuestionSchema = z
helpText: z.string().max(4000).optional(),
required: z.boolean(),
answerMode: z.enum(["single_select", "multi_select", "text"]),
initialText: z.string().max(100000).optional(),
options: z.array(paperclipQuestionOptionSchema).max(128).optional(),
customAnswer: z
.object({
@@ -1271,6 +1272,9 @@ const paperclipQuestionSchema = z
.optional(),
})
.superRefine((value, ctx) => {
if (value.initialText !== undefined && value.answerMode !== "text") {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "only text questions can define initial text", path: ["initialText"] });
}
if (
value.answerMode === "text" &&
value.options &&
@@ -1419,8 +1423,9 @@ export const askUserQuestionsPayloadSchema = z
export const askUserQuestionsAnswerSchema = z.object({
questionId: z.string().trim().min(1).max(160),
optionIds: z.array(z.string().trim().min(1).max(160)).max(129),
otherText: multilineTextSchema
.pipe(z.string().trim().max(100000))
// The persisted question determines whether this is exact editor text or a
// legacy custom answer; normalize only after that trusted context is known.
otherText: z.string().max(100000)
.nullable()
.optional(),
});
@@ -1,3 +1,4 @@
import { normalizeEscapedLineBreaks } from "@paperclipai/shared/validators/text";
import {
currentContinuationOrigins,
deliveredContinuationCommentIds,
@@ -1668,6 +1669,7 @@ function resolveRequestItemVerdictSubmissions(args: {
function normalizeQuestionAnswers(args: {
questions: AskUserQuestionsInteraction["payload"]["questions"];
textQuestionIds?: ReadonlySet<string>;
answers: RespondIssueThreadInteraction["answers"];
}) {
const questionById = new Map(
@@ -1702,7 +1704,11 @@ function normalizeQuestionAnswers(args: {
);
}
const otherText = answer.otherText?.trim() ?? "";
// Canonical text fields may contain code or intentional whitespace. The
// legacy custom-answer path keeps its historical newline/trim behavior.
const otherText = args.textQuestionIds?.has(answer.questionId)
? answer.otherText ?? ""
: normalizeEscapedLineBreaks(answer.otherText ?? "").trim();
answerByQuestionId.set(answer.questionId, {
questionId: answer.questionId,
optionIds: uniqueOptionIds,
@@ -1714,7 +1720,7 @@ function normalizeQuestionAnswers(args: {
const answer = answerByQuestionId.get(question.id);
if (
question.required &&
(!answer || (answer.optionIds.length === 0 && !answer.otherText))
(!answer || (answer.optionIds.length === 0 && !answer.otherText?.trim()))
) {
throw unprocessable(`Question ${question.id} requires an answer`);
}
@@ -4845,6 +4851,9 @@ export function issueThreadInteractionService(
) as AskUserQuestionsInteraction;
const normalizedAnswers = normalizeQuestionAnswers({
questions: interaction.payload.questions,
textQuestionIds: new Set((interaction.payload.questionSet?.questions ?? [])
.filter((question) => question.answerMode === "text")
.map((question) => question.id)),
answers: input.answers,
});
@@ -20,6 +20,7 @@ import {
nativeRunFinalizations,
} from "@paperclipai/db";
import type { PrpEvent } from "@paperclipai/paperclip-runner";
import { respondIssueThreadInteractionSchema } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
@@ -403,6 +404,54 @@ describeEmbeddedPostgres("native question bridge", () => {
release();
});
it.each(["legacy", "canonical_select"])("keeps %s custom-answer normalization and rejects blank public answers", async (mode) => {
await seed();
const event = runtimeRequestEvent();
const input = (event.payload.request as { input: { questions: Record<string, unknown>[] } }).input;
input.questions[0]!.customAnswer = { enabled: true };
const interaction = await projectNativeRuntimeRequest({ db, binding: binding(), event });
if (mode === "legacy") {
const payload = { ...interaction!.payload } as Record<string, unknown>;
delete payload.questionSet;
await db.update(issueThreadInteractions).set({ payload }).where(eq(issueThreadInteractions.id, interaction!.id));
}
const service = issueThreadInteractionService(db);
await expect(service.answerQuestions({ id: issueId, companyId, status: "in_progress" }, interaction!.id,
respondIssueThreadInteractionSchema.parse({ answers: [{ questionId: "color", optionIds: [], otherText: " \n " }] }), { userId: "operator-1" },
)).rejects.toThrow(/requires an answer/);
const answered = await service.answerQuestions({ id: issueId, companyId, status: "in_progress" }, interaction!.id,
respondIssueThreadInteractionSchema.parse({ answers: [{ questionId: "color", optionIds: [], otherText: " Extra\\nline " }] }), { userId: "operator-1" },
);
expect(answered.result).toMatchObject({ answers: [{ questionId: "color", otherText: "Extra\nline" }] });
});
it("persists initial text without resolving and delivers only an explicit edited answer", async () => {
await seed();
const initialText = " Editable draft\n漢字\n";
const event = runtimeRequestEvent();
const request = event.payload.request as Record<string, unknown>;
request.input = { schema: "paperclip.question_set.v1", questions: [{ id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText }] };
const interaction = await projectNativeRuntimeRequest({ db, binding: binding(), event });
expect(interaction).toMatchObject({ status: "pending", payload: { questionSet: { questions: [{ initialText }] } } });
const [stored] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction!.id));
expect(stored).toMatchObject({ status: "pending", result: null, payload: { questionSet: { questions: [{ initialText }] } } });
const queueCommand = vi.fn(() => ({ commandId: "edited", controllerSeq: 1 }));
const release = registerNativeQuestionCommandTarget({ binding: { companyId, issueId, runId, agentId }, queueCommand });
try {
await flushNativeQuestionResponses(db, runId);
expect(queueCommand).not.toHaveBeenCalled();
const edited = "\n Operator 漢字 edited\n\nLiteral \\n stays literal. \n";
await issueThreadInteractionService(db).answerQuestions(
{ id: issueId, companyId, status: "in_progress" }, interaction!.id,
respondIssueThreadInteractionSchema.parse({ answers: [{ questionId: "draft", optionIds: [], otherText: edited }] }), { userId: "operator-1" },
);
await flushNativeQuestionResponses(db, runId);
expect(queueCommand).toHaveBeenCalledExactlyOnceWith("request.resolve", {
requestId: "request-1", response: { schema: "paperclip.question_response.v1", answers: { draft: { text: edited } } },
}, `question_${interaction!.id}`);
} finally { release(); }
});
it("binds projection to the persisted native run and ignores legacy delivery", async () => {
await seed();
const mismatched = runtimeRequestEvent();
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PaperclipQuestionResponse, PaperclipQuestionSet } from "@paperclipai/adapter-utils";
import { QuestionForm } from "./QuestionForm";
vi.mock("./TaskChatRichInput", () => ({
TaskChatRichInput: ({ value, onChange }: { value: string; onChange(value: string): void }) =>
<textarea aria-label="Answer" value={value} onChange={event => onChange(event.target.value)} />,
}));
vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) => <div>{children}</div> }));
const questionSet: PaperclipQuestionSet = { schema: "paperclip.question_set.v1", questions: [
{ id: "draft", prompt: "Edit the draft", required: true, answerMode: "text", initialText: " Draft\n漢字\n" },
] };
describe("QuestionForm initial text", () => {
let container: HTMLDivElement;
let root: Root;
const submit = vi.fn();
beforeEach(() => { localStorage.clear(); submit.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); });
afterEach(async () => { await act(async () => root.unmount()); container.remove(); localStorage.clear(); });
const input = () => container.querySelector("textarea")!;
async function render(set = questionSet, initialResponse?: PaperclipQuestionResponse) {
await act(async () => root.render(<QuestionForm id="edit" questionSet={set} initialResponse={initialResponse} draftKey="question-initial-text" onSubmit={submit} />));
}
async function edit(text: string) {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(input(), text);
input().dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function remount(set = questionSet) { await act(async () => root.unmount()); root = createRoot(container); await render(set); }
it("shows exact editable text without sending until the operator submits", async () => {
await render();
expect(input().value).toBe(" Draft\n漢字\n");
expect(submit).not.toHaveBeenCalled();
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Submit answers")!.click());
expect(submit).toHaveBeenCalledExactlyOnceWith({ schema: "paperclip.question_response.v1", answers: { draft: { text: " Draft\n漢字\n" } } });
});
it("preserves edits through a refreshed request and reload instead of reapplying a default", async () => {
await render(); await edit("Operator edit\n");
const refreshed = { ...questionSet, questions: [{ ...questionSet.questions[0]!, initialText: "New provider default" }] };
await render(refreshed); expect(input().value).toBe("Operator edit\n");
await remount(refreshed); expect(input().value).toBe("Operator edit\n");
expect(submit).not.toHaveBeenCalled();
});
it("retains an explicitly cleared draft and required validation across reload", async () => {
await render(); await edit(""); await remount();
expect(input().value).toBe("");
expect(Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Submit answers")!.disabled).toBe(true);
expect(submit).not.toHaveBeenCalled();
});
it("prefers an existing response and keeps text constraints on defaults", async () => {
await render(questionSet, { schema: "paperclip.question_response.v1", answers: { draft: { text: "Existing answer" } } });
expect(input().value).toBe("Existing answer");
await act(async () => root.unmount()); localStorage.clear(); root = createRoot(container);
await render({ ...questionSet, questions: [{ ...questionSet.questions[0]!, initialText: "bad", textValidation: { minLength: 5 } }] });
expect(input().value).toBe("bad");
expect(Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Submit answers")!.disabled).toBe(true);
expect(submit).not.toHaveBeenCalled();
});
});
+10 -2
View File
@@ -243,6 +243,14 @@ export function QuestionForm({
onCancel,
}: QuestionFormProps) {
const takeoverActions = useTaskChatComposerTakeoverActions();
// A provider default is an editable draft only. An existing response or
// saved draft, including an explicitly cleared value, always wins. State
// initialization runs once so request refreshes cannot replace user edits.
const startingAnswers = initialResponse?.answers ?? Object.fromEntries(
questionSet.questions
.filter((question) => question.answerMode === "text" && question.initialText !== undefined)
.map((question) => [question.id, { text: question.initialText! }]),
);
const initialDraft = draftKey
? loadStructuredDraft<{
page: number;
@@ -250,7 +258,7 @@ export function QuestionForm({
customActive: Record<string, boolean>;
}>(draftKey, {
page: 0,
answers: structuredClone(initialResponse?.answers ?? {}),
answers: structuredClone(startingAnswers),
customActive: Object.fromEntries(
Object.entries(initialResponse?.answers ?? {})
.filter(([, answer]) => Boolean(answer.customText))
@@ -261,7 +269,7 @@ export function QuestionForm({
const [page, setPage] = useState(initialDraft?.page ?? 0);
const [answers, setAnswers] = useState<Record<string, Answer>>(
() =>
initialDraft?.answers ?? structuredClone(initialResponse?.answers ?? {}),
initialDraft?.answers ?? structuredClone(startingAnswers),
);
const [customActive, setCustomActive] = useState<Record<string, boolean>>(
() =>
@@ -120,3 +120,18 @@ export const AnswersSubmitted: Story = {
await expect(canvas.getByText("Sign in, Search", { exact: true })).toBeVisible();
},
};
function EditableDraftQuestion() {
const [response, setResponse] = useState<PaperclipQuestionResponse | null>(null);
const questionSet: PaperclipQuestionSet = { schema: "paperclip.question_set.v1", questions: [{
id: "draft", prompt: "Edit the release note", required: true, answerMode: "text",
initialText: "Draft release note\n\nDescribe what changed and how it was verified.",
}] };
return response
? <QuestionResponseSummary questionSet={questionSet} response={response} />
: <QuestionForm id="editable-draft" draftKey="storybook:editable-initial-text" questionSet={questionSet} onSubmit={setResponse} />;
}
/** Provider-supplied starting text stays editable and needs explicit submission. */
export const EditableInitialText: Story = { render: () => <EditableDraftQuestion /> };