Skip to content
Open
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
18 changes: 18 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const emitInTurnTaskOutputThenLateDuplicate =
process.env.T3_ACP_EMIT_IN_TURN_TASKOUTPUT_THEN_LATE_DUPLICATE === "1";
const emitBackgroundToolDuringAnswer =
process.env.T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER === "1";
const emitOptionalFieldElicitation = process.env.T3_ACP_EMIT_OPTIONAL_FIELD_ELICITATION;
const emitMcpToolApprovalElicitation =
process.env.T3_ACP_EMIT_MCP_TOOL_APPROVAL_ELICITATION === "1";
const grokKillTaskShape = process.env.T3_ACP_GROK_KILL_TASK_SHAPE;
Expand Down Expand Up @@ -1557,6 +1558,23 @@ const program = Effect.gen(function* () {
return yield* finishPrompt(requestedSessionId, "end_turn");
}

if (emitOptionalFieldElicitation) {
yield* agent.client.elicit({
sessionId: requestedSessionId,
message: "Pick a color",
mode: "form",
requestedSchema: {
type: "object",
properties: {
color: { type: "string", title: "Color", enum: ["red", "blue"] },
note: { type: "string", title: "Note" },
},
...(emitOptionalFieldElicitation === "1" ? { required: ["color"] } : {}),
},
});
return yield* finishPrompt(requestedSessionId, "end_turn");
}

if (emitToolCalls) {
const toolCallId = "tool-call-1";

Expand Down
110 changes: 109 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1446,7 +1446,15 @@ describe("AcpAdapterV2", () => {
{
scenario: "falls back for an unrelated form",
schema: { type: "object", properties: { name: { type: "string" } } },
questions: [{ id: "name", header: "Question 1", question: "Your answer?", options: [] }],
questions: [
{
id: "name",
header: "Question 1",
question: "Your answer?",
options: [],
required: false,
},
],
answers: { name: "Ada" },
result: { action: "accept", content: { name: "Ada" } },
},
Expand Down Expand Up @@ -5455,6 +5463,106 @@ describe("AcpAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.live.each([
{
schema: "with a required list",
environment: { T3_ACP_EMIT_OPTIONAL_FIELD_ELICITATION: "1" },
expected: [
["color", undefined],
["note", false],
],
answers: { color: "blue" },
},
{
schema: "without a required list",
environment: { T3_ACP_EMIT_OPTIONAL_FIELD_ELICITATION: "optional-only" },
expected: [
["color", false],
["note", false],
],
answers: {},
},
])(
"marks non-required elicitation fields as optional ($schema)",
({ environment, expected, answers }) =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const path = yield* Path.Path;
const selfInvocation = yield* resolveSelfInvocation();
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
const instanceId = ProviderInstanceId.make("acp-test-optional-field-elicitation");
const adapter = yield* makeAcpAdapterV2({
instanceId,
flavor: {
driver: ACP_TEST_DRIVER,
capabilities: AcpProviderCapabilitiesV2,
makeRuntime: makeMockRuntime({
childProcessSpawner,
mockAgentPath,
environment,
}),
},
selfInvocation,
});
const threadId = ThreadId.make("thread-acp-optional-field-elicitation");
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "approval-required",
interactionMode: "default",
cwd: process.cwd(),
});
const modelSelection = { instanceId, model: "default" } as const;
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make(
"provider-session-acp-optional-field-elicitation",
),
modelSelection,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});
yield* runtime.startTurn(
makeTurnInput({
threadId,
providerThread,
instanceId,
runtimePolicy,
now: yield* DateTime.now,
}),
);
const requestEvent = Option.getOrThrow(
yield* runtime.events.pipe(
Stream.filter(
(event) =>
event.type === "turn_item.updated" && event.turnItem.type === "user_input_request",
),
Stream.runHead,
),
);
if (
requestEvent.type !== "turn_item.updated" ||
requestEvent.turnItem.type !== "user_input_request"
) {
return yield* Effect.die("Expected a user input request");
}
const requestItem = requestEvent.turnItem;

assert.deepStrictEqual(
requestItem.questions.map((question) => [question.id, question.required]),
expected,
);
yield* runtime.respondToRuntimeRequest({
requestId: requestItem.requestId,
answers,
});
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.live("auto-approves tagged MCP elicitations under full-access policy", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
Expand Down
11 changes: 7 additions & 4 deletions apps/web/src/components/chat/usePendingThreadRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,10 +317,13 @@ export function usePendingThreadRequests(input: {
);
if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return;
const draft = useComposerDraftStore.getState().getComposerDraft(target);
providerAnswers[question.id] = projectPendingUserInputAnswer(
answers[question.id],
draft?.threadContexts ?? [],
);
// Skipped optional questions have no answer to project.
if (answers[question.id] !== undefined) {
providerAnswers[question.id] = projectPendingUserInputAnswer(
answers[question.id],
draft?.threadContexts ?? [],
);
}
const attachments = draft ? [...draft.images, ...draft.files] : [];
if (attachments.length === 0) continue;
const uploaded = getUploadedAttachments({ environmentId, images: attachments });
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/pendingUserInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ describe("pending user input question progress", () => {
});

it("derives the active question and advancement state", () => {
expect(
derivePendingUserInputProgress([{ ...singleSelectQuestion, required: false }], {}, 0),
).toMatchObject({
resolvedAnswer: null,
canAdvance: true,
isComplete: true,
});
expect(
derivePendingUserInputProgress(
questions,
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/pendingUserInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { replaceComposerContextReferences } from "@t3tools/shared/composerContex
import { formatThreadLink } from "@t3tools/shared/threadLinks";
import {
buildPendingUserInputAnswers,
canSkipPendingUserInputQuestion,
normalizeSelectedOptionValues,
resolvePendingUserInputAnswer,
type PendingUserInputAnswer,
Expand Down Expand Up @@ -105,6 +106,8 @@ export function derivePendingUserInputProgress(
answeredQuestionCount,
isLastQuestion,
isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null,
canAdvance: resolvedAnswer !== null,
canAdvance:
resolvedAnswer !== null ||
(activeQuestion !== null && canSkipPendingUserInputQuestion(activeQuestion, activeDraft)),
};
}
31 changes: 31 additions & 0 deletions packages/client-runtime/src/state/pendingUserInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,37 @@ const compatQuestion = {
} as const;

describe("buildPendingUserInputAnswers", () => {
it("leaves unanswered optional questions out of the answers", () => {
const other = {
id: "scope__other",
header: "Other (type your own)",
question: "Which scope should this use?",
options: [],
multiSelect: false,
required: false,
};
const drafts = { scope: { selectedOptionValues: ["Orchestration-first"] } };

expect(buildPendingUserInputAnswers([singleSelectQuestion, other], drafts)).toEqual({
scope: "Orchestration-first",
});
expect(
buildPendingUserInputAnswers([singleSelectQuestion, other], {
...drafts,
scope__other: { customAnswer: "Another scope" },
}),
).toEqual({ scope: "Orchestration-first", scope__other: "Another scope" });
expect(buildPendingUserInputAnswers([singleSelectQuestion, other], {})).toBeNull();
expect(buildPendingUserInputAnswers([{ ...other, required: true }], {})).toBeNull();
expect(buildPendingUserInputAnswers([other], {})).toEqual({});
expect(
buildPendingUserInputAnswers([singleSelectQuestion, other], {
...drafts,
scope__other: { attachmentCount: 1, attachmentsBlocked: true },
}),
).toBeNull();
});

it("returns a canonical answer map for complete prompts", () => {
expect(
buildPendingUserInputAnswers([singleSelectQuestion, compatQuestion], {
Expand Down
14 changes: 12 additions & 2 deletions packages/client-runtime/src/state/pendingUserInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface PendingUserInputQuestion {
readonly options: ReadonlyArray<{ readonly label: string; readonly value?: string | undefined }>;
readonly multiSelect?: boolean | undefined;
readonly allowCustomAnswer?: boolean | undefined;
readonly required?: boolean | undefined;
readonly initialAnswer?: string | undefined;
}

Expand Down Expand Up @@ -157,16 +158,25 @@ export function togglePendingUserInputOptionSelection(
};
}

/** Every question's answer keyed by question id, or null while any is unanswered. */
export function canSkipPendingUserInputQuestion(
question: PendingUserInputQuestion,
draft: PendingUserInputDraftAnswer | undefined,
): boolean {
return question.required === false && !draft?.attachmentsBlocked;
}

/** Answered questions keyed by id, or null while a required answer or attachment is pending. */
export function buildPendingUserInputAnswers(
questions: ReadonlyArray<PendingUserInputQuestion>,
draftAnswers: Record<string, PendingUserInputDraftAnswer>,
): Record<string, PendingUserInputAnswer> | null {
const answers: Record<string, PendingUserInputAnswer> = {};

for (const question of questions) {
const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
const draft = draftAnswers[question.id];
const answer = resolvePendingUserInputAnswer(question, draft);
if (answer === null) {
if (canSkipPendingUserInputQuestion(question, draft)) continue;
return null;
}
answers[question.id] = answer;
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/providerRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export const UserInputQuestion = Schema.Struct({
options: Schema.Array(UserInputQuestionOption),
allowCustomAnswer: Schema.optional(Schema.Boolean),
initialAnswer: Schema.optional(Schema.String),
required: Schema.optional(Schema.Boolean),
multiSelect: Schema.optional(Schema.Boolean).pipe(
Schema.withConstructorDefault(Effect.succeed(false)),
),
Expand Down
9 changes: 9 additions & 0 deletions packages/provider-acp/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6322,6 +6322,14 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
}
const requestedSchema = unknownRecord(params.requestedSchema);
const properties = unknownRecord(requestedSchema?.properties) ?? {};
// JSON Schema fields are optional unless listed in required.
const requiredFields = new Set(
Array.isArray(requestedSchema?.required)
? requestedSchema.required.filter(
(field): field is string => typeof field === "string",
)
: [],
);
const nativeForm = flavor.extractElicitationForm?.(params);
const elicitationScopeId =
"sessionId" in params ? params.sessionId : `request:${params.requestId}`;
Expand All @@ -6347,6 +6355,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
header: nonEmptyText(record?.title, `Question ${index + 1}`),
question: nonEmptyText(record?.description, params.message),
options,
...(requiredFields.has(id) ? {} : { required: false }),
};
},
);
Expand Down
Loading