diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx
index 14ec71c7380..1cceca08b2e 100644
--- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx
+++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx
@@ -49,6 +49,7 @@ interface ChatInputFooterProps {
pendingQuestion?: {
questionId: string;
question: string;
+ description?: string;
options?: { label: string; description?: string }[];
} | null;
isQuestionSubmitting?: boolean;
diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx
index 9064246551b..4af96469d6b 100644
--- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx
+++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx
@@ -9,6 +9,7 @@ interface QuestionInputOverlayProps {
question: {
questionId: string;
question: string;
+ description?: string;
options?: QuestionOption[];
};
isSubmitting: boolean;
@@ -63,9 +64,16 @@ export function QuestionInputOverlay({
{/* Question — pinned header */}
-
- {question.question}
-
+
+
+ {question.question}
+
+ {question.description && (
+
+ {question.description}
+
+ )}
+
;
+ if (toolName === "request_access") {
+ return (
+
+ );
}
if (toolName === "lsp_inspect") {
diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx
index 5fb7b93c428..245a903bcdd 100644
--- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx
+++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx
@@ -8,6 +8,7 @@ import {
} from "lucide-react";
import { useMemo } from "react";
import type { ToolPart } from "../../../../utils/tool-helpers";
+import { ToolStatusBadge } from "../ToolStatusBadge";
interface QuestionToolOption {
label: string;
@@ -124,27 +125,6 @@ function findAnswerForQuestion({
return undefined;
}
-type QuestionStatus = "awaiting" | "answered" | "cancelled";
-
-const QUESTION_STATUS_CONFIG: Record<
- QuestionStatus,
- { label: string; icon: typeof ClockIcon }
-> = {
- awaiting: { label: "Awaiting Response", icon: ClockIcon },
- answered: { label: "Answered", icon: CheckIcon },
- cancelled: { label: "Cancelled", icon: XIcon },
-};
-
-function QuestionStatusDescription({ status }: { status: QuestionStatus }) {
- const { label, icon: Icon } = QUESTION_STATUS_CONFIG[status];
- return (
-
-
- {label}
-
- );
-}
-
function toSingleQuestion(
args: Record,
): QuestionToolQuestion[] {
@@ -267,11 +247,11 @@ export function AskUserQuestionToolCall({
title="Question"
description={
isPending ? (
-
+
) : isAnswered ? (
-
+
) : isCancelled || isCancelledByError || isCancelledByStop ? (
-
+
) : undefined
}
>
diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/RequestSandboxAccessToolCall/RequestSandboxAccessToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/RequestSandboxAccessToolCall/RequestSandboxAccessToolCall.tsx
new file mode 100644
index 00000000000..f583d6a874f
--- /dev/null
+++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/RequestSandboxAccessToolCall/RequestSandboxAccessToolCall.tsx
@@ -0,0 +1,123 @@
+import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row";
+import {
+ CheckIcon,
+ CircleXIcon,
+ ClockIcon,
+ FolderLockIcon,
+ XIcon,
+} from "lucide-react";
+import type { ComponentType } from "react";
+import type { ToolPart } from "../../../../utils/tool-helpers";
+import type { ToolStatusBadgeVariant } from "../ToolStatusBadge";
+import { ToolStatusBadge } from "../ToolStatusBadge";
+
+interface RequestSandboxAccessToolCallProps {
+ part: ToolPart;
+ args: Record;
+ result: Record;
+ isInterrupted?: boolean;
+}
+
+type AccessStatus = "pending" | "granted" | "denied" | "cancelled" | "error";
+
+const ACCESS_STATUS_CONFIG: Record<
+ AccessStatus,
+ {
+ icon: ComponentType<{ className?: string }>;
+ label: string;
+ variant?: ToolStatusBadgeVariant;
+ }
+> = {
+ pending: { icon: ClockIcon, label: "Awaiting Response" },
+ granted: { icon: CheckIcon, label: "Access Granted" },
+ denied: { icon: XIcon, label: "Access Denied" },
+ cancelled: { icon: XIcon, label: "Cancelled" },
+ error: { icon: CircleXIcon, label: "Error", variant: "danger" },
+};
+
+function toAccessDecision(content: string): "granted" | "denied" | null {
+ if (content.startsWith("Access already granted")) return "granted";
+ if (content.startsWith("Access granted")) return "granted";
+ if (content.startsWith("Access denied")) return "denied";
+ return null;
+}
+
+function toAccessStatus(
+ part: ToolPart,
+ result: Record,
+ isInterrupted: boolean,
+): AccessStatus {
+ if (
+ isInterrupted &&
+ part.state !== "output-available" &&
+ part.state !== "output-error"
+ ) {
+ return "cancelled";
+ }
+ if (part.state !== "output-available" && part.state !== "output-error") {
+ return "pending";
+ }
+ if (part.state === "output-error" || result.isError === true) {
+ return "error";
+ }
+ const content =
+ (typeof result.content === "string" && result.content.trim()) ||
+ (typeof result.text === "string" && result.text.trim()) ||
+ "";
+ return toAccessDecision(content) ?? "error";
+}
+
+export function RequestSandboxAccessToolCall({
+ part,
+ args,
+ result,
+ isInterrupted = false,
+}: RequestSandboxAccessToolCallProps) {
+ const requestedPath = typeof args.path === "string" ? args.path.trim() : null;
+ const reason = typeof args.reason === "string" ? args.reason.trim() : null;
+
+ const status = toAccessStatus(part, result, isInterrupted);
+ const { icon, label, variant } = ACCESS_STATUS_CONFIG[status];
+ const statusBadge = (
+
+ );
+
+ const isPending = status === "pending";
+ const isCancelledOrError = status === "cancelled" || status === "error";
+ const hasContext = Boolean(requestedPath || reason);
+
+ return (
+
+ {!isPending && hasContext ? (
+
+ {requestedPath ? (
+
+ Path: {requestedPath}
+
+ ) : null}
+ {reason ? (
+
+ Reason: {reason}
+
+ ) : null}
+ {!isCancelledOrError ? (
+
+ {status === "granted" ? "Access granted" : "Access denied"}
+
+ ) : (
+
+
+ Aborted
+
+ )}
+
+ ) : undefined}
+
+ );
+}
diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/RequestSandboxAccessToolCall/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/RequestSandboxAccessToolCall/index.ts
new file mode 100644
index 00000000000..be4eeb9f3d8
--- /dev/null
+++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/RequestSandboxAccessToolCall/index.ts
@@ -0,0 +1 @@
+export { RequestSandboxAccessToolCall } from "./RequestSandboxAccessToolCall";
diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/ToolStatusBadge/ToolStatusBadge.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/ToolStatusBadge/ToolStatusBadge.tsx
new file mode 100644
index 00000000000..c9368456f15
--- /dev/null
+++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/ToolStatusBadge/ToolStatusBadge.tsx
@@ -0,0 +1,34 @@
+import { cn } from "@superset/ui/lib/utils";
+import type { ComponentType } from "react";
+
+const VARIANT_CLASSES = {
+ default: "",
+ success: "text-emerald-500",
+ danger: "text-destructive",
+} as const;
+
+export type ToolStatusBadgeVariant = keyof typeof VARIANT_CLASSES;
+
+interface ToolStatusBadgeProps {
+ icon: ComponentType<{ className?: string }>;
+ label: string;
+ variant?: ToolStatusBadgeVariant;
+}
+
+export function ToolStatusBadge({
+ icon: Icon,
+ label,
+ variant = "default",
+}: ToolStatusBadgeProps) {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/ToolStatusBadge/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/ToolStatusBadge/index.ts
new file mode 100644
index 00000000000..0a5773c27ea
--- /dev/null
+++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/ToolStatusBadge/index.ts
@@ -0,0 +1,2 @@
+export type { ToolStatusBadgeVariant } from "./ToolStatusBadge";
+export { ToolStatusBadge } from "./ToolStatusBadge";
diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.test.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.test.ts
index 502c3d31a31..dcc55d947e4 100644
--- a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.test.ts
+++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.test.ts
@@ -17,9 +17,7 @@ describe("normalizeToolName", () => {
expect(normalizeToolName("web_extract")).toBe("web_fetch");
expect(normalizeToolName("ask_user")).toBe("ask_user_question");
expect(normalizeToolName("ast_smart_edit")).toBe("ast_smart_edit");
- expect(normalizeToolName("request_sandbox_access")).toBe(
- "request_sandbox_access",
- );
+ expect(normalizeToolName("request_sandbox_access")).toBe("request_access");
expect(normalizeToolName("task_write")).toBe("task_write");
expect(normalizeToolName("task_check")).toBe("task_check");
expect(normalizeToolName("submit_plan")).toBe("submit_plan");
diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts
index 2248a598674..6d493864ab6 100644
--- a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts
+++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts
@@ -29,7 +29,8 @@ const TOOL_NAME_ALIASES: Record = {
// Keep explicit passthroughs for newer Mastra tool names
ast_smart_edit: "ast_smart_edit",
- request_sandbox_access: "request_sandbox_access",
+ request_access: "request_access",
+ request_sandbox_access: "request_access",
task_write: "task_write",
task_check: "task_check",
submit_plan: "submit_plan",
diff --git a/packages/chat/src/server/trpc/service.test.ts b/packages/chat/src/server/trpc/service.test.ts
index 7adf7272d1e..af4f55e508b 100644
--- a/packages/chat/src/server/trpc/service.test.ts
+++ b/packages/chat/src/server/trpc/service.test.ts
@@ -20,14 +20,17 @@ mock.module("mastracode", () => ({
const { ChatRuntimeService } = await import("./service");
-function createRuntime(): RuntimeSession {
+function createRuntime(options?: {
+ respondToQuestion?: RuntimeSession["harness"]["respondToQuestion"];
+}): RuntimeSession {
return {
sessionId: SESSION_ID,
cwd: CWD,
harness: {
abort: mock(() => {}),
respondToToolApproval: mock(async (payload: unknown) => payload),
- respondToQuestion: mock(async (payload: unknown) => payload),
+ respondToQuestion:
+ options?.respondToQuestion ?? mock(async (payload: unknown) => payload),
respondToPlanApproval: mock(async (payload: unknown) => payload),
} as unknown as RuntimeSession["harness"],
mcpManager: null as RuntimeSession["mcpManager"],
@@ -39,11 +42,13 @@ function createRuntime(): RuntimeSession {
path: "/tmp/secret",
reason: "Need access",
},
+ answeredQuestionIds: new Set(),
+ pendingQuestionResponses: new Map(),
};
}
-function createServiceHarness() {
- const runtime = createRuntime();
+function createServiceHarness(options?: Parameters[0]) {
+ const runtime = createRuntime(options);
const service = new ChatRuntimeService({
headers: async () => ({}),
apiUrl: "http://localhost:3000",
@@ -134,4 +139,61 @@ describe("ChatRuntimeService control mutations", () => {
});
expect(runtime.pendingSandboxQuestion).toBeNull();
});
+
+ it("does not clear pending question state when question response fails", async () => {
+ const respondToQuestion = mock(async () => {
+ throw new Error("failed to answer");
+ }) as RuntimeSession["harness"]["respondToQuestion"];
+ const { caller, runtime } = createServiceHarness({ respondToQuestion });
+
+ await expect(
+ caller.session.question.respond({
+ sessionId: SESSION_ID,
+ cwd: CWD,
+ payload: { questionId: "sandbox-1", answer: "Yes" },
+ }),
+ ).rejects.toThrow("failed to answer");
+
+ expect(runtime.answeredQuestionIds.has("sandbox-1")).toBe(false);
+ expect(runtime.pendingSandboxQuestion).toEqual({
+ questionId: "sandbox-1",
+ path: "/tmp/secret",
+ reason: "Need access",
+ });
+ });
+
+ it("deduplicates concurrent responses for the same question", async () => {
+ let resolveResponse: (value: unknown) => void = () => {};
+ const respondToQuestion = mock(
+ () =>
+ new Promise((resolve) => {
+ resolveResponse = resolve;
+ }),
+ ) as RuntimeSession["harness"]["respondToQuestion"];
+ const { caller, runtime } = createServiceHarness({ respondToQuestion });
+ const payload = { questionId: "sandbox-1", answer: "Yes" };
+
+ const firstResponse = caller.session.question.respond({
+ sessionId: SESSION_ID,
+ cwd: CWD,
+ payload,
+ });
+ const secondResponse = caller.session.question.respond({
+ sessionId: SESSION_ID,
+ cwd: CWD,
+ payload,
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(respondToQuestion).toHaveBeenCalledTimes(1);
+ expect(runtime.answeredQuestionIds.has("sandbox-1")).toBe(true);
+ expect(runtime.pendingSandboxQuestion).toBeNull();
+
+ resolveResponse({ ok: true });
+
+ await expect(firstResponse).resolves.toEqual({ ok: true });
+ await expect(secondResponse).resolves.toEqual({ ok: true });
+ expect(runtime.pendingQuestionResponses.size).toBe(0);
+ });
});
diff --git a/packages/chat/src/server/trpc/service.ts b/packages/chat/src/server/trpc/service.ts
index b173061c742..8299f9b29ee 100644
--- a/packages/chat/src/server/trpc/service.ts
+++ b/packages/chat/src/server/trpc/service.ts
@@ -11,6 +11,7 @@ import {
getRuntimeMcpOverview,
type LifecycleEvent,
onUserPromptSubmit,
+ type RuntimeQuestionResponse,
type RuntimeSession,
reloadHookConfig,
restartRuntimeFromUserMessage,
@@ -35,6 +36,55 @@ import {
const ENABLE_MASTRA_MCP_SERVERS = false;
+type RuntimeQuestionPayload = Parameters<
+ RuntimeSession["harness"]["respondToQuestion"]
+>[0];
+
+function respondToQuestionWithOptimisticState(
+ runtime: RuntimeSession,
+ payload: RuntimeQuestionPayload,
+): Promise {
+ const questionId = payload.questionId;
+ const pendingResponse = runtime.pendingQuestionResponses.get(questionId);
+ if (pendingResponse) return pendingResponse;
+
+ const wasAlreadyAnswered = runtime.answeredQuestionIds.has(questionId);
+ const previousSandboxQuestion = runtime.pendingSandboxQuestion;
+ const clearsSandboxQuestion =
+ previousSandboxQuestion?.questionId === questionId;
+
+ runtime.answeredQuestionIds.add(questionId);
+ if (clearsSandboxQuestion) {
+ runtime.pendingSandboxQuestion = null;
+ }
+
+ let responsePromise: Promise;
+ responsePromise = Promise.resolve()
+ .then(() => runtime.harness.respondToQuestion(payload))
+ .catch((error) => {
+ if (
+ runtime.pendingQuestionResponses.get(questionId) === responsePromise
+ ) {
+ if (!wasAlreadyAnswered) {
+ runtime.answeredQuestionIds.delete(questionId);
+ }
+ if (clearsSandboxQuestion && runtime.pendingSandboxQuestion === null) {
+ runtime.pendingSandboxQuestion = previousSandboxQuestion;
+ }
+ }
+ throw error;
+ })
+ .finally(() => {
+ if (
+ runtime.pendingQuestionResponses.get(questionId) === responsePromise
+ ) {
+ runtime.pendingQuestionResponses.delete(questionId);
+ }
+ });
+ runtime.pendingQuestionResponses.set(questionId, responsePromise);
+ return responsePromise;
+}
+
function resolveOmModelFromAuth(): string | undefined {
if (process.env.GOOGLE_GENERATIVE_AI_API_KEY)
return "google/gemini-2.5-flash";
@@ -141,6 +191,8 @@ export class ChatRuntimeService {
mcpManualStatuses: new Map(),
lastErrorMessage: null,
pendingSandboxQuestion: null,
+ answeredQuestionIds: new Set(),
+ pendingQuestionResponses: new Map(),
cwd: runtimeCwd,
};
syncRuntimeHookSessionId(sessionRuntime);
@@ -225,22 +277,30 @@ export class ChatRuntimeService {
? {
questionId: runtime.pendingSandboxQuestion.questionId,
question: `Grant sandbox access to "${runtime.pendingSandboxQuestion.path}"?`,
+ description: runtime.pendingSandboxQuestion.reason,
options: [
{
label: "Yes",
- description: `Allow access. Reason: ${runtime.pendingSandboxQuestion.reason}`,
- },
- {
- label: "No",
- description: "Deny access.",
+ description: "Allow access.",
},
+ { label: "No", description: "Deny access." },
],
}
: null;
+ // Skip any pending question whose ID was already answered this turn.
+ // The harness only clears pendingQuestion on agent_end, so without this
+ // filter an answered ask_user question would permanently shadow the
+ // sandbox question that fired in the same turn.
+ const harnessPendingQuestion =
+ displayState.pendingQuestion &&
+ !runtime.answeredQuestionIds.has(
+ displayState.pendingQuestion.questionId,
+ )
+ ? displayState.pendingQuestion
+ : null;
return {
...displayState,
- pendingQuestion:
- displayState.pendingQuestion ?? sandboxPendingQuestion,
+ pendingQuestion: harnessPendingQuestion ?? sandboxPendingQuestion,
errorMessage: currentMessageError ?? runtime.lastErrorMessage,
};
}),
@@ -348,13 +408,10 @@ export class ChatRuntimeService {
input.sessionId,
input.cwd,
);
- if (
- runtime.pendingSandboxQuestion?.questionId ===
- input.payload.questionId
- ) {
- runtime.pendingSandboxQuestion = null;
- }
- return runtime.harness.respondToQuestion(input.payload);
+ return respondToQuestionWithOptimisticState(
+ runtime,
+ input.payload,
+ );
}),
}),
diff --git a/packages/chat/src/server/trpc/utils/runtime/index.ts b/packages/chat/src/server/trpc/utils/runtime/index.ts
index 3ebd1de45e0..d3dc1dd02f6 100644
--- a/packages/chat/src/server/trpc/utils/runtime/index.ts
+++ b/packages/chat/src/server/trpc/utils/runtime/index.ts
@@ -7,6 +7,7 @@ export {
type RuntimeHookManager,
type RuntimeMcpManager,
type RuntimeMcpServerStatus,
+ type RuntimeQuestionResponse,
type RuntimeSession,
reloadHookConfig,
restartRuntimeFromUserMessage,
diff --git a/packages/chat/src/server/trpc/utils/runtime/runtime.test.ts b/packages/chat/src/server/trpc/utils/runtime/runtime.test.ts
index 0327f0bf5ad..d93acb70520 100644
--- a/packages/chat/src/server/trpc/utils/runtime/runtime.test.ts
+++ b/packages/chat/src/server/trpc/utils/runtime/runtime.test.ts
@@ -55,6 +55,8 @@ function createRuntimeForTest(): {
mcpManualStatuses: new Map(),
lastErrorMessage: null,
pendingSandboxQuestion: null,
+ answeredQuestionIds: new Set(),
+ pendingQuestionResponses: new Map(),
cwd: "/tmp",
};
@@ -103,6 +105,8 @@ function createRuntimeForTitleTest(options?: {
mcpManualStatuses: new Map(),
lastErrorMessage: null,
pendingSandboxQuestion: null,
+ answeredQuestionIds: new Set(),
+ pendingQuestionResponses: new Map(),
cwd: "/tmp",
};
@@ -339,6 +343,8 @@ describe("runtime message restart", () => {
mcpManualStatuses: new Map(),
lastErrorMessage: "stale error",
pendingSandboxQuestion: null,
+ answeredQuestionIds: new Set(),
+ pendingQuestionResponses: new Map(),
cwd: "/tmp",
};
diff --git a/packages/chat/src/server/trpc/utils/runtime/runtime.ts b/packages/chat/src/server/trpc/utils/runtime/runtime.ts
index 905ec9ef8ef..2b1143693fb 100644
--- a/packages/chat/src/server/trpc/utils/runtime/runtime.ts
+++ b/packages/chat/src/server/trpc/utils/runtime/runtime.ts
@@ -13,6 +13,9 @@ export type RuntimeMcpManager = Awaited<
export type RuntimeHookManager = Awaited<
ReturnType
>["hookManager"];
+export type RuntimeQuestionResponse = Awaited<
+ ReturnType
+>;
export interface RuntimeMcpServerStatus {
connected: boolean;
@@ -32,6 +35,8 @@ export interface RuntimeSession {
path: string;
reason: string;
} | null;
+ answeredQuestionIds: Set;
+ pendingQuestionResponses: Map>;
cwd: string;
}
@@ -224,6 +229,8 @@ export function subscribeToSessionEvents(
if (isHarnessAgentStartEvent(event)) {
runtime.lastErrorMessage = null;
runtime.pendingSandboxQuestion = null;
+ runtime.answeredQuestionIds.clear();
+ runtime.pendingQuestionResponses.clear();
onLifecycleEvent?.({
sessionId: runtime.sessionId,
eventType: "Start",
@@ -232,6 +239,8 @@ export function subscribeToSessionEvents(
}
if (isHarnessAgentEndEvent(event)) {
runtime.pendingSandboxQuestion = null;
+ runtime.answeredQuestionIds.clear();
+ runtime.pendingQuestionResponses.clear();
const raw = event.reason;
const reason = raw === "aborted" || raw === "error" ? raw : "complete";
if (runtime.hookManager) {
diff --git a/packages/host-service/src/runtime/chat/chat.ts b/packages/host-service/src/runtime/chat/chat.ts
index a25c253ecb6..514fb3871c3 100644
--- a/packages/host-service/src/runtime/chat/chat.ts
+++ b/packages/host-service/src/runtime/chat/chat.ts
@@ -59,12 +59,13 @@ interface PendingSandboxQuestion {
interface ChatPendingQuestionOption {
label: string;
- description: string;
+ description?: string;
}
interface ChatPendingQuestion {
questionId: string;
question: string;
+ description?: string;
options: ChatPendingQuestionOption[];
}
@@ -102,6 +103,53 @@ interface RuntimeSession {
hookManager: RuntimeHookManager;
lastErrorMessage: string | null;
pendingSandboxQuestion: PendingSandboxQuestion | null;
+ answeredQuestionIds: Set;
+ pendingQuestionResponses: Map>;
+}
+
+function respondToQuestionWithOptimisticState(
+ runtime: RuntimeSession,
+ payload: ChatQuestionPayload,
+): Promise {
+ const questionId = payload.questionId;
+ const pendingResponse = runtime.pendingQuestionResponses.get(questionId);
+ if (pendingResponse) return pendingResponse;
+
+ const wasAlreadyAnswered = runtime.answeredQuestionIds.has(questionId);
+ const previousSandboxQuestion = runtime.pendingSandboxQuestion;
+ const clearsSandboxQuestion =
+ previousSandboxQuestion?.questionId === questionId;
+
+ runtime.answeredQuestionIds.add(questionId);
+ if (clearsSandboxQuestion) {
+ runtime.pendingSandboxQuestion = null;
+ }
+
+ let responsePromise: Promise;
+ responsePromise = Promise.resolve()
+ .then(() => runtime.harness.respondToQuestion(payload))
+ .catch((error) => {
+ if (
+ runtime.pendingQuestionResponses.get(questionId) === responsePromise
+ ) {
+ if (!wasAlreadyAnswered) {
+ runtime.answeredQuestionIds.delete(questionId);
+ }
+ if (clearsSandboxQuestion && runtime.pendingSandboxQuestion === null) {
+ runtime.pendingSandboxQuestion = previousSandboxQuestion;
+ }
+ }
+ throw error;
+ })
+ .finally(() => {
+ if (
+ runtime.pendingQuestionResponses.get(questionId) === responsePromise
+ ) {
+ runtime.pendingQuestionResponses.delete(questionId);
+ }
+ });
+ runtime.pendingQuestionResponses.set(questionId, responsePromise);
+ return responsePromise;
}
interface RuntimeStoredMessage {
@@ -342,11 +390,15 @@ export class ChatRuntimeManager {
if (isObjectRecord(event) && event.type === "agent_start") {
runtime.lastErrorMessage = null;
runtime.pendingSandboxQuestion = null;
+ runtime.answeredQuestionIds.clear();
+ runtime.pendingQuestionResponses.clear();
return;
}
if (isObjectRecord(event) && event.type === "agent_end") {
runtime.pendingSandboxQuestion = null;
+ runtime.answeredQuestionIds.clear();
+ runtime.pendingQuestionResponses.clear();
}
});
}
@@ -419,6 +471,8 @@ When you need to ask the user ANY question — including simple yes/no, confirma
hookManager: runtime.hookManager,
lastErrorMessage: null,
pendingSandboxQuestion: null,
+ answeredQuestionIds: new Set(),
+ pendingQuestionResponses: new Map(),
};
this.subscribeToSessionEvents(sessionRuntime);
this.runtimes.set(sessionId, sessionRuntime);
@@ -471,26 +525,32 @@ When you need to ask the user ANY question — including simple yes/no, confirma
? currentMessage.errorMessage.trim()
: null;
+ // Skip any pending question whose ID was already answered this turn.
+ // The harness only clears pendingQuestion on agent_end, so without this
+ // filter an answered ask_user question would permanently shadow the
+ // sandbox question that fired in the same turn.
+ const harnessPendingQuestion =
+ displayState.pendingQuestion &&
+ !runtime.answeredQuestionIds.has(displayState.pendingQuestion.questionId)
+ ? displayState.pendingQuestion
+ : null;
+ const sandboxPendingQuestion = runtime.pendingSandboxQuestion
+ ? {
+ questionId: runtime.pendingSandboxQuestion.questionId,
+ question: `Grant sandbox access to "${runtime.pendingSandboxQuestion.path}"?`,
+ description: runtime.pendingSandboxQuestion.reason,
+ options: [
+ {
+ label: "Yes",
+ description: "Allow access.",
+ },
+ { label: "No", description: "Deny access." },
+ ],
+ }
+ : null;
return {
...displayState,
- pendingQuestion:
- displayState.pendingQuestion ??
- (runtime.pendingSandboxQuestion
- ? {
- questionId: runtime.pendingSandboxQuestion.questionId,
- question: `Grant sandbox access to "${runtime.pendingSandboxQuestion.path}"?`,
- options: [
- {
- label: "Yes",
- description: `Allow access. Reason: ${runtime.pendingSandboxQuestion.reason}`,
- },
- {
- label: "No",
- description: "Deny access.",
- },
- ],
- }
- : null),
+ pendingQuestion: harnessPendingQuestion ?? sandboxPendingQuestion,
errorMessage: currentMessageError ?? runtime.lastErrorMessage,
};
}
@@ -570,13 +630,7 @@ When you need to ask the user ANY question — including simple yes/no, confirma
input.workspaceId,
);
- if (
- runtime.pendingSandboxQuestion?.questionId === input.payload.questionId
- ) {
- runtime.pendingSandboxQuestion = null;
- }
-
- return runtime.harness.respondToQuestion(input.payload);
+ return respondToQuestionWithOptimisticState(runtime, input.payload);
}
async respondToPlan(input: {