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:
DottaandPaperclip committed 2026-09-29 12:40:14 -05:00
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
+8 -1
View File
@@ -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);
+1 -1
View File
@@ -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(