History

[verified] fix(server): harden bounded shell summaries

t3-code[bot] committed 2 months ago

Commit 6dba7cc1 parent cb3e6e68 files · +1132 −191Browse files

Unified · Split

8 files
  1. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +342 −11
  2. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +17 −96
  3. apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +243 −76
  4. apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +69 −8
  5. apps/server/src/persistence/Migrations.ts +2 −0
  6. apps/server/src/persistence/Migrations/042_ProjectionThreadShellSummaryState.test.ts +301 −0
  7. apps/server/src/persistence/Migrations/042_ProjectionThreadShellSummaryState.ts +148 −0
  8. apps/server/src/persistence/Services/ProjectionThreadMessages.ts +10 −0
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+342−11
@@ -24,6 +24,8 @@ import { · 23 unmodified lines
2424 SqlitePersistenceMemory,
2525 } from "../../persistence/Layers/Sqlite.ts";
2626 import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
27+import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts";
28+import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts";
2729 import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
2830 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2931 import {
@@ -264,6 +266,14 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-shel · 234 unmodified lines
264266 `;
265267 return rows[0]?.count;
266268 });
269+ const readLatestUserMessageAt = Effect.gen(function* () {
270+ const rows = yield* sql<{ readonly latestUserMessageAt: string | null }>`
271+ SELECT latest_user_message_at AS "latestUserMessageAt"
272+ FROM projection_threads
273+ WHERE thread_id = ${threadId}
274+ `;
275+ return rows[0]?.latestUserMessageAt;
276+ });
267277
268278 yield* appendAndProject({
269279 type: "project.created",
@@ -329,18 +339,27 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-shel · 59 unmodified lines
329339 summary,
330340 payload_json,
331341 sequence,
332− created_at
342+ created_at,
343+ user_input_request_id,
344+ user_input_state
333345 )
334346 SELECT
335347 'activity-shell-summary-history-' || printf('%05d', index_value),
336348 ${threadId},
337349 NULL,
338350 'info',
339− 'history.item',
340− 'Historical activity',
341− json_object('blob', replace(hex(zeroblob(2048)), '00', 'x')),
351+ 'user-input.resolved',
352+ 'Historical user input',
353+ json_object(
354+ 'requestId',
355+ 'request-shell-summary-history-' || printf('%05d', index_value),
356+ 'blob',
357+ replace(hex(zeroblob(2048)), '00', 'x')
358+ ),
342359 index_value,
343− ${createdAt}
360+ ${createdAt},
361+ 'request-shell-summary-history-' || printf('%05d', index_value),
362+ 'cleared'
344363 FROM history
345364 `;
346365 // If the shell update reads the collection, this sentinel makes the
@@ -361,7 +380,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-shel · 14 unmodified lines
361380 ${threadId},
362381 NULL,
363382 'info',
364− 'history.sentinel',
383+ 'user-input.requested',
365384 'Must not be decoded',
366385 '{',
367386 NULL,
@@ -369,6 +388,13 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-shel · 1 unmodified line
369388 )
370389 `;
371390
391+ yield* sql`
392+ INSERT INTO projection_thread_user_input_summaries (thread_id, pending_count)
393+ VALUES (${threadId}, 0)
394+ ON CONFLICT (thread_id)
395+ DO UPDATE SET pending_count = 0
396+ `;
397+
372398 yield* appendAndProject({
373399 type: "thread.message-sent",
374400 eventId: EventId.make("evt-shell-summary-assistant-message"),
@@ -393,6 +419,54 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-shel · 18 unmodified lines
393419 });
394420 assert.equal(yield* readPendingUserInputCount, 0);
395421
422+ yield* appendAndProject({
423+ type: "thread.message-sent",
424+ eventId: EventId.make("evt-shell-summary-user-message-collision"),
425+ aggregateKind: "thread",
426+ aggregateId: threadId,
427+ occurredAt: "2026-08-19T00:00:01.100Z",
428+ commandId: CommandId.make("cmd-shell-summary-user-message-collision"),
429+ causationEventId: null,
430+ correlationId: CorrelationId.make("cmd-shell-summary-user-message-collision"),
431+ metadata: {},
432+ payload: {
433+ threadId,
434+ messageId: MessageId.make("message-shell-summary-collision"),
435+ role: "user",
436+ text: "A user message",
437+ attachments: [],
438+ turnId: null,
439+ streaming: false,
440+ createdAt: "2026-08-19T00:00:01.100Z",
441+ updatedAt: "2026-08-19T00:00:01.100Z",
442+ },
443+ });
444+ assert.equal(yield* readLatestUserMessageAt, "2026-08-19T00:00:01.100Z");
445+
446+ yield* appendAndProject({
447+ type: "thread.message-sent",
448+ eventId: EventId.make("evt-shell-summary-assistant-message-collision"),
449+ aggregateKind: "thread",
450+ aggregateId: threadId,
451+ occurredAt: "2026-08-19T00:00:01.200Z",
452+ commandId: CommandId.make("cmd-shell-summary-assistant-message-collision"),
453+ causationEventId: null,
454+ correlationId: CorrelationId.make("cmd-shell-summary-assistant-message-collision"),
455+ metadata: {},
456+ payload: {
457+ threadId,
458+ messageId: MessageId.make("message-shell-summary-collision"),
459+ role: "assistant",
460+ text: "The row was replaced",
461+ attachments: [],
462+ turnId: null,
463+ streaming: false,
464+ createdAt: "2026-08-19T00:00:01.200Z",
465+ updatedAt: "2026-08-19T00:00:01.200Z",
466+ },
467+ });
468+ assert.equal(yield* readLatestUserMessageAt, null);
469+
396470 yield* appendAndProject({
397471 type: "thread.activity-appended",
398472 eventId: EventId.make("evt-shell-summary-routine-activity"),
@@ -444,6 +518,58 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-shel · 45 unmodified lines
444518 });
445519 assert.equal(yield* readPendingUserInputCount, 1);
446520
521+ yield* appendAndProject({
522+ type: "thread.activity-appended",
523+ eventId: EventId.make("evt-shell-summary-user-input-overwritten"),
524+ aggregateKind: "thread",
525+ aggregateId: threadId,
526+ occurredAt: "2026-08-19T00:00:02.500Z",
527+ commandId: CommandId.make("cmd-shell-summary-user-input-overwritten"),
528+ causationEventId: null,
529+ correlationId: CorrelationId.make("cmd-shell-summary-user-input-overwritten"),
530+ metadata: {},
531+ payload: {
532+ threadId,
533+ activity: {
534+ id: EventId.make("activity-shell-summary-user-input-requested"),
535+ tone: "info",
536+ kind: "task.progress",
537+ summary: "Request was replaced",
538+ payload: { taskId: "task-2", status: "running" },
539+ sequence: 3,
540+ turnId: null,
541+ createdAt: "2026-08-19T00:00:02.500Z",
542+ },
543+ },
544+ });
545+ assert.equal(yield* readPendingUserInputCount, 0);
546+
547+ yield* appendAndProject({
548+ type: "thread.activity-appended",
549+ eventId: EventId.make("evt-shell-summary-user-input-requested-again"),
550+ aggregateKind: "thread",
551+ aggregateId: threadId,
552+ occurredAt: "2026-08-19T00:00:02.750Z",
553+ commandId: CommandId.make("cmd-shell-summary-user-input-requested-again"),
554+ causationEventId: null,
555+ correlationId: CorrelationId.make("cmd-shell-summary-user-input-requested-again"),
556+ metadata: {},
557+ payload: {
558+ threadId,
559+ activity: {
560+ id: EventId.make("activity-shell-summary-user-input-requested"),
561+ tone: "info",
562+ kind: "user-input.requested",
563+ summary: "User input requested again",
564+ payload: { requestId: "request-shell-summary-replacement" },
565+ sequence: 4,
566+ turnId: null,
567+ createdAt: "2026-08-19T00:00:02.750Z",
568+ },
569+ },
570+ });
571+ assert.equal(yield* readPendingUserInputCount, 1);
572+
447573 yield* appendAndProject({
448574 type: "thread.activity-appended",
449575 eventId: EventId.make("evt-shell-summary-user-input-resolved"),
@@ -461,7 +587,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-shel · 11 unmodified lines
461587 tone: "info",
462588 kind: "user-input.resolved",
463589 summary: "User input resolved",
464− payload: { requestId: "request-shell-summary-cost" },
590+ payload: { requestId: "request-shell-summary-replacement" },
465591 sequence: 1,
466592 turnId: null,
467593 createdAt: "2026-08-19T00:00:03.000Z",
@@ -470,12 +596,192 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-shel · 2 unmodified lines
470596 });
471597 assert.equal(yield* readPendingUserInputCount, 0);
472598
599+ const recoveryActivity = {
600+ id: EventId.make("activity-shell-summary-recovery"),
601+ tone: "info" as const,
602+ kind: "user-input.requested",
603+ summary: "Recovery request",
604+ payload: { requestId: "request-shell-summary-recovery" },
605+ sequence: 5,
606+ turnId: null,
607+ createdAt: "2026-08-19T00:00:04.000Z",
608+ };
609+ yield* appendAndProject({
610+ type: "thread.activity-appended",
611+ eventId: EventId.make("evt-shell-summary-recovery-requested"),
612+ aggregateKind: "thread",
613+ aggregateId: threadId,
614+ occurredAt: "2026-08-19T00:00:04.000Z",
615+ commandId: CommandId.make("cmd-shell-summary-recovery-requested"),
616+ causationEventId: null,
617+ correlationId: CorrelationId.make("cmd-shell-summary-recovery-requested"),
618+ metadata: {},
619+ payload: { threadId, activity: recoveryActivity },
620+ });
621+ assert.equal(yield* readPendingUserInputCount, 1);
622+
623+ yield* sql`
624+ DELETE FROM projection_thread_user_input_summaries
625+ WHERE thread_id = ${threadId}
626+ `;
627+ yield* appendAndProject({
628+ type: "thread.activity-appended",
629+ eventId: EventId.make("evt-shell-summary-recovery-replayed"),
630+ aggregateKind: "thread",
631+ aggregateId: threadId,
632+ occurredAt: "2026-08-19T00:00:04.500Z",
633+ commandId: CommandId.make("cmd-shell-summary-recovery-replayed"),
634+ causationEventId: null,
635+ correlationId: CorrelationId.make("cmd-shell-summary-recovery-replayed"),
636+ metadata: {},
637+ payload: { threadId, activity: recoveryActivity },
638+ });
639+ assert.equal(yield* readPendingUserInputCount, 1);
640+
473641 const activityRows = yield* sql<{ readonly count: number }>`
474642 SELECT COUNT(*) AS "count"
475643 FROM projection_thread_activities
476644 WHERE thread_id = ${threadId}
477645 `;
478− assert.deepEqual(activityRows, [{ count: 10004 }]);
646+ assert.deepEqual(activityRows, [{ count: 10005 }]);
647+ }),
648+ );
649+
650+ it.effect("refreshes previous owners after cross-thread id replacement", () =>
651+ Effect.gen(function* () {
652+ const sql = yield* SqlClient.SqlClient;
653+ const messages = yield* ProjectionThreadMessageRepository;
654+ const activities = yield* ProjectionThreadActivityRepository;
655+ const threadA = ThreadId.make("thread-shell-summary-owner-a");
656+ const threadB = ThreadId.make("thread-shell-summary-owner-b");
657+
658+ yield* sql`
659+ INSERT INTO projection_threads (
660+ thread_id,
661+ project_id,
662+ title,
663+ model_selection_json,
664+ runtime_mode,
665+ interaction_mode,
666+ branch,
667+ worktree_path,
668+ latest_turn_id,
669+ created_at,
670+ updated_at,
671+ archived_at,
672+ latest_user_message_at,
673+ pending_approval_count,
674+ pending_user_input_count,
675+ has_actionable_proposed_plan,
676+ deleted_at
677+ ) VALUES
678+ (
679+ ${threadA},
680+ 'project-shell-summary-owner',
681+ 'Owner A',
682+ '{"provider":"codex","model":"gpt-5-codex"}',
683+ 'approval-required',
684+ 'default',
685+ NULL,
686+ NULL,
687+ NULL,
688+ '2026-08-19T00:00:00.000Z',
689+ '2026-08-19T00:00:00.000Z',
690+ NULL,
691+ NULL,
692+ 0,
693+ 0,
694+ 0,
695+ NULL
696+ ),
697+ (
698+ ${threadB},
699+ 'project-shell-summary-owner',
700+ 'Owner B',
701+ '{"provider":"codex","model":"gpt-5-codex"}',
702+ 'approval-required',
703+ 'default',
704+ NULL,
705+ NULL,
706+ NULL,
707+ '2026-08-19T00:00:00.000Z',
708+ '2026-08-19T00:00:00.000Z',
709+ NULL,
710+ NULL,
711+ 0,
712+ 0,
713+ 0,
714+ NULL
715+ )
716+ `;
717+
718+ yield* messages.upsert({
719+ messageId: MessageId.make("message-shell-summary-owner-move"),
720+ threadId: threadA,
721+ turnId: null,
722+ role: "user",
723+ text: "Move me",
724+ isStreaming: false,
725+ createdAt: "2026-08-19T00:00:01.000Z",
726+ updatedAt: "2026-08-19T00:00:01.000Z",
727+ });
728+ yield* activities.upsert({
729+ activityId: EventId.make("activity-shell-summary-owner-move"),
730+ threadId: threadA,
731+ turnId: null,
732+ tone: "info",
733+ kind: "user-input.requested",
734+ summary: "Move request",
735+ payload: { requestId: "request-shell-summary-owner-move" },
736+ createdAt: "2026-08-19T00:00:02.000Z",
737+ });
738+
739+ yield* messages.upsert({
740+ messageId: MessageId.make("message-shell-summary-owner-move"),
741+ threadId: threadB,
742+ turnId: null,
743+ role: "assistant",
744+ text: "Moved",
745+ isStreaming: false,
746+ createdAt: "2026-08-19T00:00:03.000Z",
747+ updatedAt: "2026-08-19T00:00:03.000Z",
748+ });
749+ yield* activities.upsert({
750+ activityId: EventId.make("activity-shell-summary-owner-move"),
751+ threadId: threadB,
752+ turnId: null,
753+ tone: "info",
754+ kind: "task.progress",
755+ summary: "Moved request",
756+ payload: { taskId: "task-owner-move", status: "running" },
757+ createdAt: "2026-08-19T00:00:04.000Z",
758+ });
759+
760+ const rows = yield* sql<{
761+ readonly threadId: string;
762+ readonly latestUserMessageAt: string | null;
763+ readonly pendingUserInputCount: number;
764+ }>`
765+ SELECT
766+ thread_id AS "threadId",
767+ latest_user_message_at AS "latestUserMessageAt",
768+ pending_user_input_count AS "pendingUserInputCount"
769+ FROM projection_threads
770+ WHERE thread_id IN (${threadA}, ${threadB})
771+ ORDER BY thread_id
772+ `;
773+ assert.deepStrictEqual(rows, [
774+ {
775+ threadId: threadA,
776+ latestUserMessageAt: null,
777+ pendingUserInputCount: 0,
778+ },
779+ {
780+ threadId: threadB,
781+ latestUserMessageAt: null,
782+ pendingUserInputCount: 0,
783+ },
784+ ]);
479785 }),
480786 );
481787 },
@@ -2261,7 +2567,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 1779 unmodified lines
22612567 payload: {
22622568 threadId: ThreadId.make("thread-stale-user-input"),
22632569 activity: {
2264− id: EventId.make("activity-stale-user-input-requested"),
2570+ id: EventId.make("A"),
22652571 tone: "info",
22662572 kind: "user-input.requested",
22672573 summary: "User input requested",
@@ -2300,7 +2606,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 32 unmodified lines
23002606 payload: {
23012607 threadId: ThreadId.make("thread-stale-user-input"),
23022608 activity: {
2303− id: EventId.make("activity-stale-user-input-failed"),
2609+ id: EventId.make("a"),
23042610 tone: "error",
23052611 kind: "provider.user-input.respond.failed",
23062612 summary: "Provider user input response failed",
@@ -2310,7 +2616,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 3 unmodified lines
23102616 "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: user-input-request-stale-1",
23112617 },
23122618 turnId: null,
2313− createdAt: "2026-02-26T12:35:03.000Z",
2619+ createdAt: "2026-02-26T12:35:02.000Z",
23142620 },
23152621 },
23162622 });
@@ -2323,6 +2629,31 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 6 unmodified lines
23232629 WHERE thread_id = 'thread-stale-user-input'
23242630 `;
23252631 assert.deepEqual(threadRows, [{ pendingUserInputCount: 0 }]);
2632+
2633+ yield* appendAndProject({
2634+ type: "thread.reverted",
2635+ eventId: EventId.make("evt-stale-user-input-5"),
2636+ aggregateKind: "thread",
2637+ aggregateId: ThreadId.make("thread-stale-user-input"),
2638+ occurredAt: "2026-02-26T12:35:04.000Z",
2639+ commandId: CommandId.make("cmd-stale-user-input-5"),
2640+ causationEventId: null,
2641+ correlationId: CorrelationId.make("cmd-stale-user-input-5"),
2642+ metadata: {},
2643+ payload: {
2644+ threadId: ThreadId.make("thread-stale-user-input"),
2645+ turnCount: 0,
2646+ },
2647+ });
2648+
2649+ const revertedThreadRows = yield* sql<{
2650+ readonly pendingUserInputCount: number;
2651+ }>`
2652+ SELECT pending_user_input_count AS "pendingUserInputCount"
2653+ FROM projection_threads
2654+ WHERE thread_id = 'thread-stale-user-input'
2655+ `;
2656+ assert.deepEqual(revertedThreadRows, [{ pendingUserInputCount: 0 }]);
23262657 }),
23272658 );
23282659
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+17−96
@@ -130,60 +130,6 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { · 129 unmodified lines
130130 );
131131 }
132132
133−function isStalePendingUserInputFailureDetail(detail: string | null): boolean {
134− if (detail === null) {
135− return false;
136− }
137− return (
138− detail.includes("stale pending user-input request") ||
139− detail.includes("unknown pending user-input request") ||
140− detail.includes("unknown pending user input request") ||
141− detail.includes("unknown pending codex user input request")
142− );
143−}
144−
145−function derivePendingUserInputCountFromActivities(
146− activities: ReadonlyArray<ProjectionThreadActivity>,
147−): number {
148− const openRequestIds = new Set<string>();
149− const ordered = [...activities].toSorted(
150− (left, right) =>
151− left.createdAt.localeCompare(right.createdAt) ||
152− left.activityId.localeCompare(right.activityId),
153− );
154−
155− for (const activity of ordered) {
156− const requestId = extractActivityRequestId(activity.payload);
157− if (requestId === null) {
158− continue;
159− }
160− const payload =
161− typeof activity.payload === "object" && activity.payload !== null
162− ? (activity.payload as Record<string, unknown>)
163− : null;
164− const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null;
165−
166− if (activity.kind === "user-input.requested") {
167− openRequestIds.add(requestId);
168− continue;
169− }
170−
171− if (activity.kind === "user-input.resolved") {
172− openRequestIds.delete(requestId);
173− continue;
174− }
175−
176− if (
177− activity.kind === "provider.user-input.respond.failed" &&
178− isStalePendingUserInputFailureDetail(detail)
179− ) {
180− openRequestIds.delete(requestId);
181− }
182− }
183−
184− return openRequestIds.size;
185−}
186−
187133 function deriveHasActionableProposedPlan(input: {
188134 readonly latestTurnId: string | null;
189135 readonly proposedPlans: ReadonlyArray<ProjectionThreadProposedPlan>;
@@ -570,27 +516,18 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 380 unmodified lines
570516 return;
571517 }
572518
573− const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([
574− projectionThreadMessageRepository.listByThreadId({ threadId }),
575− projectionThreadProposedPlanRepository.listByThreadId({ threadId }),
576− projectionThreadActivityRepository.listByThreadId({ threadId }),
577− projectionPendingApprovalRepository.listByThreadId({ threadId }),
578− ]);
579−
580− let latestUserMessageAt: string | null = null;
581− for (const message of messages) {
582− if (
583− message.role === "user" &&
584− (latestUserMessageAt === null || message.createdAt > latestUserMessageAt)
585− ) {
586− latestUserMessageAt = message.createdAt;
587− }
588− }
519+ const [latestUserMessageAtOption, proposedPlans, pendingUserInputCount, pendingApprovals] =
520+ yield* Effect.all([
521+ projectionThreadMessageRepository.getLatestUserMessageAtByThreadId({ threadId }),
522+ projectionThreadProposedPlanRepository.listByThreadId({ threadId }),
523+ projectionThreadActivityRepository.countPendingUserInputsByThreadId({ threadId }),
524+ projectionPendingApprovalRepository.listByThreadId({ threadId }),
525+ ]);
589526
527+ const latestUserMessageAt = Option.getOrNull(latestUserMessageAtOption);
590528 const pendingApprovalCount = pendingApprovals.filter(
591529 (approval) => approval.status === "pending",
592530 ).length;
593− const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities);
594531 const hasActionableProposedPlan = deriveHasActionableProposedPlan({
595532 latestTurnId: existingRow.value.latestTurnId,
596533 proposedPlans,
@@ -877,20 +814,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 280 unmodified lines
877814
878815 switch (event.type) {
879816 case "thread.message-sent": {
880− if (event.payload.role !== "user") {
881− break;
882− }
883− const projectedMessage = yield* projectionThreadMessageRepository.getByMessageId({
884− messageId: event.payload.messageId,
885− });
886− if (
887− Option.isSome(projectedMessage) &&
888− projectedMessage.value.role === "user" &&
889− (latestUserMessageAt === null ||
890− projectedMessage.value.createdAt > latestUserMessageAt)
891− ) {
892− latestUserMessageAt = projectedMessage.value.createdAt;
893− }
817+ latestUserMessageAt = Option.getOrNull(
818+ yield* projectionThreadMessageRepository.getLatestUserMessageAtByThreadId({
819+ threadId: event.payload.threadId,
820+ }),
821+ );
894822 break;
895823 }
896824
@@ -931,17 +859,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 34 unmodified lines
931859 ).length;
932860 }
933861
934− if (
935− kind === "user-input.requested" ||
936− kind === "user-input.resolved" ||
937− (kind === "provider.user-input.respond.failed" &&
938− isStalePendingUserInputFailureDetail(detail))
939− ) {
940− pendingUserInputCount =
941− yield* projectionThreadActivityRepository.countPendingUserInputsByThreadId({
942− threadId: event.payload.threadId,
943− });
944− }
862+ pendingUserInputCount =
863+ yield* projectionThreadActivityRepository.countPendingUserInputsByThreadId({
864+ threadId: event.payload.threadId,
865+ });
945866 break;
946867 }
947868
apps/server/src/persistence/Layers/ProjectionThreadActivities.ts+243−76
@@ -1,8 +1,9 @@
11 import * as SqlClient from "effect/unstable/sql/SqlClient";
22 import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3−import { NonNegativeInt } from "@t3tools/contracts";
3+import { EventId, NonNegativeInt, ThreadId } from "@t3tools/contracts";
44 import * as Effect from "effect/Effect";
55 import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
67 import * as Schema from "effect/Schema";
78 import * as Struct from "effect/Struct";
89
@@ -40,6 +41,58 @@ const PendingUserInputCountRowSchema = Schema.Struct({ · 31 unmodified lines
4041 count: NonNegativeInt,
4142 });
4243
44+const ExistingUserInputActivityRowSchema = Schema.Struct({
45+ threadId: ThreadId,
46+ requestId: Schema.NullOr(Schema.String),
47+});
48+
49+const UserInputActivityKeySchema = Schema.Struct({
50+ threadId: ThreadId,
51+ requestId: Schema.String,
52+});
53+type UserInputActivityKey = typeof UserInputActivityKeySchema.Type;
54+
55+const UserInputStateRowSchema = Schema.Struct({
56+ state: Schema.Literals(["pending", "cleared"]),
57+});
58+type UserInputState = typeof UserInputStateRowSchema.Type.state;
59+
60+function userInputStateFromActivity(activity: {
61+ readonly kind: string;
62+ readonly payload: unknown;
63+}): { readonly requestId: string; readonly state: UserInputState } | null {
64+ if (typeof activity.payload !== "object" || activity.payload === null) {
65+ return null;
66+ }
67+ const payload = activity.payload as Record<string, unknown>;
68+ if (typeof payload.requestId !== "string") {
69+ return null;
70+ }
71+ if (activity.kind === "user-input.requested") {
72+ return { requestId: payload.requestId, state: "pending" };
73+ }
74+ if (activity.kind === "user-input.resolved") {
75+ return { requestId: payload.requestId, state: "cleared" };
76+ }
77+ if (activity.kind !== "provider.user-input.respond.failed") {
78+ return null;
79+ }
80+ const detail = typeof payload.detail === "string" ? payload.detail.toLowerCase() : "";
81+ if (
82+ !detail.includes("stale pending user-input request") &&
83+ !detail.includes("unknown pending user-input request") &&
84+ !detail.includes("unknown pending user input request") &&
85+ !detail.includes("unknown pending codex user input request")
86+ ) {
87+ return null;
88+ }
89+ return { requestId: payload.requestId, state: "cleared" };
90+}
91+
92+function userInputKeyId(key: UserInputActivityKey): string {
93+ return `${key.threadId.length}:${key.threadId}${key.requestId}`;
94+}
95+
4396 function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
4497 return (cause: unknown) =>
4598 Schema.isSchemaError(cause)
@@ -50,10 +103,41 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st · 4 unmodified lines
50103 const makeProjectionThreadActivityRepository = Effect.gen(function* () {
51104 const sql = yield* SqlClient.SqlClient;
52105
106+ const getExistingUserInputActivity = SqlSchema.findOneOption({
107+ Request: Schema.Struct({ activityId: EventId }),
108+ Result: ExistingUserInputActivityRowSchema,
109+ execute: ({ activityId }) =>
110+ sql`
111+ SELECT
112+ thread_id AS "threadId",
113+ user_input_request_id AS "requestId"
114+ FROM projection_thread_activities
115+ WHERE activity_id = ${activityId}
116+ AND user_input_state IS NOT NULL
117+ LIMIT 1
118+ `,
119+ });
120+
121+ const getLatestUserInputState = SqlSchema.findOneOption({
122+ Request: UserInputActivityKeySchema,
123+ Result: UserInputStateRowSchema,
124+ execute: ({ threadId, requestId }) =>
125+ sql`
126+ SELECT user_input_state AS "state"
127+ FROM projection_thread_activities
128+ WHERE thread_id = ${threadId}
129+ AND user_input_request_id = ${requestId}
130+ AND user_input_state IS NOT NULL
131+ ORDER BY created_at DESC, activity_id DESC
132+ LIMIT 1
133+ `,
134+ });
135+
53136 const upsertProjectionThreadActivityRow = SqlSchema.void({
54137 Request: ProjectionThreadActivity,
55− execute: (row) =>
56− sql`
138+ execute: (row) => {
139+ const userInputState = userInputStateFromActivity(row);
140+ return sql`
57141 INSERT INTO projection_thread_activities (
58142 activity_id,
59143 thread_id,
@@ -63,7 +147,9 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { · 3 unmodified lines
63147 summary,
64148 payload_json,
65149 sequence,
66− created_at
150+ created_at,
151+ user_input_request_id,
152+ user_input_state
67153 )
68154 VALUES (
69155 ${row.activityId},
@@ -74,7 +160,9 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { · 4 unmodified lines
74160 ${row.summary},
75161 ${JSON.stringify(row.payload)},
76162 ${row.sequence ?? null},
77− ${row.createdAt}
163+ ${row.createdAt},
164+ ${userInputState?.requestId ?? null},
165+ ${userInputState?.state ?? null}
78166 )
79167 ON CONFLICT (activity_id)
80168 DO UPDATE SET
@@ -85,8 +173,11 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { · 4 unmodified lines
85173 summary = excluded.summary,
86174 payload_json = excluded.payload_json,
87175 sequence = excluded.sequence,
88− created_at = excluded.created_at
89− `,
176+ created_at = excluded.created_at,
177+ user_input_request_id = excluded.user_input_request_id,
178+ user_input_state = excluded.user_input_state
179+ `;
180+ },
90181 });
91182
92183 const listProjectionThreadActivityRows = SqlSchema.findAll({
@@ -114,68 +205,60 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { · 21 unmodified lines
114205 `,
115206 });
116207
117− const readPendingUserInputCount = SqlSchema.findOne({
208+ const readPendingUserInputCount = SqlSchema.findOneOption({
118209 Request: ListProjectionThreadActivitiesInput,
119210 Result: PendingUserInputCountRowSchema,
120211 execute: ({ threadId }) =>
121212 sql`
122− WITH extracted AS (
123− SELECT
124− activity_id,
125− kind,
126− created_at,
127− CASE
128− WHEN json_type(payload_json, '$.requestId') = 'text'
129− THEN json_extract(payload_json, '$.requestId')
130− ELSE NULL
131− END AS request_id,
132− lower(
133− CASE
134− WHEN json_type(payload_json, '$.detail') = 'text'
135− THEN json_extract(payload_json, '$.detail')
136− ELSE ''
137− END
138− ) AS detail
139− FROM projection_thread_activities
140− WHERE thread_id = ${threadId}
141− AND kind IN (
142− 'user-input.requested',
143− 'user-input.resolved',
144− 'provider.user-input.respond.failed'
145− )
146− ),
147− latest_state_change AS (
213+ SELECT pending_count AS "count"
214+ FROM projection_thread_user_input_summaries
215+ WHERE thread_id = ${threadId}
216+ LIMIT 1
217+ `,
218+ });
219+
220+ const rebuildPendingUserInputCount = SqlSchema.findOne({
221+ Request: ListProjectionThreadActivitiesInput,
222+ Result: PendingUserInputCountRowSchema,
223+ execute: ({ threadId }) =>
224+ sql`
225+ WITH ranked AS (
148226 SELECT
149− kind,
227+ user_input_state,
150228 ROW_NUMBER() OVER (
151− PARTITION BY request_id
152− ORDER BY
153− created_at DESC,
154− activity_id DESC
229+ PARTITION BY user_input_request_id
230+ ORDER BY created_at DESC, activity_id DESC
155231 ) AS row_number
156− FROM extracted
157− WHERE request_id IS NOT NULL
158− AND length(request_id) > 0
159− AND (
160− kind IN ('user-input.requested', 'user-input.resolved')
161− OR (
162− kind = 'provider.user-input.respond.failed'
163− AND (
164− detail LIKE '%stale pending user-input request%'
165− OR detail LIKE '%unknown pending user-input request%'
166− OR detail LIKE '%unknown pending user input request%'
167− OR detail LIKE '%unknown pending codex user input request%'
168− )
169− )
170− )
232+ FROM projection_thread_activities
233+ WHERE thread_id = ${threadId}
234+ AND user_input_request_id IS NOT NULL
235+ AND user_input_state IS NOT NULL
171236 )
172237 SELECT COUNT(*) AS "count"
173− FROM latest_state_change
238+ FROM ranked
174239 WHERE row_number = 1
175− AND kind = 'user-input.requested'
240+ AND user_input_state = 'pending'
176241 `,
177242 });
178243
244+ const getOrRebuildPendingUserInputCount = Effect.fn(
245+ "ProjectionThreadActivityRepository.getOrRebuildPendingUserInputCount",
246+ )(function* (input: ListProjectionThreadActivitiesInput) {
247+ const existing = yield* readPendingUserInputCount(input);
248+ if (Option.isSome(existing)) {
249+ return existing.value.count;
250+ }
251+
252+ const rebuilt = yield* rebuildPendingUserInputCount(input);
253+ yield* sql`
254+ INSERT INTO projection_thread_user_input_summaries (thread_id, pending_count)
255+ VALUES (${input.threadId}, ${rebuilt.count})
256+ ON CONFLICT (thread_id)
257+ DO UPDATE SET pending_count = excluded.pending_count
258+ `;
259+ return rebuilt.count;
260+ });
261+
179262 const deleteProjectionThreadActivityRows = SqlSchema.void({
180263 Request: DeleteProjectionThreadActivitiesInput,
181264 execute: ({ threadId }) =>
@@ -185,15 +268,86 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { · 3 unmodified lines
185268 `,
186269 });
187270
271+ const resetPendingUserInputSummary = SqlSchema.void({
272+ Request: DeleteProjectionThreadActivitiesInput,
273+ execute: ({ threadId }) =>
274+ sql`
275+ INSERT INTO projection_thread_user_input_summaries (thread_id, pending_count)
276+ VALUES (${threadId}, 0)
277+ ON CONFLICT (thread_id)
278+ DO UPDATE SET pending_count = 0
279+ `,
280+ });
281+
188282 const upsert: ProjectionThreadActivityRepositoryShape["upsert"] = (row) =>
189− upsertProjectionThreadActivityRow(row).pipe(
190− Effect.mapError(
191− toPersistenceSqlOrDecodeError(
192− "ProjectionThreadActivityRepository.upsert:query",
193− "ProjectionThreadActivityRepository.upsert:encodeRequest",
283+ sql
284+ .withTransaction(
285+ Effect.gen(function* () {
286+ yield* getOrRebuildPendingUserInputCount({ threadId: row.threadId });
287+ const previous = yield* getExistingUserInputActivity({ activityId: row.activityId });
288+ if (Option.isSome(previous) && previous.value.threadId !== row.threadId) {
289+ yield* getOrRebuildPendingUserInputCount({ threadId: previous.value.threadId });
290+ }
291+ const next = userInputStateFromActivity(row);
292+ const keys = new Map<string, UserInputActivityKey>();
293+ if (Option.isSome(previous) && previous.value.requestId !== null) {
294+ const key = {
295+ threadId: previous.value.threadId,
296+ requestId: previous.value.requestId,
297+ };
298+ keys.set(userInputKeyId(key), key);
299+ }
300+ if (next !== null) {
301+ const key = { threadId: row.threadId, requestId: next.requestId };
302+ keys.set(userInputKeyId(key), key);
303+ }
304+
305+ const before = new Map<string, boolean>();
306+ for (const [keyId, key] of keys) {
307+ const state = yield* getLatestUserInputState(key);
308+ before.set(keyId, Option.isSome(state) && state.value.state === "pending");
309+ }
310+
311+ yield* upsertProjectionThreadActivityRow(row);
312+
313+ const deltas = new Map<string, number>();
314+ for (const [keyId, key] of keys) {
315+ const state = yield* getLatestUserInputState(key);
316+ const wasPending = before.get(keyId) ?? false;
317+ const isPending = Option.isSome(state) && state.value.state === "pending";
318+ const delta = Number(isPending) - Number(wasPending);
319+ if (delta !== 0) {
320+ deltas.set(key.threadId, (deltas.get(key.threadId) ?? 0) + delta);
321+ }
322+ }
323+ for (const [threadId, delta] of deltas) {
324+ yield* sql`
325+ INSERT INTO projection_thread_user_input_summaries (thread_id, pending_count)
326+ VALUES (${threadId}, ${Math.max(delta, 0)})
327+ ON CONFLICT (thread_id)
328+ DO UPDATE SET pending_count = pending_count + ${delta}
329+ `;
330+ }
331+
332+ const affectedThreadIds = new Set(Array.from(keys.values(), (key) => key.threadId));
333+ for (const threadId of affectedThreadIds) {
334+ const pendingUserInputCount = yield* getOrRebuildPendingUserInputCount({ threadId });
335+ yield* sql`
336+ UPDATE projection_threads
337+ SET pending_user_input_count = ${pendingUserInputCount}
338+ WHERE thread_id = ${threadId}
339+ `;
340+ }
341+ }),
342+ )
343+ .pipe(
344+ Effect.mapError(
345+ toPersistenceSqlOrDecodeError(
346+ "ProjectionThreadActivityRepository.upsert:query",
347+ "ProjectionThreadActivityRepository.upsert:encodeRequest",
348+ ),
194349 ),
195− ),
196− );
350+ );
197351
198352 const listByThreadId: ProjectionThreadActivityRepositoryShape["listByThreadId"] = (input) =>
199353 listProjectionThreadActivityRows(input).pipe(
@@ -208,22 +362,35 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { · 8 unmodified lines
208362
209363 const countPendingUserInputsByThreadId: ProjectionThreadActivityRepositoryShape["countPendingUserInputsByThreadId"] =
210364 (input) =>
211− readPendingUserInputCount(input).pipe(
212− Effect.mapError(
213− toPersistenceSqlOrDecodeError(
214− "ProjectionThreadActivityRepository.countPendingUserInputsByThreadId:query",
215− "ProjectionThreadActivityRepository.countPendingUserInputsByThreadId:decodeRow",
365+ sql
366+ .withTransaction(getOrRebuildPendingUserInputCount(input))
367+ .pipe(
368+ Effect.mapError(
369+ toPersistenceSqlOrDecodeError(
370+ "ProjectionThreadActivityRepository.countPendingUserInputsByThreadId:query",
371+ "ProjectionThreadActivityRepository.countPendingUserInputsByThreadId:decodeRow",
372+ ),
216373 ),
217− ),
218− Effect.map((row) => row.count),
219− );
374+ );
220375
221376 const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) =>
222− deleteProjectionThreadActivityRows(input).pipe(
223− Effect.mapError(
224− toPersistenceSqlError("ProjectionThreadActivityRepository.deleteByThreadId:query"),
225− ),
226− );
377+ sql
378+ .withTransaction(
379+ Effect.gen(function* () {
380+ yield* deleteProjectionThreadActivityRows(input);
381+ yield* resetPendingUserInputSummary(input);
382+ yield* sql`
383+ UPDATE projection_threads
384+ SET pending_user_input_count = 0
385+ WHERE thread_id = ${input.threadId}
386+ `;
387+ }),
388+ )
389+ .pipe(
390+ Effect.mapError(
391+ toPersistenceSqlError("ProjectionThreadActivityRepository.deleteByThreadId:query"),
392+ ),
393+ );
227394
228395 return {
229396 upsert,
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+69−8
@@ -40,6 +40,10 @@ function toProjectionThreadMessage( · 39 unmodified lines
4040 };
4141 }
4242
43+const LatestUserMessageAtRowSchema = Schema.Struct({
44+ createdAt: ProjectionThreadMessage.fields.createdAt,
45+});
46+
4347 const makeProjectionThreadMessageRepository = Effect.gen(function* () {
4448 const sql = yield* SqlClient.SqlClient;
4549
@@ -137,6 +141,20 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 91 unmodified lines
137141 `,
138142 });
139143
144+ const getLatestUserMessageAtRow = SqlSchema.findOneOption({
145+ Request: ListProjectionThreadMessagesInput,
146+ Result: LatestUserMessageAtRowSchema,
147+ execute: ({ threadId }) =>
148+ sql`
149+ SELECT created_at AS "createdAt"
150+ FROM projection_thread_messages
151+ WHERE thread_id = ${threadId}
152+ AND role = 'user'
153+ ORDER BY created_at DESC, message_id DESC
154+ LIMIT 1
155+ `,
156+ });
157+
140158 const deleteProjectionThreadMessageRows = SqlSchema.void({
141159 Request: DeleteProjectionThreadMessagesInput,
142160 execute: ({ threadId }) =>
@@ -147,9 +165,29 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 4 unmodified lines
147165 });
148166
149167 const upsert: ProjectionThreadMessageRepositoryShape["upsert"] = (row) =>
150− upsertProjectionThreadMessageRow(row).pipe(
151− Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")),
152− );
168+ sql
169+ .withTransaction(
170+ Effect.gen(function* () {
171+ const previous = yield* getProjectionThreadMessageRow({ messageId: row.messageId });
172+ yield* upsertProjectionThreadMessageRow(row);
173+
174+ const affectedThreadIds = new Set([row.threadId]);
175+ if (Option.isSome(previous)) {
176+ affectedThreadIds.add(previous.value.threadId);
177+ }
178+ for (const threadId of affectedThreadIds) {
179+ const latest = yield* getLatestUserMessageAtRow({ threadId });
180+ yield* sql`
181+ UPDATE projection_threads
182+ SET latest_user_message_at = ${Option.isSome(latest) ? latest.value.createdAt : null}
183+ WHERE thread_id = ${threadId}
184+ `;
185+ }
186+ }),
187+ )
188+ .pipe(
189+ Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")),
190+ );
153191
154192 const getByMessageId: ProjectionThreadMessageRepositoryShape["getByMessageId"] = (input) =>
155193 getProjectionThreadMessageRow(input).pipe(
@@ -167,17 +205,40 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 11 unmodified lines
167205 Effect.map((rows) => rows.map(toProjectionThreadMessage)),
168206 );
169207
208+ const getLatestUserMessageAtByThreadId: ProjectionThreadMessageRepositoryShape["getLatestUserMessageAtByThreadId"] =
209+ (input) =>
210+ getLatestUserMessageAtRow(input).pipe(
211+ Effect.mapError(
212+ toPersistenceSqlError(
213+ "ProjectionThreadMessageRepository.getLatestUserMessageAtByThreadId:query",
214+ ),
215+ ),
216+ Effect.map(Option.map((row) => row.createdAt)),
217+ );
218+
170219 const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) =>
171− deleteProjectionThreadMessageRows(input).pipe(
172− Effect.mapError(
173− toPersistenceSqlError("ProjectionThreadMessageRepository.deleteByThreadId:query"),
174− ),
175− );
220+ sql
221+ .withTransaction(
222+ Effect.gen(function* () {
223+ yield* deleteProjectionThreadMessageRows(input);
224+ yield* sql`
225+ UPDATE projection_threads
226+ SET latest_user_message_at = NULL
227+ WHERE thread_id = ${input.threadId}
228+ `;
229+ }),
230+ )
231+ .pipe(
232+ Effect.mapError(
233+ toPersistenceSqlError("ProjectionThreadMessageRepository.deleteByThreadId:query"),
234+ ),
235+ );
176236
177237 return {
178238 upsert,
179239 getByMessageId,
180240 listByThreadId,
241+ getLatestUserMessageAtByThreadId,
181242 deleteByThreadId,
182243 } satisfies ProjectionThreadMessageRepositoryShape;
183244 });
apps/server/src/persistence/Migrations.ts+2−0
@@ -54,6 +54,7 @@ import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; · 53 unmodified lines
5454 import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";
5555 import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
5656 import Migration0041 from "./Migrations/041_ProjectionThreadUserInputLifecycleIndex.ts";
57+import Migration0042 from "./Migrations/042_ProjectionThreadShellSummaryState.ts";
5758
5859 /**
5960 * Migration loader with all migrations defined inline.
@@ -107,6 +108,7 @@ export const migrationEntries = [ · 47 unmodified lines
107108 [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039],
108109 [40, "ProjectionProjectFaviconPath", Migration0040],
109110 [41, "ProjectionThreadUserInputLifecycleIndex", Migration0041],
111+ [42, "ProjectionThreadShellSummaryState", Migration0042],
110112 ] as const;
111113
112114 export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/042_ProjectionThreadShellSummaryState.test.ts+301−0
@@ -0,0 +1,301 @@
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("042_ProjectionThreadShellSummaryState", (it) => {
12+ it.effect("upgrades recorded migration 41 and reconciles shell summaries", () =>
13+ Effect.gen(function* () {
14+ const sql = yield* SqlClient.SqlClient;
15+
16+ yield* runMigrations({ toMigrationInclusive: 41 });
17+ yield* sql`
18+ INSERT INTO projection_threads (
19+ thread_id,
20+ project_id,
21+ title,
22+ model_selection_json,
23+ runtime_mode,
24+ interaction_mode,
25+ branch,
26+ worktree_path,
27+ latest_turn_id,
28+ created_at,
29+ updated_at,
30+ archived_at,
31+ latest_user_message_at,
32+ pending_approval_count,
33+ pending_user_input_count,
34+ has_actionable_proposed_plan,
35+ deleted_at
36+ ) VALUES
37+ (
38+ 'thread-cleared',
39+ 'project-1',
40+ 'Cleared',
41+ '{"provider":"codex","model":"gpt-5-codex"}',
42+ 'approval-required',
43+ 'default',
44+ NULL,
45+ NULL,
46+ NULL,
47+ '2026-08-19T00:00:00.000Z',
48+ '2026-08-19T00:00:00.000Z',
49+ NULL,
50+ '2026-08-19T00:00:05.000Z',
51+ 0,
52+ 1,
53+ 0,
54+ NULL
55+ ),
56+ (
57+ 'thread-pending',
58+ 'project-1',
59+ 'Pending',
60+ '{"provider":"codex","model":"gpt-5-codex"}',
61+ 'approval-required',
62+ 'default',
63+ NULL,
64+ NULL,
65+ NULL,
66+ '2026-08-19T00:00:00.000Z',
67+ '2026-08-19T00:00:00.000Z',
68+ NULL,
69+ NULL,
70+ 0,
71+ 0,
72+ 0,
73+ NULL
74+ )
75+ `;
76+
77+ yield* sql`
78+ INSERT INTO projection_thread_activities (
79+ activity_id,
80+ thread_id,
81+ turn_id,
82+ tone,
83+ kind,
84+ summary,
85+ payload_json,
86+ sequence,
87+ created_at
88+ ) VALUES
89+ (
90+ 'A',
91+ 'thread-cleared',
92+ NULL,
93+ 'info',
94+ 'user-input.requested',
95+ 'Tie requested',
96+ '{"requestId":"request-tie"}',
97+ NULL,
98+ '2026-08-19T00:00:02.000Z'
99+ ),
100+ (
101+ 'a',
102+ 'thread-cleared',
103+ NULL,
104+ 'info',
105+ 'user-input.resolved',
106+ 'Tie resolved',
107+ '{"requestId":"request-tie"}',
108+ NULL,
109+ '2026-08-19T00:00:02.000Z'
110+ ),
111+ (
112+ 'activity-pending',
113+ 'thread-pending',
114+ NULL,
115+ 'info',
116+ 'user-input.requested',
117+ 'Pending request',
118+ '{"requestId":"request-pending"}',
119+ NULL,
120+ '2026-08-19T00:00:01.000Z'
121+ ),
122+ (
123+ 'activity-object-detail',
124+ 'thread-pending',
125+ NULL,
126+ 'error',
127+ 'provider.user-input.respond.failed',
128+ 'Object detail',
129+ '{"requestId":"request-pending","detail":{"message":"stale pending user-input request"}}',
130+ NULL,
131+ '2026-08-19T00:00:02.000Z'
132+ ),
133+ (
134+ 'activity-malformed',
135+ 'thread-pending',
136+ NULL,
137+ 'info',
138+ 'user-input.requested',
139+ 'Malformed',
140+ '{',
141+ NULL,
142+ '2026-08-19T00:00:03.000Z'
143+ )
144+ `;
145+
146+ yield* sql`
147+ INSERT INTO projection_thread_messages (
148+ message_id,
149+ thread_id,
150+ turn_id,
151+ role,
152+ text,
153+ attachments_json,
154+ is_streaming,
155+ created_at,
156+ updated_at
157+ ) VALUES
158+ (
159+ 'message-assistant',
160+ 'thread-cleared',
161+ NULL,
162+ 'assistant',
163+ 'No user message remains',
164+ NULL,
165+ 0,
166+ '2026-08-19T00:00:05.000Z',
167+ '2026-08-19T00:00:05.000Z'
168+ ),
169+ (
170+ 'message-user',
171+ 'thread-pending',
172+ NULL,
173+ 'user',
174+ 'Latest user message',
175+ NULL,
176+ 0,
177+ '2026-08-19T00:00:06.000Z',
178+ '2026-08-19T00:00:06.000Z'
179+ )
180+ `;
181+
182+ yield* runMigrations({ toMigrationInclusive: 42 });
183+
184+ const normalizedRows = yield* sql<{
185+ readonly activityId: string;
186+ readonly requestId: string | null;
187+ readonly state: string | null;
188+ }>`
189+ SELECT
190+ activity_id AS "activityId",
191+ user_input_request_id AS "requestId",
192+ user_input_state AS "state"
193+ FROM projection_thread_activities
194+ WHERE activity_id IN ('activity-malformed', 'activity-object-detail')
195+ ORDER BY activity_id
196+ `;
197+ assert.deepStrictEqual(normalizedRows, [
198+ { activityId: "activity-malformed", requestId: null, state: null },
199+ { activityId: "activity-object-detail", requestId: null, state: null },
200+ ]);
201+
202+ const summaryRows = yield* sql<{
203+ readonly threadId: string;
204+ readonly pendingCount: number;
205+ }>`
206+ SELECT
207+ thread_id AS "threadId",
208+ pending_count AS "pendingCount"
209+ FROM projection_thread_user_input_summaries
210+ ORDER BY thread_id
211+ `;
212+ assert.deepStrictEqual(summaryRows, [
213+ { threadId: "thread-cleared", pendingCount: 0 },
214+ { threadId: "thread-pending", pendingCount: 1 },
215+ ]);
216+
217+ const threadRows = yield* sql<{
218+ readonly threadId: string;
219+ readonly latestUserMessageAt: string | null;
220+ readonly pendingUserInputCount: number;
221+ }>`
222+ SELECT
223+ thread_id AS "threadId",
224+ latest_user_message_at AS "latestUserMessageAt",
225+ pending_user_input_count AS "pendingUserInputCount"
226+ FROM projection_threads
227+ ORDER BY thread_id
228+ `;
229+ assert.deepStrictEqual(threadRows, [
230+ {
231+ threadId: "thread-cleared",
232+ latestUserMessageAt: null,
233+ pendingUserInputCount: 0,
234+ },
235+ {
236+ threadId: "thread-pending",
237+ latestUserMessageAt: "2026-08-19T00:00:06.000Z",
238+ pendingUserInputCount: 1,
239+ },
240+ ]);
241+
242+ const legacyIndexes = yield* sql<{ readonly name: string }>`
243+ SELECT name
244+ FROM sqlite_master
245+ WHERE type = 'index'
246+ AND name = 'idx_projection_thread_activities_thread_user_input_lifecycle'
247+ `;
248+ assert.deepStrictEqual(legacyIndexes, []);
249+
250+ const lifecycleIndexColumns = yield* sql<{ readonly name: string }>`
251+ PRAGMA index_info('idx_projection_thread_activities_user_input_state')
252+ `;
253+ assert.deepStrictEqual(
254+ lifecycleIndexColumns.map((column) => column.name),
255+ ["thread_id", "user_input_request_id", "created_at", "activity_id"],
256+ );
257+
258+ const lifecyclePlan = yield* sql<{ readonly detail: string }>`
259+ EXPLAIN QUERY PLAN
260+ SELECT user_input_state
261+ FROM projection_thread_activities
262+ WHERE thread_id = 'thread-pending'
263+ AND user_input_request_id = 'request-pending'
264+ AND user_input_state IS NOT NULL
265+ ORDER BY created_at DESC, activity_id DESC
266+ LIMIT 1
267+ `;
268+ const lifecyclePlanText = lifecyclePlan.map((row) => row.detail).join("\n");
269+ assert.match(
270+ lifecyclePlanText,
271+ /USING INDEX idx_projection_thread_activities_user_input_state/,
272+ );
273+ assert.notMatch(lifecyclePlanText, /USE TEMP B-TREE/);
274+
275+ const summaryPlan = yield* sql<{ readonly detail: string }>`
276+ EXPLAIN QUERY PLAN
277+ SELECT pending_count
278+ FROM projection_thread_user_input_summaries
279+ WHERE thread_id = 'thread-pending'
280+ LIMIT 1
281+ `;
282+ assert.match(summaryPlan.map((row) => row.detail).join("\n"), /USING INDEX/);
283+
284+ const messagePlan = yield* sql<{ readonly detail: string }>`
285+ EXPLAIN QUERY PLAN
286+ SELECT created_at
287+ FROM projection_thread_messages
288+ WHERE thread_id = 'thread-pending'
289+ AND role = 'user'
290+ ORDER BY created_at DESC, message_id DESC
291+ LIMIT 1
292+ `;
293+ const messagePlanText = messagePlan.map((row) => row.detail).join("\n");
294+ assert.match(
295+ messagePlanText,
296+ /USING COVERING INDEX idx_projection_thread_messages_latest_user/,
297+ );
298+ assert.notMatch(messagePlanText, /USE TEMP B-TREE/);
299+ }),
300+ );
301+});
apps/server/src/persistence/Migrations/042_ProjectionThreadShellSummaryState.ts+148−0
@@ -0,0 +1,148 @@
1+import * as SqlClient from "effect/unstable/sql/SqlClient";
2+import * as Effect from "effect/Effect";
3+
4+export default Effect.gen(function* () {
5+ const sql = yield* SqlClient.SqlClient;
6+
7+ yield* sql`
8+ ALTER TABLE projection_thread_activities
9+ ADD COLUMN user_input_request_id TEXT
10+ `;
11+
12+ yield* sql`
13+ ALTER TABLE projection_thread_activities
14+ ADD COLUMN user_input_state TEXT
15+ CHECK (user_input_state IN ('pending', 'cleared'))
16+ `;
17+
18+ yield* sql`
19+ UPDATE projection_thread_activities
20+ SET
21+ user_input_request_id = CASE
22+ WHEN json_valid(payload_json) THEN CASE
23+ WHEN json_type(payload_json, '$.requestId') = 'text'
24+ THEN json_extract(payload_json, '$.requestId')
25+ ELSE NULL
26+ END
27+ ELSE NULL
28+ END,
29+ user_input_state = CASE
30+ WHEN kind = 'user-input.requested' THEN 'pending'
31+ WHEN kind = 'user-input.resolved' THEN 'cleared'
32+ WHEN kind = 'provider.user-input.respond.failed'
33+ AND json_valid(payload_json)
34+ AND json_type(payload_json, '$.detail') = 'text'
35+ THEN CASE
36+ WHEN lower(json_extract(payload_json, '$.detail'))
37+ LIKE '%stale pending user-input request%'
38+ OR lower(json_extract(payload_json, '$.detail'))
39+ LIKE '%unknown pending user-input request%'
40+ OR lower(json_extract(payload_json, '$.detail'))
41+ LIKE '%unknown pending user input request%'
42+ OR lower(json_extract(payload_json, '$.detail'))
43+ LIKE '%unknown pending codex user input request%'
44+ THEN 'cleared'
45+ ELSE NULL
46+ END
47+ ELSE NULL
48+ END
49+ WHERE kind IN (
50+ 'user-input.requested',
51+ 'user-input.resolved',
52+ 'provider.user-input.respond.failed'
53+ )
54+ `;
55+
56+ yield* sql`
57+ UPDATE projection_thread_activities
58+ SET user_input_state = NULL
59+ WHERE user_input_request_id IS NULL
60+ `;
61+
62+ yield* sql`
63+ UPDATE projection_thread_activities
64+ SET user_input_request_id = NULL
65+ WHERE user_input_state IS NULL
66+ `;
67+
68+ yield* sql`
69+ CREATE INDEX IF NOT EXISTS idx_projection_thread_activities_user_input_state
70+ ON projection_thread_activities(
71+ thread_id,
72+ user_input_request_id,
73+ created_at DESC,
74+ activity_id DESC
75+ )
76+ WHERE user_input_state IS NOT NULL
77+ `;
78+
79+ yield* sql`
80+ DROP INDEX IF EXISTS idx_projection_thread_activities_thread_user_input_lifecycle
81+ `;
82+
83+ yield* sql`
84+ CREATE TABLE IF NOT EXISTS projection_thread_user_input_summaries (
85+ thread_id TEXT PRIMARY KEY,
86+ pending_count INTEGER NOT NULL CHECK (pending_count >= 0)
87+ )
88+ `;
89+
90+ yield* sql`
91+ WITH ranked AS (
92+ SELECT
93+ thread_id,
94+ user_input_request_id,
95+ user_input_state,
96+ ROW_NUMBER() OVER (
97+ PARTITION BY thread_id, user_input_request_id
98+ ORDER BY created_at DESC, activity_id DESC
99+ ) AS row_number
100+ FROM projection_thread_activities
101+ WHERE user_input_state IS NOT NULL
102+ AND user_input_request_id IS NOT NULL
103+ )
104+ INSERT INTO projection_thread_user_input_summaries (
105+ thread_id,
106+ pending_count
107+ )
108+ SELECT
109+ thread_id,
110+ SUM(CASE WHEN user_input_state = 'pending' THEN 1 ELSE 0 END)
111+ FROM ranked
112+ WHERE row_number = 1
113+ GROUP BY thread_id
114+ `;
115+
116+ yield* sql`
117+ INSERT OR IGNORE INTO projection_thread_user_input_summaries (
118+ thread_id,
119+ pending_count
120+ )
121+ SELECT thread_id, 0
122+ FROM projection_threads
123+ `;
124+
125+ yield* sql`
126+ CREATE INDEX IF NOT EXISTS idx_projection_thread_messages_latest_user
127+ ON projection_thread_messages(thread_id, created_at DESC, message_id DESC)
128+ WHERE role = 'user'
129+ `;
130+
131+ yield* sql`
132+ UPDATE projection_threads
133+ SET
134+ pending_user_input_count = COALESCE((
135+ SELECT summary.pending_count
136+ FROM projection_thread_user_input_summaries AS summary
137+ WHERE summary.thread_id = projection_threads.thread_id
138+ ), 0),
139+ latest_user_message_at = (
140+ SELECT message.created_at
141+ FROM projection_thread_messages AS message
142+ WHERE message.thread_id = projection_threads.thread_id
143+ AND message.role = 'user'
144+ ORDER BY message.created_at DESC, message.message_id DESC
145+ LIMIT 1
146+ )
147+ `;
148+});
apps/server/src/persistence/Services/ProjectionThreadMessages.ts+10−0
@@ -78,6 +78,16 @@ export interface ProjectionThreadMessageRepositoryShape { · 77 unmodified lines
7878 input: ListProjectionThreadMessagesInput,
7979 ) => Effect.Effect<ReadonlyArray<ProjectionThreadMessage>, ProjectionRepositoryError>;
8080
81+ /**
82+ * Read the latest projected user-message timestamp for a thread.
83+ */
84+ readonly getLatestUserMessageAtByThreadId: (
85+ input: ListProjectionThreadMessagesInput,
86+ ) => Effect.Effect<
87+ Option.Option<ProjectionThreadMessage["createdAt"]>,
88+ ProjectionRepositoryError
89+ >;
90+
8191 /**
8292 * Delete projected thread messages by thread.
8393 */