diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 6205fd1281797..8bfb476521321 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1071,9 +1071,7 @@ function ThreadRouteContent( onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread} - onResumeQueue={ - composer.queueHeld || composer.resumableRunId !== null ? handleResumeQueue : null - } + onResumeQueue={composer.canResume ? handleResumeQueue : null} onSendMessage={composer.onSendMessage} offersSideChat={composer.sideChatUnavailableReason === null} onReconnectEnvironment={handleReconnectEnvironment} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 11b38251cec77..a3a191d54cbd1 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -13,6 +13,7 @@ import { isDetailBehindShell, } from "@t3tools/client-runtime/state/thread-activity-announcement"; import { + canResumeThread, deriveProviderSubagentStatus, deriveProviderExtensionStatuses, deriveResumableRunId, @@ -401,6 +402,9 @@ export function useThreadComposerState(input: { const resumableRunId = selectedThreadProjection ? deriveResumableRunId(selectedThreadProjection.projection, selectedThreadRuntime) : null; + const canResume = selectedThreadProjection + ? canResumeThread(selectedThreadProjection.projection, resumableRunId, queueHeld) + : queueHeld; const runlessWorkStartedAt = selectedThreadProjection ? deriveRunlessWorkStartedAt(selectedThreadProjection.projection) @@ -919,6 +923,7 @@ export function useThreadComposerState(input: { canSteerActiveTurn, queueHeld, resumableRunId, + canResume, queuedRunEdit, isSavingQueuedEdit, cancelQueuedRunEdit, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fdbb91d9073ff..f50954c425317 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -78,6 +78,7 @@ import { RuntimeMode, } from "@t3tools/contracts"; import { + canResumeThread, deriveProviderSubagentStatus, deriveProviderExtensionStatuses, deriveReportedModelSelection, @@ -840,6 +841,9 @@ export default function ChatView(props: ChatViewProps) { !isServerThread || serverProjection === null ? null : deriveResumableRunId(serverProjection, serverRuntime); + const canResume = + serverProjection !== null && + canResumeThread(serverProjection, resumableRunId, hasHeldQueuedRuns); const parentSubagentThreadId = activeThread?.lineage.relationshipToParent === "subagent" ? activeThread.lineage.parentThreadId @@ -2882,6 +2886,7 @@ export default function ChatView(props: ChatViewProps) { activeThread, resumableRunId, hasHeldQueuedRuns, + canResume, isSendBusy, isRevertingCheckpoint, threadDetailLoading, @@ -3626,7 +3631,7 @@ export default function ChatView(props: ChatViewProps) { queuedRunEdit.isSaving || isResuming } - canResume={resumableRunId !== null || hasHeldQueuedRuns} + canResume={canResume} isRevertingCheckpoint={isRevertingCheckpoint} sendDisabledReason={ !canOperateThread diff --git a/apps/web/src/components/chat/useComposerResume.browser.test.tsx b/apps/web/src/components/chat/useComposerResume.browser.test.tsx index 5aeeb5108df9b..adcca8da91c49 100644 --- a/apps/web/src/components/chat/useComposerResume.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerResume.browser.test.tsx @@ -52,6 +52,7 @@ function ResumeButton(props: { activeThread: { id: threadA } as never, resumableRunId: RunId.make("run:interrupted"), hasHeldQueuedRuns: false, + canResume: true, isSendBusy: false, isRevertingCheckpoint: false, threadDetailLoading: false, diff --git a/apps/web/src/components/chat/useComposerResume.ts b/apps/web/src/components/chat/useComposerResume.ts index 73e1fb6fca115..d525811e2fac7 100644 --- a/apps/web/src/components/chat/useComposerResume.ts +++ b/apps/web/src/components/chat/useComposerResume.ts @@ -28,6 +28,7 @@ interface ComposerResumeInput { readonly activeThread: EnvironmentThreadShell | undefined; readonly resumableRunId: RunId | null; readonly hasHeldQueuedRuns: boolean; + readonly canResume: boolean; readonly isSendBusy: boolean; readonly isRevertingCheckpoint: boolean; readonly threadDetailLoading: boolean; @@ -59,6 +60,7 @@ export function useComposerResume(input: ComposerResumeInput) { activeThread, resumableRunId, hasHeldQueuedRuns, + canResume, isSendBusy, isRevertingCheckpoint, threadDetailLoading, @@ -86,7 +88,7 @@ export function useComposerResume(input: ComposerResumeInput) { const onResume = async () => { if ( !activeThread || - (resumableRunId === null && !hasHeldQueuedRuns) || + !canResume || isSendBusy || isResuming || isRevertingCheckpoint || diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index d8dfad4aea7a1..d9967a1af2bf5 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -9,9 +9,11 @@ import { ProviderTurnId, RunAttemptId, RunId, + RuntimeRequestId, ThreadId, type OrchestrationV2ExecutionNode, type OrchestrationV2RunStatus, + type OrchestrationV2RuntimeRequest, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; @@ -20,6 +22,7 @@ import { describe, expect, it } from "vite-plus/test"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; import { + canResumeThread, deriveReportedModelSelection, deriveLatestThreadRun, deriveProviderSubagentStatus, @@ -38,6 +41,23 @@ import { const now = DateTime.makeUnsafe("2026-07-28T10:00:00.000Z"); +function request( + id: string, + status: OrchestrationV2RuntimeRequest["status"], +): OrchestrationV2RuntimeRequest { + return { + id: RuntimeRequestId.make(id), + nodeId: NodeId.make(`node-${id}`), + providerTurnId: null, + nativeRequestRef: null, + kind: "command", + status, + responseCapability: { type: "not_resumable", reason: "Session ended." }, + createdAt: now, + resolvedAt: status === "pending" ? null : now, + }; +} + function run(id: string, ordinal: number, status: OrchestrationV2RunStatus) { return { id: RunId.make(id), @@ -369,6 +389,47 @@ describe("thread execution presentation", () => { expect(deriveThreadRuntime(onlyHeld)).toBeNull(); }); + it.each([ + { name: "no requests", requests: [], runId: "run-stopped", held: false, expected: true }, + { + name: "a pending request", + requests: [request("approval", "pending")], + runId: "run-stopped", + held: false, + expected: false, + }, + { + name: "only a resolved request", + requests: [request("approval", "resolved")], + runId: "run-stopped", + held: false, + expected: true, + }, + { + name: "a held queue behind a blocked continuation", + requests: [request("approval", "pending")], + runId: "run-stopped", + held: true, + expected: false, + }, + { + name: "a held queue with no run to continue", + requests: [request("approval", "pending")], + runId: null, + held: true, + expected: true, + }, + { name: "nothing to resume", requests: [], runId: null, held: false, expected: false }, + ])("decides whether Resume can start with $name", ({ requests, runId, held, expected }) => { + expect( + canResumeThread( + { runtimeRequests: requests }, + runId === null ? null : RunId.make(runId), + held, + ), + ).toBe(expected); + }); + it("does not expose a queued-only run as interruptible", () => { const queuedRun = run("run-queued", 1, "queued"); const projection = { ...v2Projection, runs: [queuedRun], updatedAt: now }; diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index a9518e1bca309..cd85f1e8c68e4 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -319,6 +319,19 @@ export function deriveResumableRunId( : null; } +/** + * Whether Resume can start: the run's continuation, else the held queue. The server refuses that + * continuation while any request is pending, so the queue must not be released in its place. + */ +export function canResumeThread( + projection: Pick, + resumableRunId: RunId | null, + queueHeld: boolean, +): boolean { + if (resumableRunId === null) return queueHeld; + return !projection.runtimeRequests.some((request) => request.status === "pending"); +} + export function deriveThreadRuntime( projection: ThreadStructure & Pick, ): ThreadRuntimeSummary | null {