Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,23 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
) : null}
{props.pendingUserInput.questions.map((question) => {
const draft = props.drafts[question.id];
const minSelections = question.minSelections ?? (question.maxSelections === 0 ? 0 : 1);
const maxSelections = question.maxSelections;
const selectionHint =
maxSelections === 0
? "Leave all options unselected."
: maxSelections === minSelections
? `Select ${minSelections} option${minSelections === 1 ? "" : "s"}.`
: maxSelections !== undefined
? minSelections === 0
? `Select up to ${maxSelections} option${maxSelections === 1 ? "" : "s"}.`
: `Select ${minSelections} to ${maxSelections} options.`
: minSelections === 0
? "Select any number of options."
: `Select at least ${minSelections} option${minSelections === 1 ? "" : "s"}.`;
const showSelectionHint =
question.multiSelect &&
(question.minSelections !== undefined || maxSelections !== undefined);
return (
<View key={question.id} className="gap-2 pt-1">
<Text className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted">
Expand All @@ -278,6 +295,14 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
<Text className="font-sans text-base leading-snug text-foreground">
{question.question}
</Text>
{showSelectionHint || question.required === false ? (
<Text className="font-sans text-sm leading-5 text-foreground-muted">
{showSelectionHint ? selectionHint : null}
{question.required === false
? `${showSelectionHint ? " " : ""}You can skip this question.`
: null}
</Text>
) : null}
<View className="gap-2">
{question.options.map((option) => {
const optionValue = option.value ?? option.label.trim();
Expand Down
96 changes: 96 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,102 @@ describe("pending user input answers", () => {
});
});

it("omits unanswered optional questions while preserving required defaults", () => {
const question = { ...singleSelectQuestion, required: false };

expect(
buildPendingUserInputAnswers([question, multiSelectQuestion], {
scope: { selectedOptionValues: ["Orders"] },
}),
).toEqual({ scope: ["Orders"] });
expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: " " } })).toEqual(
{},
);
expect(buildPendingUserInputAnswers([singleSelectQuestion], {})).toBeNull();
});

it.each([
{ attachmentsBlocked: true },
{ attachmentCount: 1, attachmentsBlocked: true },
{ attachmentCount: 1 },
{ customAnswer: "Unlisted answer" },
{ selectedOptionValues: ["unknown"] },
])("keeps invalid optional drafts blocking submission: %j", (draft) => {
const question = { ...singleSelectQuestion, required: false, allowCustomAnswer: false };

expect(buildPendingUserInputAnswers([question], { runtime: draft })).toBeNull();
});

it.each([0, undefined])(
"submits empty arrays with a zero maximum and minimum %j",
(minSelections) => {
const question = {
...multiSelectQuestion,
allowCustomAnswer: false,
required: true,
...(minSelections === undefined ? {} : { minSelections }),
maxSelections: 0,
};

expect(buildPendingUserInputAnswers([question], {})).toEqual({ scope: [] });
expect(
buildPendingUserInputAnswers([question], { scope: { selectedOptionValues: ["Orders"] } }),
).toBeNull();
expect(buildPendingUserInputAnswers([{ ...question, required: false }], {})).toEqual({});
},
);

it("requires one selection when no zero maximum is declared", () => {
const question = { ...multiSelectQuestion, required: true, allowCustomAnswer: false };

expect(buildPendingUserInputAnswers([question], {})).toBeNull();
expect(buildPendingUserInputAnswers([{ ...question, maxSelections: 2 }], {})).toBeNull();
});

it("enforces multi-select limits and omits unanswered optional arrays", () => {
const question = {
...multiSelectQuestion,
options: [...multiSelectQuestion.options, { label: "Sales", description: "Sales" }],
allowCustomAnswer: false,
minSelections: 2,
maxSelections: 2,
};

expect(buildPendingUserInputAnswers([question], {})).toBeNull();
expect(
buildPendingUserInputAnswers([question], { scope: { selectedOptionValues: ["Orders"] } }),
).toBeNull();
expect(
buildPendingUserInputAnswers([question], {
scope: { selectedOptionValues: ["Orders", "Listings"] },
}),
).toEqual({ scope: ["Orders", "Listings"] });
expect(
buildPendingUserInputAnswers([question], {
scope: { selectedOptionValues: ["Orders", "Listings", "Sales"] },
}),
).toBeNull();
expect(buildPendingUserInputAnswers([{ ...question, required: false }], {})).toEqual({});
expect(
buildPendingUserInputAnswers([{ ...question, required: false }], {
scope: { selectedOptionValues: ["Orders"] },
}),
).toBeNull();
expect(
buildPendingUserInputAnswers([{ ...question, required: false }], {
scope: { selectedOptionValues: ["unknown"] },
}),
).toBeNull();
expect(
buildPendingUserInputAnswers([{ ...question, required: false, minSelections: 0 }], {
scope: { selectedOptionValues: ["unknown"] },
}),
).toBeNull();
expect(
buildPendingUserInputAnswers([multiSelectQuestion], { scope: { attachmentCount: 1 } }),
).toEqual({ scope: "" });
});

it("clears selected options while a custom answer is active", () => {
expect(
setPendingUserInputCustomAnswer(
Expand Down
33 changes: 29 additions & 4 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,11 +367,27 @@ function resolvePendingUserInputAnswer(

const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
if (question.multiSelect) {
return selectedOptionValues.length > 0
? selectedOptionValues
: question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0
if (
selectedOptionValues.length === 0 &&
((draft?.selectedOptionValues?.length ?? 0) > 0 ||
normalizeDraftAnswer(draft?.customAnswer) !== null ||
(question.allowCustomAnswer === false && (draft?.attachmentCount ?? 0) > 0))
)
return null;
if (
selectedOptionValues.length <
(question.minSelections ?? (question.maxSelections === 0 ? 0 : 1)) ||
(question.maxSelections !== undefined && selectedOptionValues.length > question.maxSelections)
) {
return selectedOptionValues.length === 0 &&
question.minSelections === undefined &&
question.maxSelections === undefined &&
question.allowCustomAnswer !== false &&
(draft?.attachmentCount ?? 0) > 0
? ""
: null;
}
return selectedOptionValues;
}
return (
selectedOptionValues[0] ??
Expand Down Expand Up @@ -1632,7 +1648,16 @@ export function buildPendingUserInputAnswers(
const answers: Record<string, string | ReadonlyArray<string>> = {};

for (const question of questions) {
const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
const draft = draftAnswers[question.id];
if (
question.required === false &&
!draft?.attachmentsBlocked &&
normalizeDraftAnswer(draft?.customAnswer) === null &&
(draft?.selectedOptionValues?.length ?? 0) === 0 &&
(draft?.attachmentCount ?? 0) === 0
)
continue;
const answer = resolvePendingUserInputAnswer(question, draft);
if (answer === null) {
return null;
}
Expand Down
25 changes: 18 additions & 7 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime";

import * as EffectAcpAgent from "effect-acp/agent";
import * as AcpError from "effect-acp/errors";
import type * as AcpSchema from "effect-acp/schema";
import * as AcpSchema from "effect-acp/schema";
import type * as AcpCompat from "effect-acp/compat";

import { beginAcpMockPrompt } from "./acpMockCancellationState.ts";
Expand All @@ -34,6 +34,10 @@ const emitBackgroundToolDuringAnswer =
process.env.T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER === "1";
const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
const emitElicitation = process.env.T3_ACP_EMIT_ELICITATION === "1";
const elicitationSchemaJson = process.env.T3_ACP_ELICITATION_SCHEMA;
const decodeElicitationSchema = Schema.decodeUnknownEffect(
Schema.fromJsonString(AcpSchema.ElicitationSchema),
);
const emitMcpToolApprovalElicitation =
process.env.T3_ACP_EMIT_MCP_TOOL_APPROVAL_ELICITATION === "1";
const emitUrlElicitation = process.env.T3_ACP_EMIT_URL_ELICITATION === "1";
Expand Down Expand Up @@ -1781,12 +1785,19 @@ const program = Effect.gen(function* () {
sessionId: requestedSessionId,
message: "Approve this request?",
mode: "form",
requestedSchema: {
type: "object",
properties: {
approved: { type: "boolean", title: "Approved" },
},
},
requestedSchema:
elicitationSchemaJson === undefined
? {
type: "object",
properties: {
approved: { type: "boolean", title: "Approved" },
},
}
: yield* decodeElicitationSchema(elicitationSchemaJson).pipe(
Effect.mapError(() =>
AcpError.AcpRequestError.invalidParams("Invalid elicitation schema"),
),
),
...(emitMcpToolApprovalElicitation
? { _meta: { codex_approval_kind: "mcp_tool_call" } }
: {}),
Expand Down
Loading
Loading