mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
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:
1 parent
0f04bf454d
commit
012aa52c77
23 files changed
+373
-9
No files matched your search
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 /> };
|
||||
Reference in new issue
Block a user