From 161b9855abfd2f6f6c20dbacbd4deebc08c71f23 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 17:10:43 -0700 Subject: [PATCH 1/2] feat(threads): dismiss async questions without replying An async question from the agent could only be closed by answering every part of it or settling the thread. Users who disagreed with the questions, or whose agent had already finished, had no way to release the composer without sending another message. Add a thread.user-input.dismiss command. The server records the question as resolved and dismissed without a message or a new turn. Native callback questions still need an answer because the provider is blocked on them. Web and mobile show a dismiss control only for async questions. Closes #9873 Co-Authored-By: Claude Fable 5.1 --- .../features/threads/PendingUserInputCard.tsx | 14 ++ .../features/threads/ThreadDetailScreen.tsx | 2 + .../features/threads/ThreadRouteScreen.tsx | 1 + .../src/state/use-selected-thread-requests.ts | 25 +++ .../Layers/OrchestrationEngine.ts | 3 +- apps/server/src/orchestration/decider.ts | 45 ++++++ .../decider.userInputDismiss.test.ts | 142 ++++++++++++++++++ apps/web/src/components/ChatView.tsx | 30 ++++ apps/web/src/components/chat/ChatComposer.tsx | 4 + .../ComposerPendingUserInputPanel.test.tsx | 13 +- .../chat/ComposerPendingUserInputPanel.tsx | 27 ++++ docs/user/providers-codex.md | 4 +- .../client-runtime/src/operations/commands.ts | 12 ++ .../src/pendingRequests.test.ts | 21 ++- .../client-runtime/src/pendingRequests.ts | 9 +- .../src/state/threadCommands.ts | 9 ++ packages/contracts/src/orchestration.ts | 13 ++ 17 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/orchestration/decider.userInputDismiss.test.ts diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 8fe7fc186adb..762e00e42893 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -66,6 +66,8 @@ export interface PendingUserInputCardProps { customAnswer: string, ) => void; readonly onSubmit: () => Promise; + /** Closes an async question without a reply. Hidden for native callback questions. */ + readonly onDismiss: () => Promise; } /** @@ -338,6 +340,18 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { Submit answers + {props.pendingUserInput.dismissible ? ( + void props.onDismiss()} + > + + Dismiss without answering + + + ) : null} ) : null; return ( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3392b534b634..9b8024ba915f 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -158,6 +158,7 @@ export interface ThreadDetailScreenProps { customAnswer: string, ) => void; readonly onSubmitUserInput: () => Promise; + readonly onDismissUserInput: () => Promise; readonly showContent?: boolean; } @@ -909,6 +910,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onSelectOption={props.onSelectUserInputOption} onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} onSubmit={props.onSubmitUserInput} + onDismiss={props.onDismissUserInput} /> ) : null} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index a59fa1a4450c..f0569cdb92ca 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -826,6 +826,7 @@ function ThreadRouteContent( onSelectUserInputOption={requests.onSelectUserInputOption} onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer} onSubmitUserInput={requests.onSubmitUserInput} + onDismissUserInput={requests.onDismissUserInput} /> diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 1b5209dec319..e816ac70e5ac 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -73,6 +73,10 @@ export function useSelectedThreadRequests() { threadEnvironment.respondToUserInput, "thread user input response", ); + const dismissUserInput = useAtomCommand( + threadEnvironment.dismissUserInput, + "thread user input dismissal", + ); const { selectedThread: selectedThreadShell } = useThreadSelection(); const selectedThread = useSelectedThreadDetail(); const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); @@ -170,6 +174,26 @@ export function useSelectedThreadRequests() { selectedThreadShell, ]); + // Closes an async question without messaging the agent. + const onDismissUserInput = useCallback(async () => { + if (!selectedThreadShell || !activePendingUserInput) { + return; + } + + setRespondingUserInputId(activePendingUserInput.requestId); + const result = await dismissUserInput({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + requestId: activePendingUserInput.requestId, + }, + }); + setRespondingUserInputId((current) => + current === activePendingUserInput.requestId ? null : current, + ); + return result; + }, [activePendingUserInput, dismissUserInput, selectedThreadShell]); + return { activePendingApproval, activePendingUserInput, @@ -181,5 +205,6 @@ export function useSelectedThreadRequests() { onSelectUserInputOption, onChangeUserInputCustomAnswer, onSubmitUserInput, + onDismissUserInput, }; } diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 4350d145810c..6557888c38ae 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -215,7 +215,8 @@ const makeOrchestrationEngine = Effect.gen(function* () { // Command snapshots omit activities at startup and cap them while running. // Read this request's durable state before deciding how to send the answer. const userInputActivity = - envelope.command.type === "thread.user-input.respond" + envelope.command.type === "thread.user-input.respond" || + envelope.command.type === "thread.user-input.dismiss" ? yield* projectionSnapshotQuery.getUserInputActivity(envelope.command) : Option.none(); const eventBase = yield* decideOrchestrationCommand({ diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 9762d9bfd6da..37ab4730fb79 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1289,6 +1289,51 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.user-input.dismiss": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const request = userInputActivity; + if (request === undefined || request.kind !== "user-input.requested") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "This question has already been answered.", + }); + } + // Only async questions can be dropped silently. A native callback + // question leaves the provider blocked until it gets a reply, so it + // still needs an answer or an interrupted turn. + if (!Predicate.isObject(request.payload) || request.payload.responseMode !== "message") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "This question needs an answer. Answer it or stop the turn.", + }); + } + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.activity-appended", + payload: { + threadId: command.threadId, + activity: { + id: EventId.make(`async-dismiss:${command.requestId}`), + kind: "user-input.resolved", + summary: "User input dismissed", + tone: "info", + turnId: request.turnId, + createdAt: command.createdAt, + payload: { requestId: command.requestId, responseMode: "message" }, + }, + }, + }; + } + case "thread.checkpoint.revert": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/decider.userInputDismiss.test.ts b/apps/server/src/orchestration/decider.userInputDismiss.test.ts new file mode 100644 index 000000000000..5c7b495bc245 --- /dev/null +++ b/apps/server/src/orchestration/decider.userInputDismiss.test.ts @@ -0,0 +1,142 @@ +import { + CommandId, + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + ApprovalRequestId, + type OrchestrationReadModel, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { projectEvent } from "./projector.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const threadId = ThreadId.make("thread-1"); +const requestId = ApprovalRequestId.make("question-1"); + +function makeRequest(responseMode: "message" | undefined): OrchestrationThreadActivity { + return { + id: EventId.make(requestId), + kind: "user-input.requested", + summary: "Question", + tone: "approval", + turnId: null, + createdAt: NOW, + payload: { + requestId, + ...(responseMode === undefined ? {} : { responseMode }), + questions: [{ id: "0", header: "Q", question: "Continue?", options: [] }], + }, + }; +} + +function makeReadModel( + activities: ReadonlyArray, +): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: threadId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [...activities], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const command = { + type: "thread.user-input.dismiss" as const, + commandId: CommandId.make("dismiss-1"), + threadId, + requestId, + createdAt: NOW, +}; + +it.layer(NodeServices.layer)("user input dismiss decider", (it) => { + it.effect("closes an async question without sending a message or starting a turn", () => + Effect.gen(function* () { + const request = makeRequest("message"); + const readModel = makeReadModel([request]); + const result = yield* decideOrchestrationCommand({ + command, + readModel, + userInputActivity: request, + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual(["thread.activity-appended"]); + expect(events[0]?.payload).toMatchObject({ + threadId, + activity: { + kind: "user-input.resolved", + summary: "User input dismissed", + payload: { requestId, responseMode: "message" }, + }, + }); + const projected = yield* projectEvent(readModel, { ...events[0]!, sequence: 1 }); + expect(projected.threads[0]?.messages).toEqual([]); + expect(projected.threads[0]?.latestTurn).toBeNull(); + }), + ); + + it.effect("rejects dismissing a native callback question", () => + Effect.gen(function* () { + const request = makeRequest(undefined); + const result = yield* decideOrchestrationCommand({ + command, + readModel: makeReadModel([request]), + userInputActivity: request, + }).pipe(Effect.flip); + expect(result).toMatchObject({ + _tag: "OrchestrationCommandInvariantError", + detail: "This question needs an answer. Answer it or stop the turn.", + }); + }), + ); + + it.effect("rejects dismissing a question that was already resolved", () => + Effect.gen(function* () { + const resolved: OrchestrationThreadActivity = { + ...makeRequest("message"), + id: EventId.make("resolved"), + kind: "user-input.resolved", + }; + const result = yield* decideOrchestrationCommand({ + command, + readModel: makeReadModel([makeRequest("message"), resolved]), + userInputActivity: resolved, + }).pipe(Effect.flip); + expect(result).toMatchObject({ + _tag: "OrchestrationCommandInvariantError", + detail: "This question has already been answered.", + }); + }), + ); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2f2fe5a434eb..116536abccf2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1423,6 +1423,9 @@ export default function ChatView(props: ChatViewProps) { const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, { reportFailure: false, }); + const dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, { + reportFailure: false, + }); const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { reportFailure: false, }); @@ -6960,6 +6963,32 @@ export default function ChatView(props: ChatViewProps) { [activeThreadId, environmentId, respondToThreadUserInput, setThreadError], ); + // Closes an async question without messaging the agent. The server records + // the dismissal so every client releases the composer. + const onDismissUserInput = useCallback( + async (requestId: ApprovalRequestId) => { + if (!activeThreadId) return; + + setRespondingUserInputRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + const result = await dismissThreadUserInput({ + environmentId, + input: { threadId: activeThreadId, requestId }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : "Failed to dismiss the question.", + ); + } + setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + return result; + }, + [activeThreadId, dismissThreadUserInput, environmentId, setThreadError], + ); + const setActivePendingUserInputQuestionIndex = useCallback( (nextQuestionIndex: number) => { if (!activePendingUserInput) { @@ -8055,6 +8084,7 @@ export default function ChatView(props: ChatViewProps) { onSelectActivePendingUserInputOption } onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} + onDismissActivePendingUserInput={onDismissUserInput} onPreviousActivePendingUserInputQuestion={ onPreviousActivePendingUserInputQuestion } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0a54a2a55abb..ce26f96515cc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1284,6 +1284,7 @@ export interface ChatComposerProps { ) => Promise; onSelectActivePendingUserInputOption: (questionId: string, optionValue: string) => void; onAdvanceActivePendingUserInput: () => void; + onDismissActivePendingUserInput: (requestId: ApprovalRequestId) => void; onPreviousActivePendingUserInputQuestion: () => void; onChangeActivePendingUserInputCustomAnswer: ( questionId: string, @@ -1384,6 +1385,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onRespondToApproval, onSelectActivePendingUserInputOption, onAdvanceActivePendingUserInput, + onDismissActivePendingUserInput, onPreviousActivePendingUserInputQuestion, onChangeActivePendingUserInputCustomAnswer, onProviderModelSelect, @@ -4911,6 +4913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) questionIndex={activePendingQuestionIndex} onToggleOption={onSelectActivePendingUserInputOption} onAdvance={onAdvanceActivePendingUserInput} + onDismiss={onDismissActivePendingUserInput} /> ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? ( {!isChoiceOnlyPendingQuestion || activePendingProgress?.activeQuestion?.multiSelect ? ( diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx index 817182190b79..9b68cd438a2b 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx @@ -20,17 +20,19 @@ const prompt: PendingUserInput = { multiSelect: false, }, ], + dismissible: true, }; -function renderPanel() { +function renderPanel(pendingUserInput: PendingUserInput = prompt) { return renderToStaticMarkup( {}} onAdvance={() => {}} + onDismiss={() => {}} />, ); } @@ -50,6 +52,13 @@ describe("ComposerPendingUserInputPanel", () => { expect(markup).toMatch(new RegExp(`]*\\sid="${controlledId}"`)); }); + it("offers dismiss only for async questions", () => { + expect(renderPanel()).toContain("data-pending-user-input-dismiss"); + expect(renderPanel({ ...prompt, dismissible: false })).not.toContain( + "data-pending-user-input-dismiss", + ); + }); + it("starts expanded so the question and its options are visible", () => { const markup = renderPanel(); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 2a7df2488233..4803708d7943 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -17,6 +17,7 @@ interface PendingUserInputPanelProps { questionIndex: number; onToggleOption: (questionId: string, optionValue: string) => void; onAdvance: () => void; + onDismiss: (requestId: ApprovalRequestId) => void; } export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ @@ -26,6 +27,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn questionIndex, onToggleOption, onAdvance, + onDismiss, }: PendingUserInputPanelProps) { if (pendingUserInputs.length === 0) return null; const activePrompt = pendingUserInputs[0]; @@ -40,6 +42,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn questionIndex={questionIndex} onToggleOption={onToggleOption} onAdvance={onAdvance} + onDismiss={onDismiss} /> ); }); @@ -51,6 +54,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( questionIndex, onToggleOption, onAdvance, + onDismiss, }: { prompt: PendingUserInput; isResponding: boolean; @@ -58,6 +62,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( questionIndex: number; onToggleOption: (questionId: string, optionValue: string) => void; onAdvance: () => void; + onDismiss: (requestId: ApprovalRequestId) => void; }) { const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; @@ -198,6 +203,28 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ) : null} + {prompt.dismissible ? ( + // Sits inside the trigger button, so stop the click from toggling + // the disclosure. Dismiss closes the question without a reply. + } + aria-label="Dismiss question without answering" + title="Dismiss question without answering" + disabled={isResponding} + data-pending-user-input-dismiss + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onDismiss(prompt.requestId); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + onDismiss(prompt.requestId); + }} + /> + ) : null} diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 417287cc0032..085a8e55a257 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -54,7 +54,9 @@ contains a copied Codex setup. Use a fresh shadow directory and sign in again. Codex can ask a question and keep working. Answer it in the thread's question panel. The answer becomes a new message: it reaches the active turn, or starts another turn if Codex has finished. Unanswered questions survive reconnects. -This requires a Codex version that supports async questions. +If you do not want to answer, dismiss the question from its panel. Dismissing +closes it without sending anything to Codex. This requires a Codex version that +supports async questions. ## Approve app access diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 9bf75c838a99..3118895ae182 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -50,6 +50,7 @@ export type StartThreadTurnInput = CommandInput<"thread.turn.start">; export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">; export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">; export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; +export type DismissThreadUserInputInput = CommandInput<"thread.user-input.dismiss">; export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; export type StopThreadSessionInput = CommandInput<"thread.session.stop">; @@ -320,6 +321,17 @@ export const respondToThreadUserInput: (input: RespondToThreadUserInputInput) => }); }); +export const dismissThreadUserInput: (input: DismissThreadUserInputInput) => CommandEffect = + Effect.fn("EnvironmentCommands.dismissThreadUserInput")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.user-input.dismiss", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect = Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) { const metadata = yield* timestampedCommandMetadata(input); diff --git a/packages/client-runtime/src/pendingRequests.test.ts b/packages/client-runtime/src/pendingRequests.test.ts index 6ef4579044c2..6fc64fdc5d44 100644 --- a/packages/client-runtime/src/pendingRequests.test.ts +++ b/packages/client-runtime/src/pendingRequests.test.ts @@ -282,7 +282,25 @@ describe("pending questions", () => { payload: { requestId: "async-1", responseMode: "message", questions: [question] }, }), ]; - expect(derivePendingRequests(activities).userInputs[0]?.questions).toEqual([question]); + expect(derivePendingRequests(activities).userInputs[0]).toMatchObject({ + questions: [question], + dismissible: true, + }); + }); + + it("only marks async questions as dismissible", () => { + const question = { + id: "0", + header: "Question", + question: "Continue?", + options: [{ label: "Yes", description: "" }], + multiSelect: false, + }; + const native = makeActivity({ + kind: "user-input.requested", + payload: { requestId: "native-1", questions: [question] }, + }); + expect(derivePendingRequests([native]).userInputs[0]?.dismissible).toBe(false); }); it("preserves native choice values and the custom-answer restriction", () => { @@ -378,6 +396,7 @@ describe("pending questions", () => { { requestId: "req-user-input-1", createdAt: "2026-02-23T00:00:01.000Z", + dismissible: false, questions: [ { id: "sandbox_mode", diff --git a/packages/client-runtime/src/pendingRequests.ts b/packages/client-runtime/src/pendingRequests.ts index a94c49514e5d..54cac57e1af4 100644 --- a/packages/client-runtime/src/pendingRequests.ts +++ b/packages/client-runtime/src/pendingRequests.ts @@ -22,6 +22,8 @@ export interface PendingUserInput { readonly requestId: ApprovalRequestId; readonly createdAt: string; readonly questions: ReadonlyArray; + /** Async questions can be dismissed without a reply; native callbacks cannot. */ + readonly dismissible: boolean; } const isRequestId = Schema.is(ApprovalRequestId); @@ -160,7 +162,12 @@ export function derivePendingRequests(activities: ReadonlyArray( scheduler, concurrency, }), + dismissUserInput: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:dismiss-user-input", + execute: (input: DismissThreadUserInputInput) => dismissThreadUserInput(input), + scheduler, + concurrency, + }), revertCheckpoint: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:revert-checkpoint", execute: (input: RevertThreadCheckpointInput) => revertThreadCheckpoint(input), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 37f5476fecc8..da4eac53d9e6 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1033,6 +1033,17 @@ const ThreadUserInputRespondCommand = Schema.Struct({ createdAt: IsoDateTime, }); +// Closes an async question without answering it. The agent is not messaged; +// the composer is simply released. Native callback questions cannot be dismissed +// this way because the provider is blocked waiting on a reply. +const ThreadUserInputDismissCommand = Schema.Struct({ + type: Schema.Literal("thread.user-input.dismiss"), + commandId: CommandId, + threadId: ThreadId, + requestId: ApprovalRequestId, + createdAt: IsoDateTime, +}); + const ThreadCheckpointRevertCommand = Schema.Struct({ type: Schema.Literal("thread.checkpoint.revert"), commandId: CommandId, @@ -1077,6 +1088,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, + ThreadUserInputDismissCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, ]); @@ -1106,6 +1118,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, + ThreadUserInputDismissCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, ]); From 576aac290f48f27ac1499b3b04e2a28e93f97d6f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 17:14:07 -0700 Subject: [PATCH 2/2] test(mobile): expect dismissible on pending user inputs Co-Authored-By: Claude Fable 5.1 --- apps/mobile/src/lib/threadActivity.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 03fb7118b553..68f779f06579 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -100,6 +100,7 @@ describe("pending user input answers", () => { { requestId: "interaction_1", createdAt: requested.createdAt, + dismissible: false, questions: [nativeQuestion, singleSelectQuestion], }, ]);