diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index e9dbc4a5956..a7aea90f826 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -69,6 +69,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.environmentId).toBe(second.environmentId); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); + expect(second.capabilities.threadTitleRegeneration).toBe(true); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 0eaf5a7c16a..250d4d7745a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -142,6 +142,7 @@ export const make = Effect.gen(function* () { connectionProbe: true, threadSettlement: true, threadSnooze: true, + threadTitleRegeneration: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1f24a4a0200..f6c62b610ab 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -611,6 +611,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti settledAt: null, snoozedUntil: null, snoozedAt: null, + titleRegenerationRequestId: null, + titleRegenerationStartedAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -723,6 +725,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.titleRegeneration !== undefined + ? { + titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null, + titleRegenerationStartedAt: event.payload.titleRegeneration?.startedAt ?? null, + } + : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d4a24a209ad..12afffea7e7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, + titleRegeneration: null, deletedAt: null, messages: [ { @@ -426,6 +427,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, + titleRegeneration: null, session: { threadId: ThreadId.make("thread-1"), status: "running", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3d05bef4bdf..1cd4289fe0c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -209,6 +209,15 @@ function mapLatestTurn( }; } +function mapTitleRegeneration(row: Schema.Schema.Type) { + return row.titleRegenerationRequestId != null && row.titleRegenerationStartedAt != null + ? { + requestId: row.titleRegenerationRequestId, + startedAt: row.titleRegenerationStartedAt, + } + : null; +} + function mapSessionRow( row: Schema.Schema.Type, ): OrchestrationSession { @@ -338,6 +347,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + title_regeneration_request_id AS "titleRegenerationRequestId", + title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -370,6 +381,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + title_regeneration_request_id AS "titleRegenerationRequestId", + title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -404,6 +417,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + title_regeneration_request_id AS "titleRegenerationRequestId", + title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -770,6 +785,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + title_regeneration_request_id AS "titleRegenerationRequestId", + title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1206,6 +1223,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1408,6 +1426,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1541,6 +1560,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1679,6 +1699,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1923,6 +1944,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: threadRow.value.settledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, + titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -2021,6 +2043,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: threadRow.value.settledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, + titleRegeneration: mapTitleRegeneration(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 c49646b7a4b..2aeb09c2d49 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -146,6 +146,7 @@ describe("ProviderCommandReactor", () => { readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; + readonly titleRegenerationCompletionDispatchFailures?: number; readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; @@ -353,8 +354,34 @@ describe("ProviderCommandReactor", () => { Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); + let titleRegenerationCompletionDispatchAttempts = 0; + const reactorOrchestrationLayer = Layer.effect( + OrchestrationEngineService, + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + return { + readEvents: engine.readEvents, + dispatch: (command) => { + if (command.type === "thread.title.regeneration.complete") { + titleRegenerationCompletionDispatchAttempts += 1; + if ( + titleRegenerationCompletionDispatchAttempts <= + (input?.titleRegenerationCompletionDispatchFailures ?? 0) + ) { + return Effect.die(new Error("Injected title regeneration completion failure")); + } + } + return engine.dispatch(command); + }, + get streamDomainEvents() { + return engine.streamDomainEvents; + }, + latestSequence: engine.latestSequence, + } satisfies OrchestrationEngineService["Service"]; + }), + ).pipe(Layer.provide(orchestrationLayer)); const layer = ProviderCommandReactorLive.pipe( - Layer.provideMerge(orchestrationLayer), + Layer.provideMerge(reactorOrchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), @@ -387,6 +414,7 @@ describe("ProviderCommandReactor", () => { const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); + const runEffect = (effect: Effect.Effect) => runtime!.runPromise(effect); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(reactor.drain); @@ -434,6 +462,10 @@ describe("ProviderCommandReactor", () => { runtimeSessions, stateDir, drain, + runEffect, + get titleRegenerationCompletionDispatchAttempts() { + return titleRegenerationCompletionDispatchAttempts; + }, }; } @@ -637,6 +669,445 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Generated title"); }); + it("regenerates a thread title from the current conversation", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ title: "Resolve stale reconnect state" }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-existing"), + threadId: ThreadId.make("thread-1"), + title: "Investigate reconnect regressions", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-title-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-title-regeneration"), + role: "user", + text: "Please investigate reconnect regressions after restarting the session.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-assistant-before-title-regeneration"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("assistant-message-before-title-regeneration"), + delta: "The remaining issue is stale reconnect state.", + createdAt: "2026-01-01T00:00:01.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-assistant-complete-before-title-regeneration"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("assistant-message-before-title-regeneration"), + createdAt: "2026-01-01T00:00:02.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-regenerate"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + + await harness.drain(); + + expect(harness.generateThreadTitle).toHaveBeenCalledTimes(1); + expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({ + cwd: "/tmp/provider-project", + previousTitle: "Investigate reconnect regressions", + message: [ + "USER:", + "Please investigate reconnect regressions after restarting the session.", + "", + "ASSISTANT:", + "The remaining issue is stale reconnect state.", + ].join("\n"), + }); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Resolve stale reconnect state"); + expect(thread?.titleRegeneration).toBeNull(); + }); + + it("keeps the current title when regeneration returns the fallback", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "New thread" })); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-fallback-regeneration"), + threadId: ThreadId.make("thread-1"), + title: "Keep meaningful title", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-fallback-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-fallback-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-fallback-regeneration"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + + await harness.drain(); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Keep meaningful title"); + expect(thread?.titleRegeneration).toBeNull(); + }); + + it("clears title regeneration state when generation fails", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-failed-regeneration"), + threadId: ThreadId.make("thread-1"), + title: "Keep title after failure", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-failed-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-failed-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-failed-regeneration"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + + await harness.drain(); + + 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"); + expect(thread?.titleRegeneration).toBeNull(); + }); + + it("retries a failed completion and continues regenerating", async () => { + const harness = await createHarness({ + titleRegenerationCompletionDispatchFailures: 1, + }); + const now = "2026-01-01T00:00:00.000Z"; + harness.generateThreadTitle + .mockReturnValueOnce(Effect.succeed({ title: "Title lost to completion failure" })) + .mockReturnValueOnce(Effect.succeed({ title: "Recovered regeneration worker" })); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-completion-failure"), + threadId: ThreadId.make("thread-1"), + title: "Existing title", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-completion-failure"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-completion-failure"), + 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-regeneration-completion-failure"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + await harness.drain(); + + let readModel = await harness.readModel(); + let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Title lost to completion failure"); + expect(thread?.titleRegeneration).toBeNull(); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-regeneration-after-completion-failure"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + await harness.drain(); + + expect(harness.generateThreadTitle).toHaveBeenCalledTimes(2); + expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(3); + readModel = await harness.readModel(); + thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Recovered regeneration worker"); + expect(thread?.titleRegeneration).toBeNull(); + }); + + it("keeps the full retained context and excludes attachments outside it", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const retainedContext = "x".repeat(8_000); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-truncated-regeneration"), + threadId: ThreadId.make("thread-1"), + title: "Existing title", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-truncated-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-truncated-regeneration"), + role: "user", + text: "Old visual issue", + attachments: [ + { + type: "image", + id: "old-title-context-image", + name: "old-issue.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-assistant-truncated-regeneration-context"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("assistant-truncated-regeneration-context"), + delta: `content before retained tail${"x".repeat(8_100)}`, + createdAt: "2026-01-01T00:00:01.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-assistant-truncated-regeneration-context-complete"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("assistant-truncated-regeneration-context"), + createdAt: "2026-01-01T00:00:02.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-regenerate-truncated-context"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + + await harness.drain(); + + expect(harness.generateThreadTitle.mock.calls[0]?.[0].message).toBe( + `[Earlier content truncated]\n\n${retainedContext}`, + ); + expect(harness.generateThreadTitle.mock.calls[0]?.[0].attachments).toBeUndefined(); + }); + + it("does not overwrite a manual rename while title regeneration is running", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const generatedTitle = await harness.runEffect( + Deferred.make<{ readonly title: string }, never>(), + ); + harness.generateThreadTitle.mockReturnValue(Deferred.await(generatedTitle)); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-regeneration-race"), + threadId: ThreadId.make("thread-1"), + title: "Existing thread title", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-regeneration-race"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-regeneration-race"), + 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-regeneration-race"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + const pendingReadModel = await harness.readModel(); + expect( + pendingReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")) + ?.titleRegeneration?.requestId, + ).toBe(CommandId.make("cmd-thread-title-regeneration-race")); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-manual-rename-during-regeneration"), + threadId: ThreadId.make("thread-1"), + title: "Keep manual rename", + }), + ); + await harness.runEffect( + Deferred.succeed(generatedTitle, { title: "Generated title should not win" }), + ); + await harness.drain(); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Keep manual rename"); + expect(thread?.titleRegeneration).toBeNull(); + }); + + it("does not overwrite a manual rename while title regeneration is queued", async () => { + let releaseStart = () => {}; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + const harness = await createHarness({ + startSessionEffect: (session) => Effect.promise(() => startGate).pipe(Effect.as(session)), + }); + const now = "2026-01-01T00:00:00.000Z"; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ title: "Generated title should not win" }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-queued-regeneration"), + threadId: ThreadId.make("thread-1"), + title: "Existing thread title", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-queued-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-queued-regeneration"), + role: "user", + text: "Investigate the reconnect state.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await waitFor(() => harness.startSession.mock.calls.length === 1); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-queued-regeneration"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-manual-rename-before-regeneration-starts"), + threadId: ThreadId.make("thread-1"), + title: "Keep queued manual rename", + }), + ); + releaseStart(); + await harness.drain(); + + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Keep queued manual rename"); + }); + it("does not overwrite an existing custom thread title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3044cc6029d..1f84482dac3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -51,6 +51,7 @@ type ProviderIntentEvent = Extract< OrchestrationEvent, { type: + | "thread.meta-updated" | "thread.runtime-mode-set" | "thread.turn-start-requested" | "thread.turn-interrupt-requested" @@ -90,6 +91,60 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; 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 MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; +const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"; + +function formatThreadTitleContext( + messages: ReadonlyArray<{ + readonly role: "user" | "assistant" | "system"; + readonly text: string; + readonly attachments?: ReadonlyArray | undefined; + }>, +): { + readonly message: string; + readonly attachments: ReadonlyArray; +} { + let context = ""; + let truncated = false; + const retainedAttachments: Array = []; + + for (const message of messages.toReversed()) { + if (message.role === "system") { + continue; + } + const text = message.text.trim(); + const attachmentSummary = (message.attachments ?? []) + .map((attachment) => attachment.name) + .join(", "); + const contents = [ + ...(text.length > 0 ? [text] : []), + ...(attachmentSummary.length > 0 ? [`[Attachments: ${attachmentSummary}]`] : []), + ].join("\n"); + if (contents.length === 0) { + continue; + } + + const section = `${message.role.toUpperCase()}:\n${contents}`; + const separator = context.length > 0 ? "\n\n" : ""; + const available = MAX_THREAD_TITLE_CONTEXT_CHARS - context.length - separator.length; + if (section.length > available) { + if (available > 0) { + context = `${section.slice(-available)}${separator}${context}`; + retainedAttachments.unshift(...(message.attachments ?? [])); + } + truncated = true; + break; + } + context = `${section}${separator}${context}`; + retainedAttachments.unshift(...(message.attachments ?? [])); + } + + return { + message: truncated ? `${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${context}` : context, + attachments: retainedAttachments.slice(-MAX_REGENERATION_ATTACHMENTS), + }; +} export function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); @@ -777,6 +832,130 @@ const make = Effect.gen(function* () { }, ); + const regenerateThreadTitle = Effect.fn("regenerateThreadTitle")(function* ( + event: Extract, + ) { + if (event.payload.regenerateTitle !== true) { + return; + } + + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + + const { message, attachments } = formatThreadTitleContext(thread.messages); + if (message.length === 0) { + return; + } + + const previousTitle = event.payload.previousTitle ?? thread.title; + if (thread.title !== previousTitle) { + return; + } + const project = yield* resolveProject(thread.projectId); + const cwd = + resolveThreadWorkspaceCwd({ + thread, + projects: project ? [project] : [], + }) ?? process.cwd(); + const { textGenerationModelSelection: modelSelection } = + yield* serverSettingsService.getSettings; + const generated = yield* textGeneration.generateThreadTitle({ + cwd, + message, + previousTitle, + ...(attachments.length > 0 ? { attachments } : {}), + modelSelection, + }); + if (generated.title === DEFAULT_THREAD_TITLE || generated.title === previousTitle) { + return; + } + + const latestThread = yield* resolveThread(event.payload.threadId); + if (!latestThread || latestThread.title !== previousTitle) { + return; + } + + return generated.title; + }); + const dispatchThreadTitleRegenerationCompletion = Effect.fn( + "dispatchThreadTitleRegenerationCompletion", + )(function* (input: { + readonly threadId: ThreadId; + readonly requestId: CommandId; + readonly title?: string; + }) { + yield* orchestrationEngine.dispatch({ + type: "thread.title.regeneration.complete", + commandId: yield* serverCommandId("thread-title-regeneration-complete"), + threadId: input.threadId, + requestId: input.requestId, + ...(input.title !== undefined ? { title: input.title } : {}), + }); + }); + const processThreadTitleRegenerationSafely = Effect.fn("processThreadTitleRegenerationSafely")( + function* (event: Extract) { + if (event.payload.regenerateTitle !== true) { + return; + } + + const requestId = event.payload.titleRegeneration?.requestId ?? event.commandId; + const generatedTitle = yield* regenerateThreadTitle(event).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("provider command reactor failed to regenerate thread title", { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }).pipe(Effect.as(undefined)); + }), + ); + if (requestId === null) { + return; + } + + const completion = { + threadId: event.payload.threadId, + requestId, + ...(generatedTitle !== undefined ? { title: generatedTitle } : {}), + }; + yield* dispatchThreadTitleRegenerationCompletion(completion).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning( + "provider command reactor retrying title regeneration completion", + { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }, + ).pipe(Effect.andThen(dispatchThreadTitleRegenerationCompletion(completion))); + }), + ); + }, + (effect, event) => + effect.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning( + "provider command reactor failed to complete title regeneration", + { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }, + ); + }), + ), + ); + const threadTitleRegenerationWorker = yield* makeDrainableWorker( + processThreadTitleRegenerationSafely, + ); + const processTurnStartRequested = Effect.fn("processTurnStartRequested")(function* ( event: Extract, ) { @@ -1047,6 +1226,9 @@ const make = Effect.gen(function* () { eventType: event.type, }); switch (event.type) { + case "thread.meta-updated": + yield* threadTitleRegenerationWorker.enqueue(event); + return; case "thread.runtime-mode-set": { const thread = yield* resolveThread(event.payload.threadId); if (!thread?.session || thread.session.status === "stopped") { @@ -1096,6 +1278,7 @@ const make = Effect.gen(function* () { const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) { if ( + (event.type === "thread.meta-updated" && event.payload.regenerateTitle === true) || event.type === "thread.runtime-mode-set" || event.type === "thread.turn-start-requested" || event.type === "thread.turn-interrupt-requested" || @@ -1114,7 +1297,10 @@ const make = Effect.gen(function* () { return { start, - drain: worker.drain, + drain: Effect.gen(function* () { + yield* worker.drain; + yield* threadTitleRegenerationWorker.drain; + }), } satisfies ProviderCommandReactorShape; }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 100369ae6e3..982e4495ae1 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -654,6 +654,19 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, ...(command.title !== undefined ? { title: command.title } : {}), + ...(command.regenerateTitle === true + ? { + regenerateTitle: true as const, + previousTitle: thread.title, + titleRegeneration: { + requestId: command.commandId, + startedAt: occurredAt, + }, + } + : {}), + ...(command.title !== undefined && thread.titleRegeneration != null + ? { titleRegeneration: null } + : {}), ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), @@ -664,6 +677,31 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.title.regeneration.complete": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const requestIsCurrent = thread.titleRegeneration?.requestId === command.requestId; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + ...(requestIsCurrent && command.title !== undefined ? { title: command.title } : {}), + ...(requestIsCurrent ? { titleRegeneration: null } : {}), + updatedAt: occurredAt, + }, + }; + } + case "thread.runtime-mode.set": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0504cb36f9a..54e694dd332 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -399,6 +399,9 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.titleRegeneration !== undefined + ? { titleRegeneration: payload.titleRegeneration } + : {}), ...(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 7e86d49eac3..eb423aef99e 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -47,6 +47,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at, snoozed_until, snoozed_at, + title_regeneration_request_id, + title_regeneration_started_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -70,6 +72,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.settledAt}, ${row.snoozedUntil}, ${row.snoozedAt}, + ${row.titleRegenerationRequestId ?? null}, + ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -93,6 +97,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at = excluded.settled_at, snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, + title_regeneration_request_id = excluded.title_regeneration_request_id, + title_regeneration_started_at = excluded.title_regeneration_started_at, 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, @@ -123,6 +129,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + title_regeneration_request_id AS "titleRegenerationRequestId", + title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -155,6 +163,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + title_regeneration_request_id AS "titleRegenerationRequestId", + title_regeneration_started_at AS "titleRegenerationStartedAt", 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 d25895671a9..95cb6b17f84 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -47,6 +47,7 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; +import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; /** * Migration loader with all migrations defined inline. @@ -93,6 +94,7 @@ export const migrationEntries = [ [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "ProjectionThreadsSettled", Migration0033], [34, "ProjectionThreadsSnoozed", Migration0034], + [35, "ProjectionThreadTitleRegeneration", Migration0035], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts new file mode 100644 index 00000000000..755591201de --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts @@ -0,0 +1,27 @@ +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("035_ProjectionThreadTitleRegeneration", (it) => { + it.effect("adds pending title regeneration columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 34 }); + yield* runMigrations({ toMigrationInclusive: 35 }); + + 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_request_id")); + assert.ok(names.has("title_regeneration_started_at")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.ts new file mode 100644 index 00000000000..ee3dd4a0774 --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.ts @@ -0,0 +1,23 @@ +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) + `; + + if (!columns.some((column) => column.name === "title_regeneration_request_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN title_regeneration_request_id TEXT + `; + } + + if (!columns.some((column) => column.name === "title_regeneration_started_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN title_regeneration_started_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 056425ae886..ea1b011be84 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -7,6 +7,7 @@ * @module ProjectionThreadRepository */ import { + CommandId, IsoDateTime, ModelSelection, NonNegativeInt, @@ -40,6 +41,8 @@ export const ProjectionThread = Schema.Struct({ settledAt: Schema.NullOr(IsoDateTime), snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), + titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), + titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index a27b2c8bd3e..37304795a22 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -342,6 +342,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu Effect.fn("ClaudeTextGeneration.generateThreadTitle")(function* (input) { const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, + previousTitle: input.previousTitle, attachments: input.attachments, }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 6ea710cd5a3..62d917012ae 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -384,6 +384,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ); const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, + previousTitle: input.previousTitle, attachments: input.attachments, }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index be0fc858ac5..5ae057b54f6 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -242,6 +242,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu Effect.fn("CursorTextGeneration.generateThreadTitle")(function* (input) { const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, + previousTitle: input.previousTitle, attachments: input.attachments, }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index d26a2ebe01b..1cf3d13e225 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -234,6 +234,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi Effect.fn("GrokTextGeneration.generateThreadTitle")(function* (input) { const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, + previousTitle: input.previousTitle, attachments: input.attachments, }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 2adbb829b8d..e09c3db2cff 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -598,6 +598,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" Effect.fn("OpenCodeTextGeneration.generateThreadTitle")(function* (input) { const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, + previousTitle: input.previousTitle, attachments: input.attachments, }); const generated = yield* runOpenCodeJson({ diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index ead09638776..66b7ccd465f 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -62,6 +62,8 @@ export interface BranchNameGenerationResult { export interface ThreadTitleGenerationInput { cwd: string; message: string; + /** Present when replacing an existing title from the current thread history. */ + previousTitle?: string | undefined; attachments?: ReadonlyArray | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; @@ -107,9 +109,7 @@ export class TextGeneration extends Context.Service< input: BranchNameGenerationInput, ) => Effect.Effect; - /** - * Generate a concise thread title from a user's first message. - */ + /** Generate a concise thread title from a first message or thread history. */ readonly generateThreadTitle: ( input: ThreadTitleGenerationInput, ) => Effect.Effect; diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 65b61b99bfc..ede18664051 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -175,6 +175,48 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("image/png"); expect(result.prompt).toContain("67890 bytes"); }); + + it("regenerates from recent thread contents and identifies the previous title", () => { + const result = buildThreadTitlePrompt({ + message: `USER:\nInvestigate reconnect regressions\n\nASSISTANT:\nThe remaining issue is stale session state`, + previousTitle: "Investigate reconnect regressions", + }); + + expect(result.prompt).toContain( + "The user requested a new title based on the contents of this thread.", + ); + expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".'); + expect(result.prompt).toContain("better represents the current state of the thread"); + expect(result.prompt).toContain( + "Capture the thread's intent, not a PR number or other superficial detail.", + ); + expect(result.prompt).toContain("Thread contents:"); + expect(result.prompt).toContain("The remaining issue is stale session state"); + }); + + it("keeps the latest thread contents when regeneration context is truncated", () => { + const result = buildThreadTitlePrompt({ + message: `${"old context ".repeat(1_000)}\n\nASSISTANT:\nCurrent thread state`, + previousTitle: "Old title", + }); + + expect(result.prompt).toContain("[Earlier content truncated]"); + expect(result.prompt).toContain("Current thread state"); + expect(result.prompt).not.toContain("[truncated]"); + }); + + it("does not truncate an already-marked regeneration context twice", () => { + const retainedContext = "x".repeat(7_998); + const result = buildThreadTitlePrompt({ + message: `[Earlier content truncated]\n\n${retainedContext}`, + previousTitle: "Old title", + }); + + expect(result.prompt).toContain( + `Thread contents:\n[Earlier content truncated]\n\n${retainedContext}`, + ); + expect(result.prompt.match(/\[Earlier content truncated\]/g)).toHaveLength(1); + }); }); describe("sanitizeThreadTitle", () => { diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index efa251963a5..8aed88b1f16 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -12,6 +12,8 @@ import type { ChatAttachment } from "@t3tools/contracts"; import { limitSection } from "./TextGenerationUtils.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; +const EARLIER_CONTENT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"; + function policyInstruction(instruction: string | undefined): ReadonlyArray { const trimmed = instruction?.trim(); return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : []; @@ -150,10 +152,23 @@ interface PromptFromMessageInput { responseShape: string; rules: ReadonlyArray; message: string; + messageLabel?: string | undefined; + preserveMessageEnd?: boolean | undefined; attachments?: ReadonlyArray | undefined; additionalInstructions?: string | undefined; } +function preserveMessageEnd(message: string): string { + const alreadyTruncated = message.startsWith(EARLIER_CONTENT_TRUNCATION_MARKER); + const contents = alreadyTruncated + ? message.slice(EARLIER_CONTENT_TRUNCATION_MARKER.length) + : message; + if (!alreadyTruncated && contents.length <= 8_000) { + return contents; + } + return `${EARLIER_CONTENT_TRUNCATION_MARKER}${contents.slice(-8_000)}`; +} + function buildPromptFromMessage(input: PromptFromMessageInput): string { const attachmentLines = (input.attachments ?? []).map( (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, @@ -165,8 +180,10 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string { "Rules:", ...input.rules.map((rule) => `- ${rule}`), "", - "User message:", - limitSection(input.message, 8_000), + `${input.messageLabel ?? "User message"}:`, + input.preserveMessageEnd + ? preserveMessageEnd(input.message) + : limitSection(input.message, 8_000), ...policyInstruction(input.additionalInstructions), ]; if (attachmentLines.length > 0) { @@ -207,21 +224,44 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { export interface ThreadTitlePromptInput { message: string; + previousTitle?: string | undefined; attachments?: ReadonlyArray | undefined; policy?: TextGenerationPolicy | undefined; } export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { + const isRegeneration = input.previousTitle !== undefined; const prompt = buildPromptFromMessage({ - instruction: "You write concise thread titles for coding conversations.", + instruction: isRegeneration + ? [ + "You write concise thread titles for coding conversations.", + "The user requested a new title based on the contents of this thread.", + `The previous title was ${JSON.stringify(input.previousTitle)}.`, + "Come up with a new title that better represents the current state of the thread.", + ].join("\n") + : "You write concise thread titles for coding conversations.", responseShape: "Return a JSON object with key: title.", rules: [ - "Title should summarize the user's request, not restate it verbatim.", + isRegeneration + ? "Title should summarize the thread's current state, not just its initial request." + : "Title should summarize the user's request, not restate it verbatim.", + ...(isRegeneration + ? [ + "Capture the thread's intent, not a PR number or other superficial detail.", + "Return a different title from the previous title.", + ] + : []), "Keep it short and specific (3-8 words).", "Avoid quotes, filler, prefixes, and trailing punctuation.", "If images are attached, use them as primary context for visual/UI issues.", ], message: input.message, + ...(isRegeneration + ? { + messageLabel: "Thread contents", + preserveMessageEnd: true, + } + : {}), attachments: input.attachments, additionalInstructions: input.policy?.threadTitleInstructions, }); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 59784bf8fac..fa4eb5047af 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { archiveSelectedThreadEntries, + buildBulkTitleRegenerationContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, @@ -10,6 +11,7 @@ import { getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, + isThreadTitleRegenerationPending, isContextMenuPointerDown, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -28,6 +30,7 @@ import { sortProjectsForSidebar, sortScopedProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, + THREAD_TITLE_REGENERATION_TIMEOUT_MS, } from "./Sidebar.logic"; import { EnvironmentId, @@ -46,6 +49,76 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("isThreadTitleRegenerationPending", () => { + const now = Date.parse("2026-03-09T10:00:30.000Z"); + + it("keeps a recent request pending", () => { + expect( + isThreadTitleRegenerationPending( + { + titleRegeneration: { + startedAt: new Date(now - THREAD_TITLE_REGENERATION_TIMEOUT_MS + 1).toISOString(), + }, + }, + now, + ), + ).toBe(true); + }); + + it("expires stale, missing, and malformed requests", () => { + expect( + isThreadTitleRegenerationPending( + { + titleRegeneration: { + startedAt: new Date(now - THREAD_TITLE_REGENERATION_TIMEOUT_MS).toISOString(), + }, + }, + now, + ), + ).toBe(false); + expect(isThreadTitleRegenerationPending({ titleRegeneration: null }, now)).toBe(false); + expect( + isThreadTitleRegenerationPending({ titleRegeneration: { startedAt: "not-a-date" } }, now), + ).toBe(false); + }); +}); + +describe("buildBulkTitleRegenerationContextMenuItem", () => { + it("counts only threads that can start a new regeneration", () => { + expect( + buildBulkTitleRegenerationContextMenuItem({ + supportedCount: 4, + actionableCount: 3, + }), + ).toEqual({ + id: "regenerate-title", + label: "Regenerate titles (3)", + }); + }); + + it("shows a disabled progress item when every supported thread is pending", () => { + expect( + buildBulkTitleRegenerationContextMenuItem({ + supportedCount: 2, + actionableCount: 0, + }), + ).toEqual({ + id: "regenerate-title", + label: "Regenerating… (2)", + disabled: true, + }); + }); + + it("omits the action when no selected environment supports it", () => { + expect( + buildBulkTitleRegenerationContextMenuItem({ + supportedCount: 0, + actionableCount: 0, + }), + ).toBeNull(); + }); +}); + describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7aee3100d0e..40f974a4b5e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -15,9 +15,52 @@ import { resolveServerBackedAppStageLabel } from "../branding.logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; +export const THREAD_TITLE_REGENERATION_TIMEOUT_MS = 30_000; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; + +export function threadTitleRegenerationRemainingMs( + thread: { + readonly titleRegeneration?: { readonly startedAt: string } | null | undefined; + }, + nowMs: number, +): number { + const startedAt = Date.parse(thread.titleRegeneration?.startedAt ?? ""); + if (!Number.isFinite(startedAt)) return 0; + return Math.min( + THREAD_TITLE_REGENERATION_TIMEOUT_MS, + Math.max(0, THREAD_TITLE_REGENERATION_TIMEOUT_MS - (nowMs - startedAt)), + ); +} + +export function isThreadTitleRegenerationPending( + thread: { + readonly titleRegeneration?: { readonly startedAt: string } | null | undefined; + }, + nowMs: number, +): boolean { + return threadTitleRegenerationRemainingMs(thread, nowMs) > 0; +} + +export function buildBulkTitleRegenerationContextMenuItem(input: { + supportedCount: number; + actionableCount: number; +}): ContextMenuItem<"regenerate-title"> | null { + if (input.supportedCount === 0) return null; + if (input.actionableCount === 0) { + return { + id: "regenerate-title", + label: `Regenerating… (${input.supportedCount})`, + disabled: true, + }; + } + return { + id: "regenerate-title", + label: `Regenerate titles (${input.actionableCount})`, + }; +} + type SidebarProject = { id: string; title: string; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index be93f510c97..82f6b8486bd 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -105,9 +105,11 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat" import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { + buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, + isThreadTitleRegenerationPending, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, @@ -118,6 +120,7 @@ import { sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, + threadTitleRegenerationRemainingMs, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { @@ -421,6 +424,23 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); + const titleRegeneration = thread.titleRegeneration ?? null; + const [timedOutTitleRegenerationRequestId, setTimedOutTitleRegenerationRequestId] = useState< + string | null + >(null); + const titleRegenerationRemaining = threadTitleRegenerationRemainingMs(thread, Date.now()); + const isRegeneratingTitle = + titleRegeneration !== null && + timedOutTitleRegenerationRequestId !== titleRegeneration.requestId && + titleRegenerationRemaining > 0; + useEffect(() => { + if (titleRegeneration === null || titleRegenerationRemaining <= 0) return; + const timeoutId = window.setTimeout( + () => setTimedOutTitleRegenerationRequestId(titleRegeneration.requestId), + titleRegenerationRemaining, + ); + return () => window.clearTimeout(timeoutId); + }, [titleRegeneration, titleRegenerationRemaining]); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const openPrLink = useOpenPrLink(); @@ -690,7 +710,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : ( {thread.title} @@ -749,6 +770,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { role="button" tabIndex={0} data-testid="sidebar-v2-row-slim" + aria-busy={isRegeneratingTitle || undefined} className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")} onClick={handleClick} onDoubleClick={handleDoubleClick} @@ -774,6 +796,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { /> {title} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} {/* The PR badge stays outside the hover-fading slot: it must remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} @@ -858,6 +885,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { role="button" tabIndex={0} data-testid="sidebar-v2-row-card" + aria-busy={isRegeneratingTitle || undefined} className={rowSurfaceClassName} onClick={handleClick} onDoubleClick={handleDoubleClick} @@ -953,7 +981,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null} -
{title}
+
+ {title} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} +
{thread.branch ? ( {thread.branch} @@ -1847,16 +1882,28 @@ export default function SidebarV2() { const count = threadKeys.length; // Snooze (N) is offered when every selected thread can actually take // it — a mixed selection with blocked-on-you work would half-apply. - const selectionNow = new Date().toISOString(); - const snoozableThreads = threadKeys.flatMap((threadKey) => { + const selectionNow = new Date(); + const selectedThreads = threadKeys.flatMap((threadKey) => { const thread = threadByKeyRef.current.get(threadKey); return thread ? [thread] : []; }); - const canSnoozeSelection = snoozableThreads.every( + const canSnoozeSelection = selectedThreads.every( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && - canSnooze(thread, { now: selectionNow }), + canSnooze(thread, { now: selectionNow.toISOString() }), + ); + const titleRegenerationThreads = selectedThreads.filter( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadTitleRegeneration === true, ); + const regeneratableTitleThreads = titleRegenerationThreads.filter( + (thread) => !isThreadTitleRegenerationPending(thread, selectionNow.getTime()), + ); + const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({ + supportedCount: titleRegenerationThreads.length, + actionableCount: regeneratableTitleThreads.length, + }); const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( @@ -1874,6 +1921,7 @@ export default function SidebarV2() { }, ] : []), + ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], @@ -1889,7 +1937,7 @@ export default function SidebarV2() { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. const coSnoozingKeys = new Set(threadKeys); - for (const thread of snoozableThreads) { + for (const thread of selectedThreads) { attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { coSnoozingKeys, }); @@ -1898,6 +1946,28 @@ export default function SidebarV2() { } 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(); + return; + } if (clicked.value === "settle") { // Post-settle navigation must skip threads settling in this same // batch — they are all leaving the card block together. Rows that @@ -1969,6 +2039,7 @@ export default function SidebarV2() { markThreadUnread, removeFromSelection, serverConfigs, + updateThreadMetadata, ], ); @@ -1994,6 +2065,10 @@ export default function SidebarV2() { true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const supportsTitleRegeneration = + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadTitleRegeneration === true; + const isRegeneratingTitle = isThreadTitleRegenerationPending(thread, Date.now()); const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); // Presets resolve at menu-open time (same as the popover). @@ -2032,6 +2107,15 @@ export default function SidebarV2() { ] : []), { id: "rename", label: "Rename thread" }, + ...(supportsTitleRegeneration + ? [ + { + id: "regenerate-title", + label: isRegeneratingTitle ? "Regenerating…" : "Regenerate title", + disabled: isRegeneratingTitle, + }, + ] + : []), { id: "mark-unread", label: "Mark unread" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], @@ -2082,6 +2166,24 @@ export default function SidebarV2() { case "rename": startThreadRename(threadRef, thread.title); 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.", + }), + ); + } + return; + } case "mark-unread": markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; @@ -2127,6 +2229,7 @@ export default function SidebarV2() { markThreadUnread, serverConfigs, startThreadRename, + updateThreadMetadata, ], ); diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index ce0dca52f5a..fe637770804 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -155,6 +155,9 @@ export function applyThreadDetailEvent( thread: { ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.titleRegeneration !== undefined + ? { titleRegeneration: event.payload.titleRegeneration } + : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7f4b6c16541..3d753350d99 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), + /** Server understands regenerateTitle on thread.meta.update. Absent on + older servers, so clients hide the action instead of sending it. */ + threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 1ebc23a483b..ecf7afa0610 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -320,12 +320,20 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () => Effect.gen(function* () { const parsed = yield* decodeThreadMetaUpdatedPayload({ threadId: "thread-1", + regenerateTitle: true, + previousTitle: "Previous title", + titleRegeneration: { + requestId: "cmd-title-regenerate", + startedAt: "2026-01-01T00:00:00.000Z", + }, modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", }, updatedAt: "2026-01-01T00:00:00.000Z", }); + assert.strictEqual(parsed.previousTitle, "Previous title"); + assert.strictEqual(parsed.titleRegeneration?.requestId, "cmd-title-regenerate"); assert.strictEqual(parsed.modelSelection?.instanceId, "claudeAgent"); }), ); @@ -628,6 +636,53 @@ it.effect("accepts a title seed in thread.turn.start", () => }), ); +it.effect("accepts a title regeneration intent in thread.meta.update", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationCommand({ + type: "thread.meta.update", + commandId: "cmd-title-regenerate", + threadId: "thread-1", + regenerateTitle: true, + }); + assert.strictEqual(parsed.type, "thread.meta.update"); + if (parsed.type === "thread.meta.update") { + assert.strictEqual(parsed.regenerateTitle, true); + } + }), +); + +it.effect("accepts an internal title regeneration completion", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationCommand({ + type: "thread.title.regeneration.complete", + commandId: "cmd-title-regeneration-complete", + threadId: "thread-1", + requestId: "cmd-title-regenerate", + title: "Updated title", + }); + assert.strictEqual(parsed.type, "thread.title.regeneration.complete"); + if (parsed.type === "thread.title.regeneration.complete") { + assert.strictEqual(parsed.requestId, "cmd-title-regenerate"); + assert.strictEqual(parsed.title, "Updated title"); + } + }), +); + +it.effect("rejects an explicit title combined with title regeneration", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "thread.meta.update", + commandId: "cmd-title-regenerate-with-title", + threadId: "thread-1", + title: "Explicit title", + regenerateTitle: true, + }), + ); + assert.strictEqual(result._tag, "Failure"); + }), +); + it.effect("accepts a source proposed plan reference in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b947bd63e4c..81d647ece7d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -341,6 +341,12 @@ export const OrchestrationLatestTurn = Schema.Struct({ }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; +export const ThreadTitleRegeneration = Schema.Struct({ + requestId: CommandId, + startedAt: IsoDateTime, +}); +export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -366,6 +372,8 @@ export const OrchestrationThread = Schema.Struct({ // Optional so payloads from pre-snooze servers still decode. snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + // Pending-only state. Optional so older servers remain compatible. + titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -418,6 +426,7 @@ export const OrchestrationThreadShell = Schema.Struct({ settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -616,11 +625,18 @@ const ThreadMetaUpdateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + regenerateTitle: Schema.optional(Schema.Literal(true)), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), -}); +}).check( + Schema.makeFilter( + (input) => + !(input.title !== undefined && input.regenerateTitle === true) || + "title and regenerateTitle cannot be specified together", + ), +); const ThreadRuntimeModeSetCommand = Schema.Struct({ type: Schema.Literal("thread.runtime-mode.set"), @@ -859,6 +875,14 @@ const ThreadRevertCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ + type: Schema.Literal("thread.title.regeneration.complete"), + commandId: CommandId, + threadId: ThreadId, + requestId: CommandId, + title: Schema.optional(TrimmedNonEmptyString), +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, @@ -867,6 +891,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, ThreadRevertCompleteCommand, + ThreadTitleRegenerationCompleteCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -999,6 +1024,13 @@ export const ThreadUnsnoozedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + /** Intent marker consumed by the title-generation reactor. Keeping this on + the existing event lets older clients safely ignore the new field. */ + regenerateTitle: Schema.optional(Schema.Literal(true)), + /** Title at request time, used to avoid overwriting a later manual rename. */ + previousTitle: Schema.optional(TrimmedNonEmptyString), + /** Pending state shared with clients. Null clears a matching request. */ + titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),