mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 05:23:39 +02:00
Merge reviewed shared ACP input and continuation fixes
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/runner-acp-inputs: fix(runner): refresh settled ACPX run grants during authenticated attachment fix(runner): align editable draft bounds with Unicode schema lengths fix(ui): preserve canonical drafts in classic question cards Keep the legacy question fixture payload type-safe
This commit is contained in:
commit
482471f0c1
16 files changed
+161
-18
No files matched your search
@@ -107,7 +107,7 @@ 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,
|
||||
Text-mode questions may include `initialText` (at most 100,000 Unicode code points,
|
||||
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
|
||||
@@ -116,7 +116,10 @@ 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
|
||||
JSON Schema, TypeScript and Rust count draft Unicode code points alike. Existing
|
||||
submitted-answer limits still count UTF-16 code units; a draft can require editing
|
||||
before it satisfies those limits or field-specific constraints. 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
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
{
|
||||
"path": "schemas/question-set.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json",
|
||||
"sha256": "85f698e13343319f140e1afa5af16f41abfcba7baae182baffaf45215a31e7a4"
|
||||
"sha256": "81dc76610e40665dbc4a42e4029296dffc597ba0bbee1fe92a130dffc0c07cba"
|
||||
},
|
||||
{
|
||||
"path": "schemas/request.schema.json",
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"helpText": { "type": "string", "maxLength": 4000 },
|
||||
"required": { "type": "boolean" },
|
||||
"answerMode": { "enum": ["single_select", "multi_select", "text"] },
|
||||
"initialText": { "type": "string", "maxLength": 100000 },
|
||||
"initialText": { "type": "string", "maxLength": 100000, "description": "Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds." },
|
||||
"options": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
|
||||
@@ -620,10 +620,10 @@ pub(crate) fn validate_question_set(value: &Value) -> Result<(), LocalRunnerErro
|
||||
if question
|
||||
.get("initialText")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| text.encode_utf16().count() > 100_000)
|
||||
.is_some_and(|text| text.chars().count() > 100_000)
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX initial text exceeds its UTF-16 bound",
|
||||
"ACPX initial text exceeds its Unicode code-point bound",
|
||||
));
|
||||
}
|
||||
let mut option_ids = BTreeSet::new();
|
||||
|
||||
@@ -275,10 +275,10 @@ 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)
|
||||
.is_some_and(|text| text.chars().count() > 100_000)
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"persisted initial text exceeds its UTF-16 bound",
|
||||
"persisted initial text exceeds its Unicode code-point bound",
|
||||
));
|
||||
}
|
||||
let question_id = question
|
||||
|
||||
@@ -452,6 +452,19 @@ fn input_initial_text_survives_wire_admission_with_existing_redaction() {
|
||||
}
|
||||
_ => panic!("input request expected"),
|
||||
}
|
||||
for code_points in [100_000, 100_001] {
|
||||
let initial = format!("{}😀", "a".repeat(code_points - 1));
|
||||
let payload = input(json!(initial), "text");
|
||||
assert!(serde_json::to_vec(&payload).unwrap().len() < 196 * 1024);
|
||||
let result = decode_acpx_event(
|
||||
&scope,
|
||||
&event(
|
||||
GeneratedAcpxSidecarEventType::RuntimeInputRequested,
|
||||
payload,
|
||||
),
|
||||
);
|
||||
assert_eq!(result.is_ok(), code_points == 100_000);
|
||||
}
|
||||
for invalid in [
|
||||
input(json!(1), "text"),
|
||||
input(json!("a".repeat(100_001)), "text"),
|
||||
|
||||
@@ -330,6 +330,9 @@ fn initial_text_is_never_an_implicit_answer_or_validation_bypass() {
|
||||
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));
|
||||
questions["questions"][2]["initialText"] = json!(format!("{}😀", "a".repeat(99_999)));
|
||||
assert!(serde_json::to_vec(&questions).unwrap().len() < 196 * 1024);
|
||||
validate_question_response(&questions, &valid_response()).unwrap();
|
||||
questions["questions"][2]["initialText"] = json!(format!("{}😀", "a".repeat(100_000)));
|
||||
assert!(validate_question_response(&questions, &valid_response()).is_err());
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { questionSetSchema } from "../protocol/generated/schema-bundle.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -57,7 +59,7 @@ describe("Paperclip question-set contract", () => {
|
||||
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)]) {
|
||||
for (const initialText of [null, 1, "a".repeat(100_001), "a".repeat(100_000) + "😀"]) {
|
||||
expect(() => parsePaperclipQuestionSet({ ...questionSet, questions: [{ ...questionSet.questions[1], initialText }] })).toThrow(/initialText/);
|
||||
}
|
||||
expect(() => parsePaperclipQuestionSet({ ...questionSet, questions: [{ ...questionSet.questions[0], initialText: "staging" }] })).toThrow(/only text/);
|
||||
@@ -65,6 +67,23 @@ describe("Paperclip question-set contract", () => {
|
||||
expect(() => parsePaperclipQuestionResponse(input, { schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, answers: { replicas: { text: "21" } } })).toThrow(/at most 20/);
|
||||
});
|
||||
|
||||
it("matches the published Unicode code-point draft limit within the wire byte cap", () => {
|
||||
const validate = new Ajv2020({ strict: true, strictRequired: false }).compile(questionSetSchema);
|
||||
for (const codePoints of [100_000, 100_001]) {
|
||||
const initialText = "a".repeat(codePoints - 1) + "😀";
|
||||
const input = { schema: PAPERCLIP_QUESTION_SET_SCHEMA, questions: [{ id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText }] };
|
||||
expect(Buffer.byteLength(JSON.stringify(input))).toBeLessThan(196 * 1024);
|
||||
expect(initialText.length).toBe(codePoints + 1);
|
||||
expect(validate(input)).toBe(codePoints === 100_000);
|
||||
if (codePoints === 100_000) {
|
||||
expect(parsePaperclipQuestionSet(input)).toEqual(input);
|
||||
expect(() => parsePaperclipQuestionResponse(input, { schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, answers: { draft: { text: initialText } } })).toThrow();
|
||||
} else {
|
||||
expect(() => parsePaperclipQuestionSet(input)).toThrow(/Unicode code points/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing, unknown, and provider-shaped answers", () => {
|
||||
expect(() => parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
|
||||
@@ -184,7 +184,14 @@ 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);
|
||||
const initialText = question.initialText;
|
||||
if (initialText !== undefined && (
|
||||
typeof initialText !== "string"
|
||||
|| initialText.length > 200_000
|
||||
|| Array.from(initialText).length > 100_000
|
||||
)) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/initialText`, "must be a string of at most 100000 Unicode code points");
|
||||
}
|
||||
if (initialText !== undefined && answerMode !== "text") {
|
||||
throw new PaperclipQuestionValidationError(`${path}/initialText`, "only text questions can define initial text");
|
||||
}
|
||||
|
||||
@@ -2845,7 +2845,8 @@ export const questionSetSchema = {
|
||||
},
|
||||
"initialText": {
|
||||
"type": "string",
|
||||
"maxLength": 100000
|
||||
"maxLength": 100000,
|
||||
"description": "Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds."
|
||||
},
|
||||
"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
|
||||
@@ -643,8 +643,15 @@ describe("persisted canonical question initial text", () => {
|
||||
expect(paperclipQuestionSetPayloadSchema.parse(input({ initialText }))).toEqual(input({ initialText }));
|
||||
}
|
||||
});
|
||||
it("counts mixed BMP and astral draft text like JSON Schema", () => {
|
||||
for (const codePoints of [100_000, 100_001]) {
|
||||
const initialText = "a".repeat(codePoints - 1) + "😀";
|
||||
expect(Buffer.byteLength(JSON.stringify(input({ initialText })))).toBeLessThan(196 * 1024);
|
||||
expect(paperclipQuestionSetPayloadSchema.safeParse(input({ initialText })).success).toBe(codePoints === 100_000);
|
||||
}
|
||||
});
|
||||
it("rejects non-text modes and unbounded or nonstring defaults", () => {
|
||||
for (const initialText of [null, 1, "x".repeat(100_001), "😀".repeat(50_001)]) {
|
||||
for (const initialText of [null, 1, "x".repeat(100_001), "a".repeat(100_000) + "😀"]) {
|
||||
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,7 +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(),
|
||||
initialText: z.string().refine(value => value.length <= 200000 && Array.from(value).length <= 100000, "initial text exceeds 100000 Unicode code points").optional(),
|
||||
options: z.array(paperclipQuestionOptionSchema).max(128).optional(),
|
||||
customAnswer: z
|
||||
.object({
|
||||
|
||||
@@ -411,8 +411,8 @@ describeEmbeddedPostgres("native question bridge", () => {
|
||||
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;
|
||||
const payload = { ...interaction!.payload };
|
||||
if ("questionSet" in payload) delete payload.questionSet;
|
||||
await db.update(issueThreadInteractions).set({ payload }).where(eq(issueThreadInteractions.id, interaction!.id));
|
||||
}
|
||||
const service = issueThreadInteractionService(db);
|
||||
|
||||
@@ -86,6 +86,12 @@ vi.mock("@/lib/router", () => ({
|
||||
|
||||
vi.mock("@/api/connection-intents", () => ({ connectionIntentsApi: connectionIntentsApiMocks }));
|
||||
|
||||
vi.mock("./task-chat/TaskChatRichInput", () => ({
|
||||
TaskChatRichInput: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||
<textarea aria-label="Canonical answer" value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
function renderCard(
|
||||
props: Partial<ComponentProps<typeof IssueThreadInteractionCard>> = {},
|
||||
) {
|
||||
@@ -121,9 +127,53 @@ afterEach(() => {
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("IssueThreadInteractionCard", () => {
|
||||
const canonicalDraftInteraction = {
|
||||
...pendingAskUserQuestionsInteraction,
|
||||
payload: {
|
||||
...pendingAskUserQuestionsInteraction.payload,
|
||||
questions: [{ id: "draft", prompt: "Edit draft", required: true, selectionMode: "single" as const, options: [{ id: "draft_text", label: "Write an answer", freeText: true }], allowOther: false }],
|
||||
questionSet: {
|
||||
schema: "paperclip.question_set.v1" as const,
|
||||
questions: [{ id: "draft", prompt: "Edit draft", required: true, answerMode: "text" as const, initialText: " Provider draft\n漢字\\n " }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function editCanonical(host: HTMLDivElement, value: string) {
|
||||
const textarea = host.querySelector<HTMLTextAreaElement>("textarea")!;
|
||||
await act(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, value);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
it("presents canonical drafts and submits only explicitly edited exact text", async () => {
|
||||
const submit = vi.fn();
|
||||
const host = renderCard({ interaction: canonicalDraftInteraction, onSubmitInteractionAnswers: submit });
|
||||
expect(host.querySelector<HTMLTextAreaElement>("textarea")!.value).toBe(" Provider draft\n漢字\\n ");
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
const edited = "\n Operator edit 漢字\\n\n ";
|
||||
await editCanonical(host, edited);
|
||||
await act(() => Array.from(host.querySelectorAll("button")).find((button) => button.textContent === "Submit answers")!.click());
|
||||
expect(submit).toHaveBeenCalledExactlyOnceWith(canonicalDraftInteraction, [{ questionId: "draft", optionIds: [], otherText: edited }]);
|
||||
});
|
||||
|
||||
it("keeps an explicit cleared canonical draft after reopening and requires an answer", async () => {
|
||||
const submit = vi.fn();
|
||||
const host = renderCard({ interaction: canonicalDraftInteraction, onSubmitInteractionAnswers: submit });
|
||||
await editCanonical(host, "");
|
||||
await act(() => root!.unmount());
|
||||
host.remove();
|
||||
const reopened = renderCard({ interaction: canonicalDraftInteraction, onSubmitInteractionAnswers: submit });
|
||||
expect(reopened.querySelector<HTMLTextAreaElement>("textarea")!.value).toBe("");
|
||||
expect(Array.from(reopened.querySelectorAll("button")).find((button) => button.textContent === "Submit answers")!.disabled).toBe(true);
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the shared connection setup for the addressed user", async () => {
|
||||
connectionIntentsApiMocks.setupOptions.mockResolvedValue({ existingConnections: [] });
|
||||
const host = renderCard({
|
||||
|
||||
@@ -44,6 +44,7 @@ import { ProposalJustification } from "../pages/secrets/proposal-review";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import { ConnectionIntentInteractionBody } from "@/features/connections/ConnectionIntentInteractionBody";
|
||||
import { QuestionForm } from "./task-chat/QuestionForm";
|
||||
|
||||
const OTHER_ANSWER_ID = "__paperclip_other__";
|
||||
|
||||
@@ -1194,7 +1195,46 @@ function AskUserQuestionsCard({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{interaction.status === "pending" ? (
|
||||
{interaction.status === "pending" && interaction.payload.questionSet ? (
|
||||
<QuestionForm
|
||||
id={interaction.id}
|
||||
questionSet={interaction.payload.questionSet}
|
||||
draftKey={`issue-question:${interaction.companyId}:${interaction.id}`}
|
||||
disabled={!onSubmitInteractionAnswers}
|
||||
initialResponse={interaction.result?.answers ? {
|
||||
schema: "paperclip.question_response.v1",
|
||||
answers: Object.fromEntries(interaction.result.answers.map((answer) => [
|
||||
answer.questionId,
|
||||
interaction.payload.questionSet!.questions.find((question) => question.id === answer.questionId)?.answerMode === "text"
|
||||
? { text: answer.otherText ?? "" }
|
||||
: { selectedOptionIds: answer.optionIds, ...(typeof answer.otherText === "string" ? { customText: answer.otherText } : {}) },
|
||||
])),
|
||||
} : null}
|
||||
onSubmit={async (response) => {
|
||||
const answers = interaction.payload.questionSet!.questions.map((question) => {
|
||||
const answer = response.answers[question.id];
|
||||
const otherText = question.answerMode === "text" ? answer?.text : answer?.customText?.trim();
|
||||
return {
|
||||
questionId: question.id,
|
||||
optionIds: question.answerMode === "text" ? [] : (answer?.selectedOptionIds ?? []),
|
||||
...(otherText !== undefined ? { otherText } : {}),
|
||||
};
|
||||
});
|
||||
try {
|
||||
await onSubmitInteractionAnswers?.(interaction, answers);
|
||||
} catch (error) {
|
||||
throw new Error(resolutionErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
onCancel={onCancelInteraction ? async () => {
|
||||
try {
|
||||
await onCancelInteraction(interaction);
|
||||
} catch (error) {
|
||||
throw new Error(resolutionErrorMessage(error));
|
||||
}
|
||||
} : undefined}
|
||||
/>
|
||||
) : interaction.status === "pending" ? (
|
||||
<div className="space-y-4">
|
||||
{questions.map((question, index) => {
|
||||
const hasFreeTextOption = question.options.some(
|
||||
|
||||
Reference in new issue
Block a user