authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 04:39:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 04:39:38-07:00
log5c9358acaa34f8619513bba1e6821f0fdec27f74
treef5cf4f697fcbf2f14fce0ff74cb576105d694197
parentcbe8052020aa0c02c63fbe090e7f1f25603622d1
signature Signed by PGP key B5690EEEBB952194

feat(web): regenerate thread titles from sidebar (#4810)


32 files changed, 1438 insertions(+), 24 deletions(-)

apps/server/src/environment/ServerEnvironment.test.ts+1
...@@ -69,6 +69,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {...@@ -69,6 +69,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
69 expect(first.environmentId).toBe(second.environmentId);69 expect(first.environmentId).toBe(second.environmentId);
70 expect(second.capabilities.repositoryIdentity).toBe(true);70 expect(second.capabilities.repositoryIdentity).toBe(true);
71 expect(second.capabilities.connectionProbe).toBe(true);71 expect(second.capabilities.connectionProbe).toBe(true);
72 expect(second.capabilities.threadTitleRegeneration).toBe(true);
72 }),73 }),
73 );74 );
7475
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -142,6 +142,7 @@ export const make = Effect.gen(function* () {...@@ -142,6 +142,7 @@ export const make = Effect.gen(function* () {
142 connectionProbe: true,142 connectionProbe: true,
143 threadSettlement: true,143 threadSettlement: true,
144 threadSnooze: true,144 threadSnooze: true,
145 threadTitleRegeneration: true,
145 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),146 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
146 },147 },
147 };148 };
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+28
...@@ -335,6 +335,14 @@ describe("OrchestrationEngine", () => {...@@ -335,6 +335,14 @@ describe("OrchestrationEngine", () => {
335 }),335 }),
336 );336 );
337337
338 await system.run(
339 engine.dispatch({
340 type: "thread.meta.update",
341 commandId: CommandId.make("cmd-thread-archive-title-regeneration"),
342 threadId: ThreadId.make("thread-archive"),
343 regenerateTitle: true,
344 }),
345 );
338 await system.run(346 await system.run(
339 engine.dispatch({347 engine.dispatch({
340 type: "thread.archive",348 type: "thread.archive",
...@@ -346,6 +354,10 @@ describe("OrchestrationEngine", () => {...@@ -346,6 +354,10 @@ describe("OrchestrationEngine", () => {
346 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")354 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")
347 ?.archivedAt,355 ?.archivedAt,
348 ).not.toBeNull();356 ).not.toBeNull();
357 expect(
358 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")
359 ?.titleRegeneration,
360 ).toBeNull();
349361
350 await system.run(362 await system.run(
351 engine.dispatch({363 engine.dispatch({
...@@ -358,6 +370,22 @@ describe("OrchestrationEngine", () => {...@@ -358,6 +370,22 @@ describe("OrchestrationEngine", () => {
358 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")370 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")
359 ?.archivedAt,371 ?.archivedAt,
360 ).toBeNull();372 ).toBeNull();
373 expect(
374 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")
375 ?.titleRegeneration,
376 ).toBeNull();
377 await system.run(
378 engine.dispatch({
379 type: "thread.title.regeneration.complete",
380 commandId: CommandId.make("cmd-thread-archive-stale-title-completion"),
381 threadId: ThreadId.make("thread-archive"),
382 requestId: CommandId.make("cmd-thread-archive-title-regeneration"),
383 title: "Stale generated title",
384 }),
385 );
386 expect(
387 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")?.title,
388 ).toBe("Archive me");
361389
362 await system.dispose();390 await system.dispose();
363 });391 });
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+10
...@@ -611,6 +611,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -611,6 +611,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
611 settledAt: null,611 settledAt: null,
612 snoozedUntil: null,612 snoozedUntil: null,
613 snoozedAt: null,613 snoozedAt: null,
614 titleRegenerationRequestId: null,
615 titleRegenerationStartedAt: null,
614 latestUserMessageAt: null,616 latestUserMessageAt: null,
615 pendingApprovalCount: 0,617 pendingApprovalCount: 0,
616 pendingUserInputCount: 0,618 pendingUserInputCount: 0,
...@@ -629,6 +631,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -629,6 +631,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
629 yield* projectionThreadRepository.upsert({631 yield* projectionThreadRepository.upsert({
630 ...existingRow.value,632 ...existingRow.value,
631 archivedAt: event.payload.archivedAt,633 archivedAt: event.payload.archivedAt,
634 titleRegenerationRequestId: null,
635 titleRegenerationStartedAt: null,
632 updatedAt: event.payload.updatedAt,636 updatedAt: event.payload.updatedAt,
633 });637 });
634 return;638 return;
...@@ -723,6 +727,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -723,6 +727,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
723 yield* projectionThreadRepository.upsert({727 yield* projectionThreadRepository.upsert({
724 ...existingRow.value,728 ...existingRow.value,
725 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),729 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
730 ...(event.payload.titleRegeneration !== undefined
731 ? {
732 titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null,
733 titleRegenerationStartedAt: event.payload.titleRegeneration?.startedAt ?? null,
734 }
735 : {}),
726 ...(event.payload.modelSelection !== undefined736 ...(event.payload.modelSelection !== undefined
727 ? { modelSelection: event.payload.modelSelection }737 ? { modelSelection: event.payload.modelSelection }
728 : {}),738 : {}),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
...@@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
312 settledAt: null,312 settledAt: null,
313 snoozedUntil: null,313 snoozedUntil: null,
314 snoozedAt: null,314 snoozedAt: null,
315 titleRegeneration: null,
315 deletedAt: null,316 deletedAt: null,
316 messages: [317 messages: [
317 {318 {
...@@ -426,6 +427,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -426,6 +427,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
426 settledAt: null,427 settledAt: null,
427 snoozedUntil: null,428 snoozedUntil: null,
428 snoozedAt: null,429 snoozedAt: null,
430 titleRegeneration: null,
429 session: {431 session: {
430 threadId: ThreadId.make("thread-1"),432 threadId: ThreadId.make("thread-1"),
431 status: "running",433 status: "running",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+23
...@@ -209,6 +209,15 @@ function mapLatestTurn(...@@ -209,6 +209,15 @@ function mapLatestTurn(
209 };209 };
210}210}
211211
212function mapTitleRegeneration(row: Schema.Schema.Type<typeof ProjectionThreadDbRowSchema>) {
213 return row.titleRegenerationRequestId != null && row.titleRegenerationStartedAt != null
214 ? {
215 requestId: row.titleRegenerationRequestId,
216 startedAt: row.titleRegenerationStartedAt,
217 }
218 : null;
219}
220
212function mapSessionRow(221function mapSessionRow(
213 row: Schema.Schema.Type<typeof ProjectionThreadSessionDbRowSchema>,222 row: Schema.Schema.Type<typeof ProjectionThreadSessionDbRowSchema>,
214): OrchestrationSession {223): OrchestrationSession {
...@@ -338,6 +347,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -338,6 +347,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
338 settled_at AS "settledAt",347 settled_at AS "settledAt",
339 snoozed_until AS "snoozedUntil",348 snoozed_until AS "snoozedUntil",
340 snoozed_at AS "snoozedAt",349 snoozed_at AS "snoozedAt",
350 title_regeneration_request_id AS "titleRegenerationRequestId",
351 title_regeneration_started_at AS "titleRegenerationStartedAt",
341 latest_user_message_at AS "latestUserMessageAt",352 latest_user_message_at AS "latestUserMessageAt",
342 pending_approval_count AS "pendingApprovalCount",353 pending_approval_count AS "pendingApprovalCount",
343 pending_user_input_count AS "pendingUserInputCount",354 pending_user_input_count AS "pendingUserInputCount",
...@@ -370,6 +381,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -370,6 +381,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
370 settled_at AS "settledAt",381 settled_at AS "settledAt",
371 snoozed_until AS "snoozedUntil",382 snoozed_until AS "snoozedUntil",
372 snoozed_at AS "snoozedAt",383 snoozed_at AS "snoozedAt",
384 title_regeneration_request_id AS "titleRegenerationRequestId",
385 title_regeneration_started_at AS "titleRegenerationStartedAt",
373 latest_user_message_at AS "latestUserMessageAt",386 latest_user_message_at AS "latestUserMessageAt",
374 pending_approval_count AS "pendingApprovalCount",387 pending_approval_count AS "pendingApprovalCount",
375 pending_user_input_count AS "pendingUserInputCount",388 pending_user_input_count AS "pendingUserInputCount",
...@@ -404,6 +417,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -404,6 +417,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
404 settled_at AS "settledAt",417 settled_at AS "settledAt",
405 snoozed_until AS "snoozedUntil",418 snoozed_until AS "snoozedUntil",
406 snoozed_at AS "snoozedAt",419 snoozed_at AS "snoozedAt",
420 title_regeneration_request_id AS "titleRegenerationRequestId",
421 title_regeneration_started_at AS "titleRegenerationStartedAt",
407 latest_user_message_at AS "latestUserMessageAt",422 latest_user_message_at AS "latestUserMessageAt",
408 pending_approval_count AS "pendingApprovalCount",423 pending_approval_count AS "pendingApprovalCount",
409 pending_user_input_count AS "pendingUserInputCount",424 pending_user_input_count AS "pendingUserInputCount",
...@@ -770,6 +785,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -770,6 +785,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
770 settled_at AS "settledAt",785 settled_at AS "settledAt",
771 snoozed_until AS "snoozedUntil",786 snoozed_until AS "snoozedUntil",
772 snoozed_at AS "snoozedAt",787 snoozed_at AS "snoozedAt",
788 title_regeneration_request_id AS "titleRegenerationRequestId",
789 title_regeneration_started_at AS "titleRegenerationStartedAt",
773 latest_user_message_at AS "latestUserMessageAt",790 latest_user_message_at AS "latestUserMessageAt",
774 pending_approval_count AS "pendingApprovalCount",791 pending_approval_count AS "pendingApprovalCount",
775 pending_user_input_count AS "pendingUserInputCount",792 pending_user_input_count AS "pendingUserInputCount",
...@@ -1206,6 +1223,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1206,6 +1223,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1206 settledAt: row.settledAt,1223 settledAt: row.settledAt,
1207 snoozedUntil: row.snoozedUntil,1224 snoozedUntil: row.snoozedUntil,
1208 snoozedAt: row.snoozedAt,1225 snoozedAt: row.snoozedAt,
1226 titleRegeneration: mapTitleRegeneration(row),
1209 deletedAt: row.deletedAt,1227 deletedAt: row.deletedAt,
1210 messages: messagesByThread.get(row.threadId) ?? [],1228 messages: messagesByThread.get(row.threadId) ?? [],
1211 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],1229 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
...@@ -1408,6 +1426,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1408,6 +1426,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1408 settledAt: row.settledAt,1426 settledAt: row.settledAt,
1409 snoozedUntil: row.snoozedUntil,1427 snoozedUntil: row.snoozedUntil,
1410 snoozedAt: row.snoozedAt,1428 snoozedAt: row.snoozedAt,
1429 titleRegeneration: mapTitleRegeneration(row),
1411 deletedAt: row.deletedAt,1430 deletedAt: row.deletedAt,
1412 messages: [],1431 messages: [],
1413 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],1432 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
...@@ -1541,6 +1560,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1541,6 +1560,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1541 settledAt: row.settledAt,1560 settledAt: row.settledAt,
1542 snoozedUntil: row.snoozedUntil,1561 snoozedUntil: row.snoozedUntil,
1543 snoozedAt: row.snoozedAt,1562 snoozedAt: row.snoozedAt,
1563 titleRegeneration: mapTitleRegeneration(row),
1544 session: sessionByThread.get(row.threadId) ?? null,1564 session: sessionByThread.get(row.threadId) ?? null,
1545 latestUserMessageAt: row.latestUserMessageAt,1565 latestUserMessageAt: row.latestUserMessageAt,
1546 hasPendingApprovals: row.pendingApprovalCount > 0,1566 hasPendingApprovals: row.pendingApprovalCount > 0,
...@@ -1679,6 +1699,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1679,6 +1699,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1679 settledAt: row.settledAt,1699 settledAt: row.settledAt,
1680 snoozedUntil: row.snoozedUntil,1700 snoozedUntil: row.snoozedUntil,
1681 snoozedAt: row.snoozedAt,1701 snoozedAt: row.snoozedAt,
1702 titleRegeneration: mapTitleRegeneration(row),
1682 session: sessionByThread.get(row.threadId) ?? null,1703 session: sessionByThread.get(row.threadId) ?? null,
1683 latestUserMessageAt: row.latestUserMessageAt,1704 latestUserMessageAt: row.latestUserMessageAt,
1684 hasPendingApprovals: row.pendingApprovalCount > 0,1705 hasPendingApprovals: row.pendingApprovalCount > 0,
...@@ -1923,6 +1944,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1923,6 +1944,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1923 settledAt: threadRow.value.settledAt,1944 settledAt: threadRow.value.settledAt,
1924 snoozedUntil: threadRow.value.snoozedUntil,1945 snoozedUntil: threadRow.value.snoozedUntil,
1925 snoozedAt: threadRow.value.snoozedAt,1946 snoozedAt: threadRow.value.snoozedAt,
1947 titleRegeneration: mapTitleRegeneration(threadRow.value),
1926 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,1948 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
1927 latestUserMessageAt: threadRow.value.latestUserMessageAt,1949 latestUserMessageAt: threadRow.value.latestUserMessageAt,
1928 hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,1950 hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,
...@@ -2021,6 +2043,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2021,6 +2043,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2021 settledAt: threadRow.value.settledAt,2043 settledAt: threadRow.value.settledAt,
2022 snoozedUntil: threadRow.value.snoozedUntil,2044 snoozedUntil: threadRow.value.snoozedUntil,
2023 snoozedAt: threadRow.value.snoozedAt,2045 snoozedAt: threadRow.value.snoozedAt,
2046 titleRegeneration: mapTitleRegeneration(threadRow.value),
2024 deletedAt: null,2047 deletedAt: null,
2025 messages: messageRows.map((row) => {2048 messages: messageRows.map((row) => {
2026 const message = {2049 const message = {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+602-6
...@@ -146,6 +146,8 @@ describe("ProviderCommandReactor", () => {...@@ -146,6 +146,8 @@ describe("ProviderCommandReactor", () => {
146 readonly threadModelSelection?: ModelSelection;146 readonly threadModelSelection?: ModelSelection;
147 readonly sessionModelSwitch?: "unsupported" | "in-session";147 readonly sessionModelSwitch?: "unsupported" | "in-session";
148 readonly requiresNewThreadForModelChange?: boolean;148 readonly requiresNewThreadForModelChange?: boolean;
149 readonly titleRegenerationCompletionDispatchFailures?: number;
150 readonly titleRegenerationBeforeStart?: "one" | "two";
149 readonly startSessionEffect?: (151 readonly startSessionEffect?: (
150 session: ProviderSession,152 session: ProviderSession,
151 ) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>;153 ) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>;
...@@ -353,8 +355,34 @@ describe("ProviderCommandReactor", () => {...@@ -353,8 +355,34 @@ describe("ProviderCommandReactor", () => {
353 Layer.provide(RepositoryIdentityResolver.layer),355 Layer.provide(RepositoryIdentityResolver.layer),
354 Layer.provide(SqlitePersistenceMemory),356 Layer.provide(SqlitePersistenceMemory),
355 );357 );
358 let titleRegenerationCompletionDispatchAttempts = 0;
359 const reactorOrchestrationLayer = Layer.effect(
360 OrchestrationEngineService,
361 Effect.gen(function* () {
362 const engine = yield* OrchestrationEngineService;
363 return {
364 readEvents: engine.readEvents,
365 dispatch: (command) => {
366 if (command.type === "thread.title.regeneration.complete") {
367 titleRegenerationCompletionDispatchAttempts += 1;
368 if (
369 titleRegenerationCompletionDispatchAttempts <=
370 (input?.titleRegenerationCompletionDispatchFailures ?? 0)
371 ) {
372 return Effect.die(new Error("Injected title regeneration completion failure"));
373 }
374 }
375 return engine.dispatch(command);
376 },
377 get streamDomainEvents() {
378 return engine.streamDomainEvents;
379 },
380 latestSequence: engine.latestSequence,
381 } satisfies OrchestrationEngineService["Service"];
382 }),
383 ).pipe(Layer.provide(orchestrationLayer));
356 const layer = ProviderCommandReactorLive.pipe(384 const layer = ProviderCommandReactorLive.pipe(
357 Layer.provideMerge(orchestrationLayer),385 Layer.provideMerge(reactorOrchestrationLayer),
358 Layer.provideMerge(projectionSnapshotLayer),386 Layer.provideMerge(projectionSnapshotLayer),
359 Layer.provideMerge(Layer.succeed(ProviderService, service)),387 Layer.provideMerge(Layer.succeed(ProviderService, service)),
360 Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)),388 Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)),
...@@ -387,9 +415,7 @@ describe("ProviderCommandReactor", () => {...@@ -387,9 +415,7 @@ describe("ProviderCommandReactor", () => {
387 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));415 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
388 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));416 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
389 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));417 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
390 scope = await Effect.runPromise(Scope.make("sequential"));418 const runEffect = <A, E>(effect: Effect.Effect<A, E>) => runtime!.runPromise(effect);
391 await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));
392 const drain = () => Effect.runPromise(reactor.drain);
393419
394 await Effect.runPromise(420 await Effect.runPromise(
395 engine.dispatch({421 engine.dispatch({
...@@ -417,6 +443,45 @@ describe("ProviderCommandReactor", () => {...@@ -417,6 +443,45 @@ describe("ProviderCommandReactor", () => {
417 createdAt: now,443 createdAt: now,
418 }),444 }),
419 );445 );
446 if (input?.titleRegenerationBeforeStart === "two") {
447 await Effect.runPromise(
448 engine.dispatch({
449 type: "thread.create",
450 commandId: CommandId.make("cmd-thread-create-2"),
451 threadId: ThreadId.make("thread-2"),
452 projectId: asProjectId("project-1"),
453 title: "Thread 2",
454 modelSelection: modelSelection,
455 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
456 runtimeMode: "approval-required",
457 branch: null,
458 worktreePath: null,
459 createdAt: now,
460 }),
461 );
462 }
463 const titleRegenerationThreadIds =
464 input?.titleRegenerationBeforeStart === "two"
465 ? [ThreadId.make("thread-1"), ThreadId.make("thread-2")]
466 : input?.titleRegenerationBeforeStart === "one"
467 ? [ThreadId.make("thread-1")]
468 : [];
469 for (const [index, threadId] of titleRegenerationThreadIds.entries()) {
470 await Effect.runPromise(
471 engine.dispatch({
472 type: "thread.meta.update",
473 commandId: CommandId.make(
474 `cmd-thread-title-regeneration-before-reactor-start-${index + 1}`,
475 ),
476 threadId,
477 regenerateTitle: true,
478 }),
479 );
480 }
481
482 scope = await Effect.runPromise(Scope.make("sequential"));
483 await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));
484 const drain = () => Effect.runPromise(reactor.drain);
420485
421 return {486 return {
422 engine,487 engine,
...@@ -434,6 +499,10 @@ describe("ProviderCommandReactor", () => {...@@ -434,6 +499,10 @@ describe("ProviderCommandReactor", () => {
434 runtimeSessions,499 runtimeSessions,
435 stateDir,500 stateDir,
436 drain,501 drain,
502 runEffect,
503 get titleRegenerationCompletionDispatchAttempts() {
504 return titleRegenerationCompletionDispatchAttempts;
505 },
437 };506 };
438 }507 }
439508
...@@ -637,6 +706,533 @@ describe("ProviderCommandReactor", () => {...@@ -637,6 +706,533 @@ describe("ProviderCommandReactor", () => {
637 expect(thread?.title).toBe("Generated title");706 expect(thread?.title).toBe("Generated title");
638 });707 });
639708
709 it("regenerates a thread title from the current conversation", async () => {
710 const harness = await createHarness();
711 const now = "2026-01-01T00:00:00.000Z";
712 harness.generateThreadTitle.mockReturnValue(
713 Effect.succeed({ title: "Resolve stale reconnect state" }),
714 );
715
716 await harness.runEffect(
717 harness.engine.dispatch({
718 type: "thread.meta.update",
719 commandId: CommandId.make("cmd-thread-title-existing"),
720 threadId: ThreadId.make("thread-1"),
721 title: "Investigate reconnect regressions",
722 }),
723 );
724 await harness.runEffect(
725 harness.engine.dispatch({
726 type: "thread.turn.start",
727 commandId: CommandId.make("cmd-turn-start-before-title-regeneration"),
728 threadId: ThreadId.make("thread-1"),
729 message: {
730 messageId: asMessageId("user-message-before-title-regeneration"),
731 role: "user",
732 text: "Please investigate reconnect regressions after restarting the session.",
733 attachments: [],
734 },
735 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
736 runtimeMode: "approval-required",
737 createdAt: now,
738 }),
739 );
740 await harness.runEffect(
741 harness.engine.dispatch({
742 type: "thread.message.assistant.delta",
743 commandId: CommandId.make("cmd-assistant-before-title-regeneration"),
744 threadId: ThreadId.make("thread-1"),
745 messageId: asMessageId("assistant-message-before-title-regeneration"),
746 delta: "The remaining issue is stale reconnect state.",
747 createdAt: "2026-01-01T00:00:01.000Z",
748 }),
749 );
750 await harness.runEffect(
751 harness.engine.dispatch({
752 type: "thread.message.assistant.complete",
753 commandId: CommandId.make("cmd-assistant-complete-before-title-regeneration"),
754 threadId: ThreadId.make("thread-1"),
755 messageId: asMessageId("assistant-message-before-title-regeneration"),
756 createdAt: "2026-01-01T00:00:02.000Z",
757 }),
758 );
759 await harness.runEffect(
760 harness.engine.dispatch({
761 type: "thread.meta.update",
762 commandId: CommandId.make("cmd-thread-title-regenerate"),
763 threadId: ThreadId.make("thread-1"),
764 regenerateTitle: true,
765 }),
766 );
767
768 await harness.drain();
769
770 expect(harness.generateThreadTitle).toHaveBeenCalledTimes(1);
771 expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({
772 cwd: "/tmp/provider-project",
773 previousTitle: "Investigate reconnect regressions",
774 message: [
775 "USER:",
776 "Please investigate reconnect regressions after restarting the session.",
777 "",
778 "ASSISTANT:",
779 "The remaining issue is stale reconnect state.",
780 ].join("\n"),
781 });
782 const readModel = await harness.readModel();
783 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
784 expect(thread?.title).toBe("Resolve stale reconnect state");
785 expect(thread?.titleRegeneration).toBeNull();
786 });
787
788 it("clears title regeneration state left pending across reactor startup", async () => {
789 const harness = await createHarness({
790 titleRegenerationBeforeStart: "one",
791 });
792
793 expect(harness.generateThreadTitle).not.toHaveBeenCalled();
794 expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(1);
795 const readModel = await harness.readModel();
796 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
797 expect(thread?.title).toBe("Thread");
798 expect(thread?.titleRegeneration).toBeNull();
799 });
800
801 it("continues clearing startup title regeneration state after one completion fails", async () => {
802 const harness = await createHarness({
803 titleRegenerationBeforeStart: "two",
804 titleRegenerationCompletionDispatchFailures: 1,
805 });
806
807 expect(harness.generateThreadTitle).not.toHaveBeenCalled();
808 expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(2);
809 const readModel = await harness.readModel();
810 expect(
811 readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.titleRegeneration,
812 ).not.toBeNull();
813 expect(
814 readModel.threads.find((entry) => entry.id === ThreadId.make("thread-2"))?.titleRegeneration,
815 ).toBeNull();
816 });
817
818 it("keeps the current title when regeneration returns the fallback", async () => {
819 const harness = await createHarness();
820 const now = "2026-01-01T00:00:00.000Z";
821 harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "New thread" }));
822
823 await harness.runEffect(
824 harness.engine.dispatch({
825 type: "thread.meta.update",
826 commandId: CommandId.make("cmd-thread-title-before-fallback-regeneration"),
827 threadId: ThreadId.make("thread-1"),
828 title: "Keep meaningful title",
829 }),
830 );
831 await harness.runEffect(
832 harness.engine.dispatch({
833 type: "thread.turn.start",
834 commandId: CommandId.make("cmd-turn-start-before-fallback-regeneration"),
835 threadId: ThreadId.make("thread-1"),
836 message: {
837 messageId: asMessageId("user-message-before-fallback-regeneration"),
838 role: "user",
839 text: "Investigate the reconnect state.",
840 attachments: [],
841 },
842 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
843 runtimeMode: "approval-required",
844 createdAt: now,
845 }),
846 );
847 await harness.runEffect(
848 harness.engine.dispatch({
849 type: "thread.meta.update",
850 commandId: CommandId.make("cmd-thread-title-fallback-regeneration"),
851 threadId: ThreadId.make("thread-1"),
852 regenerateTitle: true,
853 }),
854 );
855
856 await harness.drain();
857
858 const readModel = await harness.readModel();
859 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
860 expect(thread?.title).toBe("Keep meaningful title");
861 expect(thread?.titleRegeneration).toBeNull();
862 });
863
864 it("clears title regeneration state when generation fails", async () => {
865 const harness = await createHarness();
866 const now = "2026-01-01T00:00:00.000Z";
867
868 await harness.runEffect(
869 harness.engine.dispatch({
870 type: "thread.meta.update",
871 commandId: CommandId.make("cmd-thread-title-before-failed-regeneration"),
872 threadId: ThreadId.make("thread-1"),
873 title: "Keep title after failure",
874 }),
875 );
876 await harness.runEffect(
877 harness.engine.dispatch({
878 type: "thread.turn.start",
879 commandId: CommandId.make("cmd-turn-start-before-failed-regeneration"),
880 threadId: ThreadId.make("thread-1"),
881 message: {
882 messageId: asMessageId("user-message-before-failed-regeneration"),
883 role: "user",
884 text: "Investigate the reconnect state.",
885 attachments: [],
886 },
887 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
888 runtimeMode: "approval-required",
889 createdAt: now,
890 }),
891 );
892 await harness.runEffect(
893 harness.engine.dispatch({
894 type: "thread.meta.update",
895 commandId: CommandId.make("cmd-thread-title-failed-regeneration"),
896 threadId: ThreadId.make("thread-1"),
897 regenerateTitle: true,
898 }),
899 );
900
901 await harness.drain();
902
903 const readModel = await harness.readModel();
904 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
905 expect(thread?.title).toBe("Keep title after failure");
906 expect(thread?.titleRegeneration).toBeNull();
907 });
908
909 it("retries a failed completion and continues regenerating", async () => {
910 const harness = await createHarness({
911 titleRegenerationCompletionDispatchFailures: 1,
912 });
913 const now = "2026-01-01T00:00:00.000Z";
914 harness.generateThreadTitle
915 .mockReturnValueOnce(Effect.succeed({ title: "Title lost to completion failure" }))
916 .mockReturnValueOnce(Effect.succeed({ title: "Recovered regeneration worker" }));
917
918 await harness.runEffect(
919 harness.engine.dispatch({
920 type: "thread.meta.update",
921 commandId: CommandId.make("cmd-thread-title-before-completion-failure"),
922 threadId: ThreadId.make("thread-1"),
923 title: "Existing title",
924 }),
925 );
926 await harness.runEffect(
927 harness.engine.dispatch({
928 type: "thread.turn.start",
929 commandId: CommandId.make("cmd-turn-start-before-completion-failure"),
930 threadId: ThreadId.make("thread-1"),
931 message: {
932 messageId: asMessageId("user-message-before-completion-failure"),
933 role: "user",
934 text: "Investigate the reconnect state.",
935 attachments: [],
936 },
937 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
938 runtimeMode: "approval-required",
939 createdAt: now,
940 }),
941 );
942 await harness.runEffect(
943 harness.engine.dispatch({
944 type: "thread.meta.update",
945 commandId: CommandId.make("cmd-thread-title-regeneration-completion-failure"),
946 threadId: ThreadId.make("thread-1"),
947 regenerateTitle: true,
948 }),
949 );
950 await harness.drain();
951
952 let readModel = await harness.readModel();
953 let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
954 expect(thread?.title).toBe("Title lost to completion failure");
955 expect(thread?.titleRegeneration).toBeNull();
956
957 await harness.runEffect(
958 harness.engine.dispatch({
959 type: "thread.meta.update",
960 commandId: CommandId.make("cmd-thread-title-regeneration-after-completion-failure"),
961 threadId: ThreadId.make("thread-1"),
962 regenerateTitle: true,
963 }),
964 );
965 await harness.drain();
966
967 expect(harness.generateThreadTitle).toHaveBeenCalledTimes(2);
968 expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(3);
969 readModel = await harness.readModel();
970 thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
971 expect(thread?.title).toBe("Recovered regeneration worker");
972 expect(thread?.titleRegeneration).toBeNull();
973 });
974
975 it("keeps the full retained context and excludes attachments outside it", async () => {
976 const harness = await createHarness();
977 const now = "2026-01-01T00:00:00.000Z";
978 const retainedContext = "x".repeat(8_000);
979
980 await harness.runEffect(
981 harness.engine.dispatch({
982 type: "thread.meta.update",
983 commandId: CommandId.make("cmd-thread-title-before-truncated-regeneration"),
984 threadId: ThreadId.make("thread-1"),
985 title: "Existing title",
986 }),
987 );
988 await harness.runEffect(
989 harness.engine.dispatch({
990 type: "thread.turn.start",
991 commandId: CommandId.make("cmd-turn-start-before-truncated-regeneration"),
992 threadId: ThreadId.make("thread-1"),
993 message: {
994 messageId: asMessageId("user-message-before-truncated-regeneration"),
995 role: "user",
996 text: "Old visual issue",
997 attachments: [
998 {
999 type: "image",
1000 id: "old-title-context-image",
1001 name: "old-issue.png",
1002 mimeType: "image/png",
1003 sizeBytes: 5,
1004 },
1005 ],
1006 },
1007 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1008 runtimeMode: "approval-required",
1009 createdAt: now,
1010 }),
1011 );
1012 await harness.runEffect(
1013 harness.engine.dispatch({
1014 type: "thread.message.assistant.delta",
1015 commandId: CommandId.make("cmd-assistant-truncated-regeneration-context"),
1016 threadId: ThreadId.make("thread-1"),
1017 messageId: asMessageId("assistant-truncated-regeneration-context"),
1018 delta: `content before retained tail${"x".repeat(8_100)}`,
1019 createdAt: "2026-01-01T00:00:01.000Z",
1020 }),
1021 );
1022 await harness.runEffect(
1023 harness.engine.dispatch({
1024 type: "thread.message.assistant.complete",
1025 commandId: CommandId.make("cmd-assistant-truncated-regeneration-context-complete"),
1026 threadId: ThreadId.make("thread-1"),
1027 messageId: asMessageId("assistant-truncated-regeneration-context"),
1028 createdAt: "2026-01-01T00:00:02.000Z",
1029 }),
1030 );
1031 await harness.runEffect(
1032 harness.engine.dispatch({
1033 type: "thread.meta.update",
1034 commandId: CommandId.make("cmd-thread-title-regenerate-truncated-context"),
1035 threadId: ThreadId.make("thread-1"),
1036 regenerateTitle: true,
1037 }),
1038 );
1039
1040 await harness.drain();
1041
1042 expect(harness.generateThreadTitle.mock.calls[0]?.[0].message).toBe(
1043 `[Earlier content truncated]\n\n${retainedContext}`,
1044 );
1045 expect(harness.generateThreadTitle.mock.calls[0]?.[0].attachments).toBeUndefined();
1046 });
1047
1048 it("does not overwrite a manual rename while title regeneration is running", async () => {
1049 const harness = await createHarness();
1050 const now = "2026-01-01T00:00:00.000Z";
1051 const generatedTitle = await harness.runEffect(
1052 Deferred.make<{ readonly title: string }, never>(),
1053 );
1054 harness.generateThreadTitle.mockReturnValue(Deferred.await(generatedTitle));
1055
1056 await harness.runEffect(
1057 harness.engine.dispatch({
1058 type: "thread.meta.update",
1059 commandId: CommandId.make("cmd-thread-title-before-regeneration-race"),
1060 threadId: ThreadId.make("thread-1"),
1061 title: "Existing thread title",
1062 }),
1063 );
1064 await harness.runEffect(
1065 harness.engine.dispatch({
1066 type: "thread.turn.start",
1067 commandId: CommandId.make("cmd-turn-start-before-regeneration-race"),
1068 threadId: ThreadId.make("thread-1"),
1069 message: {
1070 messageId: asMessageId("user-message-before-regeneration-race"),
1071 role: "user",
1072 text: "Investigate the reconnect state.",
1073 attachments: [],
1074 },
1075 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1076 runtimeMode: "approval-required",
1077 createdAt: now,
1078 }),
1079 );
1080 await harness.runEffect(
1081 harness.engine.dispatch({
1082 type: "thread.meta.update",
1083 commandId: CommandId.make("cmd-thread-title-regeneration-race"),
1084 threadId: ThreadId.make("thread-1"),
1085 regenerateTitle: true,
1086 }),
1087 );
1088 await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1);
1089 const pendingReadModel = await harness.readModel();
1090 expect(
1091 pendingReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))
1092 ?.titleRegeneration?.requestId,
1093 ).toBe(CommandId.make("cmd-thread-title-regeneration-race"));
1094
1095 await harness.runEffect(
1096 harness.engine.dispatch({
1097 type: "thread.meta.update",
1098 commandId: CommandId.make("cmd-thread-manual-rename-during-regeneration"),
1099 threadId: ThreadId.make("thread-1"),
1100 title: "Keep manual rename",
1101 }),
1102 );
1103 await harness.runEffect(
1104 Deferred.succeed(generatedTitle, { title: "Generated title should not win" }),
1105 );
1106 await harness.drain();
1107
1108 const readModel = await harness.readModel();
1109 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1110 expect(thread?.title).toBe("Keep manual rename");
1111 expect(thread?.titleRegeneration).toBeNull();
1112 });
1113
1114 it("does not overwrite a manual rename while title regeneration is queued", async () => {
1115 let releaseStart = () => {};
1116 const startGate = new Promise<void>((resolve) => {
1117 releaseStart = resolve;
1118 });
1119 const harness = await createHarness({
1120 startSessionEffect: (session) => Effect.promise(() => startGate).pipe(Effect.as(session)),
1121 });
1122 const now = "2026-01-01T00:00:00.000Z";
1123 harness.generateThreadTitle.mockReturnValue(
1124 Effect.succeed({ title: "Generated title should not win" }),
1125 );
1126
1127 await harness.runEffect(
1128 harness.engine.dispatch({
1129 type: "thread.meta.update",
1130 commandId: CommandId.make("cmd-thread-title-before-queued-regeneration"),
1131 threadId: ThreadId.make("thread-1"),
1132 title: "Existing thread title",
1133 }),
1134 );
1135 await harness.runEffect(
1136 harness.engine.dispatch({
1137 type: "thread.turn.start",
1138 commandId: CommandId.make("cmd-turn-start-before-queued-regeneration"),
1139 threadId: ThreadId.make("thread-1"),
1140 message: {
1141 messageId: asMessageId("user-message-before-queued-regeneration"),
1142 role: "user",
1143 text: "Investigate the reconnect state.",
1144 attachments: [],
1145 },
1146 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1147 runtimeMode: "approval-required",
1148 createdAt: now,
1149 }),
1150 );
1151 await waitFor(() => harness.startSession.mock.calls.length === 1);
1152
1153 await harness.runEffect(
1154 harness.engine.dispatch({
1155 type: "thread.meta.update",
1156 commandId: CommandId.make("cmd-thread-title-queued-regeneration"),
1157 threadId: ThreadId.make("thread-1"),
1158 regenerateTitle: true,
1159 }),
1160 );
1161 await harness.runEffect(
1162 harness.engine.dispatch({
1163 type: "thread.meta.update",
1164 commandId: CommandId.make("cmd-thread-manual-rename-before-regeneration-starts"),
1165 threadId: ThreadId.make("thread-1"),
1166 title: "Keep queued manual rename",
1167 }),
1168 );
1169 releaseStart();
1170 await harness.drain();
1171
1172 expect(harness.generateThreadTitle).not.toHaveBeenCalled();
1173 const readModel = await harness.readModel();
1174 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1175 expect(thread?.title).toBe("Keep queued manual rename");
1176 });
1177
1178 it("skips superseded title regeneration before generation starts", async () => {
1179 let releaseStart = () => {};
1180 const startGate = new Promise<void>((resolve) => {
1181 releaseStart = resolve;
1182 });
1183 const harness = await createHarness({
1184 startSessionEffect: (session) => Effect.promise(() => startGate).pipe(Effect.as(session)),
1185 });
1186 const now = "2026-01-01T00:00:00.000Z";
1187 harness.generateThreadTitle.mockReturnValue(
1188 Effect.succeed({ title: "Latest regenerated title" }),
1189 );
1190
1191 await harness.runEffect(
1192 harness.engine.dispatch({
1193 type: "thread.turn.start",
1194 commandId: CommandId.make("cmd-turn-start-before-superseded-regeneration"),
1195 threadId: ThreadId.make("thread-1"),
1196 message: {
1197 messageId: asMessageId("user-message-before-superseded-regeneration"),
1198 role: "user",
1199 text: "Investigate the reconnect state.",
1200 attachments: [],
1201 },
1202 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1203 runtimeMode: "approval-required",
1204 createdAt: now,
1205 }),
1206 );
1207 await waitFor(() => harness.startSession.mock.calls.length === 1);
1208
1209 await harness.runEffect(
1210 harness.engine.dispatch({
1211 type: "thread.meta.update",
1212 commandId: CommandId.make("cmd-thread-title-superseded-regeneration"),
1213 threadId: ThreadId.make("thread-1"),
1214 regenerateTitle: true,
1215 }),
1216 );
1217 await harness.runEffect(
1218 harness.engine.dispatch({
1219 type: "thread.meta.update",
1220 commandId: CommandId.make("cmd-thread-title-latest-regeneration"),
1221 threadId: ThreadId.make("thread-1"),
1222 regenerateTitle: true,
1223 }),
1224 );
1225 releaseStart();
1226 await harness.drain();
1227
1228 expect(harness.generateThreadTitle).toHaveBeenCalledTimes(1);
1229 expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(1);
1230 const readModel = await harness.readModel();
1231 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1232 expect(thread?.title).toBe("Latest regenerated title");
1233 expect(thread?.titleRegeneration).toBeNull();
1234 });
1235
640 it("does not overwrite an existing custom thread title on the first turn", async () => {1236 it("does not overwrite an existing custom thread title on the first turn", async () => {
641 const harness = await createHarness();1237 const harness = await createHarness();
642 const now = "2026-01-01T00:00:00.000Z";1238 const now = "2026-01-01T00:00:00.000Z";
...@@ -2178,7 +2774,7 @@ describe("ProviderCommandReactor", () => {...@@ -2178,7 +2774,7 @@ describe("ProviderCommandReactor", () => {
2178 const harness = await createHarness();2774 const harness = await createHarness();
2179 const now = "2026-01-01T00:00:00.000Z";2775 const now = "2026-01-01T00:00:00.000Z";
21802776
2181 await Effect.runPromise(2777 await harness.runEffect(
2182 harness.engine.dispatch({2778 harness.engine.dispatch({
2183 type: "thread.session.set",2779 type: "thread.session.set",
2184 commandId: CommandId.make("cmd-session-set-for-stop"),2780 commandId: CommandId.make("cmd-session-set-for-stop"),
...@@ -2197,7 +2793,7 @@ describe("ProviderCommandReactor", () => {...@@ -2197,7 +2793,7 @@ describe("ProviderCommandReactor", () => {
2197 }),2793 }),
2198 );2794 );
21992795
2200 await Effect.runPromise(2796 await harness.runEffect(
2201 harness.engine.dispatch({2797 harness.engine.dispatch({
2202 type: "thread.session.stop",2798 type: "thread.session.stop",
2203 commandId: CommandId.make("cmd-session-stop"),2799 commandId: CommandId.make("cmd-session-stop"),
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+244-1
...@@ -51,6 +51,7 @@ type ProviderIntentEvent = Extract<...@@ -51,6 +51,7 @@ type ProviderIntentEvent = Extract<
51 OrchestrationEvent,51 OrchestrationEvent,
52 {52 {
53 type:53 type:
54 | "thread.meta-updated"
54 | "thread.runtime-mode-set"55 | "thread.runtime-mode-set"
55 | "thread.turn-start-requested"56 | "thread.turn-start-requested"
56 | "thread.turn-interrupt-requested"57 | "thread.turn-interrupt-requested"
...@@ -90,6 +91,60 @@ const HANDLED_TURN_START_KEY_MAX = 10_000;...@@ -90,6 +91,60 @@ const HANDLED_TURN_START_KEY_MAX = 10_000;
90const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);91const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
91const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";92const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
92const DEFAULT_THREAD_TITLE = "New thread";93const DEFAULT_THREAD_TITLE = "New thread";
94const MAX_REGENERATION_ATTACHMENTS = 4;
95const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000;
96const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n";
97
98function formatThreadTitleContext(
99 messages: ReadonlyArray<{
100 readonly role: "user" | "assistant" | "system";
101 readonly text: string;
102 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
103 }>,
104): {
105 readonly message: string;
106 readonly attachments: ReadonlyArray<ChatAttachment>;
107} {
108 let context = "";
109 let truncated = false;
110 const retainedAttachments: Array<ChatAttachment> = [];
111
112 for (const message of messages.toReversed()) {
113 if (message.role === "system") {
114 continue;
115 }
116 const text = message.text.trim();
117 const attachmentSummary = (message.attachments ?? [])
118 .map((attachment) => attachment.name)
119 .join(", ");
120 const contents = [
121 ...(text.length > 0 ? [text] : []),
122 ...(attachmentSummary.length > 0 ? [`[Attachments: ${attachmentSummary}]`] : []),
123 ].join("\n");
124 if (contents.length === 0) {
125 continue;
126 }
127
128 const section = `${message.role.toUpperCase()}:\n${contents}`;
129 const separator = context.length > 0 ? "\n\n" : "";
130 const available = MAX_THREAD_TITLE_CONTEXT_CHARS - context.length - separator.length;
131 if (section.length > available) {
132 if (available > 0) {
133 context = `${section.slice(-available)}${separator}${context}`;
134 retainedAttachments.unshift(...(message.attachments ?? []));
135 }
136 truncated = true;
137 break;
138 }
139 context = `${section}${separator}${context}`;
140 retainedAttachments.unshift(...(message.attachments ?? []));
141 }
142
143 return {
144 message: truncated ? `${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${context}` : context,
145 attachments: retainedAttachments.slice(-MAX_REGENERATION_ATTACHMENTS),
146 };
147}
93148
94export function providerErrorLabel(value: string | undefined): string {149export function providerErrorLabel(value: string | undefined): string {
95 const normalized = value?.trim();150 const normalized = value?.trim();
...@@ -777,6 +832,170 @@ const make = Effect.gen(function* () {...@@ -777,6 +832,170 @@ const make = Effect.gen(function* () {
777 },832 },
778 );833 );
779834
835 const regenerateThreadTitle = Effect.fn("regenerateThreadTitle")(function* (
836 event: Extract<ProviderIntentEvent, { type: "thread.meta-updated" }>,
837 requestId: CommandId,
838 ) {
839 if (event.payload.regenerateTitle !== true) {
840 return { _tag: "Superseded" } as const;
841 }
842
843 const thread = yield* resolveThread(event.payload.threadId);
844 if (!thread || thread.titleRegeneration?.requestId !== requestId) {
845 return { _tag: "Superseded" } as const;
846 }
847
848 const { message, attachments } = formatThreadTitleContext(thread.messages);
849 if (message.length === 0) {
850 return { _tag: "Completed", title: undefined } as const;
851 }
852
853 const previousTitle = event.payload.previousTitle ?? thread.title;
854 if (thread.title !== previousTitle) {
855 return { _tag: "Superseded" } as const;
856 }
857 const project = yield* resolveProject(thread.projectId);
858 const cwd =
859 resolveThreadWorkspaceCwd({
860 thread,
861 projects: project ? [project] : [],
862 }) ?? process.cwd();
863 const { textGenerationModelSelection: modelSelection } =
864 yield* serverSettingsService.getSettings;
865 const generated = yield* textGeneration.generateThreadTitle({
866 cwd,
867 message,
868 previousTitle,
869 ...(attachments.length > 0 ? { attachments } : {}),
870 modelSelection,
871 });
872 if (generated.title === DEFAULT_THREAD_TITLE || generated.title === previousTitle) {
873 return { _tag: "Completed", title: undefined } as const;
874 }
875
876 const latestThread = yield* resolveThread(event.payload.threadId);
877 if (
878 !latestThread ||
879 latestThread.titleRegeneration?.requestId !== requestId ||
880 latestThread.title !== previousTitle
881 ) {
882 return { _tag: "Superseded" } as const;
883 }
884
885 return { _tag: "Completed", title: generated.title } as const;
886 });
887 const dispatchThreadTitleRegenerationCompletion = Effect.fn(
888 "dispatchThreadTitleRegenerationCompletion",
889 )(function* (input: {
890 readonly threadId: ThreadId;
891 readonly requestId: CommandId;
892 readonly title?: string;
893 }) {
894 yield* orchestrationEngine.dispatch({
895 type: "thread.title.regeneration.complete",
896 commandId: yield* serverCommandId("thread-title-regeneration-complete"),
897 threadId: input.threadId,
898 requestId: input.requestId,
899 ...(input.title !== undefined ? { title: input.title } : {}),
900 });
901 });
902 const clearInterruptedThreadTitleRegenerations = Effect.fn(
903 "clearInterruptedThreadTitleRegenerations",
904 )(function* () {
905 const readModel = yield* projectionSnapshotQuery.getCommandReadModel();
906 yield* Effect.forEach(
907 readModel.threads,
908 (thread) => {
909 const requestId = thread.titleRegeneration?.requestId;
910 if (requestId === undefined) {
911 return Effect.void;
912 }
913 return dispatchThreadTitleRegenerationCompletion({
914 threadId: thread.id,
915 requestId,
916 }).pipe(
917 Effect.catchCause((cause) => {
918 if (Cause.hasInterruptsOnly(cause)) {
919 return Effect.interrupt;
920 }
921 return Effect.logWarning(
922 "provider command reactor failed to clear interrupted title regeneration",
923 {
924 threadId: thread.id,
925 cause: Cause.pretty(cause),
926 },
927 );
928 }),
929 );
930 },
931 { discard: true },
932 );
933 });
934 const processThreadTitleRegenerationSafely = Effect.fn("processThreadTitleRegenerationSafely")(
935 function* (event: Extract<ProviderIntentEvent, { type: "thread.meta-updated" }>) {
936 if (event.payload.regenerateTitle !== true) {
937 return;
938 }
939
940 const requestId = event.payload.titleRegeneration?.requestId ?? event.commandId;
941 if (requestId === null) {
942 return;
943 }
944 const result = yield* regenerateThreadTitle(event, requestId).pipe(
945 Effect.catchCause((cause) => {
946 if (Cause.hasInterruptsOnly(cause)) {
947 return Effect.failCause(cause);
948 }
949 return Effect.logWarning("provider command reactor failed to regenerate thread title", {
950 threadId: event.payload.threadId,
951 cause: Cause.pretty(cause),
952 }).pipe(Effect.as({ _tag: "Completed", title: undefined } as const));
953 }),
954 );
955 if (result._tag === "Superseded") {
956 return;
957 }
958
959 const completion = {
960 threadId: event.payload.threadId,
961 requestId,
962 ...(result.title !== undefined ? { title: result.title } : {}),
963 };
964 yield* dispatchThreadTitleRegenerationCompletion(completion).pipe(
965 Effect.catchCause((cause) => {
966 if (Cause.hasInterruptsOnly(cause)) {
967 return Effect.failCause(cause);
968 }
969 return Effect.logWarning(
970 "provider command reactor retrying title regeneration completion",
971 {
972 threadId: event.payload.threadId,
973 cause: Cause.pretty(cause),
974 },
975 ).pipe(Effect.andThen(dispatchThreadTitleRegenerationCompletion(completion)));
976 }),
977 );
978 },
979 (effect, event) =>
980 effect.pipe(
981 Effect.catchCause((cause) => {
982 if (Cause.hasInterruptsOnly(cause)) {
983 return Effect.failCause(cause);
984 }
985 return Effect.logWarning(
986 "provider command reactor failed to complete title regeneration",
987 {
988 threadId: event.payload.threadId,
989 cause: Cause.pretty(cause),
990 },
991 );
992 }),
993 ),
994 );
995 const threadTitleRegenerationWorker = yield* makeDrainableWorker(
996 processThreadTitleRegenerationSafely,
997 );
998
780 const processTurnStartRequested = Effect.fn("processTurnStartRequested")(function* (999 const processTurnStartRequested = Effect.fn("processTurnStartRequested")(function* (
781 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,1000 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,
782 ) {1001 ) {
...@@ -1047,6 +1266,9 @@ const make = Effect.gen(function* () {...@@ -1047,6 +1266,9 @@ const make = Effect.gen(function* () {
1047 eventType: event.type,1266 eventType: event.type,
1048 });1267 });
1049 switch (event.type) {1268 switch (event.type) {
1269 case "thread.meta-updated":
1270 yield* threadTitleRegenerationWorker.enqueue(event);
1271 return;
1050 case "thread.runtime-mode-set": {1272 case "thread.runtime-mode-set": {
1051 const thread = yield* resolveThread(event.payload.threadId);1273 const thread = yield* resolveThread(event.payload.threadId);
1052 if (!thread?.session || thread.session.status === "stopped") {1274 if (!thread?.session || thread.session.status === "stopped") {
...@@ -1096,6 +1318,7 @@ const make = Effect.gen(function* () {...@@ -1096,6 +1318,7 @@ const make = Effect.gen(function* () {
1096 const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () {1318 const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () {
1097 const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) {1319 const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) {
1098 if (1320 if (
1321 (event.type === "thread.meta-updated" && event.payload.regenerateTitle === true) ||
1099 event.type === "thread.runtime-mode-set" ||1322 event.type === "thread.runtime-mode-set" ||
1100 event.type === "thread.turn-start-requested" ||1323 event.type === "thread.turn-start-requested" ||
1101 event.type === "thread.turn-interrupt-requested" ||1324 event.type === "thread.turn-interrupt-requested" ||
...@@ -1110,11 +1333,31 @@ const make = Effect.gen(function* () {...@@ -1110,11 +1333,31 @@ const make = Effect.gen(function* () {
1110 yield* Effect.forkScoped(1333 yield* Effect.forkScoped(
1111 Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent),1334 Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent),
1112 );1335 );
1336
1337 // The domain event stream is hot, so work pending before this reactor
1338 // starts cannot be resumed. Correlated completions only clear the request
1339 // captured here, leaving any newer request untouched.
1340 yield* clearInterruptedThreadTitleRegenerations().pipe(
1341 Effect.catchCause((cause) => {
1342 if (Cause.hasInterruptsOnly(cause)) {
1343 return Effect.interrupt;
1344 }
1345 return Effect.logWarning(
1346 "provider command reactor failed to clear interrupted title regenerations",
1347 {
1348 cause: Cause.pretty(cause),
1349 },
1350 );
1351 }),
1352 );
1113 });1353 });
11141354
1115 return {1355 return {
1116 start,1356 start,
1117 drain: worker.drain,1357 drain: Effect.gen(function* () {
1358 yield* worker.drain;
1359 yield* threadTitleRegenerationWorker.drain;
1360 }),
1118 } satisfies ProviderCommandReactorShape;1361 } satisfies ProviderCommandReactorShape;
1119});1362});
11201363
apps/server/src/orchestration/decider.titleRegeneration.test.ts created+72
...@@ -0,0 +1,72 @@
1import {
2 CommandId,
3 ProjectId,
4 ProviderInstanceId,
5 ThreadId,
6 type OrchestrationReadModel,
7} from "@t3tools/contracts";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { expect, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";
11
12import { decideOrchestrationCommand } from "./decider.ts";
13
14const UPDATED_AT = "2026-01-01T00:00:00.000Z";
15
16const readModel: OrchestrationReadModel = {
17 snapshotSequence: 0,
18 projects: [],
19 threads: [
20 {
21 id: ThreadId.make("thread-1"),
22 projectId: ProjectId.make("project-1"),
23 title: "Manual title",
24 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
25 runtimeMode: "full-access",
26 interactionMode: "default",
27 branch: null,
28 worktreePath: null,
29 latestTurn: null,
30 createdAt: UPDATED_AT,
31 updatedAt: UPDATED_AT,
32 archivedAt: null,
33 settledOverride: null,
34 settledAt: null,
35 snoozedUntil: null,
36 snoozedAt: null,
37 deletedAt: null,
38 messages: [],
39 proposedPlans: [],
40 activities: [],
41 checkpoints: [],
42 session: null,
43 },
44 ],
45 updatedAt: UPDATED_AT,
46};
47
48it.layer(NodeServices.layer)("title regeneration decider", (it) => {
49 it.effect("preserves updatedAt for a stale completion", () =>
50 Effect.gen(function* () {
51 const result = yield* decideOrchestrationCommand({
52 command: {
53 type: "thread.title.regeneration.complete",
54 commandId: CommandId.make("cmd-regeneration-complete"),
55 threadId: ThreadId.make("thread-1"),
56 requestId: CommandId.make("cmd-old-regeneration-request"),
57 title: "Generated title",
58 },
59 readModel,
60 });
61 const event = Array.isArray(result) ? result[0] : result;
62
63 expect(event.type).toBe("thread.meta-updated");
64 if (event.type === "thread.meta-updated") {
65 expect(event.payload).toEqual({
66 threadId: ThreadId.make("thread-1"),
67 updatedAt: UPDATED_AT,
68 });
69 }
70 }),
71 );
72});
apps/server/src/orchestration/decider.ts+38
...@@ -654,6 +654,19 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -654,6 +654,19 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
654 payload: {654 payload: {
655 threadId: command.threadId,655 threadId: command.threadId,
656 ...(command.title !== undefined ? { title: command.title } : {}),656 ...(command.title !== undefined ? { title: command.title } : {}),
657 ...(command.regenerateTitle === true
658 ? {
659 regenerateTitle: true as const,
660 previousTitle: thread.title,
661 titleRegeneration: {
662 requestId: command.commandId,
663 startedAt: occurredAt,
664 },
665 }
666 : {}),
667 ...(command.title !== undefined && thread.titleRegeneration != null
668 ? { titleRegeneration: null }
669 : {}),
657 ...(command.modelSelection !== undefined670 ...(command.modelSelection !== undefined
658 ? { modelSelection: command.modelSelection }671 ? { modelSelection: command.modelSelection }
659 : {}),672 : {}),
...@@ -664,6 +677,31 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -664,6 +677,31 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
664 };677 };
665 }678 }
666679
680 case "thread.title.regeneration.complete": {
681 const thread = yield* requireThread({
682 readModel,
683 command,
684 threadId: command.threadId,
685 });
686 const requestIsCurrent = thread.titleRegeneration?.requestId === command.requestId;
687 const occurredAt = yield* nowIso;
688 return {
689 ...(yield* withEventBase({
690 aggregateKind: "thread",
691 aggregateId: command.threadId,
692 occurredAt,
693 commandId: command.commandId,
694 })),
695 type: "thread.meta-updated",
696 payload: {
697 threadId: command.threadId,
698 ...(requestIsCurrent && command.title !== undefined ? { title: command.title } : {}),
699 ...(requestIsCurrent ? { titleRegeneration: null } : {}),
700 updatedAt: requestIsCurrent ? occurredAt : thread.updatedAt,
701 },
702 };
703 }
704
667 case "thread.runtime-mode.set": {705 case "thread.runtime-mode.set": {
668 yield* requireThread({706 yield* requireThread({
669 readModel,707 readModel,
apps/server/src/orchestration/projector.ts+4
...@@ -329,6 +329,7 @@ export function projectEvent(...@@ -329,6 +329,7 @@ export function projectEvent(
329 ...nextBase,329 ...nextBase,
330 threads: updateThread(nextBase.threads, payload.threadId, {330 threads: updateThread(nextBase.threads, payload.threadId, {
331 archivedAt: payload.archivedAt,331 archivedAt: payload.archivedAt,
332 titleRegeneration: null,
332 updatedAt: payload.updatedAt,333 updatedAt: payload.updatedAt,
333 }),334 }),
334 })),335 })),
...@@ -399,6 +400,9 @@ export function projectEvent(...@@ -399,6 +400,9 @@ export function projectEvent(
399 ...nextBase,400 ...nextBase,
400 threads: updateThread(nextBase.threads, payload.threadId, {401 threads: updateThread(nextBase.threads, payload.threadId, {
401 ...(payload.title !== undefined ? { title: payload.title } : {}),402 ...(payload.title !== undefined ? { title: payload.title } : {}),
403 ...(payload.titleRegeneration !== undefined
404 ? { titleRegeneration: payload.titleRegeneration }
405 : {}),
402 ...(payload.modelSelection !== undefined406 ...(payload.modelSelection !== undefined
403 ? { modelSelection: payload.modelSelection }407 ? { modelSelection: payload.modelSelection }
404 : {}),408 : {}),
apps/server/src/persistence/Layers/ProjectionThreads.ts+10
...@@ -47,6 +47,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -47,6 +47,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
47 settled_at,47 settled_at,
48 snoozed_until,48 snoozed_until,
49 snoozed_at,49 snoozed_at,
50 title_regeneration_request_id,
51 title_regeneration_started_at,
50 latest_user_message_at,52 latest_user_message_at,
51 pending_approval_count,53 pending_approval_count,
52 pending_user_input_count,54 pending_user_input_count,
...@@ -70,6 +72,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -70,6 +72,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
70 ${row.settledAt},72 ${row.settledAt},
71 ${row.snoozedUntil},73 ${row.snoozedUntil},
72 ${row.snoozedAt},74 ${row.snoozedAt},
75 ${row.titleRegenerationRequestId ?? null},
76 ${row.titleRegenerationStartedAt ?? null},
73 ${row.latestUserMessageAt},77 ${row.latestUserMessageAt},
74 ${row.pendingApprovalCount},78 ${row.pendingApprovalCount},
75 ${row.pendingUserInputCount},79 ${row.pendingUserInputCount},
...@@ -93,6 +97,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -93,6 +97,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
93 settled_at = excluded.settled_at,97 settled_at = excluded.settled_at,
94 snoozed_until = excluded.snoozed_until,98 snoozed_until = excluded.snoozed_until,
95 snoozed_at = excluded.snoozed_at,99 snoozed_at = excluded.snoozed_at,
100 title_regeneration_request_id = excluded.title_regeneration_request_id,
101 title_regeneration_started_at = excluded.title_regeneration_started_at,
96 latest_user_message_at = excluded.latest_user_message_at,102 latest_user_message_at = excluded.latest_user_message_at,
97 pending_approval_count = excluded.pending_approval_count,103 pending_approval_count = excluded.pending_approval_count,
98 pending_user_input_count = excluded.pending_user_input_count,104 pending_user_input_count = excluded.pending_user_input_count,
...@@ -123,6 +129,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -123,6 +129,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
123 settled_at AS "settledAt",129 settled_at AS "settledAt",
124 snoozed_until AS "snoozedUntil",130 snoozed_until AS "snoozedUntil",
125 snoozed_at AS "snoozedAt",131 snoozed_at AS "snoozedAt",
132 title_regeneration_request_id AS "titleRegenerationRequestId",
133 title_regeneration_started_at AS "titleRegenerationStartedAt",
126 latest_user_message_at AS "latestUserMessageAt",134 latest_user_message_at AS "latestUserMessageAt",
127 pending_approval_count AS "pendingApprovalCount",135 pending_approval_count AS "pendingApprovalCount",
128 pending_user_input_count AS "pendingUserInputCount",136 pending_user_input_count AS "pendingUserInputCount",
...@@ -155,6 +163,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -155,6 +163,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
155 settled_at AS "settledAt",163 settled_at AS "settledAt",
156 snoozed_until AS "snoozedUntil",164 snoozed_until AS "snoozedUntil",
157 snoozed_at AS "snoozedAt",165 snoozed_at AS "snoozedAt",
166 title_regeneration_request_id AS "titleRegenerationRequestId",
167 title_regeneration_started_at AS "titleRegenerationStartedAt",
158 latest_user_message_at AS "latestUserMessageAt",168 latest_user_message_at AS "latestUserMessageAt",
159 pending_approval_count AS "pendingApprovalCount",169 pending_approval_count AS "pendingApprovalCount",
160 pending_user_input_count AS "pendingUserInputCount",170 pending_user_input_count AS "pendingUserInputCount",
apps/server/src/persistence/Migrations.ts+2
...@@ -47,6 +47,7 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";...@@ -47,6 +47,7 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";
47import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";47import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
48import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";48import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";
49import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";49import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";
50import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts";
5051
51/**52/**
52 * Migration loader with all migrations defined inline.53 * Migration loader with all migrations defined inline.
...@@ -93,6 +94,7 @@ export const migrationEntries = [...@@ -93,6 +94,7 @@ export const migrationEntries = [
93 [32, "AuthPairingProofKeyThumbprint", Migration0032],94 [32, "AuthPairingProofKeyThumbprint", Migration0032],
94 [33, "ProjectionThreadsSettled", Migration0033],95 [33, "ProjectionThreadsSettled", Migration0033],
95 [34, "ProjectionThreadsSnoozed", Migration0034],96 [34, "ProjectionThreadsSnoozed", Migration0034],
97 [35, "ProjectionThreadTitleRegeneration", Migration0035],
96] as const;98] as const;
9799
98export const makeMigrationLoader = (throughId?: number) =>100export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts created+27
...@@ -0,0 +1,27 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10
11layer("035_ProjectionThreadTitleRegeneration", (it) => {
12 it.effect("adds pending title regeneration columns", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15
16 yield* runMigrations({ toMigrationInclusive: 34 });
17 yield* runMigrations({ toMigrationInclusive: 35 });
18
19 const columns = yield* sql<{ readonly name: string }>`
20 PRAGMA table_info(projection_threads)
21 `;
22 const names = new Set(columns.map((column) => column.name));
23 assert.ok(names.has("title_regeneration_request_id"));
24 assert.ok(names.has("title_regeneration_started_at"));
25 }),
26 );
27});
apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.ts created+23
...@@ -0,0 +1,23 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_threads)
8 `;
9
10 if (!columns.some((column) => column.name === "title_regeneration_request_id")) {
11 yield* sql`
12 ALTER TABLE projection_threads
13 ADD COLUMN title_regeneration_request_id TEXT
14 `;
15 }
16
17 if (!columns.some((column) => column.name === "title_regeneration_started_at")) {
18 yield* sql`
19 ALTER TABLE projection_threads
20 ADD COLUMN title_regeneration_started_at TEXT
21 `;
22 }
23});
apps/server/src/persistence/Services/ProjectionThreads.ts+3
...@@ -7,6 +7,7 @@...@@ -7,6 +7,7 @@
7 * @module ProjectionThreadRepository7 * @module ProjectionThreadRepository
8 */8 */
9import {9import {
10 CommandId,
10 IsoDateTime,11 IsoDateTime,
11 ModelSelection,12 ModelSelection,
12 NonNegativeInt,13 NonNegativeInt,
...@@ -40,6 +41,8 @@ export const ProjectionThread = Schema.Struct({...@@ -40,6 +41,8 @@ export const ProjectionThread = Schema.Struct({
40 settledAt: Schema.NullOr(IsoDateTime),41 settledAt: Schema.NullOr(IsoDateTime),
41 snoozedUntil: Schema.NullOr(IsoDateTime),42 snoozedUntil: Schema.NullOr(IsoDateTime),
42 snoozedAt: Schema.NullOr(IsoDateTime),43 snoozedAt: Schema.NullOr(IsoDateTime),
44 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),
45 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
43 latestUserMessageAt: Schema.NullOr(IsoDateTime),46 latestUserMessageAt: Schema.NullOr(IsoDateTime),
44 pendingApprovalCount: NonNegativeInt,47 pendingApprovalCount: NonNegativeInt,
45 pendingUserInputCount: NonNegativeInt,48 pendingUserInputCount: NonNegativeInt,
apps/server/src/textGeneration/ClaudeTextGeneration.ts+1
...@@ -342,6 +342,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu...@@ -342,6 +342,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
342 Effect.fn("ClaudeTextGeneration.generateThreadTitle")(function* (input) {342 Effect.fn("ClaudeTextGeneration.generateThreadTitle")(function* (input) {
343 const { prompt, outputSchema } = buildThreadTitlePrompt({343 const { prompt, outputSchema } = buildThreadTitlePrompt({
344 message: input.message,344 message: input.message,
345 previousTitle: input.previousTitle,
345 attachments: input.attachments,346 attachments: input.attachments,
346 });347 });
347348
apps/server/src/textGeneration/CodexTextGeneration.ts+1
...@@ -384,6 +384,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -384,6 +384,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
384 );384 );
385 const { prompt, outputSchema } = buildThreadTitlePrompt({385 const { prompt, outputSchema } = buildThreadTitlePrompt({
386 message: input.message,386 message: input.message,
387 previousTitle: input.previousTitle,
387 attachments: input.attachments,388 attachments: input.attachments,
388 });389 });
389390
apps/server/src/textGeneration/CursorTextGeneration.ts+1
...@@ -242,6 +242,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu...@@ -242,6 +242,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
242 Effect.fn("CursorTextGeneration.generateThreadTitle")(function* (input) {242 Effect.fn("CursorTextGeneration.generateThreadTitle")(function* (input) {
243 const { prompt, outputSchema } = buildThreadTitlePrompt({243 const { prompt, outputSchema } = buildThreadTitlePrompt({
244 message: input.message,244 message: input.message,
245 previousTitle: input.previousTitle,
245 attachments: input.attachments,246 attachments: input.attachments,
246 });247 });
247248
apps/server/src/textGeneration/GrokTextGeneration.ts+1
...@@ -234,6 +234,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi...@@ -234,6 +234,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi
234 Effect.fn("GrokTextGeneration.generateThreadTitle")(function* (input) {234 Effect.fn("GrokTextGeneration.generateThreadTitle")(function* (input) {
235 const { prompt, outputSchema } = buildThreadTitlePrompt({235 const { prompt, outputSchema } = buildThreadTitlePrompt({
236 message: input.message,236 message: input.message,
237 previousTitle: input.previousTitle,
237 attachments: input.attachments,238 attachments: input.attachments,
238 });239 });
239240
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+1
...@@ -598,6 +598,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"...@@ -598,6 +598,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
598 Effect.fn("OpenCodeTextGeneration.generateThreadTitle")(function* (input) {598 Effect.fn("OpenCodeTextGeneration.generateThreadTitle")(function* (input) {
599 const { prompt, outputSchema } = buildThreadTitlePrompt({599 const { prompt, outputSchema } = buildThreadTitlePrompt({
600 message: input.message,600 message: input.message,
601 previousTitle: input.previousTitle,
601 attachments: input.attachments,602 attachments: input.attachments,
602 });603 });
603 const generated = yield* runOpenCodeJson({604 const generated = yield* runOpenCodeJson({
apps/server/src/textGeneration/TextGeneration.ts+3-3
...@@ -62,6 +62,8 @@ export interface BranchNameGenerationResult {...@@ -62,6 +62,8 @@ export interface BranchNameGenerationResult {
62export interface ThreadTitleGenerationInput {62export interface ThreadTitleGenerationInput {
63 cwd: string;63 cwd: string;
64 message: string;64 message: string;
65 /** Present when replacing an existing title from the current thread history. */
66 previousTitle?: string | undefined;
65 attachments?: ReadonlyArray<ChatAttachment> | undefined;67 attachments?: ReadonlyArray<ChatAttachment> | undefined;
66 /** What model and provider to use for generation. */68 /** What model and provider to use for generation. */
67 modelSelection: ModelSelection;69 modelSelection: ModelSelection;
...@@ -107,9 +109,7 @@ export class TextGeneration extends Context.Service<...@@ -107,9 +109,7 @@ export class TextGeneration extends Context.Service<
107 input: BranchNameGenerationInput,109 input: BranchNameGenerationInput,
108 ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>;110 ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>;
109111
110 /**112 /** Generate a concise thread title from a first message or thread history. */
111 * Generate a concise thread title from a user's first message.
112 */
113 readonly generateThreadTitle: (113 readonly generateThreadTitle: (
114 input: ThreadTitleGenerationInput,114 input: ThreadTitleGenerationInput,
115 ) => Effect.Effect<ThreadTitleGenerationResult, TextGenerationError>;115 ) => Effect.Effect<ThreadTitleGenerationResult, TextGenerationError>;
apps/server/src/textGeneration/TextGenerationPrompts.test.ts+42
...@@ -175,6 +175,48 @@ describe("buildThreadTitlePrompt", () => {...@@ -175,6 +175,48 @@ describe("buildThreadTitlePrompt", () => {
175 expect(result.prompt).toContain("image/png");175 expect(result.prompt).toContain("image/png");
176 expect(result.prompt).toContain("67890 bytes");176 expect(result.prompt).toContain("67890 bytes");
177 });177 });
178
179 it("regenerates from recent thread contents and identifies the previous title", () => {
180 const result = buildThreadTitlePrompt({
181 message: `USER:\nInvestigate reconnect regressions\n\nASSISTANT:\nThe remaining issue is stale session state`,
182 previousTitle: "Investigate reconnect regressions",
183 });
184
185 expect(result.prompt).toContain(
186 "The user requested a new title based on the contents of this thread.",
187 );
188 expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".');
189 expect(result.prompt).toContain("better represents the current state of the thread");
190 expect(result.prompt).toContain(
191 "Capture the thread's intent, not a PR number or other superficial detail.",
192 );
193 expect(result.prompt).toContain("Thread contents:");
194 expect(result.prompt).toContain("The remaining issue is stale session state");
195 });
196
197 it("keeps the latest thread contents when regeneration context is truncated", () => {
198 const result = buildThreadTitlePrompt({
199 message: `${"old context ".repeat(1_000)}\n\nASSISTANT:\nCurrent thread state`,
200 previousTitle: "Old title",
201 });
202
203 expect(result.prompt).toContain("[Earlier content truncated]");
204 expect(result.prompt).toContain("Current thread state");
205 expect(result.prompt).not.toContain("[truncated]");
206 });
207
208 it("does not truncate an already-marked regeneration context twice", () => {
209 const retainedContext = "x".repeat(7_998);
210 const result = buildThreadTitlePrompt({
211 message: `[Earlier content truncated]\n\n${retainedContext}`,
212 previousTitle: "Old title",
213 });
214
215 expect(result.prompt).toContain(
216 `Thread contents:\n[Earlier content truncated]\n\n${retainedContext}`,
217 );
218 expect(result.prompt.match(/\[Earlier content truncated\]/g)).toHaveLength(1);
219 });
178});220});
179221
180describe("sanitizeThreadTitle", () => {222describe("sanitizeThreadTitle", () => {
apps/server/src/textGeneration/TextGenerationPrompts.ts+44-4
...@@ -12,6 +12,8 @@ import type { ChatAttachment } from "@t3tools/contracts";...@@ -12,6 +12,8 @@ import type { ChatAttachment } from "@t3tools/contracts";
12import { limitSection } from "./TextGenerationUtils.ts";12import { limitSection } from "./TextGenerationUtils.ts";
13import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";13import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
1414
15const EARLIER_CONTENT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n";
16
15function policyInstruction(instruction: string | undefined): ReadonlyArray<string> {17function policyInstruction(instruction: string | undefined): ReadonlyArray<string> {
16 const trimmed = instruction?.trim();18 const trimmed = instruction?.trim();
17 return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : [];19 return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : [];
...@@ -150,10 +152,23 @@ interface PromptFromMessageInput {...@@ -150,10 +152,23 @@ interface PromptFromMessageInput {
150 responseShape: string;152 responseShape: string;
151 rules: ReadonlyArray<string>;153 rules: ReadonlyArray<string>;
152 message: string;154 message: string;
155 messageLabel?: string | undefined;
156 preserveMessageEnd?: boolean | undefined;
153 attachments?: ReadonlyArray<ChatAttachment> | undefined;157 attachments?: ReadonlyArray<ChatAttachment> | undefined;
154 additionalInstructions?: string | undefined;158 additionalInstructions?: string | undefined;
155}159}
156160
161function preserveMessageEnd(message: string): string {
162 const alreadyTruncated = message.startsWith(EARLIER_CONTENT_TRUNCATION_MARKER);
163 const contents = alreadyTruncated
164 ? message.slice(EARLIER_CONTENT_TRUNCATION_MARKER.length)
165 : message;
166 if (!alreadyTruncated && contents.length <= 8_000) {
167 return contents;
168 }
169 return `${EARLIER_CONTENT_TRUNCATION_MARKER}${contents.slice(-8_000)}`;
170}
171
157function buildPromptFromMessage(input: PromptFromMessageInput): string {172function buildPromptFromMessage(input: PromptFromMessageInput): string {
158 const attachmentLines = (input.attachments ?? []).map(173 const attachmentLines = (input.attachments ?? []).map(
159 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,174 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
...@@ -165,8 +180,10 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string {...@@ -165,8 +180,10 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string {
165 "Rules:",180 "Rules:",
166 ...input.rules.map((rule) => `- ${rule}`),181 ...input.rules.map((rule) => `- ${rule}`),
167 "",182 "",
168 "User message:",183 `${input.messageLabel ?? "User message"}:`,
169 limitSection(input.message, 8_000),184 input.preserveMessageEnd
185 ? preserveMessageEnd(input.message)
186 : limitSection(input.message, 8_000),
170 ...policyInstruction(input.additionalInstructions),187 ...policyInstruction(input.additionalInstructions),
171 ];188 ];
172 if (attachmentLines.length > 0) {189 if (attachmentLines.length > 0) {
...@@ -207,21 +224,44 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) {...@@ -207,21 +224,44 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) {
207224
208export interface ThreadTitlePromptInput {225export interface ThreadTitlePromptInput {
209 message: string;226 message: string;
227 previousTitle?: string | undefined;
210 attachments?: ReadonlyArray<ChatAttachment> | undefined;228 attachments?: ReadonlyArray<ChatAttachment> | undefined;
211 policy?: TextGenerationPolicy | undefined;229 policy?: TextGenerationPolicy | undefined;
212}230}
213231
214export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {232export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {
233 const isRegeneration = input.previousTitle !== undefined;
215 const prompt = buildPromptFromMessage({234 const prompt = buildPromptFromMessage({
216 instruction: "You write concise thread titles for coding conversations.",235 instruction: isRegeneration
236 ? [
237 "You write concise thread titles for coding conversations.",
238 "The user requested a new title based on the contents of this thread.",
239 `The previous title was ${JSON.stringify(input.previousTitle)}.`,
240 "Come up with a new title that better represents the current state of the thread.",
241 ].join("\n")
242 : "You write concise thread titles for coding conversations.",
217 responseShape: "Return a JSON object with key: title.",243 responseShape: "Return a JSON object with key: title.",
218 rules: [244 rules: [
219 "Title should summarize the user's request, not restate it verbatim.",245 isRegeneration
246 ? "Title should summarize the thread's current state, not just its initial request."
247 : "Title should summarize the user's request, not restate it verbatim.",
248 ...(isRegeneration
249 ? [
250 "Capture the thread's intent, not a PR number or other superficial detail.",
251 "Return a different title from the previous title.",
252 ]
253 : []),
220 "Keep it short and specific (3-8 words).",254 "Keep it short and specific (3-8 words).",
221 "Avoid quotes, filler, prefixes, and trailing punctuation.",255 "Avoid quotes, filler, prefixes, and trailing punctuation.",
222 "If images are attached, use them as primary context for visual/UI issues.",256 "If images are attached, use them as primary context for visual/UI issues.",
223 ],257 ],
224 message: input.message,258 message: input.message,
259 ...(isRegeneration
260 ? {
261 messageLabel: "Thread contents",
262 preserveMessageEnd: true,
263 }
264 : {}),
225 attachments: input.attachments,265 attachments: input.attachments,
226 additionalInstructions: input.policy?.threadTitleInstructions,266 additionalInstructions: input.policy?.threadTitleInstructions,
227 });267 });
apps/web/src/components/Sidebar.logic.test.ts+37
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import {2import {
3 archiveSelectedThreadEntries,3 archiveSelectedThreadEntries,
4 buildBulkTitleRegenerationContextMenuItem,
4 buildMultiSelectThreadContextMenuItems,5 buildMultiSelectThreadContextMenuItems,
5 createThreadJumpHintVisibilityController,6 createThreadJumpHintVisibilityController,
6 getSidebarThreadIdsToPrewarm,7 getSidebarThreadIdsToPrewarm,
...@@ -149,6 +150,42 @@ describe("archiveSelectedThreadEntries", () => {...@@ -149,6 +150,42 @@ describe("archiveSelectedThreadEntries", () => {
149 });150 });
150});151});
151152
153describe("buildBulkTitleRegenerationContextMenuItem", () => {
154 it("counts only threads that can start a new regeneration", () => {
155 expect(
156 buildBulkTitleRegenerationContextMenuItem({
157 supportedCount: 4,
158 actionableCount: 3,
159 }),
160 ).toEqual({
161 id: "regenerate-title",
162 label: "Regenerate titles (3)",
163 });
164 });
165
166 it("shows a disabled progress item when every supported thread is pending", () => {
167 expect(
168 buildBulkTitleRegenerationContextMenuItem({
169 supportedCount: 2,
170 actionableCount: 0,
171 }),
172 ).toEqual({
173 id: "regenerate-title",
174 label: "Regenerating… (2)",
175 disabled: true,
176 });
177 });
178
179 it("omits the action when no selected environment supports it", () => {
180 expect(
181 buildBulkTitleRegenerationContextMenuItem({
182 supportedCount: 0,
183 actionableCount: 0,
184 }),
185 ).toBeNull();
186 });
187});
188
152describe("buildMultiSelectThreadContextMenuItems", () => {189describe("buildMultiSelectThreadContextMenuItems", () => {
153 it("offers bulk archive with the selected count", () => {190 it("offers bulk archive with the selected count", () => {
154 expect(191 expect(
apps/web/src/components/Sidebar.logic.ts+19
...@@ -18,6 +18,7 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;...@@ -18,6 +18,7 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
18// Visible sidebar rows are prewarmed into the thread-detail cache so opening a18// Visible sidebar rows are prewarmed into the thread-detail cache so opening a
19// nearby thread usually reuses an already-hot subscription.19// nearby thread usually reuses an already-hot subscription.
20export const SIDEBAR_THREAD_PREWARM_LIMIT = 10;20export const SIDEBAR_THREAD_PREWARM_LIMIT = 10;
21
21type SidebarProject = {22type SidebarProject = {
22 id: string;23 id: string;
23 title: string;24 title: string;
...@@ -94,6 +95,24 @@ export function buildMultiSelectThreadContextMenuItems(input: {...@@ -94,6 +95,24 @@ export function buildMultiSelectThreadContextMenuItems(input: {
94 ];95 ];
95}96}
9697
98export function buildBulkTitleRegenerationContextMenuItem(input: {
99 supportedCount: number;
100 actionableCount: number;
101}): ContextMenuItem<"regenerate-title"> | null {
102 if (input.supportedCount === 0) return null;
103 if (input.actionableCount === 0) {
104 return {
105 id: "regenerate-title",
106 label: `Regenerating… (${input.supportedCount})`,
107 disabled: true,
108 };
109 }
110 return {
111 id: "regenerate-title",
112 label: `Regenerate titles (${input.actionableCount})`,
113 };
114}
115
97export interface ThreadStatusPill {116export interface ThreadStatusPill {
98 label:117 label:
99 | "Working"118 | "Working"
apps/web/src/components/SidebarV2.tsx+92-7
...@@ -106,6 +106,7 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"...@@ -106,6 +106,7 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"
106import type { SidebarThreadSummary } from "../types";106import type { SidebarThreadSummary } from "../types";
107import { cn } from "~/lib/utils";107import { cn } from "~/lib/utils";
108import {108import {
109 buildBulkTitleRegenerationContextMenuItem,
109 formatWorkingDurationLabel,110 formatWorkingDurationLabel,
110 firstValidTimestampMs,111 firstValidTimestampMs,
111 hasUnseenCompletion,112 hasUnseenCompletion,
...@@ -444,6 +445,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -444,6 +445,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
444 [thread.environmentId, thread.id],445 [thread.environmentId, thread.id],
445 );446 );
446 const threadKey = scopedThreadKey(threadRef);447 const threadKey = scopedThreadKey(threadRef);
448 const isRegeneratingTitle = thread.titleRegeneration != null;
447 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);449 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
448 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));450 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
449 const openPrLink = useOpenPrLink();451 const openPrLink = useOpenPrLink();
...@@ -721,7 +723,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -721,7 +723,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
721 ) : (723 ) : (
722 <span724 <span
723 className={cn(725 className={cn(
724 "min-w-0 flex-1 text-sm",726 "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",
725 shouldRecede ? "font-normal" : "font-medium",727 shouldRecede ? "font-normal" : "font-medium",
726 variant === "card"728 variant === "card"
727 ? cn(729 ? cn(
...@@ -742,6 +744,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -742,6 +744,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
742 ? "text-muted-foreground"744 ? "text-muted-foreground"
743 : "text-muted-foreground/70",745 : "text-muted-foreground/70",
744 ),746 ),
747 isRegeneratingTitle && "opacity-[0.55]",
745 )}748 )}
746 >749 >
747 {thread.title}750 {thread.title}
...@@ -790,6 +793,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -790,6 +793,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
790 role="button"793 role="button"
791 tabIndex={0}794 tabIndex={0}
792 data-testid="sidebar-v2-row-slim"795 data-testid="sidebar-v2-row-slim"
796 aria-busy={isRegeneratingTitle || undefined}
793 className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}797 className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}
794 onClick={handleClick}798 onClick={handleClick}
795 onDoubleClick={handleDoubleClick}799 onDoubleClick={handleDoubleClick}
...@@ -816,6 +820,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -816,6 +820,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
816 </span>820 </span>
817 {title}821 {title}
818 {terminalStatusIcon}822 {terminalStatusIcon}
823 {isRegeneratingTitle ? (
824 <span role="status" className="sr-only">
825 Regenerating title
826 </span>
827 ) : null}
819 {/* The PR badge stays outside the hover-fading slot: it must828 {/* The PR badge stays outside the hover-fading slot: it must
820 remain visible AND clickable while the row is hovered. Only829 remain visible AND clickable while the row is hovered. Only
821 the time/jump label yields to the settle affordance. */}830 the time/jump label yields to the settle affordance. */}
...@@ -900,6 +909,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -900,6 +909,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
900 role="button"909 role="button"
901 tabIndex={0}910 tabIndex={0}
902 data-testid="sidebar-v2-row-card"911 data-testid="sidebar-v2-row-card"
912 aria-busy={isRegeneratingTitle || undefined}
903 className={rowSurfaceClassName}913 className={rowSurfaceClassName}
904 onClick={handleClick}914 onClick={handleClick}
905 onDoubleClick={handleDoubleClick}915 onDoubleClick={handleDoubleClick}
...@@ -998,7 +1008,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -998,7 +1008,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
998 ) : null}1008 ) : null}
999 </span>1009 </span>
1000 </div>1010 </div>
1001 <div className="mt-1 flex min-w-0">{title}</div>1011 <div className="mt-1 flex min-w-0">
1012 {title}
1013 {isRegeneratingTitle ? (
1014 <span role="status" className="sr-only">
1015 Regenerating title
1016 </span>
1017 ) : null}
1018 </div>
1002 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/75">1019 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/75">
1003 {thread.branch ? (1020 {thread.branch ? (
1004 <span className="min-w-0 flex-1 truncate whitespace-nowrap">{thread.branch}</span>1021 <span className="min-w-0 flex-1 truncate whitespace-nowrap">{thread.branch}</span>
...@@ -1912,16 +1929,28 @@ export default function SidebarV2() {...@@ -1912,16 +1929,28 @@ export default function SidebarV2() {
1912 const count = threadKeys.length;1929 const count = threadKeys.length;
1913 // Snooze (N) is offered when every selected thread can actually take1930 // Snooze (N) is offered when every selected thread can actually take
1914 // it — a mixed selection with blocked-on-you work would half-apply.1931 // it — a mixed selection with blocked-on-you work would half-apply.
1915 const selectionNow = new Date().toISOString();1932 const selectionNow = new Date();
1916 const snoozableThreads = threadKeys.flatMap((threadKey) => {1933 const selectedThreads = threadKeys.flatMap((threadKey) => {
1917 const thread = threadByKeyRef.current.get(threadKey);1934 const thread = threadByKeyRef.current.get(threadKey);
1918 return thread ? [thread] : [];1935 return thread ? [thread] : [];
1919 });1936 });
1920 const canSnoozeSelection = snoozableThreads.every(1937 const canSnoozeSelection = selectedThreads.every(
1921 (thread) =>1938 (thread) =>
1922 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true &&1939 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true &&
1923 canSnooze(thread, { now: selectionNow }),1940 canSnooze(thread, { now: selectionNow.toISOString() }),
1924 );1941 );
1942 const titleRegenerationThreads = selectedThreads.filter(
1943 (thread) =>
1944 serverConfigs.get(thread.environmentId)?.environment.capabilities
1945 .threadTitleRegeneration === true,
1946 );
1947 const regeneratableTitleThreads = titleRegenerationThreads.filter(
1948 (thread) => thread.titleRegeneration == null,
1949 );
1950 const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({
1951 supportedCount: titleRegenerationThreads.length,
1952 actionableCount: regeneratableTitleThreads.length,
1953 });
1925 const snoozePresets = resolveSnoozePresets(new Date());1954 const snoozePresets = resolveSnoozePresets(new Date());
1926 const clicked = await settlePromise(() =>1955 const clicked = await settlePromise(() =>
1927 api.contextMenu.show(1956 api.contextMenu.show(
...@@ -1939,6 +1968,7 @@ export default function SidebarV2() {...@@ -1939,6 +1968,7 @@ export default function SidebarV2() {
1939 },1968 },
1940 ]1969 ]
1941 : []),1970 : []),
1971 ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []),
1942 { id: "mark-unread", label: `Mark unread (${count})` },1972 { id: "mark-unread", label: `Mark unread (${count})` },
1943 { id: "delete", label: `Delete (${count})`, destructive: true },1973 { id: "delete", label: `Delete (${count})`, destructive: true },
1944 ],1974 ],
...@@ -1954,7 +1984,7 @@ export default function SidebarV2() {...@@ -1954,7 +1984,7 @@ export default function SidebarV2() {
1954 // Post-snooze navigation must skip threads snoozing in this same1984 // Post-snooze navigation must skip threads snoozing in this same
1955 // batch — they are all leaving the card block together.1985 // batch — they are all leaving the card block together.
1956 const coSnoozingKeys = new Set(threadKeys);1986 const coSnoozingKeys = new Set(threadKeys);
1957 for (const thread of snoozableThreads) {1987 for (const thread of selectedThreads) {
1958 attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, {1988 attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, {
1959 coSnoozingKeys,1989 coSnoozingKeys,
1960 });1990 });
...@@ -1963,6 +1993,28 @@ export default function SidebarV2() {...@@ -1963,6 +1993,28 @@ export default function SidebarV2() {
1963 }1993 }
1964 return;1994 return;
1965 }1995 }
1996 if (clicked.value === "regenerate-title") {
1997 for (const thread of regeneratableTitleThreads) {
1998 const result = await updateThreadMetadata({
1999 environmentId: thread.environmentId,
2000 input: { threadId: thread.id, regenerateTitle: true },
2001 });
2002 if (result._tag === "Success") continue;
2003 if (!isAtomCommandInterrupted(result)) {
2004 const error = squashAtomCommandFailure(result);
2005 toastManager.add(
2006 stackedThreadToast({
2007 type: "error",
2008 title: "Failed to regenerate thread titles",
2009 description: error instanceof Error ? error.message : "An error occurred.",
2010 }),
2011 );
2012 }
2013 return;
2014 }
2015 clearSelection();
2016 return;
2017 }
1966 if (clicked.value === "settle") {2018 if (clicked.value === "settle") {
1967 // Post-settle navigation must skip threads settling in this same2019 // Post-settle navigation must skip threads settling in this same
1968 // batch — they are all leaving the card block together. Rows that2020 // batch — they are all leaving the card block together. Rows that
...@@ -2034,6 +2086,7 @@ export default function SidebarV2() {...@@ -2034,6 +2086,7 @@ export default function SidebarV2() {
2034 markThreadUnread,2086 markThreadUnread,
2035 removeFromSelection,2087 removeFromSelection,
2036 serverConfigs,2088 serverConfigs,
2089 updateThreadMetadata,
2037 ],2090 ],
2038 );2091 );
20392092
...@@ -2063,6 +2116,10 @@ export default function SidebarV2() {...@@ -2063,6 +2116,10 @@ export default function SidebarV2() {
2063 true;2116 true;
2064 const supportsSnooze =2117 const supportsSnooze =
2065 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;2118 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
2119 const supportsTitleRegeneration =
2120 serverConfigs.get(thread.environmentId)?.environment.capabilities
2121 .threadTitleRegeneration === true;
2122 const isRegeneratingTitle = thread.titleRegeneration != null;
2066 const isSettled = settledThreadKeysRef.current.has(threadKey);2123 const isSettled = settledThreadKeysRef.current.has(threadKey);
2067 const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);2124 const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
2068 // Presets resolve at menu-open time (same as the popover).2125 // Presets resolve at menu-open time (same as the popover).
...@@ -2101,6 +2158,15 @@ export default function SidebarV2() {...@@ -2101,6 +2158,15 @@ export default function SidebarV2() {
2101 ]2158 ]
2102 : []),2159 : []),
2103 { id: "rename", label: "Rename thread" },2160 { id: "rename", label: "Rename thread" },
2161 ...(supportsTitleRegeneration
2162 ? [
2163 {
2164 id: "regenerate-title",
2165 label: isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
2166 disabled: isRegeneratingTitle,
2167 },
2168 ]
2169 : []),
2104 { id: "mark-unread", label: "Mark unread" },2170 { id: "mark-unread", label: "Mark unread" },
2105 { id: "copy-path", label: "Copy path", icon: "copy" },2171 { id: "copy-path", label: "Copy path", icon: "copy" },
2106 ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []),2172 ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []),
...@@ -2153,6 +2219,24 @@ export default function SidebarV2() {...@@ -2153,6 +2219,24 @@ export default function SidebarV2() {
2153 case "rename":2219 case "rename":
2154 startThreadRename(threadRef, thread.title);2220 startThreadRename(threadRef, thread.title);
2155 return;2221 return;
2222 case "regenerate-title": {
2223 if (isRegeneratingTitle) return;
2224 const result = await updateThreadMetadata({
2225 environmentId: threadRef.environmentId,
2226 input: { threadId: threadRef.threadId, regenerateTitle: true },
2227 });
2228 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2229 const error = squashAtomCommandFailure(result);
2230 toastManager.add(
2231 stackedThreadToast({
2232 type: "error",
2233 title: "Failed to regenerate thread title",
2234 description: error instanceof Error ? error.message : "An error occurred.",
2235 }),
2236 );
2237 }
2238 return;
2239 }
2156 case "mark-unread":2240 case "mark-unread":
2157 markThreadUnread(threadKey, thread.latestTurn?.completedAt);2241 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
2158 return;2242 return;
...@@ -2219,6 +2303,7 @@ export default function SidebarV2() {...@@ -2219,6 +2303,7 @@ export default function SidebarV2() {
2219 projectCwdByKey,2303 projectCwdByKey,
2220 serverConfigs,2304 serverConfigs,
2221 startThreadRename,2305 startThreadRename,
2306 updateThreadMetadata,
2222 ],2307 ],
2223 );2308 );
22242309
packages/client-runtime/src/state/threadReducer.test.ts+11-2
...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 CheckpointRef,4 CheckpointRef,
5 CommandId,
5 EventId,6 EventId,
6 MessageId,7 MessageId,
7 ProjectId,8 ProjectId,
...@@ -123,8 +124,15 @@ describe("applyThreadDetailEvent", () => {...@@ -123,8 +124,15 @@ describe("applyThreadDetailEvent", () => {
123 });124 });
124125
125 describe("thread.archived / thread.unarchived", () => {126 describe("thread.archived / thread.unarchived", () => {
126 it("sets archivedAt", () => {127 it("sets archivedAt and clears title regeneration", () => {
127 const result = applyThreadDetailEvent(baseThread, {128 const regeneratingThread: OrchestrationThread = {
129 ...baseThread,
130 titleRegeneration: {
131 requestId: CommandId.make("regenerate-title"),
132 startedAt: "2026-04-01T02:00:00.000Z",
133 },
134 };
135 const result = applyThreadDetailEvent(regeneratingThread, {
128 ...baseEventFields,136 ...baseEventFields,
129 sequence: 3,137 sequence: 3,
130 occurredAt: "2026-04-01T03:00:00.000Z",138 occurredAt: "2026-04-01T03:00:00.000Z",
...@@ -141,6 +149,7 @@ describe("applyThreadDetailEvent", () => {...@@ -141,6 +149,7 @@ describe("applyThreadDetailEvent", () => {
141 expect(result.kind).toBe("updated");149 expect(result.kind).toBe("updated");
142 if (result.kind === "updated") {150 if (result.kind === "updated") {
143 expect(result.thread.archivedAt).toBe("2026-04-01T03:00:00.000Z");151 expect(result.thread.archivedAt).toBe("2026-04-01T03:00:00.000Z");
152 expect(result.thread.titleRegeneration).toBeNull();
144 }153 }
145 });154 });
146155
packages/client-runtime/src/state/threadReducer.ts+4
...@@ -94,6 +94,7 @@ export function applyThreadDetailEvent(...@@ -94,6 +94,7 @@ export function applyThreadDetailEvent(
94 thread: {94 thread: {
95 ...thread,95 ...thread,
96 archivedAt: event.payload.archivedAt,96 archivedAt: event.payload.archivedAt,
97 titleRegeneration: null,
97 updatedAt: event.payload.updatedAt,98 updatedAt: event.payload.updatedAt,
98 },99 },
99 };100 };
...@@ -155,6 +156,9 @@ export function applyThreadDetailEvent(...@@ -155,6 +156,9 @@ export function applyThreadDetailEvent(
155 thread: {156 thread: {
156 ...thread,157 ...thread,
157 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),158 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
159 ...(event.payload.titleRegeneration !== undefined
160 ? { titleRegeneration: event.payload.titleRegeneration }
161 : {}),
158 ...(event.payload.modelSelection !== undefined162 ...(event.payload.modelSelection !== undefined
159 ? { modelSelection: event.payload.modelSelection }163 ? { modelSelection: event.payload.modelSelection }
160 : {}),164 : {}),
packages/contracts/src/environment.ts+3
...@@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
47 /** Server understands thread.snooze / thread.unsnooze commands. Same47 /** Server understands thread.snooze / thread.unsnooze commands. Same
48 version-skew contract as threadSettlement. */48 version-skew contract as threadSettlement. */
49 threadSnooze: Schema.optionalKey(Schema.Boolean),49 threadSnooze: Schema.optionalKey(Schema.Boolean),
50 /** Server understands regenerateTitle on thread.meta.update. Absent on
51 older servers, so clients hide the action instead of sending it. */
52 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
50 /** The update path clients should offer for this server. Absent on53 /** The update path clients should offer for this server. Absent on
51 servers that must be relaunched manually (dev checkouts, Windows54 servers that must be relaunched manually (dev checkouts, Windows
52 foreground runs, pre-update servers). */55 foreground runs, pre-update servers). */
packages/contracts/src/orchestration.test.ts+55
...@@ -320,12 +320,20 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () =>...@@ -320,12 +320,20 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () =>
320 Effect.gen(function* () {320 Effect.gen(function* () {
321 const parsed = yield* decodeThreadMetaUpdatedPayload({321 const parsed = yield* decodeThreadMetaUpdatedPayload({
322 threadId: "thread-1",322 threadId: "thread-1",
323 regenerateTitle: true,
324 previousTitle: "Previous title",
325 titleRegeneration: {
326 requestId: "cmd-title-regenerate",
327 startedAt: "2026-01-01T00:00:00.000Z",
328 },
323 modelSelection: {329 modelSelection: {
324 provider: "claudeAgent",330 provider: "claudeAgent",
325 model: "claude-opus-4-6",331 model: "claude-opus-4-6",
326 },332 },
327 updatedAt: "2026-01-01T00:00:00.000Z",333 updatedAt: "2026-01-01T00:00:00.000Z",
328 });334 });
335 assert.strictEqual(parsed.previousTitle, "Previous title");
336 assert.strictEqual(parsed.titleRegeneration?.requestId, "cmd-title-regenerate");
329 assert.strictEqual(parsed.modelSelection?.instanceId, "claudeAgent");337 assert.strictEqual(parsed.modelSelection?.instanceId, "claudeAgent");
330 }),338 }),
331);339);
...@@ -628,6 +636,53 @@ it.effect("accepts a title seed in thread.turn.start", () =>...@@ -628,6 +636,53 @@ it.effect("accepts a title seed in thread.turn.start", () =>
628 }),636 }),
629);637);
630638
639it.effect("accepts a title regeneration intent in thread.meta.update", () =>
640 Effect.gen(function* () {
641 const parsed = yield* decodeOrchestrationCommand({
642 type: "thread.meta.update",
643 commandId: "cmd-title-regenerate",
644 threadId: "thread-1",
645 regenerateTitle: true,
646 });
647 assert.strictEqual(parsed.type, "thread.meta.update");
648 if (parsed.type === "thread.meta.update") {
649 assert.strictEqual(parsed.regenerateTitle, true);
650 }
651 }),
652);
653
654it.effect("accepts an internal title regeneration completion", () =>
655 Effect.gen(function* () {
656 const parsed = yield* decodeOrchestrationCommand({
657 type: "thread.title.regeneration.complete",
658 commandId: "cmd-title-regeneration-complete",
659 threadId: "thread-1",
660 requestId: "cmd-title-regenerate",
661 title: "Updated title",
662 });
663 assert.strictEqual(parsed.type, "thread.title.regeneration.complete");
664 if (parsed.type === "thread.title.regeneration.complete") {
665 assert.strictEqual(parsed.requestId, "cmd-title-regenerate");
666 assert.strictEqual(parsed.title, "Updated title");
667 }
668 }),
669);
670
671it.effect("rejects an explicit title combined with title regeneration", () =>
672 Effect.gen(function* () {
673 const result = yield* Effect.exit(
674 decodeOrchestrationCommand({
675 type: "thread.meta.update",
676 commandId: "cmd-title-regenerate-with-title",
677 threadId: "thread-1",
678 title: "Explicit title",
679 regenerateTitle: true,
680 }),
681 );
682 assert.strictEqual(result._tag, "Failure");
683 }),
684);
685
631it.effect("accepts a source proposed plan reference in thread.turn.start", () =>686it.effect("accepts a source proposed plan reference in thread.turn.start", () =>
632 Effect.gen(function* () {687 Effect.gen(function* () {
633 const parsed = yield* decodeThreadTurnStartCommand({688 const parsed = yield* decodeThreadTurnStartCommand({
packages/contracts/src/orchestration.ts+33-1
...@@ -341,6 +341,12 @@ export const OrchestrationLatestTurn = Schema.Struct({...@@ -341,6 +341,12 @@ export const OrchestrationLatestTurn = Schema.Struct({
341});341});
342export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type;342export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type;
343343
344export const ThreadTitleRegeneration = Schema.Struct({
345 requestId: CommandId,
346 startedAt: IsoDateTime,
347});
348export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type;
349
344export const OrchestrationThread = Schema.Struct({350export const OrchestrationThread = Schema.Struct({
345 id: ThreadId,351 id: ThreadId,
346 projectId: ProjectId,352 projectId: ProjectId,
...@@ -366,6 +372,8 @@ export const OrchestrationThread = Schema.Struct({...@@ -366,6 +372,8 @@ export const OrchestrationThread = Schema.Struct({
366 // Optional so payloads from pre-snooze servers still decode.372 // Optional so payloads from pre-snooze servers still decode.
367 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),373 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
368 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),374 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
375 // Pending-only state. Optional so older servers remain compatible.
376 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
369 deletedAt: Schema.NullOr(IsoDateTime),377 deletedAt: Schema.NullOr(IsoDateTime),
370 messages: Schema.Array(OrchestrationMessage),378 messages: Schema.Array(OrchestrationMessage),
371 proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(379 proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(
...@@ -418,6 +426,7 @@ export const OrchestrationThreadShell = Schema.Struct({...@@ -418,6 +426,7 @@ export const OrchestrationThreadShell = Schema.Struct({
418 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),426 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
419 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),427 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
420 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),428 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
429 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
421 session: Schema.NullOr(OrchestrationSession),430 session: Schema.NullOr(OrchestrationSession),
422 latestUserMessageAt: Schema.NullOr(IsoDateTime),431 latestUserMessageAt: Schema.NullOr(IsoDateTime),
423 hasPendingApprovals: Schema.Boolean,432 hasPendingApprovals: Schema.Boolean,
...@@ -616,11 +625,18 @@ const ThreadMetaUpdateCommand = Schema.Struct({...@@ -616,11 +625,18 @@ const ThreadMetaUpdateCommand = Schema.Struct({
616 commandId: CommandId,625 commandId: CommandId,
617 threadId: ThreadId,626 threadId: ThreadId,
618 title: Schema.optional(TrimmedNonEmptyString),627 title: Schema.optional(TrimmedNonEmptyString),
628 regenerateTitle: Schema.optional(Schema.Literal(true)),
619 modelSelection: Schema.optional(ModelSelection),629 modelSelection: Schema.optional(ModelSelection),
620 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),630 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
621 expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),631 expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
622 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),632 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
623});633}).check(
634 Schema.makeFilter(
635 (input) =>
636 !(input.title !== undefined && input.regenerateTitle === true) ||
637 "title and regenerateTitle cannot be specified together",
638 ),
639);
624640
625const ThreadRuntimeModeSetCommand = Schema.Struct({641const ThreadRuntimeModeSetCommand = Schema.Struct({
626 type: Schema.Literal("thread.runtime-mode.set"),642 type: Schema.Literal("thread.runtime-mode.set"),
...@@ -859,6 +875,14 @@ const ThreadRevertCompleteCommand = Schema.Struct({...@@ -859,6 +875,14 @@ const ThreadRevertCompleteCommand = Schema.Struct({
859 createdAt: IsoDateTime,875 createdAt: IsoDateTime,
860});876});
861877
878const ThreadTitleRegenerationCompleteCommand = Schema.Struct({
879 type: Schema.Literal("thread.title.regeneration.complete"),
880 commandId: CommandId,
881 threadId: ThreadId,
882 requestId: CommandId,
883 title: Schema.optional(TrimmedNonEmptyString),
884});
885
862const InternalOrchestrationCommand = Schema.Union([886const InternalOrchestrationCommand = Schema.Union([
863 ThreadSessionSetCommand,887 ThreadSessionSetCommand,
864 ThreadMessageAssistantDeltaCommand,888 ThreadMessageAssistantDeltaCommand,
...@@ -867,6 +891,7 @@ const InternalOrchestrationCommand = Schema.Union([...@@ -867,6 +891,7 @@ const InternalOrchestrationCommand = Schema.Union([
867 ThreadTurnDiffCompleteCommand,891 ThreadTurnDiffCompleteCommand,
868 ThreadActivityAppendCommand,892 ThreadActivityAppendCommand,
869 ThreadRevertCompleteCommand,893 ThreadRevertCompleteCommand,
894 ThreadTitleRegenerationCompleteCommand,
870]);895]);
871export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;896export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
872897
...@@ -999,6 +1024,13 @@ export const ThreadUnsnoozedPayload = Schema.Struct({...@@ -999,6 +1024,13 @@ export const ThreadUnsnoozedPayload = Schema.Struct({
999export const ThreadMetaUpdatedPayload = Schema.Struct({1024export const ThreadMetaUpdatedPayload = Schema.Struct({
1000 threadId: ThreadId,1025 threadId: ThreadId,
1001 title: Schema.optional(TrimmedNonEmptyString),1026 title: Schema.optional(TrimmedNonEmptyString),
1027 /** Intent marker consumed by the title-generation reactor. Keeping this on
1028 the existing event lets older clients safely ignore the new field. */
1029 regenerateTitle: Schema.optional(Schema.Literal(true)),
1030 /** Title at request time, used to avoid overwriting a later manual rename. */
1031 previousTitle: Schema.optional(TrimmedNonEmptyString),
1032 /** Pending state shared with clients. Null clears a matching request. */
1033 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
1002 modelSelection: Schema.optional(ModelSelection),1034 modelSelection: Schema.optional(ModelSelection),
1003 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),1035 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
1004 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),1036 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),