From ef18f8e9753c5c089deed31251728aa78d97d593 Mon Sep 17 00:00:00 2001 From: Srijan Date: Mon, 26 Oct 2026 02:25:02 +0530 Subject: [PATCH 1/4] fix(clients): background work Stop stays pending until the stopped tasks leave Fixes #24993. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadDetailScreen.tsx | 36 +++++++++++++--- .../components/chat/useThreadLifecycle.tsx | 41 +++++++++++++++---- .../src/state/threadExecution.test.ts | 22 ++++++++++ .../src/state/threadExecution.ts | 19 +++++++++ 4 files changed, 105 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 40cdfc1e3f83d..312033ceb7bdd 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -61,6 +61,9 @@ import { type ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; import { + BACKGROUND_WORK_STOP_RETRY_AFTER_MS, + type BackgroundWorkStopRequest, + isBackgroundWorkStopPending, presentPendingBackgroundWork, presentProviderGoal, } from "@t3tools/client-runtime/state/thread-execution"; @@ -669,8 +672,19 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); - const [statusRequest, setStatusRequest] = useState<{ threadKey: string } | null>(null); - const statusPending = statusRequest?.threadKey === selectedThreadKey; + const [statusRequest, setStatusRequest] = useState(null); + const statusPending = isBackgroundWorkStopPending( + statusRequest, + selectedThreadKey, + props.selectedThread.pendingBackgroundTasks, + ); + useEffect(() => { + if (statusRequest?.accepted !== true) return; + const timer = setTimeout(() => { + setStatusRequest((current) => (current === statusRequest ? null : current)); + }, BACKGROUND_WORK_STOP_RETRY_AFTER_MS); + return () => clearTimeout(timer); + }, [statusRequest]); const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString()); useFocusEffect( useCallback(() => { @@ -707,7 +721,14 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai }); const runStatusAction = async (action: "stop" | "wake" | "unsettle" | "dismiss") => { if (statusPending || !props.canOperateThread) return; - const request = { threadKey: selectedThreadKey }; + const request: BackgroundWorkStopRequest = { + threadKey: selectedThreadKey, + taskIds: + action === "stop" + ? props.selectedThread.pendingBackgroundTasks.map((task) => task.taskId) + : [], + accepted: false, + }; setStatusRequest(request); const target = { environmentId: props.environmentId, @@ -742,8 +763,13 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai ...target, input: { ...target.input, visitedAt: wokeAt ?? lifecycleNow }, }); - // An accepted interrupt can leave tasks running. Release the action for retry. - setStatusRequest((current) => (current === request ? null : current)); + setStatusRequest((current) => + current !== request + ? current + : action === "stop" && result._tag !== "Failure" + ? { ...request, accepted: true } + : null, + ); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); Dialog.alert( diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index 8e9af1b2d8997..e876b407dcf08 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -23,6 +23,11 @@ import { RuntimeMode, } from "@t3tools/contracts"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; +import { + BACKGROUND_WORK_STOP_RETRY_AFTER_MS, + type BackgroundWorkStopRequest, + isBackgroundWorkStopPending, +} from "@t3tools/client-runtime/state/thread-execution"; import { useAcknowledgeThreadWoke, useThreadActions } from "../../hooks/useThreadActions"; import { settleAndAdvance } from "../../settleAndAdvance"; import type { ChatShortcutCommands } from "./useChatShortcuts"; @@ -386,21 +391,41 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { // The interrupt path also accepts a completed run while its provider still // has background work. const activeBackgroundTasks = !isWorking && activeThread ? pendingBackgroundTasks : []; - const [stoppingBackgroundWorkKey, setStoppingBackgroundWorkKey] = useState(null); - const isStoppingBackgroundWork = - stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`; + const [backgroundWorkStop, setBackgroundWorkStop] = useState( + null, + ); + const isStoppingBackgroundWork = isBackgroundWorkStopPending( + backgroundWorkStop, + `${environmentId}:${activeThreadId}`, + pendingBackgroundTasks, + ); + useEffect(() => { + if (backgroundWorkStop?.accepted !== true) return; + const timer = setTimeout(() => { + setBackgroundWorkStop((current) => (current === backgroundWorkStop ? null : current)); + }, BACKGROUND_WORK_STOP_RETRY_AFTER_MS); + return () => clearTimeout(timer); + }, [backgroundWorkStop]); const handleStopBackgroundWork = async () => { if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; - const requestKey = `${environmentId}:${activeThread.id}`; - setStoppingBackgroundWorkKey(requestKey); + const request: BackgroundWorkStopRequest = { + threadKey: `${environmentId}:${activeThread.id}`, + taskIds: pendingBackgroundTasks.map((task) => task.taskId), + accepted: false, + }; + setBackgroundWorkStop(request); const result = await interruptThreadTurn({ environmentId, input: { threadId: activeThread.id }, }); - // Acceptance does not confirm termination. Allow retry while the provider - // finishes stopping the tasks or reports a failure. - setStoppingBackgroundWorkKey((current) => (current === requestKey ? null : current)); + setBackgroundWorkStop((current) => + current !== request + ? current + : result._tag === "Failure" + ? null + : { ...request, accepted: true }, + ); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 8ef37f959cd40..c9e37ae396122 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -25,6 +25,7 @@ import { deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, + isBackgroundWorkStopPending, threadRuntimeHasInterruptibleRun, } from "./threadExecution.ts"; @@ -393,3 +394,24 @@ describe("provider-reported model selection", () => { ).toBeNull(); }); }); + +describe("isBackgroundWorkStopPending", () => { + const tasks = [{ taskId: "dev-server" }]; + const sent = { threadKey: "env:thread", taskIds: ["dev-server"], accepted: false }; + const accepted = { ...sent, accepted: true }; + + it("stays pending after acceptance while a stopped task is still listed", () => { + expect(isBackgroundWorkStopPending(sent, "env:thread", tasks)).toBe(true); + expect(isBackgroundWorkStopPending(accepted, "env:thread", tasks)).toBe(true); + }); + + it("ends once the stopped tasks leave, even if new work starts", () => { + expect(isBackgroundWorkStopPending(accepted, "env:thread", [])).toBe(false); + expect(isBackgroundWorkStopPending(accepted, "env:thread", [{ taskId: "next" }])).toBe(false); + }); + + it("only applies to the thread that sent the Stop", () => { + expect(isBackgroundWorkStopPending(null, "env:thread", tasks)).toBe(false); + expect(isBackgroundWorkStopPending(sent, "env:other", tasks)).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index accf5c98df9a9..9f01428b4791b 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -311,6 +311,25 @@ function joinWithAnd(parts: ReadonlyArray): string { return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`; } +// The Claude adapter forces a stuck query closed after 10 seconds. +export const BACKGROUND_WORK_STOP_RETRY_AFTER_MS = 10_000; + +export interface BackgroundWorkStopRequest { + readonly threadKey: string; + readonly taskIds: ReadonlyArray; + readonly accepted: boolean; +} + +/** Acceptance does not end the tasks, so an accepted Stop stays pending while they are listed. */ +export function isBackgroundWorkStopPending( + request: BackgroundWorkStopRequest | null, + threadKey: string, + tasks: ReadonlyArray>, +): boolean { + if (request === null || request.threadKey !== threadKey) return false; + return !request.accepted || tasks.some((task) => request.taskIds.includes(task.taskId)); +} + export function presentPendingBackgroundWork( tasks: ReadonlyArray, ): PendingBackgroundWorkPresentation | null { From ff50eb7dc101d7b7205de5e9be442c885a0f5a4d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 02:12:55 +0000 Subject: [PATCH 2/4] fix(clients): reuse deadline tracking for background stop retry --- .../features/threads/ThreadDetailScreen.tsx | 13 ++++------ .../components/chat/useThreadLifecycle.tsx | 20 +++++++--------- .../src/state/threadExecution.test.ts | 24 ++++++++++++------- .../src/state/threadExecution.ts | 8 +++++-- 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ccef30f3b1b02..30eb0798772ff 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -57,6 +57,7 @@ import { type ApprovalResponder, type ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; +import { useNowAfter } from "../../lib/clock"; import { BACKGROUND_WORK_STOP_RETRY_AFTER_MS, type BackgroundWorkStopRequest, @@ -532,14 +533,8 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { statusRequest, selectedThreadKey, props.selectedThread.pendingBackgroundTasks, + useNowAfter(statusRequest?.retryAt ?? null), ); - useEffect(() => { - if (statusRequest?.accepted !== true) return; - const timer = setTimeout(() => { - setStatusRequest((current) => (current === statusRequest ? null : current)); - }, BACKGROUND_WORK_STOP_RETRY_AFTER_MS); - return () => clearTimeout(timer); - }, [statusRequest]); const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString()); useFocusEffect( useCallback(() => { @@ -582,7 +577,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { action === "stop" ? props.selectedThread.pendingBackgroundTasks.map((task) => task.taskId) : [], - accepted: false, + retryAt: null, }; setStatusRequest(request); const target = { @@ -622,7 +617,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { current !== request ? current : action === "stop" && result._tag !== "Failure" - ? { ...request, accepted: true } + ? { ...request, retryAt: Date.now() + BACKGROUND_WORK_STOP_RETRY_AFTER_MS } : null, ); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index 04204ac70f03c..912f7af2201ea 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -38,7 +38,7 @@ import { settleAndAdvance } from "../../settleAndAdvance"; import type { ChatShortcutCommands } from "./useChatShortcuts"; import { resolveThreadLastVisitedAt } from "../Sidebar.logic"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { type SetStateAction, useEffect, useRef, useState } from "react"; +import { type SetStateAction, useRef, useState } from "react"; import { useNavigate, useRouter } from "@tanstack/react-router"; import { isAtomCommandInterrupted, @@ -382,21 +382,19 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { backgroundWorkStop, `${environmentId}:${activeThreadId}`, pendingBackgroundTasks, + useNowAfter(backgroundWorkStop?.retryAt ?? null), ); - useEffect(() => { - if (backgroundWorkStop?.accepted !== true) return; - const timer = setTimeout(() => { - setBackgroundWorkStop((current) => (current === backgroundWorkStop ? null : current)); - }, BACKGROUND_WORK_STOP_RETRY_AFTER_MS); - return () => clearTimeout(timer); - }, [backgroundWorkStop]); const handleStopBackgroundWork = async () => { - if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + if ( + isStoppingBackgroundWork || + !activeThread || + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) + ) return; const request: BackgroundWorkStopRequest = { threadKey: `${environmentId}:${activeThread.id}`, taskIds: pendingBackgroundTasks.map((task) => task.taskId), - accepted: false, + retryAt: null, }; setBackgroundWorkStop(request); const result = await interruptThreadTurn({ @@ -408,7 +406,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { ? current : result._tag === "Failure" ? null - : { ...request, accepted: true }, + : { ...request, retryAt: Date.now() + BACKGROUND_WORK_STOP_RETRY_AFTER_MS }, ); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index c9e37ae396122..b924d865a7cd5 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -397,21 +397,29 @@ describe("provider-reported model selection", () => { describe("isBackgroundWorkStopPending", () => { const tasks = [{ taskId: "dev-server" }]; - const sent = { threadKey: "env:thread", taskIds: ["dev-server"], accepted: false }; - const accepted = { ...sent, accepted: true }; + const sent = { threadKey: "env:thread", taskIds: ["dev-server"], retryAt: null }; + const accepted = { ...sent, retryAt: 10_000 }; it("stays pending after acceptance while a stopped task is still listed", () => { - expect(isBackgroundWorkStopPending(sent, "env:thread", tasks)).toBe(true); - expect(isBackgroundWorkStopPending(accepted, "env:thread", tasks)).toBe(true); + expect(isBackgroundWorkStopPending(sent, "env:thread", tasks, 0)).toBe(true); + expect(isBackgroundWorkStopPending(accepted, "env:thread", tasks, 0)).toBe(true); }); it("ends once the stopped tasks leave, even if new work starts", () => { - expect(isBackgroundWorkStopPending(accepted, "env:thread", [])).toBe(false); - expect(isBackgroundWorkStopPending(accepted, "env:thread", [{ taskId: "next" }])).toBe(false); + expect(isBackgroundWorkStopPending(accepted, "env:thread", [], 0)).toBe(false); + expect(isBackgroundWorkStopPending(accepted, "env:thread", [{ taskId: "next" }], 0)).toBe( + false, + ); + }); + + it("allows retry at the deadline even if a stopped task remains", () => { + const request = { ...accepted, retryAt: 10_000 }; + expect(isBackgroundWorkStopPending(request, "env:thread", tasks, 9_999)).toBe(true); + expect(isBackgroundWorkStopPending(request, "env:thread", tasks, 10_000)).toBe(false); }); it("only applies to the thread that sent the Stop", () => { - expect(isBackgroundWorkStopPending(null, "env:thread", tasks)).toBe(false); - expect(isBackgroundWorkStopPending(sent, "env:other", tasks)).toBe(false); + expect(isBackgroundWorkStopPending(null, "env:thread", tasks, 0)).toBe(false); + expect(isBackgroundWorkStopPending(sent, "env:other", tasks, 0)).toBe(false); }); }); diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 9f01428b4791b..dedaaa616dde5 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -317,7 +317,7 @@ export const BACKGROUND_WORK_STOP_RETRY_AFTER_MS = 10_000; export interface BackgroundWorkStopRequest { readonly threadKey: string; readonly taskIds: ReadonlyArray; - readonly accepted: boolean; + readonly retryAt: number | null; } /** Acceptance does not end the tasks, so an accepted Stop stays pending while they are listed. */ @@ -325,9 +325,13 @@ export function isBackgroundWorkStopPending( request: BackgroundWorkStopRequest | null, threadKey: string, tasks: ReadonlyArray>, + nowMs: number, ): boolean { if (request === null || request.threadKey !== threadKey) return false; - return !request.accepted || tasks.some((task) => request.taskIds.includes(task.taskId)); + return ( + request.retryAt === null || + (nowMs < request.retryAt && tasks.some((task) => request.taskIds.includes(task.taskId))) + ); } export function presentPendingBackgroundWork( From ad44bcb3f87cb77c0d148a854193171eb67e1e33 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 02:20:57 +0000 Subject: [PATCH 3/4] fix(clients): derive stop retry from deadline notification --- .../src/features/threads/ThreadDetailScreen.tsx | 4 ++-- .../src/components/chat/useThreadLifecycle.tsx | 4 ++-- .../src/state/threadExecution.test.ts | 16 ++++++++-------- .../client-runtime/src/state/threadExecution.ts | 9 +++------ 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 30eb0798772ff..386318281d35b 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -57,7 +57,7 @@ import { type ApprovalResponder, type ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; -import { useNowAfter } from "../../lib/clock"; +import { useDeadlinePassed } from "../../lib/clock"; import { BACKGROUND_WORK_STOP_RETRY_AFTER_MS, type BackgroundWorkStopRequest, @@ -533,7 +533,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { statusRequest, selectedThreadKey, props.selectedThread.pendingBackgroundTasks, - useNowAfter(statusRequest?.retryAt ?? null), + useDeadlinePassed(statusRequest?.retryAt ?? null), ); const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString()); useFocusEffect( diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index 912f7af2201ea..f5d4df588f6c8 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -12,7 +12,7 @@ import { makeThreadErrorSetter, } from "../ChatView.logic"; import { useStableCallback } from "../../hooks/useStableCallback"; -import { useNowAfter } from "../../hooks/useDeadline"; +import { useDeadlinePassed, useNowAfter } from "../../hooks/useDeadline"; import type { usePendingThreadRequests } from "./usePendingThreadRequests"; import { AuthOrchestrationOperateScope, @@ -382,7 +382,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { backgroundWorkStop, `${environmentId}:${activeThreadId}`, pendingBackgroundTasks, - useNowAfter(backgroundWorkStop?.retryAt ?? null), + useDeadlinePassed(backgroundWorkStop?.retryAt ?? null), ); const handleStopBackgroundWork = async () => { if ( diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index b924d865a7cd5..35a51624f7364 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -401,25 +401,25 @@ describe("isBackgroundWorkStopPending", () => { const accepted = { ...sent, retryAt: 10_000 }; it("stays pending after acceptance while a stopped task is still listed", () => { - expect(isBackgroundWorkStopPending(sent, "env:thread", tasks, 0)).toBe(true); - expect(isBackgroundWorkStopPending(accepted, "env:thread", tasks, 0)).toBe(true); + expect(isBackgroundWorkStopPending(sent, "env:thread", tasks, false)).toBe(true); + expect(isBackgroundWorkStopPending(accepted, "env:thread", tasks, false)).toBe(true); }); it("ends once the stopped tasks leave, even if new work starts", () => { - expect(isBackgroundWorkStopPending(accepted, "env:thread", [], 0)).toBe(false); - expect(isBackgroundWorkStopPending(accepted, "env:thread", [{ taskId: "next" }], 0)).toBe( + expect(isBackgroundWorkStopPending(accepted, "env:thread", [], false)).toBe(false); + expect(isBackgroundWorkStopPending(accepted, "env:thread", [{ taskId: "next" }], false)).toBe( false, ); }); it("allows retry at the deadline even if a stopped task remains", () => { const request = { ...accepted, retryAt: 10_000 }; - expect(isBackgroundWorkStopPending(request, "env:thread", tasks, 9_999)).toBe(true); - expect(isBackgroundWorkStopPending(request, "env:thread", tasks, 10_000)).toBe(false); + expect(isBackgroundWorkStopPending(request, "env:thread", tasks, false)).toBe(true); + expect(isBackgroundWorkStopPending(request, "env:thread", tasks, true)).toBe(false); }); it("only applies to the thread that sent the Stop", () => { - expect(isBackgroundWorkStopPending(null, "env:thread", tasks, 0)).toBe(false); - expect(isBackgroundWorkStopPending(sent, "env:other", tasks, 0)).toBe(false); + expect(isBackgroundWorkStopPending(null, "env:thread", tasks, false)).toBe(false); + expect(isBackgroundWorkStopPending(sent, "env:other", tasks, false)).toBe(false); }); }); diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index dedaaa616dde5..ffad30cc431c1 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -325,13 +325,10 @@ export function isBackgroundWorkStopPending( request: BackgroundWorkStopRequest | null, threadKey: string, tasks: ReadonlyArray>, - nowMs: number, + retryExpired: boolean, ): boolean { - if (request === null || request.threadKey !== threadKey) return false; - return ( - request.retryAt === null || - (nowMs < request.retryAt && tasks.some((task) => request.taskIds.includes(task.taskId))) - ); + if (request === null || request.threadKey !== threadKey || retryExpired) return false; + return request.retryAt === null || tasks.some((task) => request.taskIds.includes(task.taskId)); } export function presentPendingBackgroundWork( From 67f92582145b56796551633c8eb647f830716909 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 05:50:31 +0000 Subject: [PATCH 4/4] test(web): cover background stop across thread navigation --- .../chat/useThreadLifecycle.browser.test.tsx | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx b/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx index a0b10dd4c643f..6c02319d25a63 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { expect, it, vi } from "vite-plus/test"; @@ -20,7 +21,7 @@ vi.mock(import("../../state/use-atom-command"), async (importOriginal) => ({ vi.mock(import("../../state/use-orchestration-command"), async (importOriginal) => ({ ...(await importOriginal()), - useOrchestrationCommand: (() => async () => ({ _tag: "Success", value: undefined })) as never, + useOrchestrationCommand: (() => (input: unknown) => state.switchRef(input)) as never, })); vi.mock(import("../../hooks/useThreadActions"), async (importOriginal) => ({ @@ -77,7 +78,7 @@ function RestoreBranchHarness(props: { serverConfig: null, runtimeMode: "full-access" as never, isWorking: false, - pendingBackgroundTasks: [], + pendingBackgroundTasks: [{ taskId: "background-command", kind: "command" }] as never, turnItems: [], activePendingApproval: null as never, activePendingUserInput: null as never, @@ -95,13 +96,16 @@ function RestoreBranchHarness(props: { persistThreadSettingsForNextTurn: async () => ({ _tag: "Success", value: undefined }) as never, }); return ( - + <> + {lifecycle.workNow?.actions} + + ); } @@ -181,3 +185,44 @@ it.each(["a", "b", "navigation"] as const)( expect(focusComposer).toHaveBeenCalledTimes(scenario === "navigation" ? 0 : 1); }, ); + +it.each(["Success", "Failure"] as const)( + "keeps background stop requests per thread after %s", + async (outcome) => { + setup(); + const completions: Array<(result: unknown) => void> = []; + state.switchRef = vi.fn(() => new Promise((resolve) => completions.push(resolve))); + const threadA = threadInput("thread:a", "feature-a"); + const threadB = threadInput("thread:b", "feature-b"); + const currentRouteThreadKeyRef = { current: threadA.routeThreadKey }; + const harness = (thread: ThreadInput) => ( + undefined} + focusComposer={() => undefined} + /> + ); + await using screen = await render(harness(threadA)); + await page.getByRole("button", { name: "Stop", exact: true }).click(); + await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled(); + await screen.rerender(harness(threadB)); + await page.getByRole("button", { name: "Stop", exact: true }).click(); + await screen.rerender(harness(threadA)); + await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled(); + completions[0]!( + outcome === "Success" + ? { _tag: "Success", value: undefined } + : { _tag: "Failure", cause: Cause.fail(new Error("stop failed")) }, + ); + if (outcome === "Success") { + await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled(); + } else { + await expect.element(page.getByRole("button", { name: "Stop", exact: true })).toBeEnabled(); + } + await screen.rerender(harness(threadB)); + await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled(); + expect(state.switchRef).toHaveBeenCalledTimes(2); + completions[1]!({ _tag: "Success", value: undefined }); + }, +);