authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 17:16:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 17:16:38-07:00
log7112697e8be2d6f231726a91eb565e00ddbeebec
treea5536a5c8ba180076b21abca9511fcc935cf4996
parentec36176e4f25fac7e3c380f6a4b646116e4ccf3d
signature Signed by PGP key B5690EEEBB952194

feat(threads): dismiss async questions without replying (#10431)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

18 files changed, 369 insertions(+), 6 deletions(-)

apps/mobile/src/features/threads/PendingUserInputCard.tsx+14
......@@ -66,6 +66,8 @@ export interface PendingUserInputCardProps {
6666 customAnswer: string,
6767 ) => void;
6868 readonly onSubmit: () => Promise<unknown>;
69 /** Closes an async question without a reply. Hidden for native callback questions. */
70 readonly onDismiss: () => Promise<unknown>;
6971}
7072
7173/**
......@@ -338,6 +340,18 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
338340 Submit answers
339341 </Text>
340342 </Pressable>
343 {props.pendingUserInput.dismissible ? (
344 <Pressable
345 accessibilityRole="button"
346 className="items-center justify-center rounded-2xl px-4 py-2.5 active:opacity-70"
347 disabled={props.respondingUserInputId === props.pendingUserInput.requestId}
348 onPress={() => void props.onDismiss()}
349 >
350 <Text className="font-t3-bold text-sm text-foreground-muted">
351 Dismiss without answering
352 </Text>
353 </Pressable>
354 ) : null}
341355 </Animated.View>
342356 ) : null;
343357 return (
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+2
......@@ -158,6 +158,7 @@ export interface ThreadDetailScreenProps {
158158 customAnswer: string,
159159 ) => void;
160160 readonly onSubmitUserInput: () => Promise<unknown>;
161 readonly onDismissUserInput: () => Promise<unknown>;
161162 readonly showContent?: boolean;
162163}
163164
......@@ -909,6 +910,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
909910 onSelectOption={props.onSelectUserInputOption}
910911 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
911912 onSubmit={props.onSubmitUserInput}
913 onDismiss={props.onDismissUserInput}
912914 />
913915 ) : null}
914916 </Animated.View>
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1
......@@ -826,6 +826,7 @@ function ThreadRouteContent(
826826 onSelectUserInputOption={requests.onSelectUserInputOption}
827827 onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer}
828828 onSubmitUserInput={requests.onSubmitUserInput}
829 onDismissUserInput={requests.onDismissUserInput}
829830 />
830831 </View>
831832 </>
apps/mobile/src/lib/threadActivity.test.ts+1
......@@ -100,6 +100,7 @@ describe("pending user input answers", () => {
100100 {
101101 requestId: "interaction_1",
102102 createdAt: requested.createdAt,
103 dismissible: false,
103104 questions: [nativeQuestion, singleSelectQuestion],
104105 },
105106 ]);
apps/mobile/src/state/use-selected-thread-requests.ts+25
......@@ -73,6 +73,10 @@ export function useSelectedThreadRequests() {
7373 threadEnvironment.respondToUserInput,
7474 "thread user input response",
7575 );
76 const dismissUserInput = useAtomCommand(
77 threadEnvironment.dismissUserInput,
78 "thread user input dismissal",
79 );
7680 const { selectedThread: selectedThreadShell } = useThreadSelection();
7781 const selectedThread = useSelectedThreadDetail();
7882 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);
......@@ -170,6 +174,26 @@ export function useSelectedThreadRequests() {
170174 selectedThreadShell,
171175 ]);
172176
177 // Closes an async question without messaging the agent.
178 const onDismissUserInput = useCallback(async () => {
179 if (!selectedThreadShell || !activePendingUserInput) {
180 return;
181 }
182
183 setRespondingUserInputId(activePendingUserInput.requestId);
184 const result = await dismissUserInput({
185 environmentId: selectedThreadShell.environmentId,
186 input: {
187 threadId: selectedThreadShell.id,
188 requestId: activePendingUserInput.requestId,
189 },
190 });
191 setRespondingUserInputId((current) =>
192 current === activePendingUserInput.requestId ? null : current,
193 );
194 return result;
195 }, [activePendingUserInput, dismissUserInput, selectedThreadShell]);
196
173197 return {
174198 activePendingApproval,
175199 activePendingUserInput,
......@@ -181,5 +205,6 @@ export function useSelectedThreadRequests() {
181205 onSelectUserInputOption,
182206 onChangeUserInputCustomAnswer,
183207 onSubmitUserInput,
208 onDismissUserInput,
184209 };
185210}
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+2-1
......@@ -215,7 +215,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
215215 // Command snapshots omit activities at startup and cap them while running.
216216 // Read this request's durable state before deciding how to send the answer.
217217 const userInputActivity =
218 envelope.command.type === "thread.user-input.respond"
218 envelope.command.type === "thread.user-input.respond" ||
219 envelope.command.type === "thread.user-input.dismiss"
219220 ? yield* projectionSnapshotQuery.getUserInputActivity(envelope.command)
220221 : Option.none();
221222 const eventBase = yield* decideOrchestrationCommand({
apps/server/src/orchestration/decider.ts+45
......@@ -1289,6 +1289,51 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
12891289 };
12901290 }
12911291
1292 case "thread.user-input.dismiss": {
1293 yield* requireThread({
1294 readModel,
1295 command,
1296 threadId: command.threadId,
1297 });
1298 const request = userInputActivity;
1299 if (request === undefined || request.kind !== "user-input.requested") {
1300 return yield* new OrchestrationCommandInvariantError({
1301 commandType: command.type,
1302 detail: "This question has already been answered.",
1303 });
1304 }
1305 // Only async questions can be dropped silently. A native callback
1306 // question leaves the provider blocked until it gets a reply, so it
1307 // still needs an answer or an interrupted turn.
1308 if (!Predicate.isObject(request.payload) || request.payload.responseMode !== "message") {
1309 return yield* new OrchestrationCommandInvariantError({
1310 commandType: command.type,
1311 detail: "This question needs an answer. Answer it or stop the turn.",
1312 });
1313 }
1314 return {
1315 ...(yield* withEventBase({
1316 aggregateKind: "thread",
1317 aggregateId: command.threadId,
1318 occurredAt: command.createdAt,
1319 commandId: command.commandId,
1320 })),
1321 type: "thread.activity-appended",
1322 payload: {
1323 threadId: command.threadId,
1324 activity: {
1325 id: EventId.make(`async-dismiss:${command.requestId}`),
1326 kind: "user-input.resolved",
1327 summary: "User input dismissed",
1328 tone: "info",
1329 turnId: request.turnId,
1330 createdAt: command.createdAt,
1331 payload: { requestId: command.requestId, responseMode: "message" },
1332 },
1333 },
1334 };
1335 }
1336
12921337 case "thread.checkpoint.revert": {
12931338 yield* requireThread({
12941339 readModel,
apps/server/src/orchestration/decider.userInputDismiss.test.ts created+142
......@@ -0,0 +1,142 @@
1import {
2 CommandId,
3 EventId,
4 ProjectId,
5 ProviderInstanceId,
6 ThreadId,
7 ApprovalRequestId,
8 type OrchestrationReadModel,
9 type OrchestrationThreadActivity,
10} from "@t3tools/contracts";
11import * as NodeServices from "@effect/platform-node/NodeServices";
12import { expect, it } from "@effect/vitest";
13import * as Effect from "effect/Effect";
14
15import { decideOrchestrationCommand } from "./decider.ts";
16import { projectEvent } from "./projector.ts";
17
18const NOW = "2026-01-01T00:00:00.000Z";
19const threadId = ThreadId.make("thread-1");
20const requestId = ApprovalRequestId.make("question-1");
21
22function makeRequest(responseMode: "message" | undefined): OrchestrationThreadActivity {
23 return {
24 id: EventId.make(requestId),
25 kind: "user-input.requested",
26 summary: "Question",
27 tone: "approval",
28 turnId: null,
29 createdAt: NOW,
30 payload: {
31 requestId,
32 ...(responseMode === undefined ? {} : { responseMode }),
33 questions: [{ id: "0", header: "Q", question: "Continue?", options: [] }],
34 },
35 };
36}
37
38function makeReadModel(
39 activities: ReadonlyArray<OrchestrationThreadActivity>,
40): OrchestrationReadModel {
41 return {
42 snapshotSequence: 0,
43 projects: [],
44 threads: [
45 {
46 id: threadId,
47 projectId: ProjectId.make("project-1"),
48 title: "Thread",
49 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
50 runtimeMode: "full-access",
51 interactionMode: "default",
52 branch: null,
53 worktreePath: null,
54 latestTurn: null,
55 createdAt: NOW,
56 updatedAt: NOW,
57 archivedAt: null,
58 settledOverride: null,
59 settledAt: null,
60 snoozedUntil: null,
61 snoozedAt: null,
62 pinnedAt: null,
63 deletedAt: null,
64 messages: [],
65 proposedPlans: [],
66 activities: [...activities],
67 checkpoints: [],
68 session: null,
69 },
70 ],
71 updatedAt: NOW,
72 };
73}
74
75const command = {
76 type: "thread.user-input.dismiss" as const,
77 commandId: CommandId.make("dismiss-1"),
78 threadId,
79 requestId,
80 createdAt: NOW,
81};
82
83it.layer(NodeServices.layer)("user input dismiss decider", (it) => {
84 it.effect("closes an async question without sending a message or starting a turn", () =>
85 Effect.gen(function* () {
86 const request = makeRequest("message");
87 const readModel = makeReadModel([request]);
88 const result = yield* decideOrchestrationCommand({
89 command,
90 readModel,
91 userInputActivity: request,
92 });
93 const events = Array.isArray(result) ? result : [result];
94 expect(events.map((event) => event.type)).toEqual(["thread.activity-appended"]);
95 expect(events[0]?.payload).toMatchObject({
96 threadId,
97 activity: {
98 kind: "user-input.resolved",
99 summary: "User input dismissed",
100 payload: { requestId, responseMode: "message" },
101 },
102 });
103 const projected = yield* projectEvent(readModel, { ...events[0]!, sequence: 1 });
104 expect(projected.threads[0]?.messages).toEqual([]);
105 expect(projected.threads[0]?.latestTurn).toBeNull();
106 }),
107 );
108
109 it.effect("rejects dismissing a native callback question", () =>
110 Effect.gen(function* () {
111 const request = makeRequest(undefined);
112 const result = yield* decideOrchestrationCommand({
113 command,
114 readModel: makeReadModel([request]),
115 userInputActivity: request,
116 }).pipe(Effect.flip);
117 expect(result).toMatchObject({
118 _tag: "OrchestrationCommandInvariantError",
119 detail: "This question needs an answer. Answer it or stop the turn.",
120 });
121 }),
122 );
123
124 it.effect("rejects dismissing a question that was already resolved", () =>
125 Effect.gen(function* () {
126 const resolved: OrchestrationThreadActivity = {
127 ...makeRequest("message"),
128 id: EventId.make("resolved"),
129 kind: "user-input.resolved",
130 };
131 const result = yield* decideOrchestrationCommand({
132 command,
133 readModel: makeReadModel([makeRequest("message"), resolved]),
134 userInputActivity: resolved,
135 }).pipe(Effect.flip);
136 expect(result).toMatchObject({
137 _tag: "OrchestrationCommandInvariantError",
138 detail: "This question has already been answered.",
139 });
140 }),
141 );
142});
apps/web/src/components/ChatView.tsx+30
......@@ -1423,6 +1423,9 @@ export default function ChatView(props: ChatViewProps) {
14231423 const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, {
14241424 reportFailure: false,
14251425 });
1426 const dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, {
1427 reportFailure: false,
1428 });
14261429 const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, {
14271430 reportFailure: false,
14281431 });
......@@ -6960,6 +6963,32 @@ export default function ChatView(props: ChatViewProps) {
69606963 [activeThreadId, environmentId, respondToThreadUserInput, setThreadError],
69616964 );
69626965
6966 // Closes an async question without messaging the agent. The server records
6967 // the dismissal so every client releases the composer.
6968 const onDismissUserInput = useCallback(
6969 async (requestId: ApprovalRequestId) => {
6970 if (!activeThreadId) return;
6971
6972 setRespondingUserInputRequestIds((existing) =>
6973 existing.includes(requestId) ? existing : [...existing, requestId],
6974 );
6975 const result = await dismissThreadUserInput({
6976 environmentId,
6977 input: { threadId: activeThreadId, requestId },
6978 });
6979 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
6980 const error = squashAtomCommandFailure(result);
6981 setThreadError(
6982 activeThreadId,
6983 error instanceof Error ? error.message : "Failed to dismiss the question.",
6984 );
6985 }
6986 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
6987 return result;
6988 },
6989 [activeThreadId, dismissThreadUserInput, environmentId, setThreadError],
6990 );
6991
69636992 const setActivePendingUserInputQuestionIndex = useCallback(
69646993 (nextQuestionIndex: number) => {
69656994 if (!activePendingUserInput) {
......@@ -8055,6 +8084,7 @@ export default function ChatView(props: ChatViewProps) {
80558084 onSelectActivePendingUserInputOption
80568085 }
80578086 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
8087 onDismissActivePendingUserInput={onDismissUserInput}
80588088 onPreviousActivePendingUserInputQuestion={
80598089 onPreviousActivePendingUserInputQuestion
80608090 }
apps/web/src/components/chat/ChatComposer.tsx+4
......@@ -1284,6 +1284,7 @@ export interface ChatComposerProps {
12841284 ) => Promise<unknown>;
12851285 onSelectActivePendingUserInputOption: (questionId: string, optionValue: string) => void;
12861286 onAdvanceActivePendingUserInput: () => void;
1287 onDismissActivePendingUserInput: (requestId: ApprovalRequestId) => void;
12871288 onPreviousActivePendingUserInputQuestion: () => void;
12881289 onChangeActivePendingUserInputCustomAnswer: (
12891290 questionId: string,
......@@ -1384,6 +1385,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13841385 onRespondToApproval,
13851386 onSelectActivePendingUserInputOption,
13861387 onAdvanceActivePendingUserInput,
1388 onDismissActivePendingUserInput,
13871389 onPreviousActivePendingUserInputQuestion,
13881390 onChangeActivePendingUserInputCustomAnswer,
13891391 onProviderModelSelect,
......@@ -4911,6 +4913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
49114913 questionIndex={activePendingQuestionIndex}
49124914 onToggleOption={onSelectActivePendingUserInputOption}
49134915 onAdvance={onAdvanceActivePendingUserInput}
4916 onDismiss={onDismissActivePendingUserInput}
49144917 />
49154918 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (
49164919 <ComposerPlanFollowUpBanner
......@@ -4926,6 +4929,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
49264929 questionIndex={activePendingQuestionIndex}
49274930 onToggleOption={onSelectActivePendingUserInputOption}
49284931 onAdvance={onAdvanceActivePendingUserInput}
4932 onDismiss={onDismissActivePendingUserInput}
49294933 />
49304934 {!isChoiceOnlyPendingQuestion ||
49314935 activePendingProgress?.activeQuestion?.multiSelect ? (
apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx+11-2
......@@ -20,17 +20,19 @@ const prompt: PendingUserInput = {
2020 multiSelect: false,
2121 },
2222 ],
23 dismissible: true,
2324};
2425
25function renderPanel() {
26function renderPanel(pendingUserInput: PendingUserInput = prompt) {
2627 return renderToStaticMarkup(
2728 <ComposerPendingUserInputPanel
28 pendingUserInputs={[prompt]}
29 pendingUserInputs={[pendingUserInput]}
2930 respondingRequestIds={[]}
3031 answers={{}}
3132 questionIndex={0}
3233 onToggleOption={() => {}}
3334 onAdvance={() => {}}
35 onDismiss={() => {}}
3436 />,
3537 );
3638}
......@@ -50,6 +52,13 @@ describe("ComposerPendingUserInputPanel", () => {
5052 expect(markup).toMatch(new RegExp(`<div[^>]*\\sid="${controlledId}"`));
5153 });
5254
55 it("offers dismiss only for async questions", () => {
56 expect(renderPanel()).toContain("data-pending-user-input-dismiss");
57 expect(renderPanel({ ...prompt, dismissible: false })).not.toContain(
58 "data-pending-user-input-dismiss",
59 );
60 });
61
5362 it("starts expanded so the question and its options are visible", () => {
5463 const markup = renderPanel();
5564
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+27
......@@ -17,6 +17,7 @@ interface PendingUserInputPanelProps {
1717 questionIndex: number;
1818 onToggleOption: (questionId: string, optionValue: string) => void;
1919 onAdvance: () => void;
20 onDismiss: (requestId: ApprovalRequestId) => void;
2021}
2122
2223export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
......@@ -26,6 +27,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
2627 questionIndex,
2728 onToggleOption,
2829 onAdvance,
30 onDismiss,
2931}: PendingUserInputPanelProps) {
3032 if (pendingUserInputs.length === 0) return null;
3133 const activePrompt = pendingUserInputs[0];
......@@ -40,6 +42,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
4042 questionIndex={questionIndex}
4143 onToggleOption={onToggleOption}
4244 onAdvance={onAdvance}
45 onDismiss={onDismiss}
4346 />
4447 );
4548});
......@@ -51,6 +54,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
5154 questionIndex,
5255 onToggleOption,
5356 onAdvance,
57 onDismiss,
5458}: {
5559 prompt: PendingUserInput;
5660 isResponding: boolean;
......@@ -58,6 +62,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
5862 questionIndex: number;
5963 onToggleOption: (questionId: string, optionValue: string) => void;
6064 onAdvance: () => void;
65 onDismiss: (requestId: ApprovalRequestId) => void;
6166}) {
6267 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
6368 const activeQuestion = progress.activeQuestion;
......@@ -198,6 +203,28 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
198203 </span>
199204 ) : null}
200205 <ComposerBanner.ToggleIcon expanded={!isCollapsed} />
206 {prompt.dismissible ? (
207 // Sits inside the trigger button, so stop the click from toggling
208 // the disclosure. Dismiss closes the question without a reply.
209 <ComposerBanner.Dismiss
210 render={<span role="button" tabIndex={0} />}
211 aria-label="Dismiss question without answering"
212 title="Dismiss question without answering"
213 disabled={isResponding}
214 data-pending-user-input-dismiss
215 onClick={(event) => {
216 event.preventDefault();
217 event.stopPropagation();
218 onDismiss(prompt.requestId);
219 }}
220 onKeyDown={(event) => {
221 if (event.key !== "Enter" && event.key !== " ") return;
222 event.preventDefault();
223 event.stopPropagation();
224 onDismiss(prompt.requestId);
225 }}
226 />
227 ) : null}
201228 </ComposerBanner.Actions>
202229 </CollapsibleTrigger>
203230 <CollapsiblePanel>
docs/user/providers-codex.md+3-1
......@@ -54,7 +54,9 @@ contains a copied Codex setup. Use a fresh shadow directory and sign in again.
5454Codex can ask a question and keep working. Answer it in the thread's question
5555panel. The answer becomes a new message: it reaches the active turn, or starts
5656another turn if Codex has finished. Unanswered questions survive reconnects.
57This requires a Codex version that supports async questions.
57If you do not want to answer, dismiss the question from its panel. Dismissing
58closes it without sending anything to Codex. This requires a Codex version that
59supports async questions.
5860
5961## Approve app access
6062
packages/client-runtime/src/operations/commands.ts+12
......@@ -50,6 +50,7 @@ export type StartThreadTurnInput = CommandInput<"thread.turn.start">;
5050export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">;
5151export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">;
5252export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">;
53export type DismissThreadUserInputInput = CommandInput<"thread.user-input.dismiss">;
5354export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">;
5455export type StopThreadSessionInput = CommandInput<"thread.session.stop">;
5556
......@@ -320,6 +321,17 @@ export const respondToThreadUserInput: (input: RespondToThreadUserInputInput) =>
320321 });
321322 });
322323
324export const dismissThreadUserInput: (input: DismissThreadUserInputInput) => CommandEffect =
325 Effect.fn("EnvironmentCommands.dismissThreadUserInput")(function* (input) {
326 const metadata = yield* timestampedCommandMetadata(input);
327 return yield* dispatch({
328 ...input,
329 type: "thread.user-input.dismiss",
330 commandId: metadata.commandId,
331 createdAt: metadata.createdAt,
332 });
333 });
334
323335export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect =
324336 Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) {
325337 const metadata = yield* timestampedCommandMetadata(input);
packages/client-runtime/src/pendingRequests.test.ts+20-1
......@@ -282,7 +282,25 @@ describe("pending questions", () => {
282282 payload: { requestId: "async-1", responseMode: "message", questions: [question] },
283283 }),
284284 ];
285 expect(derivePendingRequests(activities).userInputs[0]?.questions).toEqual([question]);
285 expect(derivePendingRequests(activities).userInputs[0]).toMatchObject({
286 questions: [question],
287 dismissible: true,
288 });
289 });
290
291 it("only marks async questions as dismissible", () => {
292 const question = {
293 id: "0",
294 header: "Question",
295 question: "Continue?",
296 options: [{ label: "Yes", description: "" }],
297 multiSelect: false,
298 };
299 const native = makeActivity({
300 kind: "user-input.requested",
301 payload: { requestId: "native-1", questions: [question] },
302 });
303 expect(derivePendingRequests([native]).userInputs[0]?.dismissible).toBe(false);
286304 });
287305
288306 it("preserves native choice values and the custom-answer restriction", () => {
......@@ -378,6 +396,7 @@ describe("pending questions", () => {
378396 {
379397 requestId: "req-user-input-1",
380398 createdAt: "2026-02-23T00:00:01.000Z",
399 dismissible: false,
381400 questions: [
382401 {
383402 id: "sandbox_mode",
packages/client-runtime/src/pendingRequests.ts+8-1
......@@ -22,6 +22,8 @@ export interface PendingUserInput {
2222 readonly requestId: ApprovalRequestId;
2323 readonly createdAt: string;
2424 readonly questions: ReadonlyArray<UserInputQuestion>;
25 /** Async questions can be dismissed without a reply; native callbacks cannot. */
26 readonly dismissible: boolean;
2527}
2628
2729const isRequestId = Schema.is(ApprovalRequestId);
......@@ -160,7 +162,12 @@ export function derivePendingRequests(activities: ReadonlyArray<OrchestrationThr
160162 if (closedUserInputs.has(requestId)) continue;
161163 const questions = parseQuestions(payload.questions);
162164 if (questions.length === 0) continue;
163 userInputs.set(requestId, { requestId, createdAt: activity.createdAt, questions });
165 userInputs.set(requestId, {
166 requestId,
167 createdAt: activity.createdAt,
168 questions,
169 dismissible: payload.responseMode === "message",
170 });
164171 } else if (
165172 activity.kind === "approval.resolved" ||
166173 (activity.kind === "provider.approval.respond.failed" &&
packages/client-runtime/src/state/threadCommands.ts+9
......@@ -14,6 +14,7 @@ import {
1414 type InterruptThreadTurnInput,
1515 type RespondToThreadApprovalInput,
1616 type RespondToThreadUserInputInput,
17 type DismissThreadUserInputInput,
1718 type RevertThreadCheckpointInput,
1819 type SetThreadInteractionModeInput,
1920 type SetThreadRuntimeModeInput,
......@@ -35,6 +36,7 @@ import {
3536 interruptThreadTurn,
3637 respondToThreadApproval,
3738 respondToThreadUserInput,
39 dismissThreadUserInput,
3840 revertThreadCheckpoint,
3941 setThreadInteractionMode,
4042 setThreadRuntimeMode,
......@@ -60,6 +62,7 @@ export type {
6062 InterruptThreadTurnInput,
6163 RespondToThreadApprovalInput,
6264 RespondToThreadUserInputInput,
65 DismissThreadUserInputInput,
6366 RevertThreadCheckpointInput,
6467 SetThreadInteractionModeInput,
6568 SetThreadRuntimeModeInput,
......@@ -201,6 +204,12 @@ export function createThreadEnvironmentAtoms<R, E>(
201204 scheduler,
202205 concurrency,
203206 }),
207 dismissUserInput: createEnvironmentCommand(runtime, {
208 label: "environment-data:commands:thread:dismiss-user-input",
209 execute: (input: DismissThreadUserInputInput) => dismissThreadUserInput(input),
210 scheduler,
211 concurrency,
212 }),
204213 revertCheckpoint: createEnvironmentCommand(runtime, {
205214 label: "environment-data:commands:thread:revert-checkpoint",
206215 execute: (input: RevertThreadCheckpointInput) => revertThreadCheckpoint(input),
packages/contracts/src/orchestration.ts+13
......@@ -1033,6 +1033,17 @@ const ThreadUserInputRespondCommand = Schema.Struct({
10331033 createdAt: IsoDateTime,
10341034});
10351035
1036// Closes an async question without answering it. The agent is not messaged;
1037// the composer is simply released. Native callback questions cannot be dismissed
1038// this way because the provider is blocked waiting on a reply.
1039const ThreadUserInputDismissCommand = Schema.Struct({
1040 type: Schema.Literal("thread.user-input.dismiss"),
1041 commandId: CommandId,
1042 threadId: ThreadId,
1043 requestId: ApprovalRequestId,
1044 createdAt: IsoDateTime,
1045});
1046
10361047const ThreadCheckpointRevertCommand = Schema.Struct({
10371048 type: Schema.Literal("thread.checkpoint.revert"),
10381049 commandId: CommandId,
......@@ -1077,6 +1088,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
10771088 ThreadTurnInterruptCommand,
10781089 ThreadApprovalRespondCommand,
10791090 ThreadUserInputRespondCommand,
1091 ThreadUserInputDismissCommand,
10801092 ThreadCheckpointRevertCommand,
10811093 ThreadSessionStopCommand,
10821094]);
......@@ -1106,6 +1118,7 @@ export const ClientOrchestrationCommand = Schema.Union([
11061118 ThreadTurnInterruptCommand,
11071119 ThreadApprovalRespondCommand,
11081120 ThreadUserInputRespondCommand,
1121 ThreadUserInputDismissCommand,
11091122 ThreadCheckpointRevertCommand,
11101123 ThreadSessionStopCommand,
11111124]);