| author | |
| committer | |
| log | 5c9358acaa34f8619513bba1e6821f0fdec27f74 |
| tree | f5cf4f697fcbf2f14fce0ff74cb576105d694197 |
| parent | cbe8052020aa0c02c63fbe090e7f1f25603622d1 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 74 | 75 |
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 | ); |
| 337 | 337 | ||
| 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(); | ||
| 349 | 361 | ||
| 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"); | ||
| 361 | 389 | ||
| 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 !== undefined | 736 | ...(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 | } |
| 211 | 211 | ||
| 212 | function 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 | |||
| 212 | function mapSessionRow( | 221 | function 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); | ||
| 393 | 419 | ||
| 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); | ||
| 420 | 485 | ||
| 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 | } |
| 439 | 508 | ||
| ... | @@ -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 | }); |
| 639 | 708 | ||
| 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"; |
| 2180 | 2776 | ||
| 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 | ); |
| 2199 | 2795 | ||
| 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; |
| 90 | const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); | 91 | const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); |
| 91 | const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | 92 | const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 92 | const DEFAULT_THREAD_TITLE = "New thread"; | 93 | const DEFAULT_THREAD_TITLE = "New thread"; |
| 94 | const MAX_REGENERATION_ATTACHMENTS = 4; | ||
| 95 | const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; | ||
| 96 | const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"; | ||
| 97 | |||
| 98 | function 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 | } | ||
| 93 | 148 | ||
| 94 | export function providerErrorLabel(value: string | undefined): string { | 149 | export 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 | ); |
| 779 | 834 | ||
| 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 | }); |
| 1114 | 1354 | ||
| 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 | }); |
| 1120 | 1363 |
apps/server/src/orchestration/decider.titleRegeneration.test.ts created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | import { | ||
| 2 | CommandId, | ||
| 3 | ProjectId, | ||
| 4 | ProviderInstanceId, | ||
| 5 | ThreadId, | ||
| 6 | type OrchestrationReadModel, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 9 | import { expect, it } from "@effect/vitest"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | |||
| 12 | import { decideOrchestrationCommand } from "./decider.ts"; | ||
| 13 | |||
| 14 | const UPDATED_AT = "2026-01-01T00:00:00.000Z"; | ||
| 15 | |||
| 16 | const 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 | |||
| 48 | it.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 !== undefined | 670 | ...(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 | } |
| 666 | 679 | ||
| 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 !== undefined | 406 | ...(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"; |
| 47 | import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; | 47 | import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; |
| 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; | 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; |
| 49 | import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; | 49 | import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; |
| 50 | import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; | ||
| 50 | 51 | ||
| 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; |
| 97 | 99 | ||
| 98 | export const makeMigrationLoader = (throughId?: number) => | 100 | export const makeMigrationLoader = (throughId?: number) => |
apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 5 | |||
| 6 | import { runMigrations } from "../Migrations.ts"; | ||
| 7 | import * as NodeSqliteClient from "../NodeSqliteClient.ts"; | ||
| 8 | |||
| 9 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | ||
| 10 | |||
| 11 | layer("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 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 3 | |||
| 4 | export 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 ProjectionThreadRepository | 7 | * @module ProjectionThreadRepository |
| 8 | */ | 8 | */ |
| 9 | import { | 9 | import { |
| 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 | }); |
| 347 | 348 |
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 | }); |
| 389 | 390 |
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 | }); |
| 247 | 248 |
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 | }); |
| 239 | 240 |
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 { |
| 62 | export interface ThreadTitleGenerationInput { | 62 | export 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>; |
| 109 | 111 | ||
| 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 | }); |
| 179 | 221 | ||
| 180 | describe("sanitizeThreadTitle", () => { | 222 | describe("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"; |
| 12 | import { limitSection } from "./TextGenerationUtils.ts"; | 12 | import { limitSection } from "./TextGenerationUtils.ts"; |
| 13 | import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; | 13 | import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; |
| 14 | 14 | ||
| 15 | const EARLIER_CONTENT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"; | ||
| 16 | |||
| 15 | function policyInstruction(instruction: string | undefined): ReadonlyArray<string> { | 17 | function 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 | } |
| 156 | 160 | ||
| 161 | function 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 | |||
| 157 | function buildPromptFromMessage(input: PromptFromMessageInput): string { | 172 | function 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) { |
| 207 | 224 | ||
| 208 | export interface ThreadTitlePromptInput { | 225 | export 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 | } |
| 213 | 231 | ||
| 214 | export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { | 232 | export 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 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { | 2 | import { |
| 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 | }); |
| 151 | 152 | ||
| 153 | describe("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 | |||
| 152 | describe("buildMultiSelectThreadContextMenuItems", () => { | 189 | describe("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 a | 18 | // 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. |
| 20 | export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; | 20 | export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; |
| 21 | |||
| 21 | type SidebarProject = { | 22 | type 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 | } |
| 96 | 97 | ||
| 98 | export 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 | |||
| 97 | export interface ThreadStatusPill { | 116 | export 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" |
| 106 | import type { SidebarThreadSummary } from "../types"; | 106 | import type { SidebarThreadSummary } from "../types"; |
| 107 | import { cn } from "~/lib/utils"; | 107 | import { cn } from "~/lib/utils"; |
| 108 | import { | 108 | import { |
| 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 | <span | 724 | <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 must | 828 | {/* The PR badge stays outside the hover-fading slot: it must |
| 820 | remain visible AND clickable while the row is hovered. Only | 829 | 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 take | 1930 | // 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 same | 1984 | // 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 same | 2019 | // Post-settle navigation must skip threads settling in this same |
| 1968 | // batch — they are all leaving the card block together. Rows that | 2020 | // 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 | ); |
| 2039 | 2092 | ||
| ... | @@ -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 | ); |
| 2224 | 2309 |
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"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | }); |
| 124 | 125 | ||
| 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 | }); |
| 146 | 155 |
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 !== undefined | 162 | ...(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. Same | 47 | /** 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 on | 53 | /** The update path clients should offer for this server. Absent on |
| 51 | servers that must be relaunched manually (dev checkouts, Windows | 54 | 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 | ); |
| 630 | 638 | ||
| 639 | it.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 | |||
| 654 | it.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 | |||
| 671 | it.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 | |||
| 631 | it.effect("accepts a source proposed plan reference in thread.turn.start", () => | 686 | it.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 | }); |
| 342 | export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; | 342 | export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; |
| 343 | 343 | ||
| 344 | export const ThreadTitleRegeneration = Schema.Struct({ | ||
| 345 | requestId: CommandId, | ||
| 346 | startedAt: IsoDateTime, | ||
| 347 | }); | ||
| 348 | export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; | ||
| 349 | |||
| 344 | export const OrchestrationThread = Schema.Struct({ | 350 | export 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 | ); | ||
| 624 | 640 | ||
| 625 | const ThreadRuntimeModeSetCommand = Schema.Struct({ | 641 | const 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 | }); |
| 861 | 877 | ||
| 878 | const 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 | |||
| 862 | const InternalOrchestrationCommand = Schema.Union([ | 886 | const 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 | ]); |
| 871 | export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; | 896 | export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; |
| 872 | 897 | ||
| ... | @@ -999,6 +1024,13 @@ export const ThreadUnsnoozedPayload = Schema.Struct({ | ... | @@ -999,6 +1024,13 @@ export const ThreadUnsnoozedPayload = Schema.Struct({ |
| 999 | export const ThreadMetaUpdatedPayload = Schema.Struct({ | 1024 | export 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)), |