diff --git a/AGENTS.md b/AGENTS.md index ac24c6a2c7..f30c0c2532 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,14 @@ - All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed. - NEVER run `bun test`. Always use `bun run test` (runs Vitest). +## Bun Gotcha + +- In this environment, `bun` may not be on `PATH` even though Bun is installed at `/home/claude/.bun/bin/bun`. +- If plain `bun ...` fails with `bun: command not found`, use the absolute binary path instead. +- For `bun typecheck`, also prepend Bun to `PATH` so Turbo can find the package manager binary: + `env PATH="/home/claude/.bun/bin:$PATH" /home/claude/.bun/bin/bun typecheck` +- `bun fmt` and `bun lint` can be run directly with `/home/claude/.bun/bin/bun fmt` and `/home/claude/.bun/bin/bun lint`. + ## Project Snapshot T3 Code is a minimal web GUI for using code agents like Codex and Claude Code (coming soon). diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8de44d78f9..f06e5be868 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -713,6 +713,117 @@ describe("ProviderCommandReactor", () => { }); }); + it("surfaces stale provider user-input failures with a clear recovery message", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + harness.respondToUserInput.mockImplementation(() => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "item/tool/requestUserInput", + detail: "Unknown pending user input request: user-input-request-1", + }), + ), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-for-user-input-error"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.makeUnsafe("cmd-user-input-requested"), + threadId: ThreadId.makeUnsafe("thread-1"), + activity: { + id: EventId.makeUnsafe("activity-user-input-requested"), + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { + requestId: "user-input-request-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + turnId: null, + createdAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.user-input.respond", + commandId: CommandId.makeUnsafe("cmd-user-input-respond-stale"), + threadId: ThreadId.makeUnsafe("thread-1"), + requestId: asApprovalRequestId("user-input-request-1"), + answers: { + sandbox_mode: "workspace-write", + }, + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + if (!thread) return false; + return thread.activities.some( + (activity) => activity.kind === "provider.user-input.respond.failed", + ); + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread).toBeDefined(); + + const failureActivity = thread?.activities.find( + (activity) => activity.kind === "provider.user-input.respond.failed", + ); + expect(failureActivity?.payload).toMatchObject({ + requestId: "user-input-request-1", + detail: + "This question came from an earlier provider session and expired after the session restarted. Please ask the agent to re-ask the question.", + }); + + const resolvedActivity = thread?.activities.find( + (activity) => + activity.kind === "user-input.resolved" && + typeof activity.payload === "object" && + activity.payload !== null && + (activity.payload as Record).requestId === "user-input-request-1", + ); + expect(resolvedActivity).toBeUndefined(); + }); + it("surfaces stale provider approval request failures without faking approval resolution", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fe02188450..0877e68e82 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -90,6 +90,28 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { + const error = Cause.squash(cause); + if (Schema.is(ProviderAdapterRequestError)(error)) { + return error.detail.toLowerCase().includes("unknown pending user input request"); + } + const message = Cause.pretty(cause).toLowerCase(); + return ( + message.includes("unknown pending user input request") || + message.includes("belongs to a previous provider session") + ); +} + +function describePendingUserInputFailure(cause: Cause.Cause): string { + if (isStalePendingUserInputError(cause)) { + return ( + "This question came from an earlier provider session and expired after the session restarted. " + + "Please ask the agent to re-ask the question." + ); + } + return Cause.pretty(cause); +} + function isTemporaryWorktreeBranch(branch: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); } @@ -578,7 +600,7 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, kind: "provider.user-input.respond.failed", summary: "Provider user input response failed", - detail: Cause.pretty(cause), + detail: describePendingUserInputFailure(cause), turnId: null, createdAt: event.payload.createdAt, requestId: event.payload.requestId, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index d5cf4424b1..74db09fd4e 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -493,6 +493,42 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("fails fast for user input replies after the provider session is gone", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-user-input-stale"), { + provider: "codex", + threadId: asThreadId("thread-user-input-stale"), + runtimeMode: "full-access", + }); + yield* routing.codex.stopSession(session.threadId); + routing.codex.startSession.mockClear(); + routing.codex.respondToUserInput.mockClear(); + + const response = yield* Effect.result( + provider.respondToUserInput({ + threadId: session.threadId, + requestId: asRequestId("req-user-input-stale"), + answers: { + sandbox_mode: "workspace-write", + }, + }), + ); + + assertFailure( + response, + new ProviderValidationError({ + operation: "ProviderService.respondToUserInput", + issue: + "This question belongs to a previous provider session and can no longer be answered. Ask the agent to re-ask it.", + }), + ); + assert.equal(routing.codex.startSession.mock.calls.length, 0); + assert.equal(routing.codex.respondToUserInput.mock.calls.length, 0); + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 8e3bc72041..a7bd34c6ff 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -397,8 +397,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const routed = yield* resolveRoutableSession({ threadId: input.threadId, operation: "ProviderService.respondToUserInput", - allowRecovery: true, + allowRecovery: false, }); + if (!routed.isActive) { + return yield* toValidationError( + "ProviderService.respondToUserInput", + "This question belongs to a previous provider session and can no longer be answered. Ask the agent to re-ask it.", + ); + } yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f625762cf..cc348a8040 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -106,6 +106,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; +import { logUserInputDebug } from "~/debug/userInputDebug"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { @@ -273,6 +274,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const planSidebarOpenOnNextThreadRef = useRef(false); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); + const lastPendingUserInputDebugStateRef = useRef(null); + const lastThreadErrorDebugValueRef = useRef(null); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -2516,24 +2519,47 @@ export default function ChatView({ threadId }: ChatViewProps) { const api = readNativeApi(); if (!api || !activeThreadId) return; + logUserInputDebug({ + level: "info", + stage: "dispatch-start", + message: "Dispatching thread.user-input.respond", + threadId: activeThreadId, + requestId, + detail: JSON.stringify(answers, null, 2), + }); setRespondingUserInputRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); - await api.orchestration - .dispatchCommand({ + try { + const result = await api.orchestration.dispatchCommand({ type: "thread.user-input.respond", commandId: newCommandId(), threadId: activeThreadId, requestId, answers, createdAt: new Date().toISOString(), - }) - .catch((err: unknown) => { - setStoreThreadError( - activeThreadId, - err instanceof Error ? err.message : "Failed to submit user input.", - ); }); + logUserInputDebug({ + level: "success", + stage: "dispatch-success", + message: "thread.user-input.respond accepted by orchestration", + threadId: activeThreadId, + requestId, + detail: JSON.stringify(result, null, 2), + }); + } catch (err: unknown) { + logUserInputDebug({ + level: "error", + stage: "dispatch-error", + message: err instanceof Error ? err.message : "Failed to submit user input.", + threadId: activeThreadId, + requestId, + }); + setStoreThreadError( + activeThreadId, + err instanceof Error ? err.message : "Failed to submit user input.", + ); + } setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, [activeThreadId, setStoreThreadError], @@ -2557,6 +2583,14 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activePendingUserInput) { return; } + logUserInputDebug({ + level: "info", + stage: "option-selected", + message: `Selected option "${optionLabel}"`, + threadId: activeThreadId, + requestId: activePendingUserInput.requestId, + detail: JSON.stringify({ questionId }, null, 2), + }); setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, [activePendingUserInput.requestId]: { @@ -2571,7 +2605,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(0); setComposerTrigger(null); }, - [activePendingUserInput], + [activePendingUserInput, activeThreadId], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -2606,16 +2640,50 @@ export default function ChatView({ threadId }: ChatViewProps) { const onAdvanceActivePendingUserInput = useCallback(() => { if (!activePendingUserInput || !activePendingProgress) { + logUserInputDebug({ + level: "warning", + stage: "advance-click", + message: "Advance clicked without an active pending question", + threadId: activeThreadId, + }); return; } + logUserInputDebug({ + level: "info", + stage: "advance-click", + message: activePendingProgress.isLastQuestion + ? "Submit answers clicked" + : "Next question clicked", + threadId: activeThreadId, + requestId: activePendingUserInput.requestId, + detail: JSON.stringify( + { + questionIndex: activePendingProgress.questionIndex, + isLastQuestion: activePendingProgress.isLastQuestion, + canAdvance: activePendingProgress.canAdvance, + hasResolvedAnswers: activePendingResolvedAnswers !== null, + }, + null, + 2, + ), + }); if (activePendingProgress.isLastQuestion) { if (activePendingResolvedAnswers) { void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); + } else { + logUserInputDebug({ + level: "warning", + stage: "submit-blocked", + message: "Submit was clicked but answers were not fully resolved", + threadId: activeThreadId, + requestId: activePendingUserInput.requestId, + }); } return; } setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); }, [ + activeThreadId, activePendingProgress, activePendingResolvedAnswers, activePendingUserInput, @@ -2623,6 +2691,65 @@ export default function ChatView({ threadId }: ChatViewProps) { setActivePendingUserInputQuestionIndex, ]); + useEffect(() => { + if (!activeThreadId) { + lastPendingUserInputDebugStateRef.current = null; + return; + } + const nextState = JSON.stringify({ + pendingCount: pendingUserInputs.length, + activeRequestId: activePendingUserInput?.requestId ?? null, + questionIndex: activePendingQuestionIndex, + isResponding: activePendingIsResponding, + isLastQuestion: activePendingProgress?.isLastQuestion ?? null, + canAdvance: activePendingProgress?.canAdvance ?? null, + isComplete: activePendingProgress?.isComplete ?? null, + answeredQuestionCount: activePendingProgress?.answeredQuestionCount ?? null, + hasResolvedAnswers: activePendingResolvedAnswers !== null, + }); + if (lastPendingUserInputDebugStateRef.current === nextState) { + return; + } + lastPendingUserInputDebugStateRef.current = nextState; + logUserInputDebug({ + level: "info", + stage: "pending-state", + message: "Pending user input state changed", + threadId: activeThreadId, + requestId: activePendingUserInput?.requestId ?? null, + detail: nextState, + }); + }, [ + activePendingIsResponding, + activePendingProgress?.answeredQuestionCount, + activePendingProgress?.canAdvance, + activePendingProgress?.isComplete, + activePendingProgress?.isLastQuestion, + activePendingQuestionIndex, + activePendingResolvedAnswers, + activePendingUserInput?.requestId, + activeThreadId, + pendingUserInputs.length, + ]); + + useEffect(() => { + const nextError = activeThread?.error ?? null; + if (lastThreadErrorDebugValueRef.current === nextError) { + return; + } + lastThreadErrorDebugValueRef.current = nextError; + if (!nextError) { + return; + } + logUserInputDebug({ + level: "error", + stage: "thread-error", + message: nextError, + threadId: activeThread?.id ?? activeThreadId, + requestId: activePendingUserInput?.requestId ?? null, + }); + }, [activePendingUserInput?.requestId, activeThread?.error, activeThread?.id, activeThreadId]); + const onPreviousActivePendingUserInputQuestion = useCallback(() => { if (!activePendingProgress) { return; @@ -3636,9 +3763,10 @@ export default function ChatView({ threadId }: ChatViewProps) { ) : null} + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/debug/userInputDebug.ts b/apps/web/src/debug/userInputDebug.ts new file mode 100644 index 0000000000..08316297d2 --- /dev/null +++ b/apps/web/src/debug/userInputDebug.ts @@ -0,0 +1,225 @@ +import { create } from "zustand"; + +const USER_INPUT_DEBUG_QUERY_PARAM = "debugUserInput"; +const USER_INPUT_DEBUG_STORAGE_KEY = "t3code:debug-user-input"; +const MAX_DEBUG_ENTRIES = 200; + +type UserInputDebugLevel = "info" | "success" | "warning" | "error"; + +export interface UserInputDebugEntry { + id: string; + timestamp: string; + level: UserInputDebugLevel; + stage: string; + message: string; + threadId?: string | null; + requestId?: string | null; + detail?: string; +} + +interface UserInputDebugState { + enabled: boolean; + collapsed: boolean; + position: { x: number; y: number } | null; + entries: UserInputDebugEntry[]; + setEnabled: (enabled: boolean) => void; + setCollapsed: (collapsed: boolean) => void; + setPosition: (position: { x: number; y: number } | null) => void; + pushEntry: (entry: Omit) => void; + clear: () => void; +} + +function readSearchParamEnabled(): boolean { + if (typeof window === "undefined") { + return false; + } + const value = new URLSearchParams(window.location.search).get(USER_INPUT_DEBUG_QUERY_PARAM); + return value === "1" || value === "true" || value === "on"; +} + +function readPersistedEnabled(): boolean { + if (typeof window === "undefined") { + return false; + } + try { + const raw = window.localStorage.getItem(USER_INPUT_DEBUG_STORAGE_KEY); + if (!raw) { + return false; + } + if (raw === "1") { + return true; + } + const parsed = JSON.parse(raw) as { enabled?: boolean }; + return parsed.enabled === true; + } catch { + return false; + } +} + +function readPersistedLayout(): { + collapsed: boolean; + position: { x: number; y: number } | null; +} { + if (typeof window === "undefined") { + return { collapsed: false, position: null }; + } + try { + const raw = window.localStorage.getItem(USER_INPUT_DEBUG_STORAGE_KEY); + if (!raw || raw === "1") { + return { collapsed: false, position: null }; + } + const parsed = JSON.parse(raw) as { + collapsed?: boolean; + position?: { x?: number; y?: number } | null; + }; + return { + collapsed: parsed.collapsed === true, + position: + parsed.position && + typeof parsed.position.x === "number" && + typeof parsed.position.y === "number" + ? { x: parsed.position.x, y: parsed.position.y } + : null, + }; + } catch { + return { collapsed: false, position: null }; + } +} + +function persistDebugState(input: { + enabled: boolean; + collapsed: boolean; + position: { x: number; y: number } | null; +}): void { + if (typeof window === "undefined") { + return; + } + try { + if (input.enabled) { + window.localStorage.setItem( + USER_INPUT_DEBUG_STORAGE_KEY, + JSON.stringify({ + enabled: true, + collapsed: input.collapsed, + position: input.position, + }), + ); + return; + } + window.localStorage.removeItem(USER_INPUT_DEBUG_STORAGE_KEY); + } catch { + // Ignore storage write failures in debug mode. + } +} + +function resolveInitialEnabled(): boolean { + const enabled = readSearchParamEnabled() || readPersistedEnabled(); + if (enabled) { + const layout = readPersistedLayout(); + persistDebugState({ + enabled: true, + collapsed: layout.collapsed, + position: layout.position, + }); + } + return enabled; +} + +function nextDebugId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `user-input-debug-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +const initialEnabled = resolveInitialEnabled(); +const initialLayout = readPersistedLayout(); + +export const useUserInputDebugStore = create((set) => ({ + enabled: initialEnabled, + collapsed: initialLayout.collapsed, + position: initialLayout.position, + entries: [], + setEnabled: (enabled) => { + set((state) => { + persistDebugState({ + enabled, + collapsed: enabled ? state.collapsed : false, + position: enabled ? state.position : null, + }); + return { + enabled, + collapsed: enabled ? state.collapsed : false, + position: enabled ? state.position : null, + }; + }); + }, + setCollapsed: (collapsed) => { + set((state) => { + persistDebugState({ + enabled: state.enabled, + collapsed, + position: state.position, + }); + return { collapsed }; + }); + }, + setPosition: (position) => { + set((state) => { + persistDebugState({ + enabled: state.enabled, + collapsed: state.collapsed, + position, + }); + return { position }; + }); + }, + pushEntry: (entry) => + set((state) => { + if (!state.enabled) { + return state; + } + const nextEntry: UserInputDebugEntry = { + ...entry, + id: nextDebugId(), + timestamp: new Date().toISOString(), + }; + const nextEntries = [...state.entries, nextEntry]; + return { + entries: + nextEntries.length > MAX_DEBUG_ENTRIES + ? nextEntries.slice(nextEntries.length - MAX_DEBUG_ENTRIES) + : nextEntries, + }; + }), + clear: () => set({ entries: [] }), +})); + +export function logUserInputDebug(entry: Omit): void { + const store = useUserInputDebugStore.getState(); + if (!store.enabled) { + return; + } + store.pushEntry(entry); + console.debug("[user-input-debug]", entry); +} + +export function setUserInputDebugEnabled(enabled: boolean): void { + useUserInputDebugStore.getState().setEnabled(enabled); +} + +export function setUserInputDebugCollapsed(collapsed: boolean): void { + useUserInputDebugStore.getState().setCollapsed(collapsed); +} + +export function setUserInputDebugPosition(position: { x: number; y: number } | null): void { + useUserInputDebugStore.getState().setPosition(position); +} + +export function clearUserInputDebugEntries(): void { + useUserInputDebugStore.getState().clear(); +} + +export function isUserInputDebugEnabled(): boolean { + return useUserInputDebugStore.getState().enabled; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82f..76787c6fc3 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type OrchestrationThreadActivity } from "@t3tools/contracts"; import { Outlet, createRootRouteWithContext, @@ -13,6 +13,8 @@ import { Throttler } from "@tanstack/react-pacer"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; +import { UserInputDebugPanel } from "../components/debug/UserInputDebugPanel"; +import { logUserInputDebug } from "../debug/userInputDebug"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; @@ -53,6 +55,7 @@ function RootRouteView() { + @@ -207,6 +210,41 @@ function EventRouter() { ); const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { + if (event.type === "thread.user-input-response-requested") { + logUserInputDebug({ + level: "info", + stage: "domain-event", + message: "Observed thread.user-input-response-requested", + threadId: event.payload.threadId, + requestId: event.payload.requestId, + ...withDebugDetail(stringifyDebugDetail(event.payload.answers)), + }); + } + if (event.type === "thread.activity-appended") { + const activity = event.payload.activity; + if (isInterestingUserInputActivity(activity)) { + logUserInputDebug({ + level: activity.kind === "provider.user-input.respond.failed" ? "error" : "success", + stage: "domain-activity", + message: `Observed ${activity.kind}`, + threadId: event.payload.threadId, + requestId: requestIdFromActivity(activity), + ...withDebugDetail( + stringifyDebugDetail({ + summary: activity.summary, + payload: activity.payload, + }), + ), + }); + } + if (activity.kind === "provider.user-input.respond.failed") { + toastManager.add({ + type: "error", + title: "Question expired", + description: describePendingUserInputFailure(activity), + }); + } + } if (event.sequence <= latestSequence) { return; } @@ -321,6 +359,61 @@ function EventRouter() { return null; } +function isInterestingUserInputActivity(activity: OrchestrationThreadActivity): boolean { + return ( + activity.kind === "user-input.requested" || + activity.kind === "user-input.resolved" || + activity.kind === "provider.user-input.respond.failed" + ); +} + +function requestIdFromActivity(activity: OrchestrationThreadActivity): string | null { + const payload = activity.payload; + if (!payload || typeof payload !== "object") { + return null; + } + const requestId = (payload as Record).requestId; + return typeof requestId === "string" ? requestId : null; +} + +function stringifyDebugDetail(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function withDebugDetail(detail: string | undefined): { detail: string } | undefined { + return typeof detail === "string" ? { detail } : undefined; +} + +function describePendingUserInputFailure(activity: OrchestrationThreadActivity): string { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const detail = typeof payload?.detail === "string" ? payload.detail : null; + if (!detail) { + return "This question could not be answered."; + } + const normalized = detail.toLowerCase(); + if ( + normalized.includes("unknown pending user input request") || + normalized.includes("belongs to a previous provider session") || + normalized.includes("expired after the session restarted") + ) { + return ( + "This question came from an earlier session and can no longer be answered. " + + "Ask the agent to re-ask it." + ); + } + return detail; +} + function DesktopProjectBootstrap() { // Desktop hydration runs through EventRouter project + orchestration sync. return null; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 74ba3a814f..e0fc5e5e51 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -220,6 +220,48 @@ describe("derivePendingUserInputs", () => { }, ]); }); + + it("clears stale pending user input when the provider reports an expired request", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open-stale", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-stale", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + }), + makeActivity({ + id: "user-input-stale-failed", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + tone: "error", + payload: { + requestId: "req-user-input-stale", + detail: + "This question came from an earlier provider session and expired after the session restarted. Please ask the agent to re-ask the question.", + }, + }), + ]; + + expect(derivePendingUserInputs(activities)).toEqual([]); + }); }); describe("deriveActivePlanState", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e389f10e2d..b51510023c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -156,6 +156,18 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ } } +function isStalePendingUserInputDetail(detail: string | undefined): boolean { + if (!detail) { + return false; + } + const normalized = detail.toLowerCase(); + return ( + normalized.includes("unknown pending user input request") || + normalized.includes("belongs to a previous provider session") || + normalized.includes("expired after the session restarted") + ); +} + export function derivePendingApprovals( activities: ReadonlyArray, ): PendingApproval[] { @@ -292,6 +304,16 @@ export function derivePendingUserInputs( if (activity.kind === "user-input.resolved" && requestId) { openByRequestId.delete(requestId); + continue; + } + + const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; + if ( + activity.kind === "provider.user-input.respond.failed" && + requestId && + isStalePendingUserInputDetail(detail) + ) { + openByRequestId.delete(requestId); } }