diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1a08f7aeeae9..4f4fb61d6985 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -733,6 +733,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti titleRegenerationStartedAt: event.payload.titleRegeneration?.startedAt ?? null, } : {}), + ...(event.payload.titleRegenerationFailure !== undefined + ? { + titleRegenerationFailureRequestId: + event.payload.titleRegenerationFailure?.requestId ?? null, + titleRegenerationFailureAt: + event.payload.titleRegenerationFailure?.failedAt ?? null, + titleRegenerationFailureError: + event.payload.titleRegenerationFailure?.error ?? null, + } + : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 4dcc43913c4b..3aaa36243250 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -255,6 +255,18 @@ function mapTitleRegeneration(row: Schema.Schema.Type) { + return row.titleRegenerationFailureRequestId != null && + row.titleRegenerationFailureAt != null && + row.titleRegenerationFailureError != null + ? { + requestId: row.titleRegenerationFailureRequestId, + failedAt: row.titleRegenerationFailureAt, + error: row.titleRegenerationFailureError, + } + : null; +} + function mapSessionRow( row: Schema.Schema.Type, ): OrchestrationSession { @@ -386,6 +398,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", + title_regeneration_failure_at AS "titleRegenerationFailureAt", + title_regeneration_failure_error AS "titleRegenerationFailureError", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -420,6 +435,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", + title_regeneration_failure_at AS "titleRegenerationFailureAt", + title_regeneration_failure_error AS "titleRegenerationFailureError", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -456,6 +474,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", + title_regeneration_failure_at AS "titleRegenerationFailureAt", + title_regeneration_failure_error AS "titleRegenerationFailureError", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -892,6 +913,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", + title_regeneration_failure_at AS "titleRegenerationFailureAt", + title_regeneration_failure_error AS "titleRegenerationFailureError", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1329,6 +1353,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, titleRegeneration: mapTitleRegeneration(row), + titleRegenerationFailure: mapTitleRegenerationFailure(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1532,6 +1557,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, titleRegeneration: mapTitleRegeneration(row), + titleRegenerationFailure: mapTitleRegenerationFailure(row), deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1666,6 +1692,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, titleRegeneration: mapTitleRegeneration(row), + titleRegenerationFailure: mapTitleRegenerationFailure(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1805,6 +1832,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, titleRegeneration: mapTitleRegeneration(row), + titleRegenerationFailure: mapTitleRegenerationFailure(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -2076,6 +2104,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, titleRegeneration: mapTitleRegeneration(threadRow.value), + titleRegenerationFailure: mapTitleRegenerationFailure(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -2175,6 +2204,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, titleRegeneration: mapTitleRegeneration(threadRow.value), + titleRegenerationFailure: mapTitleRegenerationFailure(threadRow.value), deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2e04b68180d0..ce3d71b07f3f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -864,7 +864,7 @@ describe("ProviderCommandReactor", () => { expect(thread?.titleRegeneration).toBeNull(); }); - it("clears title regeneration state when generation fails", async () => { + it("records the failure reason when generation fails", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -906,7 +906,68 @@ describe("ProviderCommandReactor", () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.title).toBe("Keep title after failure"); + // The request is over — the pending record clears exactly as it does on + // success, so clients that only understand "pending or not" are unaffected + // — but the reason survives on its own field. expect(thread?.titleRegeneration).toBeNull(); + expect(thread?.titleRegenerationFailure).toMatchObject({ + requestId: CommandId.make("cmd-thread-title-failed-regeneration"), + error: "disabled in test harness", + }); + }); + + it("clears a recorded failure when regeneration is requested again", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-retried-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-retried-regeneration"), + role: "user", + text: "Investigate the reconnect state.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-first-failed-regeneration"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + await harness.drain(); + + let readModel = await harness.readModel(); + expect( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")) + ?.titleRegenerationFailure?.error, + ).toBe("disabled in test harness"); + + harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Recovered title" })); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-retried-regeneration"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + await harness.drain(); + + readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Recovered title"); + expect(thread?.titleRegeneration).toBeNull(); + expect(thread?.titleRegenerationFailure).toBeNull(); }); it("retries a failed completion and continues regenerating", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index df3c09ff8cb2..942864c68cc1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -92,6 +92,7 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const DEFAULT_THREAD_TITLE = "New thread"; const MAX_REGENERATION_ATTACHMENTS = 4; +const TITLE_REGENERATION_FALLBACK_ERROR = "Title generation failed unexpectedly."; const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"; @@ -890,6 +891,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly requestId: CommandId; readonly title?: string; + readonly error?: string; }) { yield* orchestrationEngine.dispatch({ type: "thread.title.regeneration.complete", @@ -897,6 +899,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, requestId: input.requestId, ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.error !== undefined ? { error: input.error } : {}), }); }); const clearInterruptedThreadTitleRegenerations = Effect.fn( @@ -941,7 +944,24 @@ const make = Effect.gen(function* () { if (requestId === null) { return; } + // Generation failures are carried into the completion instead of being + // swallowed: without them the request completes "successfully" with no + // title and the user sees a spinner clear with nothing changed. Providers + // whose text generation is unimplemented fail this way every time. const result = yield* regenerateThreadTitle(event, requestId).pipe( + Effect.catchTag("TextGenerationError", (error) => + Effect.logWarning("provider command reactor failed to regenerate thread title", { + threadId: event.payload.threadId, + detail: error.detail, + }).pipe( + Effect.as({ + _tag: "Failed", + // The completion payload requires a non-empty reason; a provider + // that fails without one still has to produce a visible error. + error: error.detail.trim() || TITLE_REGENERATION_FALLBACK_ERROR, + } as const), + ), + ), Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { return Effect.failCause(cause); @@ -949,7 +969,7 @@ const make = Effect.gen(function* () { return Effect.logWarning("provider command reactor failed to regenerate thread title", { threadId: event.payload.threadId, cause: Cause.pretty(cause), - }).pipe(Effect.as({ _tag: "Completed", title: undefined } as const)); + }).pipe(Effect.as({ _tag: "Failed", error: TITLE_REGENERATION_FALLBACK_ERROR } as const)); }), ); if (result._tag === "Superseded") { @@ -959,7 +979,10 @@ const make = Effect.gen(function* () { const completion = { threadId: event.payload.threadId, requestId, - ...(result.title !== undefined ? { title: result.title } : {}), + ...(result._tag === "Completed" && result.title !== undefined + ? { title: result.title } + : {}), + ...(result._tag === "Failed" ? { error: result.error } : {}), }; yield* dispatchThreadTitleRegenerationCompletion(completion).pipe( Effect.catchCause((cause) => { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index ae9a068864ce..e5007adb8e4a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -662,11 +662,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" requestId: command.commandId, startedAt: occurredAt, }, + // A retry supersedes whatever the last attempt reported. + titleRegenerationFailure: null, } : {}), ...(command.title !== undefined && thread.titleRegeneration != null ? { titleRegeneration: null } : {}), + // A manual rename answers the question the failure was reporting. + ...(command.title !== undefined && thread.titleRegenerationFailure != null + ? { titleRegenerationFailure: null } + : {}), ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), @@ -685,6 +691,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); const requestIsCurrent = thread.titleRegeneration?.requestId === command.requestId; const occurredAt = yield* nowIso; + // Every completion clears the pending record — the request is over either + // way. A failure additionally records why, on its own field, so clients + // that only understand "pending or not" are unaffected. + const failure = + command.error !== undefined + ? { requestId: command.requestId, failedAt: occurredAt, error: command.error } + : null; return { ...(yield* withEventBase({ aggregateKind: "thread", @@ -696,7 +709,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, ...(requestIsCurrent && command.title !== undefined ? { title: command.title } : {}), - ...(requestIsCurrent ? { titleRegeneration: null } : {}), + ...(requestIsCurrent + ? { titleRegeneration: null, titleRegenerationFailure: failure } + : {}), updatedAt: requestIsCurrent ? occurredAt : thread.updatedAt, }, }; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 4be5843c1ae6..a6bc35fd7d6e 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -403,6 +403,9 @@ export function projectEvent( ...(payload.titleRegeneration !== undefined ? { titleRegeneration: payload.titleRegeneration } : {}), + ...(payload.titleRegenerationFailure !== undefined + ? { titleRegenerationFailure: payload.titleRegenerationFailure } + : {}), ...(payload.modelSelection !== undefined ? { modelSelection: payload.modelSelection } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index eb423aef99e6..1b1f5651cbd6 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -49,6 +49,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at, title_regeneration_request_id, title_regeneration_started_at, + title_regeneration_failure_request_id, + title_regeneration_failure_at, + title_regeneration_failure_error, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -74,6 +77,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedAt}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, + ${row.titleRegenerationFailureRequestId ?? null}, + ${row.titleRegenerationFailureAt ?? null}, + ${row.titleRegenerationFailureError ?? null}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -99,6 +105,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at = excluded.snoozed_at, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, + title_regeneration_failure_request_id = excluded.title_regeneration_failure_request_id, + title_regeneration_failure_at = excluded.title_regeneration_failure_at, + title_regeneration_failure_error = excluded.title_regeneration_failure_error, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -131,6 +140,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", + title_regeneration_failure_at AS "titleRegenerationFailureAt", + title_regeneration_failure_error AS "titleRegenerationFailureError", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -165,6 +177,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", + title_regeneration_failure_at AS "titleRegenerationFailureAt", + title_regeneration_failure_error AS "titleRegenerationFailureError", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 516f8086c103..9fac92d5655e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -58,6 +58,7 @@ import Migration0035 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0036 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0037 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0038 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; +import Migration0039 from "./Migrations/036_ProjectionThreadTitleRegenerationFailure.ts"; /** * Migration loader with all migrations defined inline. @@ -108,6 +109,7 @@ export const migrationEntries = [ [36, "ProjectionThreadsSettled", Migration0036], [37, "ProjectionThreadsSnoozed", Migration0037], [38, "ProjectionThreadTitleRegeneration", Migration0038], + [39, "ProjectionThreadTitleRegenerationFailure", Migration0039], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/036_ProjectionThreadTitleRegenerationFailure.test.ts b/apps/server/src/persistence/Migrations/036_ProjectionThreadTitleRegenerationFailure.test.ts new file mode 100644 index 000000000000..fd97c31ac095 --- /dev/null +++ b/apps/server/src/persistence/Migrations/036_ProjectionThreadTitleRegenerationFailure.test.ts @@ -0,0 +1,47 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("036_ProjectionThreadTitleRegenerationFailure", (it) => { + it.effect("adds the title regeneration failure columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 38 }); + yield* runMigrations({ toMigrationInclusive: 39 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + const names = new Set(columns.map((column) => column.name)); + assert.ok(names.has("title_regeneration_failure_request_id")); + assert.ok(names.has("title_regeneration_failure_at")); + assert.ok(names.has("title_regeneration_failure_error")); + // The pending columns stay separate so "in flight" keeps its meaning. + assert.ok(names.has("title_regeneration_request_id")); + assert.ok(names.has("title_regeneration_started_at")); + }), + ); + + it.effect("is idempotent when the columns already exist", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 39 }); + yield* runMigrations({ toMigrationInclusive: 39 }); + + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.strictEqual( + columns.filter((column) => column.name === "title_regeneration_failure_error").length, + 1, + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/036_ProjectionThreadTitleRegenerationFailure.ts b/apps/server/src/persistence/Migrations/036_ProjectionThreadTitleRegenerationFailure.ts new file mode 100644 index 000000000000..0dc1ec3ba5c4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/036_ProjectionThreadTitleRegenerationFailure.ts @@ -0,0 +1,34 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + const names = new Set(columns.map((column) => column.name)); + + // Siblings of the pending title_regeneration_* columns rather than fields on + // them: the pending record has to stay strictly pending, because clients read + // "regeneration in flight" as "titleRegeneration is not null". + if (!names.has("title_regeneration_failure_request_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN title_regeneration_failure_request_id TEXT + `; + } + + if (!names.has("title_regeneration_failure_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN title_regeneration_failure_at TEXT + `; + } + + if (!names.has("title_regeneration_failure_error")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN title_regeneration_failure_error TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index ea1b011be84f..50ab80c101c5 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -43,6 +43,9 @@ export const ProjectionThread = Schema.Struct({ snoozedAt: Schema.NullOr(IsoDateTime), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + titleRegenerationFailureRequestId: Schema.optional(Schema.NullOr(CommandId)), + titleRegenerationFailureAt: Schema.optional(Schema.NullOr(IsoDateTime)), + titleRegenerationFailureError: Schema.optional(Schema.NullOr(Schema.String)), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index d58d2b37cd1d..a0c5609b3772 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -6,6 +6,11 @@ import { effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; +import { + collectTitleRegenerationFailures, + isTitleRegenerationPending, + titleRegenerationFailureReason, +} from "@t3tools/client-runtime/state/thread-title-regeneration"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, @@ -445,7 +450,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); - const isRegeneratingTitle = thread.titleRegeneration != null; + const isRegeneratingTitle = isTitleRegenerationPending(thread); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const openPrLink = useOpenPrLink(); @@ -1068,10 +1073,52 @@ function latestTurnDiff( return null; } +const TITLE_REGENERATION_ERROR_MENU_MAX_CHARS = 72; + +/** + * Provider failure details are prose and can run long or wrap lines. The + * context menu is where the reason survives a reload (the toast only fires on + * a live transition), so it is collapsed to one readable clause rather than + * dropped. + */ +function summarizeTitleRegenerationError(error: string): string { + const collapsed = error.replace(/\s+/g, " ").trim(); + return collapsed.length > TITLE_REGENERATION_ERROR_MENU_MAX_CHARS + ? `${collapsed.slice(0, TITLE_REGENERATION_ERROR_MENU_MAX_CHARS - 1).trimEnd()}…` + : collapsed; +} + export default function SidebarV2() { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); + // Title regeneration runs on the server and can fail long after the click + // (an unreachable CLI, a provider with no text-generation support). The row + // spinner just clears, so the toast is the only place the reason surfaces. + const seenTitleRegenerationFailures = useRef(new Map()); + useEffect(() => { + const failures = collectTitleRegenerationFailures( + threads, + (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + seenTitleRegenerationFailures.current, + ); + if (failures.length === 0) return; + const titleByKey = new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + thread.title, + ]), + ); + for (const failure of failures) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not regenerate title for “${titleByKey.get(failure.key) ?? "thread"}”`, + description: failure.error, + }), + ); + } + }, [threads]); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -1945,7 +1992,7 @@ export default function SidebarV2() { .threadTitleRegeneration === true, ); const regeneratableTitleThreads = titleRegenerationThreads.filter( - (thread) => thread.titleRegeneration == null, + (thread) => !isTitleRegenerationPending(thread), ); const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({ supportedCount: titleRegenerationThreads.length, @@ -2119,7 +2166,8 @@ export default function SidebarV2() { const supportsTitleRegeneration = serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true; - const isRegeneratingTitle = thread.titleRegeneration != null; + const isRegeneratingTitle = isTitleRegenerationPending(thread); + const lastTitleRegenerationError = titleRegenerationFailureReason(thread); const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); // Presets resolve at menu-open time (same as the popover). @@ -2162,7 +2210,13 @@ export default function SidebarV2() { ? [ { id: "regenerate-title", - label: isRegeneratingTitle ? "Regenerating…" : "Regenerate title", + label: isRegeneratingTitle + ? "Regenerating…" + : lastTitleRegenerationError + ? `Retry regenerate title — ${summarizeTitleRegenerationError( + lastTitleRegenerationError, + )}` + : "Regenerate title", disabled: isRegeneratingTitle, }, ] diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 0b7b078a5226..e4b3d9efbb64 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -131,6 +131,10 @@ "types": "./src/state/threadSettled.ts", "default": "./src/state/threadSettled.ts" }, + "./state/thread-title-regeneration": { + "types": "./src/state/threadTitleRegeneration.ts", + "default": "./src/state/threadTitleRegeneration.ts" + }, "./state/thread-search": { "types": "./src/state/threadSearch.ts", "default": "./src/state/threadSearch.ts" diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 6b04f094d826..9d88ed8b1576 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -159,6 +159,9 @@ export function applyThreadDetailEvent( ...(event.payload.titleRegeneration !== undefined ? { titleRegeneration: event.payload.titleRegeneration } : {}), + ...(event.payload.titleRegenerationFailure !== undefined + ? { titleRegenerationFailure: event.payload.titleRegenerationFailure } + : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/packages/client-runtime/src/state/threadTitleRegeneration.test.ts b/packages/client-runtime/src/state/threadTitleRegeneration.test.ts new file mode 100644 index 000000000000..8bb30098620f --- /dev/null +++ b/packages/client-runtime/src/state/threadTitleRegeneration.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { CommandId } from "@t3tools/contracts"; + +import { + collectTitleRegenerationFailures, + isTitleRegenerationPending, + titleRegenerationFailureReason, + type ThreadTitleRegenerationHolder, +} from "./threadTitleRegeneration.ts"; + +const pending = (requestId: string): ThreadTitleRegenerationHolder => ({ + titleRegeneration: { + requestId: CommandId.make(requestId), + startedAt: "2026-01-01T00:00:00.000Z", + }, +}); + +const failed = (requestId: string, error: string): ThreadTitleRegenerationHolder => ({ + titleRegenerationFailure: { + requestId: CommandId.make(requestId), + failedAt: "2026-01-01T00:00:01.000Z", + error, + }, +}); + +describe("isTitleRegenerationPending", () => { + it("is pending only while a request is in flight", () => { + expect(isTitleRegenerationPending(pending("req-1"))).toBe(true); + expect(isTitleRegenerationPending(failed("req-1", "nope"))).toBe(false); + expect(isTitleRegenerationPending({ titleRegeneration: null })).toBe(false); + expect(isTitleRegenerationPending({})).toBe(false); + }); +}); + +describe("titleRegenerationFailureReason", () => { + it("returns the recorded failure reason", () => { + expect(titleRegenerationFailureReason(failed("req-1", "provider offline"))).toBe( + "provider offline", + ); + expect(titleRegenerationFailureReason(pending("req-1"))).toBeNull(); + expect(titleRegenerationFailureReason({})).toBeNull(); + }); + + it("lets a pending retry win over a stale failure", () => { + expect( + titleRegenerationFailureReason({ + ...pending("req-2"), + ...failed("req-1", "provider offline"), + }), + ).toBeNull(); + }); +}); + +describe("collectTitleRegenerationFailures", () => { + const keyOf = (thread: { id: string }) => thread.id; + + it("reports a failure that appears after the thread was already observed", () => { + const seen = new Map(); + expect( + collectTitleRegenerationFailures([{ id: "a", ...pending("req-1") }], keyOf, seen), + ).toEqual([]); + + expect( + collectTitleRegenerationFailures( + [{ id: "a", ...failed("req-1", "provider offline") }], + keyOf, + seen, + ), + ).toEqual([{ key: "a", requestId: "req-1", error: "provider offline" }]); + }); + + it("stays quiet for a failure that is already present on first observation", () => { + const seen = new Map(); + expect( + collectTitleRegenerationFailures( + [{ id: "a", ...failed("req-1", "provider offline") }], + keyOf, + seen, + ), + ).toEqual([]); + }); + + it("does not repeat the same failure on later observations", () => { + const seen = new Map(); + collectTitleRegenerationFailures([{ id: "a", ...pending("req-1") }], keyOf, seen); + collectTitleRegenerationFailures([{ id: "a", ...failed("req-1", "boom") }], keyOf, seen); + expect( + collectTitleRegenerationFailures([{ id: "a", ...failed("req-1", "boom") }], keyOf, seen), + ).toEqual([]); + }); + + it("reports a second failure because each request carries its own id", () => { + const seen = new Map(); + collectTitleRegenerationFailures([{ id: "a", ...pending("req-1") }], keyOf, seen); + collectTitleRegenerationFailures([{ id: "a", ...failed("req-1", "boom") }], keyOf, seen); + collectTitleRegenerationFailures([{ id: "a", ...pending("req-2") }], keyOf, seen); + expect( + collectTitleRegenerationFailures( + [{ id: "a", ...failed("req-2", "boom again") }], + keyOf, + seen, + ), + ).toEqual([{ key: "a", requestId: "req-2", error: "boom again" }]); + }); + + it("forgets threads that leave the set", () => { + const seen = new Map(); + collectTitleRegenerationFailures([{ id: "a", ...pending("req-1") }], keyOf, seen); + collectTitleRegenerationFailures([], keyOf, seen); + expect(seen.size).toBe(0); + }); +}); diff --git a/packages/client-runtime/src/state/threadTitleRegeneration.ts b/packages/client-runtime/src/state/threadTitleRegeneration.ts new file mode 100644 index 000000000000..859fbbe64376 --- /dev/null +++ b/packages/client-runtime/src/state/threadTitleRegeneration.ts @@ -0,0 +1,77 @@ +/** + * Thread title regeneration state helpers. + * + * Regeneration has two independent pieces of state: `titleRegeneration` while a + * request is in flight, and `titleRegenerationFailure` describing why the last + * finished request produced no title. Both clients read them, so the predicates + * and the failure diff live here rather than being re-derived per surface. + * + * @module threadTitleRegeneration + */ +import type { ThreadTitleRegeneration, ThreadTitleRegenerationFailure } from "@t3tools/contracts"; + +export interface ThreadTitleRegenerationHolder { + readonly titleRegeneration?: ThreadTitleRegeneration | null | undefined; + readonly titleRegenerationFailure?: ThreadTitleRegenerationFailure | null | undefined; +} + +/** True while generation is in flight — the only state that shows a spinner. */ +export function isTitleRegenerationPending(thread: ThreadTitleRegenerationHolder): boolean { + return thread.titleRegeneration != null; +} + +/** Why the last finished request produced no title, if it failed. */ +export function titleRegenerationFailureReason( + thread: ThreadTitleRegenerationHolder, +): string | null { + // A pending retry supersedes the previous reason on the server, but a client + // applying events out of order could briefly hold both. Pending wins. + if (thread.titleRegeneration != null) return null; + return thread.titleRegenerationFailure?.error ?? null; +} + +export interface TitleRegenerationFailureNotice { + readonly key: Key; + readonly requestId: string; + readonly error: string; +} + +/** + * Failures that appeared between two observations of the same thread set. + * + * Only transitions are reported. A thread that is already failed the first time + * it is seen (a fresh page load, a newly loaded environment) is recorded + * silently, so restoring persisted state never replays old errors as if they + * just happened; the reason stays available on the thread for surfaces that + * render it directly. A repeated failure still reports because each request + * carries its own id. + * + * `seen` is mutated in place with the current state and is expected to be + * long-lived (one map per client session). + */ +export function collectTitleRegenerationFailures( + threads: ReadonlyArray, + keyOf: (thread: Thread) => Key, + seen: Map, +): ReadonlyArray> { + const failures: TitleRegenerationFailureNotice[] = []; + const present = new Set(); + + for (const thread of threads) { + const key = keyOf(thread); + present.add(key); + const failure = thread.titleRegenerationFailure ?? null; + const wasKnown = seen.has(key); + const previous = seen.get(key) ?? null; + seen.set(key, failure?.requestId ?? null); + if (failure != null && wasKnown && previous !== failure.requestId) { + failures.push({ key, requestId: failure.requestId, error: failure.error }); + } + } + + for (const key of seen.keys()) { + if (!present.has(key)) seen.delete(key); + } + + return failures; +} diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 2f2a8de491c9..64a9cdcb6684 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -350,6 +350,24 @@ export const ThreadTitleRegeneration = Schema.Struct({ }); export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; +/** + * Why the last regeneration request produced no title. + * + * Deliberately a sibling of `titleRegeneration` rather than a field on it: + * clients — including older ones that predate this field — treat any non-null + * `titleRegeneration` as "in flight", so a failure recorded there would leave + * them spinning forever. Keeping the pending record strictly pending means an + * old client sees a failure exactly as it sees a success (cleared) and simply + * ignores what it cannot read, while a current client can say why the title + * did not change. Cleared by the next regeneration request or a manual rename. + */ +export const ThreadTitleRegenerationFailure = Schema.Struct({ + requestId: CommandId, + failedAt: IsoDateTime, + error: TrimmedNonEmptyString, +}); +export type ThreadTitleRegenerationFailure = typeof ThreadTitleRegenerationFailure.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -377,6 +395,7 @@ export const OrchestrationThread = Schema.Struct({ snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), + titleRegenerationFailure: Schema.optional(Schema.NullOr(ThreadTitleRegenerationFailure)), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -430,6 +449,7 @@ export const OrchestrationThreadShell = Schema.Struct({ snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), + titleRegenerationFailure: Schema.optional(Schema.NullOr(ThreadTitleRegenerationFailure)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -884,6 +904,11 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ threadId: ThreadId, requestId: CommandId, title: Schema.optional(TrimmedNonEmptyString), + /** + * Why generation produced no title. Mutually exclusive with `title`: a + * completion carries either the new title or the reason there is none. + */ + error: Schema.optional(TrimmedNonEmptyString), }); const InternalOrchestrationCommand = Schema.Union([ @@ -1034,6 +1059,8 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ previousTitle: Schema.optional(TrimmedNonEmptyString), /** Pending state shared with clients. Null clears a matching request. */ titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), + /** Why the request produced no title. Null clears a recorded failure. */ + titleRegenerationFailure: Schema.optional(Schema.NullOr(ThreadTitleRegenerationFailure)), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),