From ffc31e4fac5259afa4aa70a48c734ab47ec96af2 Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:24:00 +0100 Subject: [PATCH 1/6] fix: a failed title regeneration says why instead of silently keeping the title Regenerating a thread title ran the text generation in the background and, when it failed, logged a warning and cleared the pending marker. Clients saw the same thing as a successful regeneration that kept the title, so a broken text generation login looked like the model insisting on the old title. The completion command now carries the failure. The thread keeps it as titleRegenerationFailure until the next request, the shell and orchestrator MCP thread view expose it, and web and mobile wait for the request they sent and show the reason. --- .../home/useThreadListActions.test.ts | 2 + .../src/features/home/useThreadListActions.ts | 20 ++++++- apps/mobile/src/state/threads.ts | 16 +++++- apps/server/src/mcp/OrchestratorMcpService.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 11 +++- .../src/orchestration-v2/ProjectionStore.ts | 2 + .../ThreadTitleRegenerationService.test.ts | 19 ++++++- .../ThreadTitleRegenerationService.ts | 32 +++++++++-- apps/web/src/components/Sidebar.tsx | 56 ++++++++++++++++--- .../web/src/hooks/useThreadActionMenu.test.ts | 1 + apps/web/src/hooks/useThreadActionMenu.ts | 28 +++++++--- apps/web/src/state/entities.ts | 13 +++++ packages/client-runtime/src/state/models.ts | 5 ++ .../src/state/threadShell.test.ts | 37 +++++++++++- .../client-runtime/src/state/threadShell.ts | 38 ++++++++++++- packages/contracts/src/orchestrationV2.ts | 4 ++ packages/contracts/src/orchestratorMcp.ts | 3 +- packages/contracts/src/threadTitle.ts | 9 ++- 18 files changed, 266 insertions(+), 31 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 9313aef299881..6acde7ab38549 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -119,7 +119,9 @@ vi.mock("../../state/thread-order", () => ({ }; }, })); +vi.mock("../../lib/uuid", () => ({ uuidv4: () => "command" })); vi.mock("../../state/threads", () => ({ + waitForTitleRegenerationFailure: async () => null, environmentThreadShells: { threadShellsAtom: "thread-shells", navigationThreadShellsAtom: "thread-shells", diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 248c436905311..9766770d8aa72 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -3,7 +3,7 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo"; import { showThreadUndoNotice } from "./thread-undo-notice"; import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; -import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { AuthOrchestrationOperateScope, CommandId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useRef } from "react"; @@ -11,13 +11,18 @@ import { useRef } from "react"; import { withThreadDismissal } from "./thread-dismissal"; import * as Dialog from "../../components/ui/Dialog"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { uuidv4 } from "../../lib/uuid"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; import { connectedEnvironmentIdsAtom } from "../../state/presentation"; import { readEnvironmentScope } from "../../state/session"; -import { environmentThreadShells, threadEnvironment } from "../../state/threads"; +import { + environmentThreadShells, + threadEnvironment, + waitForTitleRegenerationFailure, +} from "../../state/threads"; import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; import { useAtomCommand } from "../../state/use-atom-command"; import { @@ -510,10 +515,11 @@ export function useThreadListActions(): { titleRegenerationInFlightThreadKeys.current.add(key); selectionHaptic(); + const commandId = CommandId.make(uuidv4()); const succeeded = await (async () => { const result = await updateThreadMetadata({ environmentId: thread.environmentId, - input: { threadId: thread.id, regenerateTitle: true }, + input: { threadId: thread.id, regenerateTitle: true, commandId }, }); if (result._tag === "Failure") { const error = Cause.squash(result.cause); @@ -528,6 +534,14 @@ export function useThreadListActions(): { return true; })(); titleRegenerationInFlightThreadKeys.current.delete(key); + if (succeeded) { + void waitForTitleRegenerationFailure( + { environmentId: thread.environmentId, threadId: thread.id }, + commandId, + ).then((failure) => { + if (failure !== null) Dialog.alert("Could not regenerate title", failure); + }); + } return succeeded; }; const renameThread = (thread: EnvironmentThreadShell) => { diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index d6e784377cca6..3f544e5cf06c9 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -1,6 +1,7 @@ import { createChildThreadApprovalsAtom } from "@t3tools/client-runtime/state/thread-requests"; import { useAtomValue } from "@effect/atom-react"; import { + awaitTitleRegenerationFailure, createEnvironmentThreadDetailAtoms, createEnvironmentThreadShellAtoms, createEnvironmentThreadStateAtoms, @@ -10,7 +11,7 @@ import { createThreadEnvironmentAtoms, createThreadFamilyAtoms, } from "@t3tools/client-runtime/state/threads"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; @@ -46,6 +47,19 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ snapshotAtom: threadEnvironment.snapshotAtom, hiddenThreadTagsAtom: serverEnvironment.hiddenThreadTagsAtom, }); + +/** Resolves with why the requested regeneration kept the title, or null. */ +export function waitForTitleRegenerationFailure( + ref: ScopedThreadRef, + requestId: string, +): Promise { + return awaitTitleRegenerationFailure({ + registry: appAtomRegistry, + atom: environmentThreadShells.threadShellAtom(ref), + requestId, + }); +} + export const childThreadApprovalsAtom = createChildThreadApprovalsAtom({ childThreadInputsAtom: environmentThreadShells.childThreadInputsAtom, pendingRequestsAtom: environmentThreadDetails.pendingRequestsAtom, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index b937453f39b22..6470abc176522 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -854,6 +854,7 @@ function threadDetail( requestId: projection.thread.titleRegeneration.requestId, startedAt: DateTime.formatIso(projection.thread.titleRegeneration.startedAt), }, + titleRegenerationFailure: projection.thread.titleRegenerationFailure ?? null, branch: projection.thread.branch, worktreePath: projection.thread.worktreePath, parentThreadId: projection.thread.lineage.parentThreadId, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 600764704e57f..a76f0bf257466 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3674,9 +3674,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ], }), ...(command.regenerateTitle === true - ? { titleRegeneration: { requestId: command.commandId, startedAt: now } } + ? { + titleRegeneration: { requestId: command.commandId, startedAt: now }, + titleRegenerationFailure: null, + } : command.regenerateTitle === false || command.title !== undefined - ? { titleRegeneration: null } + ? { titleRegeneration: null, titleRegenerationFailure: null } : {}), updatedAt: now, }; @@ -3903,6 +3906,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, ...(command.title === undefined ? {} : { title: command.title }), titleRegeneration: null, + titleRegenerationFailure: + command.failure === undefined + ? null + : { requestId: command.requestId, message: command.failure }, updatedAt: now, } : thread; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 3cffce15b70d6..f6c943582a967 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1613,6 +1613,7 @@ export function threadShellFromProjection( lastVisitedAt: projection.thread.lastVisitedAt, opens: projection.thread.opens ?? null, titleRegeneration: projection.thread.titleRegeneration ?? null, + titleRegenerationFailure: projection.thread.titleRegenerationFailure ?? null, limitRecovery: projection.thread.limitRecovery ?? null, deletedAt: projection.thread.deletedAt, }; @@ -1879,6 +1880,7 @@ function shellFromState(input: { lastVisitedAt: input.state.thread.lastVisitedAt, opens: input.state.thread.opens ?? null, titleRegeneration: input.state.thread.titleRegeneration ?? null, + titleRegenerationFailure: input.state.thread.titleRegenerationFailure ?? null, limitRecovery: input.state.thread.limitRecovery ?? null, deletedAt: input.state.thread.deletedAt, }; diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 8f7de819f5718..0eb4e24dc4ca4 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -10,6 +10,7 @@ import { ProviderInstanceId, ProviderThreadId, RunId, + TextGenerationError, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -496,10 +497,16 @@ describe("ThreadTitleRegenerationService", () => { }), ); - it.effect("clears the marker and keeps the title when generation fails", () => + it.effect("keeps the title and records why when generation fails", () => Effect.gen(function* () { const harness = makeHarness({ - generateTitle: () => Effect.die(new Error("model unavailable")), + generateTitle: () => + Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Claude CLI command failed: OAuth session expired", + }), + ), }); yield* Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; @@ -521,6 +528,14 @@ describe("ThreadTitleRegenerationService", () => { const projection = yield* threads.getThreadProjection(threadId); assert.equal(projection.thread.title, "Seed title"); assert.isNotOk(projection.thread.titleRegeneration); + assert.deepEqual(projection.thread.titleRegenerationFailure, { + requestId, + message: "Claude CLI command failed: OAuth session expired", + }); + + yield* armRegeneration({ command: "command:title:failure:2", threadId }); + const retried = yield* threads.getThreadProjection(threadId); + assert.isNotOk(retried.thread.titleRegenerationFailure); }).pipe(Effect.provide(harness.layer)); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index 601b783675d0e..acabc3d84d24d 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -1,7 +1,9 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; +import { truncate } from "@t3tools/shared/String"; import { CommandId, + TextGenerationError, type ChatAttachment, type MessageId, type OrchestrationV2AppThread, @@ -45,6 +47,21 @@ export class ThreadTitleRegenerationService extends Context.Service< } >()("t3/orchestration-v2/ThreadTitleRegenerationService") {} +type TitleOutcome = + | { readonly type: "stale" } + | { readonly type: "complete"; readonly title?: string; readonly failure?: string }; + +const GENERIC_FAILURE = "The title could not be generated."; + +function failureMessage(cause: Cause.Cause): string { + const error = Cause.findErrorOption(cause); + const detail = + Option.isSome(error) && error.value instanceof TextGenerationError + ? error.value.detail.trim() + : ""; + return detail.length === 0 ? GENERIC_FAILURE : truncate(detail, 500); +} + const make = Effect.gen(function* () { const threads = yield* ThreadManagementService.ThreadManagementService; const projects = yield* ProjectStore.ProjectStoreV2; @@ -55,6 +72,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly requestId: CommandId; readonly title?: string; + readonly failure?: string; }) => threads .dispatch({ @@ -63,6 +81,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, requestId: input.requestId, ...(input.title === undefined ? {} : { title: input.title }), + ...(input.failure === undefined ? {} : { failure: input.failure }), }) .pipe(Effect.asVoid); @@ -108,9 +127,7 @@ const make = Effect.gen(function* () { const execute: ThreadTitleRegenerationService["Service"]["execute"] = Effect.fn( "ThreadTitleRegenerationService.execute", )(function* (input) { - const outcome: - | { readonly type: "stale" } - | { readonly type: "complete"; readonly title?: string } = yield* Effect.gen(function* () { + const outcome: TitleOutcome = yield* Effect.gen(function* () { const projection = yield* threads.getThreadRecords( input.threadId, ["messages"], @@ -186,7 +203,13 @@ const make = Effect.gen(function* () { threadId: input.threadId, requestId: input.requestId, cause, - }).pipe(Effect.as({ type: "complete" as const })), + }).pipe( + Effect.as({ + type: "complete" as const, + // A first title falls back to the prompt; only a requested regeneration reports. + ...(input.kind.type === "regenerate" ? { failure: failureMessage(cause) } : {}), + }), + ), ), ); @@ -196,6 +219,7 @@ const make = Effect.gen(function* () { yield* complete({ ...input, ...(outcome.title === undefined ? {} : { title: outcome.title }), + ...(outcome.failure === undefined ? {} : { failure: outcome.failure }), }); }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cbf0191a7f40f..8b4b28f3e1dab 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -55,6 +55,7 @@ import { } from "@t3tools/client-runtime/environment"; import { AuthOrchestrationOperateScope, + CommandId, ProjectId, type ScopedThreadRef, type ThreadId, @@ -101,7 +102,7 @@ import { useShortcutModifierState } from "../shortcutModifierState"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { isMacPlatform } from "~/lib/utils"; +import { isMacPlatform, randomUUID } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { @@ -150,6 +151,7 @@ import { useProjects, useServerConfigs, useQuietThreadShells, + waitForTitleRegenerationFailure, } from "../state/entities"; import { environmentPresentations } from "../state/presentation"; import { primaryServerKeybindingsAtom } from "../state/server"; @@ -2440,12 +2442,22 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return; } if (clicked.value === "regenerate-title") { + const failures: Array> = []; for (const thread of regeneratableTitleThreads) { + const commandId = CommandId.make(randomUUID()); const result = await updateThreadMetadata({ environmentId: thread.environmentId, - input: { threadId: thread.id, regenerateTitle: true }, + input: { threadId: thread.id, regenerateTitle: true, commandId }, }); - if (result._tag === "Success") continue; + if (result._tag === "Success") { + failures.push( + waitForTitleRegenerationFailure( + scopeThreadRef(thread.environmentId, thread.id), + commandId, + ), + ); + continue; + } if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -2459,6 +2471,20 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return; } clearSelection(); + void Promise.all(failures).then((outcomes) => { + const messages = outcomes.filter((message) => message !== null); + if (messages.length === 0) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: + messages.length === 1 + ? "Failed to regenerate a thread title" + : `Failed to regenerate ${messages.length} thread titles`, + description: messages[0], + }), + ); + }); return; } if (clicked.value === "settle") { @@ -2771,20 +2797,34 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return; case "regenerate-title": { if (isRegeneratingTitle) return; + const commandId = CommandId.make(randomUUID()); const result = await updateThreadMetadata({ environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, regenerateTitle: true }, + input: { threadId: threadRef.threadId, regenerateTitle: true, commandId }, }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to regenerate thread title", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + void waitForTitleRegenerationFailure(threadRef, commandId).then((failure) => { + if (failure === null) return; toastManager.add( stackedThreadToast({ type: "error", title: "Failed to regenerate thread title", - description: error instanceof Error ? error.message : "An error occurred.", + description: failure, }), ); - } + }); return; } case "mark-unread": diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 3c8255b04c088..5ca2a588f4b43 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -35,6 +35,7 @@ function recordEffect(action: string) { } vi.mock("../components/CustomSnoozeDialog", () => ({ requestCustomSnooze: vi.fn() })); +vi.mock("../lib/utils", () => ({ randomUUID: () => "command" })); vi.mock("react", () => ({ useCallback: (callback: unknown) => callback, useMemo: (factory: () => unknown) => factory(), diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index cddef6046cf8b..cf5976ac6c1bb 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -10,6 +10,7 @@ import { import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { AuthOrchestrationOperateScope, + CommandId, type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; @@ -23,6 +24,7 @@ import { } from "../components/threadActionMenu.logic"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { copyThreadReference } from "../lib/copyThreadReference"; +import { randomUUID } from "../lib/utils"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { threadEnvironment } from "../state/threads"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; @@ -32,6 +34,7 @@ import { readEnvironmentSupportsAutoSettleOptOut, readThreadShell, useProjects, + waitForTitleRegenerationFailure, } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; import { readLocalApi } from "../localApi"; @@ -246,15 +249,26 @@ export function useThreadActionMenu(input: { case "rename": onStartRename(); return; - case "regenerate-title": + case "regenerate-title": { if (isRegeneratingTitle) return; - await reportFailure("Failed to regenerate thread title", () => - updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, regenerateTitle: true }, - }), - ); + const commandId = CommandId.make(randomUUID()); + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, regenerateTitle: true, commandId }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + failureToast("Failed to regenerate thread title", squashAtomCommandFailure(result)); + } + return; + } + void waitForTitleRegenerationFailure(threadRef, commandId).then((failure) => { + if (failure !== null) { + failureToast("Failed to regenerate thread title", new Error(failure)); + } + }); return; + } case "mark-unread": markThreadUnread(threadRef); return; diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 19c0736779897..f2d39392da456 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -8,6 +8,7 @@ import type { ThreadStructureVisibleTurnItem, } from "@t3tools/client-runtime/state/shell"; import { + awaitTitleRegenerationFailure, EMPTY_THREAD_HISTORY_META, type EnvironmentThreadStatus, type ThreadHistoryMeta, @@ -338,6 +339,18 @@ export function waitForThreadShell(ref: ScopedThreadRef, timeoutMs = 5_000): Pro }); } +/** Resolves with why the requested regeneration kept the title, or null. */ +export function waitForTitleRegenerationFailure( + ref: ScopedThreadRef, + requestId: string, +): Promise { + return awaitTitleRegenerationFailure({ + registry: appAtomRegistry, + atom: environmentThreadShells.threadShellAtom(ref), + requestId, + }); +} + export function useEnvironmentSupportsServerBrowser(environmentId: EnvironmentId | null): boolean { const configs = useServerConfigs(); return ( diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 196b5ff39e171..4c1df9083354e 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -194,6 +194,10 @@ export interface EnvironmentThreadShell { /** Server-tracked open history for search ranking. */ readonly opens?: import("@t3tools/contracts").OrchestrationV2ThreadOpens | null; readonly titleRegeneration?: { readonly requestId: string; readonly startedAt: string } | null; + readonly titleRegenerationFailure?: { + readonly requestId: string; + readonly message: string; + } | null; readonly deletedAt: string | null; readonly source: OrchestrationV2ThreadShell; } @@ -344,6 +348,7 @@ export function presentThreadShell( requestId: thread.titleRegeneration.requestId, startedAt: iso(thread.titleRegeneration.startedAt), }, + titleRegenerationFailure: thread.titleRegenerationFailure ?? null, deletedAt: nullableIso(thread.deletedAt), source: thread, }; diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 39607575627dd..02638ef239517 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -16,7 +16,12 @@ import { PrimaryConnectionTarget } from "../connection/model.ts"; import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; import { isThreadWorking } from "./threadInbox.ts"; -import { childThreadInputsTitle, createEnvironmentThreadShellAtoms } from "./threadShell.ts"; +import type { EnvironmentThreadShell } from "./models.ts"; +import { + awaitTitleRegenerationFailure, + childThreadInputsTitle, + createEnvironmentThreadShellAtoms, +} from "./threadShell.ts"; const environmentId = EnvironmentId.make("environment-v2"); const remoteEnvironmentId = EnvironmentId.make("remote-environment-v2"); @@ -634,3 +639,33 @@ describe("v2 thread shell lists", () => { } }); }); + +describe("awaitTitleRegenerationFailure", () => { + const shell = ( + fields: Pick, + ) => fields as EnvironmentThreadShell; + const pending = (requestId: string) => + shell({ titleRegeneration: { requestId, startedAt: "2026-11-03T00:00:00.000Z" } }); + + it.each([ + { + name: "reports its own failure", + after: shell({ titleRegenerationFailure: { requestId: "a", message: "Login expired" } }), + expected: "Login expired", + }, + { name: "settles when the title lands", after: shell({}), expected: null }, + { + name: "ignores a newer request's failure", + after: shell({ titleRegenerationFailure: { requestId: "b", message: "Login expired" } }), + expected: null, + }, + ])("$name", async ({ after, expected }) => { + const registry = AtomRegistry.make(); + const atom = Atom.make(shell({})).pipe(Atom.keepAlive); + const outcome = awaitTitleRegenerationFailure({ registry, atom, requestId: "a" }); + registry.set(atom, pending("a")); + registry.set(atom, after); + await expect(outcome).resolves.toBe(expected); + registry.dispose(); + }); +}); diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index f5ced761c5665..b26cabb429ed0 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -12,7 +12,7 @@ import { OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { Atom } from "effect/reactivity"; +import { Atom, type AtomRegistry } from "effect/reactivity"; import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts"; import { presentThreadShell, threadRuntimeIsActive } from "./models.ts"; @@ -200,6 +200,42 @@ function sameApartFromStreaming( ); } +/** + * Resolves with the server's reason once a title regeneration request ends without a new + * title, or null when it lands, is superseded, the thread goes away, or it times out. + */ +export function awaitTitleRegenerationFailure(input: { + readonly registry: AtomRegistry.AtomRegistry; + readonly atom: Atom.Atom; + readonly requestId: string; + readonly timeoutMs?: number; +}): Promise { + return new Promise((resolve) => { + let pending = false; + let settled = false; + let unsubscribe: (() => void) | null = null; + const finish = (failure: string | null) => { + if (settled) return; + settled = true; + globalThis.clearTimeout(timeout); + unsubscribe?.(); + resolve(failure); + }; + const inspect = (shell: EnvironmentThreadShell | null) => { + if (shell === null) return finish(null); + if (shell.titleRegenerationFailure?.requestId === input.requestId) { + return finish(shell.titleRegenerationFailure.message); + } + if (shell.titleRegeneration?.requestId === input.requestId) pending = true; + else if (pending) finish(null); + }; + const timeout = globalThis.setTimeout(() => finish(null), input.timeoutMs ?? 180_000); + unsubscribe = input.registry.subscribe(input.atom, inspect); + if (settled) unsubscribe(); + else inspect(input.registry.get(input.atom)); + }); +} + export function createEnvironmentThreadShellAtoms(input: { readonly catalogValueAtom: Atom.Atom; readonly snapshotAtom: ( diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 785f41e3aa0fb..f330c06a10d5e 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -41,6 +41,7 @@ import { TrimmedNonEmptyString, TurnItemId, } from "./baseSchemas.ts"; +import { ThreadTitleRegenerationFailure } from "./threadTitle.ts"; import { AuthMcpClientAccess } from "./auth.ts"; import { ChatAttachment } from "./chatAttachment.ts"; import { ThreadGuestShare } from "./guestShare.ts"; @@ -504,6 +505,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ }), ), ), + titleRegenerationFailure: Schema.optional(Schema.NullOr(ThreadTitleRegenerationFailure)), rollbackRequestId: Schema.optional(CommandId), rollbackFailure: Schema.optional( Schema.NullOr( @@ -2147,6 +2149,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ }), ), ), + titleRegenerationFailure: Schema.optional(Schema.NullOr(ThreadTitleRegenerationFailure)), deletedAt: Schema.NullOr(Schema.DateTimeUtc), }); export type OrchestrationV2ThreadShell = typeof OrchestrationV2ThreadShell.Type; @@ -3117,6 +3120,7 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, requestId: CommandId, title: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(TrimmedNonEmptyString), }), Schema.Struct({ type: Schema.Literal("thread.runtime-mode.set"), diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 1d6d7f3d67908..98d39514fa36c 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -33,7 +33,7 @@ import { ThreadPullRequestLinkSource, } from "./threadPullRequest.ts"; import { hasOnlyPairedSurrogates } from "./threadMetadataMcp.ts"; -import { ThreadTitleRegeneration } from "./threadTitle.ts"; +import { ThreadTitleRegeneration, ThreadTitleRegenerationFailure } from "./threadTitle.ts"; import { OrchestrationV2Actor, OrchestrationV2CreationSource, @@ -439,6 +439,7 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), pullRequests: Schema.optional(Schema.Array(OrchestratorMcpThreadPullRequest)), titleRegeneration: Schema.NullOr(ThreadTitleRegeneration), + titleRegenerationFailure: Schema.NullOr(ThreadTitleRegenerationFailure), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), parentThreadId: Schema.NullOr(ThreadId), diff --git a/packages/contracts/src/threadTitle.ts b/packages/contracts/src/threadTitle.ts index 8f14f431ecff2..01edc1821cb76 100644 --- a/packages/contracts/src/threadTitle.ts +++ b/packages/contracts/src/threadTitle.ts @@ -1,9 +1,16 @@ import * as Schema from "effect/Schema"; -import { CommandId, IsoDateTime } from "./baseSchemas.ts"; +import { CommandId, IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const ThreadTitleRegeneration = Schema.Struct({ requestId: CommandId, startedAt: IsoDateTime, }); export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; + +/** Why the latest title regeneration request kept the current title. */ +export const ThreadTitleRegenerationFailure = Schema.Struct({ + requestId: CommandId, + message: TrimmedNonEmptyString, +}); +export type ThreadTitleRegenerationFailure = typeof ThreadTitleRegenerationFailure.Type; From 100249b2e92c92b7ccdf1f32c707a711585ef1f1 Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:31:06 +0100 Subject: [PATCH 2/6] fix: satisfy Effect diagnostics in the title failure helpers --- .../src/orchestration-v2/ThreadTitleRegenerationService.ts | 6 +++--- packages/client-runtime/src/state/threadShell.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index acabc3d84d24d..f17a25ae4577d 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -18,6 +18,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; @@ -52,13 +53,12 @@ type TitleOutcome = | { readonly type: "complete"; readonly title?: string; readonly failure?: string }; const GENERIC_FAILURE = "The title could not be generated."; +const isTextGenerationError = Schema.is(TextGenerationError); function failureMessage(cause: Cause.Cause): string { const error = Cause.findErrorOption(cause); const detail = - Option.isSome(error) && error.value instanceof TextGenerationError - ? error.value.detail.trim() - : ""; + Option.isSome(error) && isTextGenerationError(error.value) ? error.value.detail.trim() : ""; return detail.length === 0 ? GENERIC_FAILURE : truncate(detail, 500); } diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index b26cabb429ed0..fb2bb81a7252e 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -229,6 +229,7 @@ export function awaitTitleRegenerationFailure(input: { if (shell.titleRegeneration?.requestId === input.requestId) pending = true; else if (pending) finish(null); }; + // @effect-diagnostics-next-line globalTimers:off - Client UI waits on atoms, outside an Effect runtime. const timeout = globalThis.setTimeout(() => finish(null), input.timeoutMs ?? 180_000); unsubscribe = input.registry.subscribe(input.atom, inspect); if (settled) unsubscribe(); From 2bb52b8780d7087d87dd81b779906b16f34593b0 Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:50:41 +0100 Subject: [PATCH 3/6] fix: watch title regeneration before dispatch and report bulk failures as they arrive A regeneration that finished before the client started waiting never showed as pending, so its success waited out the 180-second timeout. Clients now start the watch before dispatching and cancel it if the dispatch fails. Bulk regeneration reported failures only after every request settled; each failure now shows as soon as it arrives, with the thread it belongs to. --- .../home/useThreadListActions.test.ts | 2 +- .../src/features/home/useThreadListActions.ts | 13 +++--- apps/mobile/src/state/threads.ts | 11 ++--- apps/web/src/components/Sidebar.tsx | 42 +++++++++---------- apps/web/src/hooks/useThreadActionMenu.ts | 6 ++- apps/web/src/state/entities.ts | 11 ++--- .../src/state/threadShell.test.ts | 17 ++++++-- .../client-runtime/src/state/threadShell.ts | 22 ++++++---- 8 files changed, 72 insertions(+), 52 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 6acde7ab38549..402ffec582227 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -121,7 +121,7 @@ vi.mock("../../state/thread-order", () => ({ })); vi.mock("../../lib/uuid", () => ({ uuidv4: () => "command" })); vi.mock("../../state/threads", () => ({ - waitForTitleRegenerationFailure: async () => null, + watchThreadTitleRegeneration: () => ({ failure: Promise.resolve(null), cancel: () => {} }), environmentThreadShells: { threadShellsAtom: "thread-shells", navigationThreadShellsAtom: "thread-shells", diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 9766770d8aa72..00ad59b1bfd03 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -21,7 +21,7 @@ import { readEnvironmentScope } from "../../state/session"; import { environmentThreadShells, threadEnvironment, - waitForTitleRegenerationFailure, + watchThreadTitleRegeneration, } from "../../state/threads"; import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -516,6 +516,10 @@ export function useThreadListActions(): { titleRegenerationInFlightThreadKeys.current.add(key); selectionHaptic(); const commandId = CommandId.make(uuidv4()); + const watch = watchThreadTitleRegeneration( + { environmentId: thread.environmentId, threadId: thread.id }, + commandId, + ); const succeeded = await (async () => { const result = await updateThreadMetadata({ environmentId: thread.environmentId, @@ -535,12 +539,11 @@ export function useThreadListActions(): { })(); titleRegenerationInFlightThreadKeys.current.delete(key); if (succeeded) { - void waitForTitleRegenerationFailure( - { environmentId: thread.environmentId, threadId: thread.id }, - commandId, - ).then((failure) => { + void watch.failure.then((failure) => { if (failure !== null) Dialog.alert("Could not regenerate title", failure); }); + } else { + watch.cancel(); } return succeeded; }; diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 3f544e5cf06c9..42edfc42b8c67 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -1,7 +1,6 @@ import { createChildThreadApprovalsAtom } from "@t3tools/client-runtime/state/thread-requests"; import { useAtomValue } from "@effect/atom-react"; import { - awaitTitleRegenerationFailure, createEnvironmentThreadDetailAtoms, createEnvironmentThreadShellAtoms, createEnvironmentThreadStateAtoms, @@ -10,6 +9,8 @@ import { createOlderSettledThreadAtoms, createThreadEnvironmentAtoms, createThreadFamilyAtoms, + type TitleRegenerationWatch, + watchTitleRegeneration, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -48,12 +49,12 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ hiddenThreadTagsAtom: serverEnvironment.hiddenThreadTagsAtom, }); -/** Resolves with why the requested regeneration kept the title, or null. */ -export function waitForTitleRegenerationFailure( +/** Start before dispatching the request; see `watchTitleRegeneration`. */ +export function watchThreadTitleRegeneration( ref: ScopedThreadRef, requestId: string, -): Promise { - return awaitTitleRegenerationFailure({ +): TitleRegenerationWatch { + return watchTitleRegeneration({ registry: appAtomRegistry, atom: environmentThreadShells.threadShellAtom(ref), requestId, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b4b28f3e1dab..4f0daafadb721 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -151,7 +151,7 @@ import { useProjects, useServerConfigs, useQuietThreadShells, - waitForTitleRegenerationFailure, + watchThreadTitleRegeneration, } from "../state/entities"; import { environmentPresentations } from "../state/presentation"; import { primaryServerKeybindingsAtom } from "../state/server"; @@ -2442,22 +2442,30 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return; } if (clicked.value === "regenerate-title") { - const failures: Array> = []; for (const thread of regeneratableTitleThreads) { const commandId = CommandId.make(randomUUID()); + const watch = watchThreadTitleRegeneration( + scopeThreadRef(thread.environmentId, thread.id), + commandId, + ); const result = await updateThreadMetadata({ environmentId: thread.environmentId, input: { threadId: thread.id, regenerateTitle: true, commandId }, }); if (result._tag === "Success") { - failures.push( - waitForTitleRegenerationFailure( - scopeThreadRef(thread.environmentId, thread.id), - commandId, - ), - ); + void watch.failure.then((failure) => { + if (failure === null) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to regenerate thread title", + description: `${thread.title}: ${failure}`, + }), + ); + }); continue; } + watch.cancel(); if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -2471,20 +2479,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return; } clearSelection(); - void Promise.all(failures).then((outcomes) => { - const messages = outcomes.filter((message) => message !== null); - if (messages.length === 0) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: - messages.length === 1 - ? "Failed to regenerate a thread title" - : `Failed to regenerate ${messages.length} thread titles`, - description: messages[0], - }), - ); - }); return; } if (clicked.value === "settle") { @@ -2798,11 +2792,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve case "regenerate-title": { if (isRegeneratingTitle) return; const commandId = CommandId.make(randomUUID()); + const watch = watchThreadTitleRegeneration(threadRef, commandId); const result = await updateThreadMetadata({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId, regenerateTitle: true, commandId }, }); if (result._tag === "Failure") { + watch.cancel(); if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -2815,7 +2811,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } return; } - void waitForTitleRegenerationFailure(threadRef, commandId).then((failure) => { + void watch.failure.then((failure) => { if (failure === null) return; toastManager.add( stackedThreadToast({ diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index cf5976ac6c1bb..d16068107d711 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -34,7 +34,7 @@ import { readEnvironmentSupportsAutoSettleOptOut, readThreadShell, useProjects, - waitForTitleRegenerationFailure, + watchThreadTitleRegeneration, } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; import { readLocalApi } from "../localApi"; @@ -252,17 +252,19 @@ export function useThreadActionMenu(input: { case "regenerate-title": { if (isRegeneratingTitle) return; const commandId = CommandId.make(randomUUID()); + const watch = watchThreadTitleRegeneration(threadRef, commandId); const result = await updateThreadMetadata({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId, regenerateTitle: true, commandId }, }); if (result._tag === "Failure") { + watch.cancel(); if (!isAtomCommandInterrupted(result)) { failureToast("Failed to regenerate thread title", squashAtomCommandFailure(result)); } return; } - void waitForTitleRegenerationFailure(threadRef, commandId).then((failure) => { + void watch.failure.then((failure) => { if (failure !== null) { failureToast("Failed to regenerate thread title", new Error(failure)); } diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index f2d39392da456..c1db44640b51c 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -8,10 +8,11 @@ import type { ThreadStructureVisibleTurnItem, } from "@t3tools/client-runtime/state/shell"; import { - awaitTitleRegenerationFailure, EMPTY_THREAD_HISTORY_META, type EnvironmentThreadStatus, type ThreadHistoryMeta, + type TitleRegenerationWatch, + watchTitleRegeneration, } from "@t3tools/client-runtime/state/threads"; import { arrayElementsEqual, @@ -339,12 +340,12 @@ export function waitForThreadShell(ref: ScopedThreadRef, timeoutMs = 5_000): Pro }); } -/** Resolves with why the requested regeneration kept the title, or null. */ -export function waitForTitleRegenerationFailure( +/** Start before dispatching the request; see `watchTitleRegeneration`. */ +export function watchThreadTitleRegeneration( ref: ScopedThreadRef, requestId: string, -): Promise { - return awaitTitleRegenerationFailure({ +): TitleRegenerationWatch { + return watchTitleRegeneration({ registry: appAtomRegistry, atom: environmentThreadShells.threadShellAtom(ref), requestId, diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 02638ef239517..f2a6425a2685e 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -18,7 +18,7 @@ import { applyShellStreamEvent } from "./shellReducer.ts"; import { isThreadWorking } from "./threadInbox.ts"; import type { EnvironmentThreadShell } from "./models.ts"; import { - awaitTitleRegenerationFailure, + watchTitleRegeneration, childThreadInputsTitle, createEnvironmentThreadShellAtoms, } from "./threadShell.ts"; @@ -640,7 +640,7 @@ describe("v2 thread shell lists", () => { }); }); -describe("awaitTitleRegenerationFailure", () => { +describe("watchTitleRegeneration", () => { const shell = ( fields: Pick, ) => fields as EnvironmentThreadShell; @@ -662,10 +662,19 @@ describe("awaitTitleRegenerationFailure", () => { ])("$name", async ({ after, expected }) => { const registry = AtomRegistry.make(); const atom = Atom.make(shell({})).pipe(Atom.keepAlive); - const outcome = awaitTitleRegenerationFailure({ registry, atom, requestId: "a" }); + const watch = watchTitleRegeneration({ registry, atom, requestId: "a" }); registry.set(atom, pending("a")); registry.set(atom, after); - await expect(outcome).resolves.toBe(expected); + await expect(watch.failure).resolves.toBe(expected); + registry.dispose(); + }); + + it("settles when cancelled because the request was never accepted", async () => { + const registry = AtomRegistry.make(); + const atom = Atom.make(shell({})).pipe(Atom.keepAlive); + const watch = watchTitleRegeneration({ registry, atom, requestId: "a" }); + watch.cancel(); + await expect(watch.failure).resolves.toBeNull(); registry.dispose(); }); }); diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index fb2bb81a7252e..0fbbfe618248f 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -200,26 +200,33 @@ function sameApartFromStreaming( ); } +export interface TitleRegenerationWatch { + /** The server's reason when the request kept the title; null when it landed or stopped mattering. */ + readonly failure: Promise; + readonly cancel: () => void; +} + /** - * Resolves with the server's reason once a title regeneration request ends without a new - * title, or null when it lands, is superseded, the thread goes away, or it times out. + * Watches one title regeneration request. Start it before dispatching: a request that + * finishes before the watch starts never shows as pending, so its success goes unseen. */ -export function awaitTitleRegenerationFailure(input: { +export function watchTitleRegeneration(input: { readonly registry: AtomRegistry.AtomRegistry; readonly atom: Atom.Atom; readonly requestId: string; readonly timeoutMs?: number; -}): Promise { - return new Promise((resolve) => { +}): TitleRegenerationWatch { + let finish: (failure: string | null) => void = () => {}; + const failure = new Promise((resolve) => { let pending = false; let settled = false; let unsubscribe: (() => void) | null = null; - const finish = (failure: string | null) => { + finish = (value) => { if (settled) return; settled = true; globalThis.clearTimeout(timeout); unsubscribe?.(); - resolve(failure); + resolve(value); }; const inspect = (shell: EnvironmentThreadShell | null) => { if (shell === null) return finish(null); @@ -235,6 +242,7 @@ export function awaitTitleRegenerationFailure(input: { if (settled) unsubscribe(); else inspect(input.registry.get(input.atom)); }); + return { failure, cancel: () => finish(null) }; } export function createEnvironmentThreadShellAtoms(input: { From 15be9a5d8d469c59c239d4ec5cda59e8364b89b9 Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:09:04 +0100 Subject: [PATCH 4/6] fix: older servers' thread reads still decode, and mobile's failure alert is tested titleRegenerationFailure is optional in the orchestrator MCP thread detail, so the CLI and other readers still decode thread reads from servers that predate it. The mobile thread list test now checks that a regeneration watches the command it dispatched and alerts its failure. --- .../home/useThreadListActions.test.ts | 39 +++++++++++++++++-- packages/contracts/src/orchestratorMcp.ts | 2 +- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 402ffec582227..05cc2ecc530f3 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -23,13 +23,22 @@ const state = vi.hoisted(() => ({ requests: [] as { action: string; environmentId: string; - input: { threadId: string; orderKey?: string; snoozedUntil?: string; reason?: string }; + input: { + threadId: string; + orderKey?: string; + snoozedUntil?: string; + reason?: string; + commandId?: string; + }; }[], dialogs: [] as { onConfirm: () => void }[], alerts: [] as { title: string; + message?: string; buttons?: { text: string; onPress?: () => void }[]; }[], + titleFailure: null as string | null, + watchedTitleRequests: [] as string[], afterRequest: undefined as (() => void) | undefined, })); @@ -48,8 +57,8 @@ vi.mock("expo-haptics", () => ({ ImpactFeedbackStyle: { Light: "light" }, })); vi.mock("../../components/ui/Dialog", () => ({ - alert: (title: string, _message: string, buttons?: { text: string; onPress?: () => void }[]) => - state.alerts.push({ title, buttons }), + alert: (title: string, message: string, buttons?: { text: string; onPress?: () => void }[]) => + state.alerts.push({ title, message, buttons }), confirm: (dialog: { onConfirm: () => void }) => state.dialogs.push(dialog), })); vi.mock("../archive/useArchivedThreadSnapshots", () => ({ @@ -121,7 +130,10 @@ vi.mock("../../state/thread-order", () => ({ })); vi.mock("../../lib/uuid", () => ({ uuidv4: () => "command" })); vi.mock("../../state/threads", () => ({ - watchThreadTitleRegeneration: () => ({ failure: Promise.resolve(null), cancel: () => {} }), + watchThreadTitleRegeneration: (_ref: unknown, requestId: string) => { + state.watchedTitleRequests.push(requestId); + return { failure: Promise.resolve(state.titleFailure), cancel: () => {} }; + }, environmentThreadShells: { threadShellsAtom: "thread-shells", navigationThreadShellsAtom: "thread-shells", @@ -201,6 +213,8 @@ beforeEach(() => { state.shells = []; state.dialogs = []; state.alerts = []; + state.titleFailure = null; + state.watchedTitleRequests = []; state.afterRequest = undefined; }); @@ -244,6 +258,23 @@ describe("thread list operation permissions", () => { }); }); +describe("thread title regeneration", () => { + it("alerts why the request it dispatched kept the title", async () => { + state.titleFailure = "Claude CLI command failed: OAuth session expired"; + + await useThreadListActions().regenerateThreadTitle(makeThread()); + await Promise.resolve(); + + expect(state.watchedTitleRequests).toEqual([state.requests[0]?.input.commandId]); + expect(state.alerts).toEqual([ + expect.objectContaining({ + title: "Could not regenerate title", + message: "Claude CLI command failed: OAuth session expired", + }), + ]); + }); +}); + describe("pinned thread operation permissions", () => { it("checks every materialization target before writing any keys", async () => { const moved = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z" }); diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 98d39514fa36c..fb997d9322f2d 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -439,7 +439,7 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), pullRequests: Schema.optional(Schema.Array(OrchestratorMcpThreadPullRequest)), titleRegeneration: Schema.NullOr(ThreadTitleRegeneration), - titleRegenerationFailure: Schema.NullOr(ThreadTitleRegenerationFailure), + titleRegenerationFailure: Schema.optional(Schema.NullOr(ThreadTitleRegenerationFailure)), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), parentThreadId: Schema.NullOr(ThreadId), From 45d2c0972e20da7e656d75f0ff3d681a79808371 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 16:15:55 +0000 Subject: [PATCH 5/6] fix(server): preserve the codex title generation failure reason --- .../CodexTextGeneration.test.ts | 29 +++++++++++++++++++ .../src/textGeneration/CodexTextGeneration.ts | 7 ++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 04ae83952b3c4..399f64877628e 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -196,6 +196,35 @@ function withFakeCodexEnv( } it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { + it.effect("reports the final CLI error instead of its echoed prompt", () => + withFakeCodexEnv( + { + output: "", + exitCode: 1, + stderr: [ + "OpenAI Codex v0.164.0", + "user", + "Regenerate the thread title. ".repeat(40), + "ERROR: Reconnecting... 5/5", + "ERROR: unknown provider for model unavailable-model", + ].join("\n"), + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + expect(error.detail).toBe( + "Codex CLI command failed: unknown provider for model unavailable-model", + ); + }), + ), + ); + it.effect.each(["exec", "mcp list"] as const)( "kills a stalled %s CLI when its request is interrupted", (command) => diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index e17fa4c4c00e7..96d92c9a1bd2f 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -334,7 +334,12 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ); if (exitCode !== 0) { - const stderrDetail = stderr.trim(); + const stderrDetail = + stderr + .split("\n") + .findLast((line) => line.startsWith("ERROR: ")) + ?.slice("ERROR: ".length) + .trim() || stderr.trim(); const stdoutDetail = stdout.trim(); const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; return yield* new TextGenerationError({ From a9e73bf9f0447cc24b002b9d66cde206f52eae68 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 06:57:47 +0000 Subject: [PATCH 6/6] fix(mobile): hold the title regeneration guard until the watched request settles --- apps/mobile/src/features/home/useThreadListActions.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 93527f9741227..cc269821e735d 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -535,13 +535,15 @@ export function useThreadListActions(): { } return true; })(); - titleRegenerationInFlightThreadKeys.current.delete(key); if (succeeded) { - void watch.failure.then((failure) => { - if (failure !== null) Dialog.alert("Could not regenerate title", failure); - }); + void watch.failure + .then((failure) => { + if (failure !== null) Dialog.alert("Could not regenerate title", failure); + }) + .finally(() => titleRegenerationInFlightThreadKeys.current.delete(key)); } else { watch.cancel(); + titleRegenerationInFlightThreadKeys.current.delete(key); } return succeeded; };