From 53d6ba26b5a6680d6d8709ead1d45c8d56cab58a Mon Sep 17 00:00:00 2001 From: flamboh Date: Thu, 5 Nov 2026 10:39:52 +0000 Subject: [PATCH 1/6] fix: Regenerate title always says what happened --- .../home/useThreadListActions.test.ts | 3 +- .../src/features/home/useThreadListActions.ts | 15 ++- apps/server/src/mcp/OrchestratorMcpService.ts | 7 ++ .../src/orchestration-v2/Orchestrator.ts | 9 ++ .../src/orchestration-v2/ProjectionStore.ts | 2 + .../ThreadTitleRegenerationService.test.ts | 52 +++++++- .../ThreadTitleRegenerationService.ts | 64 +++++++--- apps/web/src/components/Sidebar.tsx | 54 ++++---- .../threadTitleRegenerationToast.ts | 59 +++++++++ apps/web/src/hooks/useThreadActionMenu.ts | 15 ++- packages/client-runtime/src/state/runtime.ts | 7 ++ .../src/state/threadCommands.test.ts | 115 +++++++++++++++++- .../src/state/threadCommands.ts | 58 ++++++++- .../src/state/threadTitleRegeneration.ts | 64 ++++++++++ packages/contracts/src/orchestrationV2.ts | 26 ++++ packages/contracts/src/orchestratorMcp.ts | 3 +- packages/contracts/src/threadTitle.ts | 16 +++ 17 files changed, 506 insertions(+), 63 deletions(-) create mode 100644 apps/web/src/components/threadTitleRegenerationToast.ts create mode 100644 packages/client-runtime/src/state/threadTitleRegeneration.ts diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 9313aef299881..6d861356161aa 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -138,6 +138,7 @@ vi.mock("../../state/threads", () => ({ "reorderPin", "reorderActive", "updateMetadata", + "regenerateTitle", ].map((action) => [ action, async (request: { @@ -182,7 +183,7 @@ const mutationCases = [ ["unsnoozeThread", "unsnooze"], ["pinThread", "pin"], ["unpinThread", "unpin"], - ["regenerateThreadTitle", "updateMetadata"], + ["regenerateThreadTitle", "regenerateTitle"], ] as const; beforeEach(() => { diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 248c436905311..846fecbdffb46 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -220,6 +220,9 @@ export function useThreadListActions(): { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const regenerateTitle = useAtomCommand(threadEnvironment.regenerateTitle, { + reportFailure: false, + }); const snoozeInFlightThreadKeys = useRef(new Set()); const titleRegenerationInFlightThreadKeys = useRef(new Set()); @@ -511,9 +514,9 @@ export function useThreadListActions(): { titleRegenerationInFlightThreadKeys.current.add(key); selectionHaptic(); const succeeded = await (async () => { - const result = await updateThreadMetadata({ + const result = await regenerateTitle({ environmentId: thread.environmentId, - input: { threadId: thread.id, regenerateTitle: true }, + input: { threadId: thread.id }, }); if (result._tag === "Failure") { const error = Cause.squash(result.cause); @@ -525,6 +528,14 @@ export function useThreadListActions(): { ); return false; } + if (result.value?.status === "failed") { + Dialog.alert( + "Could not regenerate title", + result.value.message ?? "The thread title could not be regenerated.", + ); + } else if (result.value?.status === "unchanged") { + Dialog.alert("Title kept", "The current title already fits this thread."); + } return true; })(); titleRegenerationInFlightThreadKeys.current.delete(key); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index e53ed03a7c6fa..5cbb0521376bd 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -856,6 +856,13 @@ function threadDetail( requestId: projection.thread.titleRegeneration.requestId, startedAt: DateTime.formatIso(projection.thread.titleRegeneration.startedAt), }, + lastTitleRegeneration: + projection.thread.lastTitleRegeneration == null + ? null + : { + ...projection.thread.lastTitleRegeneration, + completedAt: DateTime.formatIso(projection.thread.lastTitleRegeneration.completedAt), + }, 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 a6b876db1de96..6fa1cc7d70cbe 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4010,6 +4010,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? { ...thread, ...(command.title === undefined ? {} : { title: command.title }), + ...(command.outcome === undefined + ? {} + : { + lastTitleRegeneration: { + ...command.outcome, + requestId: command.requestId, + completedAt: now, + }, + }), titleRegeneration: null, updatedAt: now, } diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index bd6a61db9030b..f64224bdd792a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1684,6 +1684,7 @@ export function threadShellFromProjection( lastVisitedAt: projection.thread.lastVisitedAt, opens: projection.thread.opens ?? null, titleRegeneration: projection.thread.titleRegeneration ?? null, + lastTitleRegeneration: projection.thread.lastTitleRegeneration ?? null, limitRecovery: projection.thread.limitRecovery ?? null, deletedAt: projection.thread.deletedAt, }; @@ -1950,6 +1951,7 @@ function shellFromState(input: { lastVisitedAt: input.state.thread.lastVisitedAt, opens: input.state.thread.opens ?? null, titleRegeneration: input.state.thread.titleRegeneration ?? null, + lastTitleRegeneration: input.state.thread.lastTitleRegeneration ?? 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 c54e6e1f4815d..4ccdcc6e38a57 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -11,6 +11,7 @@ import { ProviderInstanceId, ProviderThreadId, RunId, + TextGenerationError, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -432,6 +433,10 @@ describe("ThreadTitleRegenerationService", () => { const projection = yield* threads.getThreadProjection(threadId); assert.equal(projection.thread.title, "Fresh title"); assert.isNotOk(projection.thread.titleRegeneration); + assert.deepInclude(projection.thread.lastTitleRegeneration, { + requestId, + status: "renamed", + }); } }).pipe(Effect.provide(harness.layer)); }).pipe(Effect.provide(NodeServices.layer)), @@ -518,7 +523,14 @@ describe("ThreadTitleRegenerationService", () => { it.effect("clears the marker and keeps the title when generation fails", () => Effect.gen(function* () { const harness = makeHarness({ - generateTitle: () => Effect.die(new Error("model unavailable")), + generateTitle: () => + Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: + "Codex CLI command failed: OpenAI Codex\nuser\nConversation text\nmodel unavailable\n", + }), + ), }); yield* Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; @@ -540,6 +552,44 @@ describe("ThreadTitleRegenerationService", () => { const projection = yield* threads.getThreadProjection(threadId); assert.equal(projection.thread.title, "Seed title"); assert.isNotOk(projection.thread.titleRegeneration); + assert.deepInclude(projection.thread.lastTitleRegeneration, { + requestId, + status: "failed", + message: "model unavailable", + }); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("records an unchanged outcome when the model keeps the current title", () => + Effect.gen(function* () { + const harness = makeHarness({ + generateTitle: () => Effect.succeed({ title: "Seed title" }), + }); + yield* Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; + const threadId = yield* createThread({ + command: "command:title:unchanged:create", + thread: "thread:title:unchanged", + }); + yield* dispatchUserMessage({ + command: "command:title:unchanged:message", + threadId, + text: "Some conversation", + }); + const requestId = yield* armRegeneration({ + command: "command:title:unchanged:1", + threadId, + }); + + yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } }); + + const projection = yield* threads.getThreadProjection(threadId); + assert.deepInclude(projection.thread.lastTitleRegeneration, { + requestId, + status: "unchanged", + }); }).pipe(Effect.provide(harness.layer)); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index 6c80def39059f..232e4dfc41020 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -8,6 +8,7 @@ import { type OrchestrationV2ConversationMessage, type RunId, type ServerSettingsError, + TextGenerationError, type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -17,6 +18,7 @@ import * as FileSystem from "effect/FileSystem"; 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"; @@ -46,6 +48,29 @@ export class ThreadTitleRegenerationService extends Context.Service< } >()("t3/orchestration-v2/ThreadTitleRegenerationService") {} +type Outcome = + | { readonly status: "renamed"; readonly title: string } + | { readonly status: "unchanged" } + | { readonly status: "failed"; readonly message: string }; + +const MAX_FAILURE_MESSAGE_LENGTH = 300; +const isTextGenerationError = Schema.is(TextGenerationError); + +function failureMessage(cause: Cause.Cause): string { + const error = Cause.squash(cause); + const lines = isTextGenerationError(error) + ? error.detail + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + : []; + const detail = lines.at(-1); + if (detail === undefined) return "Title generation failed."; + return detail.length > MAX_FAILURE_MESSAGE_LENGTH + ? `${detail.slice(0, MAX_FAILURE_MESSAGE_LENGTH - 1)}…` + : detail; +} + const make = Effect.gen(function* () { const threads = yield* ThreadManagementService.ThreadManagementService; const projects = yield* ProjectStore.ProjectStoreV2; @@ -56,7 +81,8 @@ const make = Effect.gen(function* () { const complete = (input: { readonly threadId: ThreadId; readonly requestId: CommandId; - readonly title?: string; + readonly kind: { readonly type: "initial" | "regenerate" }; + readonly outcome: Outcome; }) => threads .dispatch({ @@ -64,7 +90,15 @@ const make = Effect.gen(function* () { commandId: CommandId.make(`${input.requestId}:title-complete`), threadId: input.threadId, requestId: input.requestId, - ...(input.title === undefined ? {} : { title: input.title }), + ...(input.outcome.status === "renamed" ? { title: input.outcome.title } : {}), + ...(input.kind.type === "regenerate" + ? { + outcome: { + status: input.outcome.status, + ...(input.outcome.status === "failed" ? { message: input.outcome.message } : {}), + }, + } + : {}), }) .pipe(Effect.asVoid); @@ -110,9 +144,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: Outcome | { readonly status: "stale" } = yield* Effect.gen(function* () { const projection = yield* threads.getThreadRecords( input.threadId, ["messages"], @@ -121,12 +153,12 @@ const make = Effect.gen(function* () { : { messageRoles: ["user", "assistant"] }, ); if (projection.thread.titleRegeneration?.requestId !== input.requestId) { - return { type: "stale" as const }; + return { status: "stale" as const }; } const project = yield* projects.get(projection.thread.projectId); if (Option.isNone(project)) { - return { type: "complete" as const }; + return { status: "failed" as const, message: "This thread's project no longer exists." }; } let context: { @@ -151,7 +183,10 @@ const make = Effect.gen(function* () { ); } if (context.message.length === 0 && context.attachments.length === 0) { - return { type: "complete" as const }; + return { + status: "failed" as const, + message: "This thread has no messages to title yet.", + }; } // A deleted source should not cost the fork its title. @@ -179,8 +214,8 @@ const make = Effect.gen(function* () { const generatedTitle = result.title.trim(); return generatedTitle === "New thread" || (input.kind.type === "regenerate" && generatedTitle === projection.thread.title.trim()) - ? { type: "complete" as const } - : { type: "complete" as const, title: result.title }; + ? { status: "unchanged" as const } + : { status: "renamed" as const, title: result.title }; }).pipe( Effect.retry({ times: input.kind.type === "initial" ? 2 : 0, @@ -193,17 +228,14 @@ const make = Effect.gen(function* () { threadId: input.threadId, requestId: input.requestId, cause, - }).pipe(Effect.as({ type: "complete" as const })), + }).pipe(Effect.as({ status: "failed" as const, message: failureMessage(cause) })), ), ); - if (outcome.type === "stale") { + if (outcome.status === "stale") { return; } - yield* complete({ - ...input, - ...(outcome.title === undefined ? {} : { title: outcome.title }), - }); + yield* complete({ ...input, outcome }); }); return ThreadTitleRegenerationService.of({ execute }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a82da0bf5f4de..5f672f2b6daf5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -234,6 +234,7 @@ import { useActiveRowReveal, } from "./sidebar/routeReveal"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { titleRegenerationToast } from "./threadTitleRegenerationToast"; import { copyThreadReference } from "../lib/copyThreadReference"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { Button } from "./ui/button"; @@ -930,6 +931,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const regenerateThreadTitle = useOrchestrationCommand(threadEnvironment.regenerateTitle, { + reportFailure: false, + }); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ @@ -2420,25 +2424,18 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return; } if (clicked.value === "regenerate-title") { - for (const thread of regeneratableTitleThreads) { - const result = await updateThreadMetadata({ - environmentId: thread.environmentId, - input: { threadId: thread.id, regenerateTitle: true }, - }); - if (result._tag === "Success") continue; - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to regenerate thread titles", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } clearSelection(); + const toast = titleRegenerationToast( + await Promise.all( + regeneratableTitleThreads.map((thread) => + regenerateThreadTitle({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }), + ), + ), + ); + if (toast) toastManager.add(toast); return; } if (clicked.value === "settle") { @@ -2751,20 +2748,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return; case "regenerate-title": { if (isRegeneratingTitle) return; - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, regenerateTitle: true }, - }); - if (result._tag === "Failure" && !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.", - }), - ); - } + const toast = titleRegenerationToast([ + await regenerateThreadTitle({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }), + ]); + if (toast) toastManager.add(toast); return; } case "mark-unread": diff --git a/apps/web/src/components/threadTitleRegenerationToast.ts b/apps/web/src/components/threadTitleRegenerationToast.ts new file mode 100644 index 0000000000000..67aa98aaef229 --- /dev/null +++ b/apps/web/src/components/threadTitleRegenerationToast.ts @@ -0,0 +1,59 @@ +import type { ThreadTitleRegenerationOutcome } from "@t3tools/contracts"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { stackedThreadToast } from "./ui/toast"; + +type TitleRegenerationResult = AtomCommandResult; + +function failureMessage(result: TitleRegenerationResult): string | null { + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return null; + const error = squashAtomCommandFailure(result); + return error instanceof Error ? error.message : "An error occurred."; + } + return result.value?.status === "failed" + ? (result.value.message ?? "Title generation failed.") + : null; +} + +function isKept(result: TitleRegenerationResult): boolean { + return result._tag === "Success" && result.value?.status === "unchanged"; +} + +export function titleRegenerationToast(results: ReadonlyArray) { + const failures = results.flatMap((result) => { + const message = failureMessage(result); + return message === null ? [] : [message]; + }); + if (failures.length > 0) { + return stackedThreadToast({ + type: "error", + title: + results.length === 1 + ? "Failed to regenerate thread title" + : `Failed to regenerate ${failures.length} of ${results.length} thread titles`, + description: failures[0], + }); + } + const kept = results.filter(isKept).length; + if (kept === 0) return null; + if (results.length === 1) { + return stackedThreadToast({ + type: "info", + title: "Title kept", + description: "The current title already fits this thread.", + }); + } + return stackedThreadToast({ + type: "info", + title: kept === results.length ? "Titles kept" : `Kept ${kept} of ${results.length} titles`, + description: + kept === results.length + ? "The current titles already fit these threads." + : "The rest were renamed. The kept titles already fit their threads.", + }); +} diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index d22b1d9cf6c4f..0655e34f5b57a 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -21,6 +21,7 @@ import { threadActionRequiresOperate, type ThreadActionMenuId, } from "../components/threadActionMenu.logic"; +import { titleRegenerationToast } from "../components/threadTitleRegenerationToast"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { copyThreadReference } from "../lib/copyThreadReference"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; @@ -88,7 +89,7 @@ export function useThreadActionMenu(input: { markThreadUnread, turnThreadIntoProject, } = useThreadActions(); - const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { + const regenerateThreadTitle = useOrchestrationCommand(threadEnvironment.regenerateTitle, { reportFailure: false, }); const handleNewThread = useNewThreadHandler(); @@ -247,15 +248,17 @@ 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({ + const toast = titleRegenerationToast([ + await regenerateThreadTitle({ environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, regenerateTitle: true }, + input: { threadId: threadRef.threadId }, }), - ); + ]); + if (toast) toastManager.add(toast); return; + } case "mark-unread": markThreadUnread(threadRef); return; diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 58c37070fd8d6..6248b2aace8d7 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -605,6 +605,13 @@ function environmentConnectionAtoms( return family; } +export function environmentConnectionAtom( + runtime: Atom.AtomRuntime, + environmentId: EnvironmentIdType, +) { + return environmentConnectionAtoms(runtime)(environmentId); +} + const sessionGenerationAtomsByRuntime = new WeakMap< object, (environmentId: EnvironmentIdType) => Atom.Atom diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 5fbfb1225a38b..70960cc7e65fb 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -22,11 +22,16 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom, AtomRegistry } from "effect/reactivity"; import * as EnvironmentRegistry from "../connection/registry.ts"; -import { BearerConnectionTarget } from "../connection/model.ts"; +import { + AVAILABLE_CONNECTION_STATE, + BearerConnectionTarget, + type SupervisorConnectionState, +} from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { RpcSession } from "../rpc/session.ts"; import { createThreadEnvironmentAtoms } from "./threadCommands.ts"; @@ -96,12 +101,19 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio reply: Deferred.Deferred<{ sequence: number }, Error>; }>(); const reconciled = yield* Queue.unbounded(); + const connectionFollowed = yield* Deferred.make(); + const connection = yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + desired: true, + phase: "connected", + }); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: new BearerConnectionTarget({ environmentId: ENVIRONMENT_ID, label: "Test", connectionId: "test", }), + state: connection, session: yield* SubscriptionRef.make( Option.some({ client: { @@ -120,6 +132,12 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, { run: (_environmentId, effect) => Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + followStream: (_environmentId, stream) => + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ).pipe(Stream.onStart(Deferred.succeed(connectionFollowed, undefined))), } as EnvironmentRegistry.EnvironmentRegistry["Service"]), Layer.succeed( SecureRandom.SecureRandom, @@ -143,7 +161,16 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); const visibleAtom = commands.snapshotAtom(ENVIRONMENT_ID); registry.mount(visibleAtom); - return { registry, commands, snapshotAtom, visibleAtom, requests, reconciled }; + return { + registry, + commands, + snapshotAtom, + visibleAtom, + requests, + reconciled, + connection, + connectionFollowed, + }; }); describe("history loading", () => { @@ -255,6 +282,90 @@ describe("interrupt turn", () => { ); }); +describe("title regeneration", () => { + it.effect("resolves with the outcome recorded for its own request", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const source = h.snapshotAtom(ENVIRONMENT_ID); + const thread = SNAPSHOT.threads[0]!; + const target = { environmentId: ENVIRONMENT_ID, input: { threadId: THREAD_ID } }; + const earlier = { + requestId: CommandId.make("earlier"), + status: "renamed" as const, + completedAt: NOW, + }; + + const result = h.commands.regenerateTitle.run(h.registry, target); + const request = yield* Queue.take(h.requests); + const requestId = request.command.commandId; + yield* Deferred.succeed(request.reply, { sequence: 2 }); + yield* Deferred.await(h.connectionFollowed); + h.registry.set(source, { + ...SNAPSHOT, + snapshotSequence: 2, + threads: [ + { + ...thread, + titleRegeneration: { requestId, startedAt: NOW }, + lastTitleRegeneration: earlier, + }, + ], + }); + h.registry.set(source, { + ...SNAPSHOT, + snapshotSequence: 3, + threads: [ + { + ...thread, + titleRegeneration: null, + lastTitleRegeneration: { + requestId, + status: "failed", + message: "model unavailable", + completedAt: NOW, + }, + }, + ], + }); + const outcome = yield* Effect.promise(() => result); + expect(outcome).toMatchObject({ + _tag: "Success", + value: { status: "failed", message: "model unavailable" }, + }); + + const superseded = h.commands.regenerateTitle.run(h.registry, target); + const second = yield* Queue.take(h.requests); + h.registry.set(source, { + ...SNAPSHOT, + snapshotSequence: 4, + threads: [{ ...thread, titleRegeneration: null, lastTitleRegeneration: earlier }], + }); + yield* Deferred.succeed(second.reply, { sequence: 4 }); + expect(yield* Effect.promise(() => superseded)).toMatchObject({ + _tag: "Success", + value: null, + }); + }), + ); + + it.effect("settles without an outcome when the environment disconnects while waiting", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const result = h.commands.regenerateTitle.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID }, + }); + const request = yield* Queue.take(h.requests); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + yield* SubscriptionRef.update(h.connection, (state) => ({ + ...state, + phase: "backoff" as const, + })); + expect(yield* Effect.promise(() => result)).toMatchObject({ _tag: "Success", value: null }); + }), + ); +}); + describe("remote thread lifecycle commands", () => { const actions = [ ["settle", {}, { settledOverride: "settled", pinnedAt: null, snoozedUntil: null }], diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 88a5759c5ed50..baca3f4578191 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -1,8 +1,8 @@ -import type { ThreadId } from "@t3tools/contracts"; +import { CommandId, type ThreadId } from "@t3tools/contracts"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { Atom } from "effect/reactivity"; +import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; import { WS_METHODS, type EnvironmentId, @@ -16,7 +16,10 @@ import { createAtomCommandScheduler, createEnvironmentCommand, createEnvironmentRpcCommand, + environmentConnectionAtom, + mapAtomCommandResult, } from "./runtime.ts"; +import { waitForTitleRegenerationOutcome } from "./threadTitleRegeneration.ts"; import { type InterruptThreadTurnInput, archiveThread, @@ -58,6 +61,7 @@ import { visitThread, watchThreadPullRequest, } from "../operations/commands.ts"; +import { connectionProjectionPhase } from "../connection/model.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as ThreadHistoryController from "./threadHistoryController.ts"; @@ -118,7 +122,26 @@ export function createThreadEnvironmentAtoms( ); }); + const armTitleRegeneration = Effect.fn("ThreadCommands.armTitleRegeneration")(function* (input: { + readonly threadId: ThreadId; + }) { + const random = yield* SecureRandom.SecureRandom; + const requestId = CommandId.make(yield* random.uuidV4()); + const result = yield* updateThreadMetadata({ + threadId: input.threadId, + regenerateTitle: true, + commandId: requestId, + }); + return { requestId, sequence: result.sequence }; + }); + const commands = { + armTitleRegeneration: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:regenerate-title", + execute: armTitleRegeneration, + scheduler, + concurrency, + }), create: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:create", execute: createThread, @@ -375,8 +398,39 @@ export function createThreadEnvironmentAtoms( }), }; const optimistic = createOptimisticThreadLifecycle(snapshotAtom); + const disconnectedAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const connection = AsyncResult.getOrElse( + get(environmentConnectionAtom(runtime, environmentId)), + () => null, + ); + return connection !== null && connectionProjectionPhase(connection[0]) === "disconnected"; + }), + ); + const regenerateTitle = { + label: commands.armTitleRegeneration.label, + run: async ( + registry: AtomRegistry.AtomRegistry, + target: { readonly environmentId: EnvironmentId; readonly input: { threadId: ThreadId } }, + ) => { + const armed = await commands.armTitleRegeneration.run(registry, target); + const outcome = + armed._tag === "Success" + ? await waitForTitleRegenerationOutcome( + registry, + { + snapshot: snapshotAtom(target.environmentId), + disconnected: disconnectedAtom(target.environmentId), + }, + { threadId: target.input.threadId, ...armed.value }, + ) + : null; + return mapAtomCommandResult(armed, () => outcome); + }, + }; return { ...commands, + regenerateTitle, snapshotAtom: optimistic.snapshotAtom, settle: optimistic.wrap(commands.settle, (thread, _input, now, accepted) => !accepted && diff --git a/packages/client-runtime/src/state/threadTitleRegeneration.ts b/packages/client-runtime/src/state/threadTitleRegeneration.ts new file mode 100644 index 0000000000000..79a8ced5e72d8 --- /dev/null +++ b/packages/client-runtime/src/state/threadTitleRegeneration.ts @@ -0,0 +1,64 @@ +import type { + CommandId, + OrchestrationV2ShellSnapshot, + ThreadId, + ThreadTitleRegenerationOutcome, +} from "@t3tools/contracts"; +import type { Atom, AtomRegistry } from "effect/reactivity"; + +export interface TitleRegenerationRequest { + readonly threadId: ThreadId; + readonly requestId: CommandId; + readonly sequence: number; +} + +export interface TitleRegenerationSources { + readonly snapshot: Atom.Atom; + readonly disconnected: Atom.Atom; +} + +function settledOutcome( + snapshot: OrchestrationV2ShellSnapshot | null, + request: TitleRegenerationRequest, +): ThreadTitleRegenerationOutcome | null | undefined { + if (snapshot === null) return null; + if (snapshot.snapshotSequence < request.sequence) return undefined; + const thread = snapshot.threads.find((candidate) => candidate.id === request.threadId); + if (thread === undefined) return null; + const last = thread.lastTitleRegeneration; + if (last?.requestId === request.requestId) { + return last.message === undefined + ? { status: last.status } + : { status: last.status, message: last.message }; + } + return thread.titleRegeneration?.requestId === request.requestId ? undefined : null; +} + +export function waitForTitleRegenerationOutcome( + registry: AtomRegistry.AtomRegistry, + sources: TitleRegenerationSources, + request: TitleRegenerationRequest, +): Promise { + return new Promise((resolve) => { + let settled = false; + let unsubscribes: ReadonlyArray<() => void> = []; + const release = () => { + for (const unsubscribe of unsubscribes) unsubscribe(); + }; + const check = () => { + if (settled) return; + let outcome = settledOutcome(registry.get(sources.snapshot), request); + if (outcome === undefined && registry.get(sources.disconnected)) outcome = null; + if (outcome === undefined) return; + settled = true; + release(); + resolve(outcome); + }; + unsubscribes = [ + registry.subscribe(sources.snapshot, check), + registry.subscribe(sources.disconnected, check), + ]; + if (settled) release(); + else check(); + }); +} diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 5eb2b6ed4bfdd..acdb98840fcb2 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -45,6 +45,7 @@ import { AuthMcpClientAccess } from "./auth.ts"; import { ChatAttachment } from "./chatAttachment.ts"; import { ThreadGuestShare } from "./guestShare.ts"; import { StoredThreadTags, ThreadTag } from "./threadTags.ts"; +import { ThreadTitleRegenerationOutcome } from "./threadTitle.ts"; import { OrchestrationSearchThreadsInput, OrchestrationSearchThreadsResult, @@ -441,6 +442,12 @@ const LenientThreadOpens = Schema.NullOr(OrchestrationV2ThreadOpens).pipe( Schema.catchDecoding(() => Effect.succeedSome(null)), ); +const OrchestrationV2LastTitleRegeneration = Schema.Struct({ + ...ThreadTitleRegenerationOutcome.fields, + requestId: CommandId, + completedAt: Schema.DateTimeUtc, +}); + export const OrchestrationV2AppThread = Schema.Struct({ ...OrchestrationV2CreationFields, id: ThreadId, @@ -504,6 +511,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ }), ), ), + lastTitleRegeneration: Schema.optional(Schema.NullOr(OrchestrationV2LastTitleRegeneration)), rollbackRequestId: Schema.optional(CommandId), rollbackFailure: Schema.optional( Schema.NullOr( @@ -2147,6 +2155,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ }), ), ), + lastTitleRegeneration: Schema.optional(Schema.NullOr(OrchestrationV2LastTitleRegeneration)), deletedAt: Schema.NullOr(Schema.DateTimeUtc), }); export type OrchestrationV2ThreadShell = typeof OrchestrationV2ThreadShell.Type; @@ -2293,6 +2302,14 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields(( }), ), ), + lastTitleRegeneration: Schema.optional( + Schema.NullOr( + OrchestrationV2LastTitleRegeneration.mapFields((fields) => ({ + ...fields, + completedAt: Schema.DateTimeUtcFromString, + })), + ), + ), deletedAt: Schema.NullOr(Schema.DateTimeUtcFromString), })); export type OrchestrationV2AppThreadJson = typeof OrchestrationV2AppThreadJson.Type; @@ -2727,6 +2744,14 @@ export const OrchestrationV2ThreadShellJson = OrchestrationV2ThreadShell.mapFiel }), ), ), + lastTitleRegeneration: Schema.optional( + Schema.NullOr( + OrchestrationV2LastTitleRegeneration.mapFields((fields) => ({ + ...fields, + completedAt: Schema.DateTimeUtcFromString, + })), + ), + ), deletedAt: Schema.NullOr(Schema.DateTimeUtcFromString), })); export type OrchestrationV2ThreadShellJson = typeof OrchestrationV2ThreadShellJson.Type; @@ -3117,6 +3142,7 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, requestId: CommandId, title: Schema.optional(TrimmedNonEmptyString), + outcome: Schema.optional(ThreadTitleRegenerationOutcome), }), Schema.Struct({ type: Schema.Literal("thread.runtime-mode.set"), diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 6756225666fd4..3c239c31667e4 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 { LastThreadTitleRegeneration, ThreadTitleRegeneration } 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), + lastTitleRegeneration: Schema.NullOr(LastThreadTitleRegeneration), 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..08f66bfb663ed 100644 --- a/packages/contracts/src/threadTitle.ts +++ b/packages/contracts/src/threadTitle.ts @@ -7,3 +7,19 @@ export const ThreadTitleRegeneration = Schema.Struct({ startedAt: IsoDateTime, }); export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; + +export const ThreadTitleRegenerationStatus = Schema.Literals(["renamed", "unchanged", "failed"]); +export type ThreadTitleRegenerationStatus = typeof ThreadTitleRegenerationStatus.Type; + +export const ThreadTitleRegenerationOutcome = Schema.Struct({ + status: ThreadTitleRegenerationStatus, + message: Schema.optional(Schema.String), +}); +export type ThreadTitleRegenerationOutcome = typeof ThreadTitleRegenerationOutcome.Type; + +export const LastThreadTitleRegeneration = Schema.Struct({ + ...ThreadTitleRegenerationOutcome.fields, + requestId: CommandId, + completedAt: IsoDateTime, +}); +export type LastThreadTitleRegeneration = typeof LastThreadTitleRegeneration.Type; From 4eeed52596572a009567d99c5d66034cba60b3ba Mon Sep 17 00:00:00 2001 From: flamboh Date: Thu, 5 Nov 2026 11:09:18 +0000 Subject: [PATCH 2/6] fix: bulk kept-title toast no longer claims the rest were renamed --- apps/web/src/components/threadTitleRegenerationToast.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/threadTitleRegenerationToast.ts b/apps/web/src/components/threadTitleRegenerationToast.ts index 67aa98aaef229..4d420b9521f08 100644 --- a/apps/web/src/components/threadTitleRegenerationToast.ts +++ b/apps/web/src/components/threadTitleRegenerationToast.ts @@ -54,6 +54,6 @@ export function titleRegenerationToast(results: ReadonlyArray Date: Thu, 12 Nov 2026 00:19:09 -0600 Subject: [PATCH 3/6] fix(clients): keep title regeneration feedback reliable --- apps/web/src/components/chat/useThreadLifecycle.tsx | 13 ++++++++++--- .../client-runtime/src/state/threadCommands.test.ts | 9 +++++++-- .../src/state/threadTitleRegeneration.ts | 6 ++++-- packages/contracts/src/orchestratorMcp.ts | 2 +- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index c0b9baec3111c..66b2a9f6b3fd3 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -49,6 +49,7 @@ import { useUiStateStore } from "../../uiStateStore"; import { type ChatMessage, DEFAULT_INTERACTION_MODE } from "../../types"; import { AlarmClockIcon, CheckCircle2Icon } from "lucide-react"; import { stackedThreadToast, toastManager } from "../ui/toast"; +import { titleRegenerationToast } from "../threadTitleRegenerationToast"; import { newMessageId } from "~/lib/utils"; import { buildThreadRouteParams } from "../../threadRoutes"; import { composerDraftHasUserContent, useComposerDraftStore } from "../../composerDraftStore"; @@ -186,6 +187,9 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const regenerateThreadTitle = useOrchestrationCommand(threadEnvironment.regenerateTitle, { + reportFailure: false, + }); const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { reportFailure: false, }); @@ -688,10 +692,13 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false; if (!activeThreadShell || !activeThreadRef || !serverConfigLoaded) return; if (activeThreadShell.titleRegeneration != null) return; - void updateThreadMetadata({ + void regenerateThreadTitle({ environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, regenerateTitle: true }, - }).then(toastThreadActionFailure("Failed to regenerate thread title")); + input: { threadId: activeThreadRef.threadId }, + }).then((result) => { + const toast = titleRegenerationToast([result]); + if (toast) toastManager.add(toast); + }); }, } satisfies ChatShortcutCommands; return { diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index e5032e6ffabdc..2a30288634161 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -155,7 +155,9 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio ), ), ); - const snapshotAtom = Atom.family((_environmentId: EnvironmentId) => Atom.make(SNAPSHOT)); + const snapshotAtom = Atom.family((_environmentId: EnvironmentId) => + Atom.make(SNAPSHOT), + ); const commands = createThreadEnvironmentAtoms(runtime, snapshotAtom); const registry = AtomRegistry.make(); yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); @@ -295,6 +297,7 @@ describe("title regeneration", () => { completedAt: NOW, }; + h.registry.set(source, null); const result = h.commands.regenerateTitle.run(h.registry, target); const request = yield* Queue.take(h.requests); const requestId = request.command.commandId; @@ -314,9 +317,11 @@ describe("title regeneration", () => { h.registry.set(source, { ...SNAPSHOT, snapshotSequence: 3, - threads: [ + threads: [], + archivedThreads: [ { ...thread, + archivedAt: NOW, titleRegeneration: null, lastTitleRegeneration: { requestId, diff --git a/packages/client-runtime/src/state/threadTitleRegeneration.ts b/packages/client-runtime/src/state/threadTitleRegeneration.ts index 79a8ced5e72d8..07779dd779837 100644 --- a/packages/client-runtime/src/state/threadTitleRegeneration.ts +++ b/packages/client-runtime/src/state/threadTitleRegeneration.ts @@ -21,9 +21,11 @@ function settledOutcome( snapshot: OrchestrationV2ShellSnapshot | null, request: TitleRegenerationRequest, ): ThreadTitleRegenerationOutcome | null | undefined { - if (snapshot === null) return null; + if (snapshot === null) return undefined; if (snapshot.snapshotSequence < request.sequence) return undefined; - const thread = snapshot.threads.find((candidate) => candidate.id === request.threadId); + const thread = + snapshot.threads.find((candidate) => candidate.id === request.threadId) ?? + snapshot.archivedThreads.find((candidate) => candidate.id === request.threadId); if (thread === undefined) return null; const last = thread.lastTitleRegeneration; if (last?.requestId === request.requestId) { diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 628f2f06c45bf..acf8cc806cc38 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -447,7 +447,7 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), pullRequests: Schema.optional(Schema.Array(OrchestratorMcpThreadPullRequest)), titleRegeneration: Schema.NullOr(ThreadTitleRegeneration), - lastTitleRegeneration: Schema.NullOr(LastThreadTitleRegeneration), + lastTitleRegeneration: Schema.optional(Schema.NullOr(LastThreadTitleRegeneration)), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), parentThreadId: Schema.NullOr(ThreadId), From 72e2532a8cbb96665edef5e66c5d3164ae31c580 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 12 Nov 2026 12:34:13 -0600 Subject: [PATCH 4/6] test(server): make VCS watch expectations deterministic --- .../src/vcs/VcsStatusBroadcaster.test.ts | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 37d767c29c044..87620e5d4fc4f 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1365,13 +1365,10 @@ describe("VcsStatusBroadcaster", () => { it.effect("a checkout read that fails while index.lock is held retries until it succeeds", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const gitDir = yield* fs.makeTempDirectoryScoped({ prefix: "vcs-head-watch-" }); - const headPath = path.join(gitDir, "HEAD"); - yield* fs.writeFileString(headPath, "ref: refs/heads/main\n"); + const events = yield* Queue.unbounded(); + const headPath = "/repo/.git/HEAD"; const failed = yield* Deferred.make(); - const state = { + const state: Parameters[0] = { currentLocalStatus: { ...baseLocalStatus, isDefaultRef: true, refName: "main" }, currentRemoteStatus: null, localStatusCalls: 0, @@ -1379,6 +1376,12 @@ describe("VcsStatusBroadcaster", () => { localInvalidationCalls: 0, remoteInvalidationCalls: 0, headPath, + fileSystem: FileSystem.makeNoop({ + realPath: (target) => Effect.succeed(target), + exists: () => Effect.succeed(true), + watch: (_target, options) => + Stream.fromQueue(events).pipe(Stream.onStart(Effect.sync(() => options?.onReady?.()))), + }), failLocalStatus: false, onLocalStatusFailure: Deferred.succeed(failed, undefined).pipe(Effect.asVoid), }; @@ -1402,9 +1405,7 @@ describe("VcsStatusBroadcaster", () => { // A terminal commit holds index.lock across the checkout, so the HEAD event's read fails. state.failLocalStatus = true; state.currentLocalStatus = { ...baseLocalStatus, isDefaultRef: false, refName: "repro" }; - const lockPath = path.join(gitDir, "HEAD.lock"); - yield* fs.writeFileString(lockPath, "ref: refs/heads/repro\n"); - yield* fs.rename(lockPath, headPath); + yield* Queue.offer(events, { _tag: "Update", path: "HEAD" }); yield* Deferred.await(failed); const failedCalls = state.localStatusCalls; @@ -1414,7 +1415,7 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.await(checkedOut); assert.strictEqual(state.localStatusCalls, failedCalls + 1); }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.scoped), ); it.effect("subscribers share one HEAD watch that ends with the last of them", () => @@ -1555,13 +1556,13 @@ describe("VcsStatusBroadcaster", () => { yield* Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const branches = yield* Stream.toPull(broadcaster.streamBranchStatus({ cwd: "/repo" })); - assert.deepEqual(yield* branches, [{ refName: "main" }]); + assert.deepEqual(yield* branches, [{ refName: "main", isRepo: true }]); // Every checkout removes HEAD.lock, which leaves the watch running. state.currentLocalStatus = { ...baseLocalStatus, refName: "other" }; yield* Queue.offer(events, { _tag: "Remove", path: "HEAD.lock" }); yield* Queue.offer(events, { _tag: "Update", path: "HEAD" }); - assert.deepEqual(yield* branches, [{ refName: "other" }]); + assert.deepEqual(yield* branches, [{ refName: "other", isRepo: true }]); // `git worktree remove` deletes the watched directory; Windows keeps reporting it. gitDirExists = false; From c6047c2f190c9badfa9b51a40c8fe55c5cc6871d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 12 Nov 2026 12:38:34 -0600 Subject: [PATCH 5/6] fix(mobile): return failure when title generation fails --- .../features/home/useThreadListActions.test.ts | 15 ++++++++++++++- .../src/features/home/useThreadListActions.ts | 1 + 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 027d07d5b08da..0bea39e419e21 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -7,6 +7,7 @@ import { ProviderInstanceId, RunId, ThreadId, + type ThreadTitleRegenerationOutcome, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; @@ -18,6 +19,7 @@ const state = vi.hoisted(() => ({ dropBusy: false, holdingRows: false, failedAction: null as string | null, + titleOutcome: null as ThreadTitleRegenerationOutcome | null, archivedRefreshes: [] as string[], scopes: new Map>(), shells: [] as EnvironmentThreadShell[], @@ -156,7 +158,7 @@ vi.mock("../../state/threads", () => ({ if (action === state.failedAction) return AsyncResult.failure(Cause.fail(new Error("Server rejected action"))); state.afterRequest?.(); - return AsyncResult.success(undefined); + return AsyncResult.success(action === "regenerateTitle" ? state.titleOutcome : undefined); }, ]), ), @@ -193,6 +195,7 @@ const mutationCases = [ beforeEach(() => { state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId]; state.failedAction = null; + state.titleOutcome = null; state.archivedRefreshes = []; state.pendingOrder = null; state.dropBusy = false; @@ -215,6 +218,16 @@ afterEach(() => { vi.unstubAllEnvs(); }); +it("reports a failed title generation as unsuccessful and allows another request", async () => { + const actions = useThreadListActions(); + const thread = makeThread(); + state.titleOutcome = { status: "failed", message: "Model unavailable" }; + expect(await actions.regenerateThreadTitle(thread)).toBe(false); + + state.titleOutcome = { status: "renamed" }; + expect(await actions.regenerateThreadTitle(thread)).toBe(true); +}); + describe("thread list operation permissions", () => { it.each(mutationCases)( "%s rejects a retained callback after its environment loses permission", diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index d7bccd4f305a0..8ec2e0d68bd93 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -551,6 +551,7 @@ export function useThreadListActions(): { "Could not regenerate title", result.value.message ?? "The thread title could not be regenerated.", ); + return false; } else if (result.value?.status === "unchanged") { Dialog.alert("Title kept", "The current title already fits this thread."); } From b0b3a5b77c0ace5a4e1f282c3680505a6c411c1b Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 12 Nov 2026 13:01:57 -0600 Subject: [PATCH 6/6] fix(mobile): remove the unchanged title alert --- apps/mobile/src/features/home/useThreadListActions.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 8ec2e0d68bd93..3cd180db6273f 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -552,8 +552,6 @@ export function useThreadListActions(): { result.value.message ?? "The thread title could not be regenerated.", ); return false; - } else if (result.value?.status === "unchanged") { - Dialog.alert("Title kept", "The current title already fits this thread."); } return true; })();