| author | |
| committer | |
| log | 8ac5462920c45cdee63af15b2598909736f2ec84 |
| tree | b845a2b99f3409aaa33e8b4c06f5df03b34eb8d7 |
| parent | 560afffdea82000d757c98ea79678aee75f8648c |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 205 insertions(+), 20 deletions(-)
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+101-1| ... | ... | @@ -2534,7 +2534,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 2534 | 2534 | }), |
| 2535 | 2535 | ); |
| 2536 | 2536 | |
| 2537 | it.effect("maintains shell summary fields across message and activity streams", () => | |
| 2537 | it.effect("maintains shell summaries without reading message bodies", () => | |
| 2538 | 2538 | Effect.gen(function* () { |
| 2539 | 2539 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| 2540 | 2540 | const eventStore = yield* OrchestrationEventStore; |
| ... | ... | @@ -2742,6 +2742,106 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 2742 | 2742 | updatedAt: "2026-03-01T08:00:05.000Z", |
| 2743 | 2743 | }, |
| 2744 | 2744 | ]); |
| 2745 | ||
| 2746 | // Summary refreshes must not decode message bodies or attachment metadata. | |
| 2747 | yield* sql` | |
| 2748 | UPDATE projection_thread_messages | |
| 2749 | SET attachments_json = '{not-json' | |
| 2750 | WHERE thread_id = 'thread-shell-summary' | |
| 2751 | `; | |
| 2752 | yield* sql` | |
| 2753 | INSERT INTO projection_pending_approvals ( | |
| 2754 | request_id, thread_id, turn_id, status, decision, created_at, resolved_at | |
| 2755 | ) VALUES | |
| 2756 | ('summary-pending', 'thread-shell-summary', NULL, 'pending', NULL, | |
| 2757 | '2026-03-01T08:00:06.000Z', NULL), | |
| 2758 | ('summary-resolved', 'thread-shell-summary', NULL, 'resolved', 'accept', | |
| 2759 | '2026-03-01T08:00:06.000Z', '2026-03-01T08:00:06.000Z'), | |
| 2760 | ('summary-other-thread', 'thread-shell-summary-other', NULL, 'pending', NULL, | |
| 2761 | '2026-03-01T08:00:06.000Z', NULL) | |
| 2762 | `; | |
| 2763 | yield* sql` | |
| 2764 | INSERT INTO projection_thread_proposed_plans ( | |
| 2765 | plan_id, thread_id, turn_id, plan_markdown, implemented_at, | |
| 2766 | implementation_thread_id, created_at, updated_at | |
| 2767 | ) VALUES ( | |
| 2768 | 'summary-plan', 'thread-shell-summary', 'turn-shell-summary-1', '# Plan', NULL, | |
| 2769 | NULL, '2026-03-01T08:00:06.000Z', '2026-03-01T08:00:06.000Z' | |
| 2770 | ) | |
| 2771 | `; | |
| 2772 | ||
| 2773 | const refreshEvents = [ | |
| 2774 | { | |
| 2775 | type: "thread.session-set", | |
| 2776 | eventId: EventId.make("evt-shell-summary-7"), | |
| 2777 | aggregateKind: "thread", | |
| 2778 | aggregateId: ThreadId.make("thread-shell-summary"), | |
| 2779 | occurredAt: "2026-03-01T08:00:07.000Z", | |
| 2780 | commandId: CommandId.make("cmd-shell-summary-7"), | |
| 2781 | causationEventId: null, | |
| 2782 | correlationId: CorrelationId.make("cmd-shell-summary-7"), | |
| 2783 | metadata: {}, | |
| 2784 | payload: { | |
| 2785 | threadId: ThreadId.make("thread-shell-summary"), | |
| 2786 | session: { | |
| 2787 | threadId: ThreadId.make("thread-shell-summary"), | |
| 2788 | status: "ready", | |
| 2789 | providerName: "codex", | |
| 2790 | runtimeMode: "approval-required", | |
| 2791 | activeTurnId: null, | |
| 2792 | lastError: null, | |
| 2793 | updatedAt: "2026-03-01T08:00:07.000Z", | |
| 2794 | }, | |
| 2795 | }, | |
| 2796 | }, | |
| 2797 | { | |
| 2798 | type: "thread.turn-diff-completed", | |
| 2799 | eventId: EventId.make("evt-shell-summary-8"), | |
| 2800 | aggregateKind: "thread", | |
| 2801 | aggregateId: ThreadId.make("thread-shell-summary"), | |
| 2802 | occurredAt: "2026-03-01T08:00:08.000Z", | |
| 2803 | commandId: CommandId.make("cmd-shell-summary-8"), | |
| 2804 | causationEventId: null, | |
| 2805 | correlationId: CorrelationId.make("cmd-shell-summary-8"), | |
| 2806 | metadata: {}, | |
| 2807 | payload: { | |
| 2808 | threadId: ThreadId.make("thread-shell-summary"), | |
| 2809 | turnId: TurnId.make("turn-shell-summary-1"), | |
| 2810 | checkpointTurnCount: 1, | |
| 2811 | checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-shell-summary/1"), | |
| 2812 | status: "ready", | |
| 2813 | files: [], | |
| 2814 | assistantMessageId: MessageId.make("message-shell-summary-assistant"), | |
| 2815 | completedAt: "2026-03-01T08:00:08.000Z", | |
| 2816 | }, | |
| 2817 | }, | |
| 2818 | ] satisfies ReadonlyArray<Parameters<typeof eventStore.append>[0]>; | |
| 2819 | ||
| 2820 | for (const event of refreshEvents) { | |
| 2821 | yield* appendAndProject(event); | |
| 2822 | const summary = yield* sql<{ | |
| 2823 | readonly latestUserMessageAt: string | null; | |
| 2824 | readonly pendingApprovalCount: number; | |
| 2825 | readonly pendingUserInputCount: number; | |
| 2826 | readonly hasActionableProposedPlan: number; | |
| 2827 | }>` | |
| 2828 | SELECT | |
| 2829 | latest_user_message_at AS "latestUserMessageAt", | |
| 2830 | pending_approval_count AS "pendingApprovalCount", | |
| 2831 | pending_user_input_count AS "pendingUserInputCount", | |
| 2832 | has_actionable_proposed_plan AS "hasActionableProposedPlan" | |
| 2833 | FROM projection_threads | |
| 2834 | WHERE thread_id = 'thread-shell-summary' | |
| 2835 | `; | |
| 2836 | assert.deepEqual(summary, [ | |
| 2837 | { | |
| 2838 | latestUserMessageAt: "2026-03-01T08:00:02.000Z", | |
| 2839 | pendingApprovalCount: 1, | |
| 2840 | pendingUserInputCount: 1, | |
| 2841 | hasActionableProposedPlan: 1, | |
| 2842 | }, | |
| 2843 | ]); | |
| 2844 | } | |
| 2745 | 2845 | }), |
| 2746 | 2846 | ); |
| 2747 | 2847 |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+7-19| ... | ... | @@ -587,26 +587,14 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 587 | 587 | return; |
| 588 | 588 | } |
| 589 | 589 | |
| 590 | const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ | |
| 591 | projectionThreadMessageRepository.listByThreadId({ threadId }), | |
| 592 | projectionThreadProposedPlanRepository.listByThreadId({ threadId }), | |
| 593 | projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), | |
| 594 | projectionPendingApprovalRepository.listByThreadId({ threadId }), | |
| 595 | ]); | |
| 596 | ||
| 597 | let latestUserMessageAt: string | null = null; | |
| 598 | for (const message of messages) { | |
| 599 | if ( | |
| 600 | message.role === "user" && | |
| 601 | (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) | |
| 602 | ) { | |
| 603 | latestUserMessageAt = message.createdAt; | |
| 604 | } | |
| 605 | } | |
| 590 | const [latestUserMessageAt, proposedPlans, activities, pendingApprovalCount] = | |
| 591 | yield* Effect.all([ | |
| 592 | projectionThreadMessageRepository.getLatestUserMessageAt({ threadId }), | |
| 593 | projectionThreadProposedPlanRepository.listByThreadId({ threadId }), | |
| 594 | projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), | |
| 595 | projectionPendingApprovalRepository.countPendingByThreadId({ threadId }), | |
| 596 | ]); | |
| 606 | 597 | |
| 607 | const pendingApprovalCount = pendingApprovals.filter( | |
| 608 | (approval) => approval.status === "pending", | |
| 609 | ).length; | |
| 610 | 598 | const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities); |
| 611 | 599 | const hasActionableProposedPlan = deriveHasActionableProposedPlan({ |
| 612 | 600 | latestTurnId: existingRow.value.latestTurnId, |
apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts+21| ... | ... | @@ -2,6 +2,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 2 | 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Schema from "effect/Schema"; | |
| 5 | 6 | |
| 6 | 7 | import { toPersistenceSqlError } from "../Errors.ts"; |
| 7 | 8 | import { |
| ... | ... | @@ -68,6 +69,16 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { |
| 68 | 69 | `, |
| 69 | 70 | }); |
| 70 | 71 | |
| 72 | const countPendingApprovalRows = SqlSchema.findOne({ | |
| 73 | Request: ListProjectionPendingApprovalsInput, | |
| 74 | Result: Schema.Struct({ count: Schema.Number }), | |
| 75 | execute: ({ threadId }) => sql` | |
| 76 | SELECT COUNT(*) AS count | |
| 77 | FROM projection_pending_approvals | |
| 78 | WHERE thread_id = ${threadId} AND status = 'pending' | |
| 79 | `, | |
| 80 | }); | |
| 81 | ||
| 71 | 82 | const getProjectionPendingApprovalRow = SqlSchema.findOneOption({ |
| 72 | 83 | Request: GetProjectionPendingApprovalInput, |
| 73 | 84 | Result: ProjectionPendingApproval, |
| ... | ... | @@ -116,6 +127,15 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { |
| 116 | 127 | ), |
| 117 | 128 | ); |
| 118 | 129 | |
| 130 | const countPendingByThreadId: ProjectionPendingApprovalRepositoryShape["countPendingByThreadId"] = | |
| 131 | (input) => | |
| 132 | countPendingApprovalRows(input).pipe( | |
| 133 | Effect.mapError( | |
| 134 | toPersistenceSqlError("ProjectionPendingApprovalRepository.countPendingByThreadId:query"), | |
| 135 | ), | |
| 136 | Effect.map((row) => row.count), | |
| 137 | ); | |
| 138 | ||
| 119 | 139 | const getByRequestId: ProjectionPendingApprovalRepositoryShape["getByRequestId"] = (input) => |
| 120 | 140 | getProjectionPendingApprovalRow(input).pipe( |
| 121 | 141 | Effect.mapError( |
| ... | ... | @@ -142,6 +162,7 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { |
| 142 | 162 | return { |
| 143 | 163 | upsert, |
| 144 | 164 | listByThreadId, |
| 165 | countPendingByThreadId, | |
| 145 | 166 | getByRequestId, |
| 146 | 167 | deleteByRequestId, |
| 147 | 168 | deleteByThreadId, |
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts+43| ... | ... | @@ -12,6 +12,49 @@ const layer = it.layer( |
| 12 | 12 | ); |
| 13 | 13 | |
| 14 | 14 | layer("ProjectionThreadMessageRepository", (it) => { |
| 15 | it.effect("finds the latest user-message time within one thread", () => | |
| 16 | Effect.gen(function* () { | |
| 17 | const repository = yield* ProjectionThreadMessageRepository; | |
| 18 | const threadId = ThreadId.make("thread-latest-user-message"); | |
| 19 | assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); | |
| 20 | ||
| 21 | const messages = [ | |
| 22 | { role: "user", createdAt: "2026-02-28T19:05:02.000Z" }, | |
| 23 | { role: "user", createdAt: "2026-02-28T19:05:01.000Z" }, | |
| 24 | { role: "assistant", createdAt: "2026-02-28T19:05:03.000Z" }, | |
| 25 | { role: "system", createdAt: "2026-02-28T19:05:04.000Z" }, | |
| 26 | ] as const; | |
| 27 | for (const [index, message] of messages.entries()) { | |
| 28 | yield* repository.upsert({ | |
| 29 | messageId: MessageId.make(`latest-user-message-${index}`), | |
| 30 | threadId, | |
| 31 | turnId: null, | |
| 32 | ...message, | |
| 33 | text: "Message body", | |
| 34 | isStreaming: false, | |
| 35 | updatedAt: "2026-02-28T19:06:00.000Z", | |
| 36 | }); | |
| 37 | } | |
| 38 | yield* repository.upsert({ | |
| 39 | messageId: MessageId.make("latest-user-message-other-thread"), | |
| 40 | threadId: ThreadId.make("thread-latest-user-message-other"), | |
| 41 | turnId: null, | |
| 42 | role: "user", | |
| 43 | text: "Other thread", | |
| 44 | isStreaming: false, | |
| 45 | createdAt: "2026-02-28T19:05:05.000Z", | |
| 46 | updatedAt: "2026-02-28T19:05:05.000Z", | |
| 47 | }); | |
| 48 | ||
| 49 | assert.strictEqual( | |
| 50 | yield* repository.getLatestUserMessageAt({ threadId }), | |
| 51 | "2026-02-28T19:05:02.000Z", | |
| 52 | ); | |
| 53 | yield* repository.deleteByThreadId({ threadId }); | |
| 54 | assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); | |
| 55 | }), | |
| 56 | ); | |
| 57 | ||
| 15 | 58 | it.effect("appends streaming text and applies attachment updates", () => |
| 16 | 59 | Effect.gen(function* () { |
| 17 | 60 | const repository = yield* ProjectionThreadMessageRepository; |
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+23| ... | ... | @@ -182,6 +182,18 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 182 | 182 | `, |
| 183 | 183 | }); |
| 184 | 184 | |
| 185 | const getLatestUserMessageAtRow = SqlSchema.findOne({ | |
| 186 | Request: ListProjectionThreadMessagesInput, | |
| 187 | Result: Schema.Struct({ | |
| 188 | latestUserMessageAt: Schema.NullOr(ProjectionThreadMessage.fields.createdAt), | |
| 189 | }), | |
| 190 | execute: ({ threadId }) => sql` | |
| 191 | SELECT MAX(created_at) AS "latestUserMessageAt" | |
| 192 | FROM projection_thread_messages | |
| 193 | WHERE thread_id = ${threadId} AND role = 'user' | |
| 194 | `, | |
| 195 | }); | |
| 196 | ||
| 185 | 197 | const deleteProjectionThreadMessageRows = SqlSchema.void({ |
| 186 | 198 | Request: DeleteProjectionThreadMessagesInput, |
| 187 | 199 | execute: ({ threadId }) => |
| ... | ... | @@ -219,6 +231,16 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 219 | 231 | Effect.map((rows) => rows.map(toProjectionThreadMessage)), |
| 220 | 232 | ); |
| 221 | 233 | |
| 234 | const getLatestUserMessageAt: ProjectionThreadMessageRepositoryShape["getLatestUserMessageAt"] = ( | |
| 235 | input, | |
| 236 | ) => | |
| 237 | getLatestUserMessageAtRow(input).pipe( | |
| 238 | Effect.mapError( | |
| 239 | toPersistenceSqlError("ProjectionThreadMessageRepository.getLatestUserMessageAt:query"), | |
| 240 | ), | |
| 241 | Effect.map((row) => row.latestUserMessageAt), | |
| 242 | ); | |
| 243 | ||
| 222 | 244 | const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => |
| 223 | 245 | deleteProjectionThreadMessageRows(input).pipe( |
| 224 | 246 | Effect.mapError( |
| ... | ... | @@ -231,6 +253,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 231 | 253 | appendStreaming, |
| 232 | 254 | getByMessageId, |
| 233 | 255 | listByThreadId, |
| 256 | getLatestUserMessageAt, | |
| 234 | 257 | deleteByThreadId, |
| 235 | 258 | } satisfies ProjectionThreadMessageRepositoryShape; |
| 236 | 259 | }); |
apps/server/src/persistence/Services/ProjectionPendingApprovals.ts+5| ... | ... | @@ -69,6 +69,11 @@ export interface ProjectionPendingApprovalRepositoryShape { |
| 69 | 69 | input: ListProjectionPendingApprovalsInput, |
| 70 | 70 | ) => Effect.Effect<ReadonlyArray<ProjectionPendingApproval>, ProjectionRepositoryError>; |
| 71 | 71 | |
| 72 | /** Count pending approvals without loading resolved request history. */ | |
| 73 | readonly countPendingByThreadId: ( | |
| 74 | input: ListProjectionPendingApprovalsInput, | |
| 75 | ) => Effect.Effect<number, ProjectionRepositoryError>; | |
| 76 | ||
| 72 | 77 | /** |
| 73 | 78 | * Read a pending approval row by request id. |
| 74 | 79 | */ |
apps/server/src/persistence/Services/ProjectionThreadMessages.ts+5| ... | ... | @@ -90,6 +90,11 @@ export interface ProjectionThreadMessageRepositoryShape { |
| 90 | 90 | input: ListProjectionThreadMessagesInput, |
| 91 | 91 | ) => Effect.Effect<ReadonlyArray<ProjectionThreadMessage>, ProjectionRepositoryError>; |
| 92 | 92 | |
| 93 | /** Read the latest user-message timestamp without loading message bodies. */ | |
| 94 | readonly getLatestUserMessageAt: ( | |
| 95 | input: ListProjectionThreadMessagesInput, | |
| 96 | ) => Effect.Effect<ProjectionThreadMessage["createdAt"] | null, ProjectionRepositoryError>; | |
| 97 | ||
| 93 | 98 | /** |
| 94 | 99 | * Delete projected thread messages by thread. |
| 95 | 100 | */ |