| author | |
| committer | |
| log | e4e9fa9a0b1a77e393ab6cffd75ec22dbb258bdc |
| tree | 10bd68fad603b5cc257dc165080fa58ed89c5605 |
| parent | e63ddb48e2fd23854a0b4a480b32cbf33e601981 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Ross Cawston <rcawston@users.noreply.github.com>11 files changed, 690 insertions(+), 269 deletions(-)
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+208-59| ... | ... | @@ -286,9 +286,20 @@ describe("ProviderRuntimeIngestion", () => { |
| 286 | 286 | Layer.provide(RepositoryIdentityResolver.layer), |
| 287 | 287 | Layer.provide(SqlitePersistenceMemory), |
| 288 | 288 | ); |
| 289 | const ingestionProjectionSnapshotLayer = Layer.effect( | |
| 290 | ProjectionSnapshotQuery, | |
| 291 | Effect.gen(function* () { | |
| 292 | const query = yield* ProjectionSnapshotQuery; | |
| 293 | return ProjectionSnapshotQuery.of({ | |
| 294 | ...query, | |
| 295 | getThreadDetailById: () => | |
| 296 | Effect.die("provider runtime ingestion must not hydrate thread detail"), | |
| 297 | }); | |
| 298 | }), | |
| 299 | ).pipe(Layer.provide(projectionSnapshotLayer)); | |
| 289 | 300 | const layer = ProviderRuntimeIngestionLive.pipe( |
| 290 | 301 | Layer.provideMerge(orchestrationLayer), |
| 291 | Layer.provideMerge(projectionSnapshotLayer), | |
| 302 | Layer.provideMerge(ingestionProjectionSnapshotLayer), | |
| 292 | 303 | // Single shared liveness instance across ingestion (writer), the |
| 293 | 304 | // engine, and the snapshot query (reader). |
| 294 | 305 | Layer.provideMerge(ThreadBackgroundLiveness.layer), |
| ... | ... | @@ -483,6 +494,89 @@ describe("ProviderRuntimeIngestion", () => { |
| 483 | 494 | ]); |
| 484 | 495 | }); |
| 485 | 496 | |
| 497 | it.each(["turn.completed", "turn.aborted"] as const)( | |
| 498 | "finalizes old buffered text on late %s without stopping the newer turn", | |
| 499 | async (terminalType) => { | |
| 500 | const harness = await createHarness({ | |
| 501 | serverSettings: { enableLegacyTokenStreaming: false }, | |
| 502 | }); | |
| 503 | const threadId = asThreadId("thread-1"); | |
| 504 | const oldTurnId = asTurnId("old-buffered-turn"); | |
| 505 | const newTurnId = asTurnId("new-active-turn"); | |
| 506 | const base = { | |
| 507 | provider: ProviderDriverKind.make("opencode"), | |
| 508 | threadId, | |
| 509 | createdAt: "2026-01-01T00:00:01.000Z", | |
| 510 | }; | |
| 511 | await harness.emitAndDrain([ | |
| 512 | { | |
| 513 | ...base, | |
| 514 | type: "turn.started", | |
| 515 | eventId: asEventId("old-buffered-started"), | |
| 516 | turnId: oldTurnId, | |
| 517 | }, | |
| 518 | { | |
| 519 | ...base, | |
| 520 | type: "content.delta", | |
| 521 | eventId: asEventId("old-buffered-delta"), | |
| 522 | turnId: oldTurnId, | |
| 523 | itemId: asItemId("old-buffered-message"), | |
| 524 | payload: { streamKind: "assistant_text", delta: "Keep the old answer." }, | |
| 525 | }, | |
| 526 | ]); | |
| 527 | await harness.dispatch({ | |
| 528 | type: "thread.turn.start", | |
| 529 | commandId: CommandId.make("start-new-while-old-finishes"), | |
| 530 | threadId, | |
| 531 | message: { | |
| 532 | messageId: asMessageId("new-turn-prompt"), | |
| 533 | role: "user", | |
| 534 | text: "Continue", | |
| 535 | attachments: [], | |
| 536 | }, | |
| 537 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 538 | runtimeMode: "approval-required", | |
| 539 | createdAt: base.createdAt, | |
| 540 | }); | |
| 541 | harness.setProviderSession({ | |
| 542 | provider: base.provider, | |
| 543 | status: "running", | |
| 544 | runtimeMode: "approval-required", | |
| 545 | threadId, | |
| 546 | createdAt: base.createdAt, | |
| 547 | updatedAt: base.createdAt, | |
| 548 | activeTurnId: newTurnId, | |
| 549 | }); | |
| 550 | await harness.emitAndDrain([ | |
| 551 | { | |
| 552 | ...base, | |
| 553 | type: "turn.started", | |
| 554 | eventId: asEventId("new-active-started"), | |
| 555 | turnId: newTurnId, | |
| 556 | }, | |
| 557 | { | |
| 558 | ...base, | |
| 559 | type: terminalType, | |
| 560 | eventId: asEventId("old-buffered-terminal"), | |
| 561 | turnId: oldTurnId, | |
| 562 | payload: | |
| 563 | terminalType === "turn.completed" | |
| 564 | ? { state: "completed" } | |
| 565 | : { reason: "Interrupted by user." }, | |
| 566 | }, | |
| 567 | ]); | |
| 568 | const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); | |
| 569 | expect(thread?.session).toMatchObject({ activeTurnId: newTurnId, status: "running" }); | |
| 570 | expect(thread?.messages).toContainEqual( | |
| 571 | expect.objectContaining({ | |
| 572 | turnId: oldTurnId, | |
| 573 | text: "Keep the old answer.", | |
| 574 | streaming: false, | |
| 575 | }), | |
| 576 | ); | |
| 577 | }, | |
| 578 | ); | |
| 579 | ||
| 486 | 580 | it.each([ |
| 487 | 581 | { source: "the previous turn", turnId: asTurnId("opencode-stopped-turn") }, |
| 488 | 582 | { source: "an unspecified turn", turnId: undefined }, |
| ... | ... | @@ -1690,6 +1784,31 @@ describe("ProviderRuntimeIngestion", () => { |
| 1690 | 1784 | ).toMatchObject({ |
| 1691 | 1785 | implementationThreadId: "thread-implement", |
| 1692 | 1786 | }); |
| 1787 | const implementedPlan = sourceThreadAfterStart.proposedPlans.find( | |
| 1788 | (entry) => entry.id === sourcePlan.id, | |
| 1789 | ); | |
| 1790 | await harness.emitAndDrain([ | |
| 1791 | { | |
| 1792 | type: "turn.proposed.completed", | |
| 1793 | eventId: asEventId("evt-plan-source-late-completion"), | |
| 1794 | provider: ProviderDriverKind.make("codex"), | |
| 1795 | createdAt: "2026-01-01T00:01:00.000Z", | |
| 1796 | threadId: sourceThreadId, | |
| 1797 | turnId: sourceTurnId, | |
| 1798 | payload: { planMarkdown: "# Source plan with late details" }, | |
| 1799 | }, | |
| 1800 | ]); | |
| 1801 | const sourceAfterLateCompletion = (await harness.readModel()).threads.find( | |
| 1802 | (entry) => entry.id === sourceThreadId, | |
| 1803 | ); | |
| 1804 | expect( | |
| 1805 | sourceAfterLateCompletion?.proposedPlans.find((entry) => entry.id === sourcePlan.id), | |
| 1806 | ).toMatchObject({ | |
| 1807 | planMarkdown: "# Source plan with late details", | |
| 1808 | createdAt: sourcePlan.createdAt, | |
| 1809 | implementedAt: implementedPlan?.implementedAt, | |
| 1810 | implementationThreadId: targetThreadId, | |
| 1811 | }); | |
| 1693 | 1812 | }); |
| 1694 | 1813 | |
| 1695 | 1814 | it("does not mark the source proposed plan implemented for a rejected turn.started event", async () => { |
| ... | ... | @@ -2120,7 +2239,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2120 | 2239 | type: "turn.proposed.delta", |
| 2121 | 2240 | eventId: asEventId("evt-plan-delta-1"), |
| 2122 | 2241 | provider: ProviderDriverKind.make("codex"), |
| 2123 | createdAt: now, | |
| 2242 | createdAt: "", | |
| 2124 | 2243 | threadId: asThreadId("thread-1"), |
| 2125 | 2244 | turnId: asTurnId("turn-plan-buffer"), |
| 2126 | 2245 | payload: { |
| ... | ... | @@ -2131,7 +2250,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2131 | 2250 | type: "turn.proposed.delta", |
| 2132 | 2251 | eventId: asEventId("evt-plan-delta-2"), |
| 2133 | 2252 | provider: ProviderDriverKind.make("codex"), |
| 2134 | createdAt: now, | |
| 2253 | createdAt: "", | |
| 2135 | 2254 | threadId: asThreadId("thread-1"), |
| 2136 | 2255 | turnId: asTurnId("turn-plan-buffer"), |
| 2137 | 2256 | payload: { |
| ... | ... | @@ -2161,6 +2280,42 @@ describe("ProviderRuntimeIngestion", () => { |
| 2161 | 2280 | entry.id === "plan:thread-1:turn:turn-plan-buffer", |
| 2162 | 2281 | ); |
| 2163 | 2282 | expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second"); |
| 2283 | expect(proposedPlan?.createdAt).toBe(now); | |
| 2284 | }); | |
| 2285 | ||
| 2286 | it("releases a blank completed plan before a late replacement", async () => { | |
| 2287 | const harness = await createHarness(); | |
| 2288 | const threadId = asThreadId("thread-1"); | |
| 2289 | const turnId = asTurnId("blank-plan-turn"); | |
| 2290 | const base = { provider: ProviderDriverKind.make("codex"), threadId, turnId }; | |
| 2291 | const replacementTime = "2026-01-01T00:00:02.000Z"; | |
| 2292 | await harness.emitAndDrain([ | |
| 2293 | { | |
| 2294 | ...base, | |
| 2295 | type: "turn.proposed.delta", | |
| 2296 | eventId: asEventId("blank-plan-delta"), | |
| 2297 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 2298 | payload: { delta: " \n " }, | |
| 2299 | }, | |
| 2300 | { | |
| 2301 | ...base, | |
| 2302 | type: "turn.completed", | |
| 2303 | eventId: asEventId("blank-plan-completed"), | |
| 2304 | createdAt: "2026-01-01T00:00:01.000Z", | |
| 2305 | payload: { state: "completed" }, | |
| 2306 | }, | |
| 2307 | { | |
| 2308 | ...base, | |
| 2309 | type: "turn.proposed.completed", | |
| 2310 | eventId: asEventId("late-plan-completed"), | |
| 2311 | createdAt: replacementTime, | |
| 2312 | payload: { planMarkdown: "# Replacement plan" }, | |
| 2313 | }, | |
| 2314 | ]); | |
| 2315 | const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); | |
| 2316 | expect(thread?.proposedPlans).toEqual([ | |
| 2317 | expect.objectContaining({ planMarkdown: "# Replacement plan", createdAt: replacementTime }), | |
| 2318 | ]); | |
| 2164 | 2319 | }); |
| 2165 | 2320 | |
| 2166 | 2321 | it("buffers assistant deltas with one lifecycle query per event until completion", async () => { |
| ... | ... | @@ -4037,71 +4192,65 @@ describe("ProviderRuntimeIngestion", () => { |
| 4037 | 4192 | expect(completedPayload?.title).toBe("wait for codex review to finish"); |
| 4038 | 4193 | }); |
| 4039 | 4194 | |
| 4040 | it("titles task completion from persisted activities after the description cache is swept", async () => { | |
| 4195 | it("recovers a task title past untitled progress after the cache is swept", async () => { | |
| 4041 | 4196 | const harness = await createHarness(); |
| 4042 | 4197 | const now = "2026-01-01T00:00:00.000Z"; |
| 4198 | const threadId = asThreadId("thread-1"); | |
| 4199 | const turnId = asTurnId("turn-swept-task"); | |
| 4200 | const provider = ProviderDriverKind.make("claudeAgent"); | |
| 4043 | 4201 | |
| 4044 | harness.emit({ | |
| 4045 | type: "task.progress", | |
| 4046 | eventId: asEventId("evt-swept-task-progress"), | |
| 4047 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 4048 | createdAt: now, | |
| 4049 | threadId: asThreadId("thread-1"), | |
| 4050 | turnId: asTurnId("turn-swept-task"), | |
| 4051 | payload: { | |
| 4052 | taskId: "swept-task-1", | |
| 4053 | description: "Watch round-3 CI and bots", | |
| 4202 | await harness.emitAndDrain([ | |
| 4203 | { | |
| 4204 | type: "task.started", | |
| 4205 | eventId: asEventId("evt-swept-task-started"), | |
| 4206 | provider, | |
| 4207 | createdAt: now, | |
| 4208 | threadId, | |
| 4209 | turnId, | |
| 4210 | payload: { taskId: "swept-task-1", description: "Watch round-3 CI and bots" }, | |
| 4211 | }, | |
| 4212 | ]); | |
| 4213 | // Older saved progress rows can have no title even when the start has one. | |
| 4214 | await harness.dispatch({ | |
| 4215 | type: "thread.activity.append", | |
| 4216 | commandId: CommandId.make("cmd-swept-task-progress"), | |
| 4217 | threadId, | |
| 4218 | activity: { | |
| 4219 | id: asEventId("evt-swept-task-progress"), | |
| 4220 | kind: "task.progress", | |
| 4221 | tone: "info", | |
| 4054 | 4222 | summary: "Polling CI checks.", |
| 4223 | payload: { taskId: "swept-task-1" }, | |
| 4224 | turnId, | |
| 4225 | createdAt: "2026-01-01T00:00:01.000Z", | |
| 4055 | 4226 | }, |
| 4227 | createdAt: "2026-01-01T00:00:01.000Z", | |
| 4056 | 4228 | }); |
| 4057 | ||
| 4058 | await waitForThread(harness.readModel, (entry) => | |
| 4059 | entry.activities.some( | |
| 4060 | (activity: ProviderRuntimeTestActivity) => | |
| 4061 | activity.id === "task-progress:thread-1:swept-task-1", | |
| 4062 | ), | |
| 4063 | ); | |
| 4064 | ||
| 4065 | // session.exited sweeps the in-memory description cache; the completion | |
| 4066 | // that follows must recover the name from persisted activities. | |
| 4067 | harness.emit({ | |
| 4068 | type: "session.exited", | |
| 4069 | eventId: asEventId("evt-swept-task-session-exited"), | |
| 4070 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 4071 | createdAt: now, | |
| 4072 | threadId: asThreadId("thread-1"), | |
| 4073 | payload: {}, | |
| 4074 | }); | |
| 4075 | ||
| 4076 | harness.emit({ | |
| 4077 | type: "task.completed", | |
| 4078 | eventId: asEventId("evt-swept-task-completed"), | |
| 4079 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 4080 | createdAt: now, | |
| 4081 | threadId: asThreadId("thread-1"), | |
| 4082 | turnId: asTurnId("turn-swept-task"), | |
| 4083 | payload: { | |
| 4084 | taskId: "swept-task-1", | |
| 4085 | status: "completed", | |
| 4086 | summary: "CI is green.", | |
| 4229 | await harness.emitAndDrain([ | |
| 4230 | { | |
| 4231 | type: "session.exited", | |
| 4232 | eventId: asEventId("evt-swept-task-session-exited"), | |
| 4233 | provider, | |
| 4234 | createdAt: "2026-01-01T00:00:02.000Z", | |
| 4235 | threadId, | |
| 4236 | payload: {}, | |
| 4087 | 4237 | }, |
| 4088 | }); | |
| 4089 | ||
| 4090 | const thread = await waitForThread(harness.readModel, (entry) => | |
| 4091 | entry.activities.some( | |
| 4092 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", | |
| 4093 | ), | |
| 4094 | ); | |
| 4238 | { | |
| 4239 | type: "task.completed", | |
| 4240 | eventId: asEventId("evt-swept-task-completed"), | |
| 4241 | provider, | |
| 4242 | createdAt: "2026-01-01T00:00:03.000Z", | |
| 4243 | threadId, | |
| 4244 | turnId, | |
| 4245 | payload: { taskId: "swept-task-1", status: "completed", summary: "CI is green." }, | |
| 4246 | }, | |
| 4247 | ]); | |
| 4095 | 4248 | |
| 4096 | const completed = thread.activities.find( | |
| 4097 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", | |
| 4249 | const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); | |
| 4250 | const completed = thread?.activities.find( | |
| 4251 | (activity) => activity.id === "evt-swept-task-completed", | |
| 4098 | 4252 | ); |
| 4099 | const completedPayload = | |
| 4100 | completed?.payload && typeof completed.payload === "object" | |
| 4101 | ? (completed.payload as Record<string, unknown>) | |
| 4102 | : undefined; | |
| 4103 | ||
| 4104 | expect(completedPayload?.title).toBe("Watch round-3 CI and bots"); | |
| 4253 | expect(completed?.payload).toMatchObject({ title: "Watch round-3 CI and bots" }); | |
| 4105 | 4254 | }); |
| 4106 | 4255 | |
| 4107 | 4256 | it("projects structured user input request and resolution as thread activities", async () => { |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+116-197| ... | ... | @@ -4,8 +4,7 @@ import { |
| 4 | 4 | CommandId, |
| 5 | 5 | MessageId, |
| 6 | 6 | type OrchestrationEvent, |
| 7 | type OrchestrationMessage, | |
| 8 | type OrchestrationProposedPlanId, | |
| 7 | OrchestrationProposedPlanId, | |
| 9 | 8 | CheckpointRef, |
| 10 | 9 | classifyTaskAgentKind, |
| 11 | 10 | EventId, |
| ... | ... | @@ -14,8 +13,6 @@ import { |
| 14 | 13 | type ThreadTokenUsageSnapshot, |
| 15 | 14 | TurnId, |
| 16 | 15 | type OrchestrationCheckpointSummary, |
| 17 | type OrchestrationProposedPlan, | |
| 18 | type OrchestrationThread, | |
| 19 | 16 | type OrchestrationThreadActivity, |
| 20 | 17 | type ProviderRuntimeEvent, |
| 21 | 18 | RuntimeRequestId, |
| ... | ... | @@ -39,6 +36,10 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio |
| 39 | 36 | import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; |
| 40 | 37 | import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; |
| 41 | 38 | import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; |
| 39 | import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; | |
| 40 | import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; | |
| 41 | import { ProjectionThreadProposedPlanRepository } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; | |
| 42 | import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; | |
| 42 | 43 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 43 | 44 | import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; |
| 44 | 45 | import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; |
| ... | ... | @@ -54,13 +55,12 @@ import { canReplaceThreadTitle } from "../threadTitles.ts"; |
| 54 | 55 | |
| 55 | 56 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; |
| 56 | 57 | const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; |
| 57 | const TASK_TITLE_ACTIVITY_KINDS = ["task.started", "task.progress"] as const; | |
| 58 | 58 | |
| 59 | 59 | // Fallback when the in-memory description cache no longer has the task name |
| 60 | 60 | // (server restart, session-exit sweep, TTL/capacity eviction): earlier |
| 61 | 61 | // task.started/task.progress activities for the task are persisted with it. |
| 62 | 62 | function findTaskTitleInActivities( |
| 63 | activities: ReadonlyArray<OrchestrationThreadActivity> | undefined, | |
| 63 | activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }> | undefined, | |
| 64 | 64 | taskId: string, |
| 65 | 65 | ): string | undefined { |
| 66 | 66 | if (!activities) { |
| ... | ... | @@ -138,57 +138,6 @@ function sameId(left: string | null | undefined, right: string | null | undefine |
| 138 | 138 | return left === right; |
| 139 | 139 | } |
| 140 | 140 | |
| 141 | function hasAssistantMessageForTurn( | |
| 142 | messages: ReadonlyArray<OrchestrationMessage>, | |
| 143 | turnId: TurnId, | |
| 144 | options?: { readonly streamingOnly?: boolean }, | |
| 145 | ): boolean { | |
| 146 | for (let index = 0; index < messages.length; index += 1) { | |
| 147 | const message = messages[index]; | |
| 148 | if (!message) { | |
| 149 | continue; | |
| 150 | } | |
| 151 | if (message.role !== "assistant" || message.turnId !== turnId) { | |
| 152 | continue; | |
| 153 | } | |
| 154 | if (options?.streamingOnly === true && !message.streaming) { | |
| 155 | continue; | |
| 156 | } | |
| 157 | return true; | |
| 158 | } | |
| 159 | return false; | |
| 160 | } | |
| 161 | ||
| 162 | function findMessageById( | |
| 163 | messages: ReadonlyArray<OrchestrationMessage>, | |
| 164 | messageId: MessageId, | |
| 165 | ): OrchestrationMessage | undefined { | |
| 166 | for (let index = 0; index < messages.length; index += 1) { | |
| 167 | const message = messages[index]; | |
| 168 | if (message?.id === messageId) { | |
| 169 | return message; | |
| 170 | } | |
| 171 | } | |
| 172 | return undefined; | |
| 173 | } | |
| 174 | ||
| 175 | function findProposedPlanById( | |
| 176 | proposedPlans: ReadonlyArray< | |
| 177 | Pick<OrchestrationProposedPlan, "id" | "createdAt" | "implementedAt" | "implementationThreadId"> | |
| 178 | >, | |
| 179 | planId: string, | |
| 180 | ): | |
| 181 | | Pick<OrchestrationProposedPlan, "id" | "createdAt" | "implementedAt" | "implementationThreadId"> | |
| 182 | | undefined { | |
| 183 | for (let index = 0; index < proposedPlans.length; index += 1) { | |
| 184 | const proposedPlan = proposedPlans[index]; | |
| 185 | if (proposedPlan?.id === planId) { | |
| 186 | return proposedPlan; | |
| 187 | } | |
| 188 | } | |
| 189 | return undefined; | |
| 190 | } | |
| 191 | ||
| 192 | 141 | function hasCheckpointForTurn( |
| 193 | 142 | checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, |
| 194 | 143 | turnId: TurnId, |
| ... | ... | @@ -264,16 +213,15 @@ function buildContextWindowActivityPayload( |
| 264 | 213 | } |
| 265 | 214 | |
| 266 | 215 | function compactedTokenCountsFromActivities( |
| 267 | activities: ReadonlyArray<OrchestrationThreadActivity> | undefined, | |
| 216 | activities: ReadonlyArray< | |
| 217 | Pick<OrchestrationThreadActivity, "kind" | "payload" | "sequence" | "createdAt"> | |
| 218 | >, | |
| 268 | 219 | ): { readonly beforeTokens: number; readonly afterTokens: number } | undefined { |
| 269 | const lastCompactionIndex = activities?.findLastIndex( | |
| 220 | const lastCompactionIndex = activities.findLastIndex( | |
| 270 | 221 | (activity) => activity.kind === "context-compaction", |
| 271 | 222 | ); |
| 272 | const lastCompaction = | |
| 273 | lastCompactionIndex !== undefined && lastCompactionIndex >= 0 | |
| 274 | ? activities?.[lastCompactionIndex] | |
| 275 | : undefined; | |
| 276 | const activitiesSinceLastCompaction = activities?.slice((lastCompactionIndex ?? -1) + 1) ?? []; | |
| 223 | const lastCompaction = activities[lastCompactionIndex]; | |
| 224 | const activitiesSinceLastCompaction = activities.slice(lastCompactionIndex + 1); | |
| 277 | 225 | const usedTokens = activitiesSinceLastCompaction.flatMap((activity) => { |
| 278 | 226 | if (activity.kind !== "context-window.updated") return []; |
| 279 | 227 | if (lastCompaction !== undefined) { |
| ... | ... | @@ -956,6 +904,8 @@ const make = Effect.gen(function* () { |
| 956 | 904 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 957 | 905 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 958 | 906 | const providerService = yield* ProviderService; |
| 907 | const projectionThreadMessages = yield* ProjectionThreadMessageRepository; | |
| 908 | const projectionThreadProposedPlans = yield* ProjectionThreadProposedPlanRepository; | |
| 959 | 909 | const projectionTurnRepository = yield* ProjectionTurnRepository; |
| 960 | 910 | const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; |
| 961 | 911 | const serverSettingsService = yield* ServerSettingsService; |
| ... | ... | @@ -1013,21 +963,22 @@ const make = Effect.gen(function* () { |
| 1013 | 963 | ), |
| 1014 | 964 | ); |
| 1015 | 965 | |
| 1016 | const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* ( | |
| 966 | const resolveThreadRuntimeContext = Effect.fn("resolveThreadRuntimeContext")(function* ( | |
| 1017 | 967 | threadId: ThreadId, |
| 1018 | activityKinds: ReadonlyArray<string> = [], | |
| 1019 | 968 | ) { |
| 1020 | 969 | return yield* projectionSnapshotQuery |
| 1021 | .getThreadDetailById(threadId, { activityKinds }) | |
| 970 | .getThreadRuntimeContext(threadId) | |
| 1022 | 971 | .pipe(Effect.map(Option.getOrUndefined)); |
| 1023 | 972 | }); |
| 1024 | 973 | |
| 1025 | const resolveThreadRuntimeContext = Effect.fn("resolveThreadRuntimeContext")(function* ( | |
| 974 | const getThreadMessageById = Effect.fn("getThreadMessageById")(function* ( | |
| 1026 | 975 | threadId: ThreadId, |
| 976 | messageId: MessageId, | |
| 1027 | 977 | ) { |
| 1028 | return yield* projectionSnapshotQuery | |
| 1029 | .getThreadRuntimeContext(threadId) | |
| 1030 | .pipe(Effect.map(Option.getOrUndefined)); | |
| 978 | const message = yield* projectionThreadMessages.getByMessageId({ messageId }); | |
| 979 | return Option.filter(message, (entry) => entry.threadId === threadId).pipe( | |
| 980 | Option.getOrUndefined, | |
| 981 | ); | |
| 1031 | 982 | }); |
| 1032 | 983 | |
| 1033 | 984 | const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => |
| ... | ... | @@ -1195,15 +1146,6 @@ const make = Effect.gen(function* () { |
| 1195 | 1146 | }), |
| 1196 | 1147 | ); |
| 1197 | 1148 | |
| 1198 | const takeBufferedProposedPlan = (planId: string) => | |
| 1199 | Cache.getOption(bufferedProposedPlanById, planId).pipe( | |
| 1200 | Effect.flatMap((existingEntry) => | |
| 1201 | Cache.invalidate(bufferedProposedPlanById, planId).pipe( | |
| 1202 | Effect.as(Option.getOrUndefined(existingEntry)), | |
| 1203 | ), | |
| 1204 | ), | |
| 1205 | ); | |
| 1206 | ||
| 1207 | 1149 | const clearBufferedProposedPlan = (planId: string) => |
| 1208 | 1150 | Cache.invalidate(bufferedProposedPlanById, planId); |
| 1209 | 1151 | |
| ... | ... | @@ -1357,83 +1299,45 @@ const make = Effect.gen(function* () { |
| 1357 | 1299 | } |
| 1358 | 1300 | }); |
| 1359 | 1301 | |
| 1360 | const upsertProposedPlan = (input: { | |
| 1361 | event: ProviderRuntimeEvent; | |
| 1362 | threadId: ThreadId; | |
| 1363 | threadProposedPlans: ReadonlyArray<{ | |
| 1364 | id: string; | |
| 1365 | createdAt: string; | |
| 1366 | implementedAt: string | null; | |
| 1367 | implementationThreadId: ThreadId | null; | |
| 1368 | }>; | |
| 1369 | planId: string; | |
| 1370 | turnId?: TurnId; | |
| 1371 | planMarkdown: string | undefined; | |
| 1372 | createdAt: string; | |
| 1373 | updatedAt: string; | |
| 1374 | }) => | |
| 1375 | Effect.gen(function* () { | |
| 1376 | const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown); | |
| 1377 | if (!planMarkdown) { | |
| 1378 | return; | |
| 1379 | } | |
| 1380 | ||
| 1381 | const existingPlan = findProposedPlanById(input.threadProposedPlans, input.planId); | |
| 1382 | yield* orchestrationEngine.dispatch({ | |
| 1383 | type: "thread.proposed-plan.upsert", | |
| 1384 | commandId: yield* providerCommandId(input.event, "proposed-plan-upsert"), | |
| 1385 | threadId: input.threadId, | |
| 1386 | proposedPlan: { | |
| 1387 | id: input.planId, | |
| 1388 | turnId: input.turnId ?? null, | |
| 1389 | planMarkdown, | |
| 1390 | implementedAt: existingPlan?.implementedAt ?? null, | |
| 1391 | implementationThreadId: existingPlan?.implementationThreadId ?? null, | |
| 1392 | createdAt: existingPlan?.createdAt ?? input.createdAt, | |
| 1393 | updatedAt: input.updatedAt, | |
| 1394 | }, | |
| 1395 | createdAt: input.updatedAt, | |
| 1396 | }); | |
| 1397 | }); | |
| 1398 | ||
| 1399 | const finalizeBufferedProposedPlan = (input: { | |
| 1302 | const finalizeBufferedProposedPlan = Effect.fn("finalizeBufferedProposedPlan")(function* (input: { | |
| 1400 | 1303 | event: ProviderRuntimeEvent; |
| 1401 | 1304 | threadId: ThreadId; |
| 1402 | threadProposedPlans: ReadonlyArray<{ | |
| 1403 | id: string; | |
| 1404 | createdAt: string; | |
| 1405 | implementedAt: string | null; | |
| 1406 | implementationThreadId: ThreadId | null; | |
| 1407 | }>; | |
| 1408 | 1305 | planId: string; |
| 1409 | 1306 | turnId?: TurnId; |
| 1410 | 1307 | fallbackMarkdown?: string; |
| 1411 | 1308 | updatedAt: string; |
| 1412 | }) => | |
| 1413 | Effect.gen(function* () { | |
| 1414 | const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); | |
| 1415 | const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); | |
| 1416 | const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); | |
| 1417 | const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; | |
| 1418 | if (!planMarkdown) { | |
| 1419 | return; | |
| 1420 | } | |
| 1309 | }) { | |
| 1310 | const bufferedPlan = Option.getOrUndefined( | |
| 1311 | yield* Cache.getOption(bufferedProposedPlanById, input.planId), | |
| 1312 | ); | |
| 1313 | const planMarkdown = | |
| 1314 | normalizeProposedPlanMarkdown(bufferedPlan?.text) ?? | |
| 1315 | normalizeProposedPlanMarkdown(input.fallbackMarkdown); | |
| 1316 | if (!planMarkdown) return yield* clearBufferedProposedPlan(input.planId); | |
| 1421 | 1317 | |
| 1422 | yield* upsertProposedPlan({ | |
| 1423 | event: input.event, | |
| 1318 | const existingPlan = Option.getOrUndefined( | |
| 1319 | yield* projectionThreadProposedPlans.getByPlanId({ | |
| 1424 | 1320 | threadId: input.threadId, |
| 1425 | threadProposedPlans: input.threadProposedPlans, | |
| 1426 | planId: input.planId, | |
| 1427 | ...(input.turnId ? { turnId: input.turnId } : {}), | |
| 1321 | planId: OrchestrationProposedPlanId.make(input.planId), | |
| 1322 | }), | |
| 1323 | ); | |
| 1324 | yield* orchestrationEngine.dispatch({ | |
| 1325 | type: "thread.proposed-plan.upsert", | |
| 1326 | commandId: yield* providerCommandId(input.event, "proposed-plan-upsert"), | |
| 1327 | threadId: input.threadId, | |
| 1328 | proposedPlan: { | |
| 1329 | id: input.planId, | |
| 1330 | turnId: input.turnId ?? null, | |
| 1428 | 1331 | planMarkdown, |
| 1429 | createdAt: | |
| 1430 | bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0 | |
| 1431 | ? bufferedPlan.createdAt | |
| 1432 | : input.updatedAt, | |
| 1332 | implementedAt: existingPlan?.implementedAt ?? null, | |
| 1333 | implementationThreadId: existingPlan?.implementationThreadId ?? null, | |
| 1334 | createdAt: existingPlan?.createdAt ?? (bufferedPlan?.createdAt || input.updatedAt), | |
| 1433 | 1335 | updatedAt: input.updatedAt, |
| 1434 | }); | |
| 1435 | yield* clearBufferedProposedPlan(input.planId); | |
| 1336 | }, | |
| 1337 | createdAt: input.updatedAt, | |
| 1436 | 1338 | }); |
| 1339 | yield* clearBufferedProposedPlan(input.planId); | |
| 1340 | }); | |
| 1437 | 1341 | |
| 1438 | 1342 | const clearTurnStateForSession = (threadId: ThreadId) => |
| 1439 | 1343 | Effect.gen(function* () { |
| ... | ... | @@ -1538,8 +1442,13 @@ const make = Effect.gen(function* () { |
| 1538 | 1442 | implementationThreadId: ThreadId, |
| 1539 | 1443 | implementedAt: string, |
| 1540 | 1444 | ) { |
| 1541 | const sourceThread = yield* resolveThreadDetail(sourceThreadId); | |
| 1542 | const sourcePlan = sourceThread?.proposedPlans.find((entry) => entry.id === sourcePlanId); | |
| 1445 | const sourceThread = yield* resolveThreadRuntimeContext(sourceThreadId); | |
| 1446 | const sourcePlan = Option.getOrUndefined( | |
| 1447 | yield* projectionThreadProposedPlans.getByPlanId({ | |
| 1448 | threadId: sourceThreadId, | |
| 1449 | planId: sourcePlanId, | |
| 1450 | }), | |
| 1451 | ); | |
| 1543 | 1452 | if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) { |
| 1544 | 1453 | return; |
| 1545 | 1454 | } |
| ... | ... | @@ -1550,9 +1459,12 @@ const make = Effect.gen(function* () { |
| 1550 | 1459 | commandId: CommandId.make( |
| 1551 | 1460 | `provider:source-proposed-plan-implemented:${implementationThreadId}:${commandUuid}`, |
| 1552 | 1461 | ), |
| 1553 | threadId: sourceThread.id, | |
| 1462 | threadId: sourceThreadId, | |
| 1554 | 1463 | proposedPlan: { |
| 1555 | ...sourcePlan, | |
| 1464 | id: sourcePlan.planId, | |
| 1465 | turnId: sourcePlan.turnId, | |
| 1466 | planMarkdown: sourcePlan.planMarkdown, | |
| 1467 | createdAt: sourcePlan.createdAt, | |
| 1556 | 1468 | implementedAt, |
| 1557 | 1469 | implementationThreadId, |
| 1558 | 1470 | updatedAt: implementedAt, |
| ... | ... | @@ -1571,16 +1483,6 @@ const make = Effect.gen(function* () { |
| 1571 | 1483 | const thread = yield* resolveThreadRuntimeContext(event.threadId); |
| 1572 | 1484 | if (!thread) return; |
| 1573 | 1485 | |
| 1574 | let loadedThreadDetail: OrchestrationThread | null | undefined; | |
| 1575 | const getLoadedThreadDetail = () => | |
| 1576 | Effect.gen(function* () { | |
| 1577 | if (loadedThreadDetail !== undefined) { | |
| 1578 | return loadedThreadDetail; | |
| 1579 | } | |
| 1580 | loadedThreadDetail = (yield* resolveThreadDetail(thread.id)) ?? null; | |
| 1581 | return loadedThreadDetail; | |
| 1582 | }); | |
| 1583 | ||
| 1584 | 1486 | const now = event.createdAt; |
| 1585 | 1487 | const eventTurnId = toTurnId(event.turnId); |
| 1586 | 1488 | const activeTurnId = thread.session?.activeTurnId ?? null; |
| ... | ... | @@ -1800,7 +1702,11 @@ const make = Effect.gen(function* () { |
| 1800 | 1702 | ? toTurnId(event.turnId) |
| 1801 | 1703 | : undefined; |
| 1802 | 1704 | if (pauseForUserTurnId) { |
| 1803 | const detailedThread = yield* getLoadedThreadDetail(); | |
| 1705 | const hasProjectedMessage = yield* projectionThreadMessages.hasAssistantMessageForTurn({ | |
| 1706 | threadId: thread.id, | |
| 1707 | turnId: pauseForUserTurnId, | |
| 1708 | streamingOnly: true, | |
| 1709 | }); | |
| 1804 | 1710 | const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( |
| 1805 | 1711 | serverSettingsService.getSettings, |
| 1806 | 1712 | (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), |
| ... | ... | @@ -1831,11 +1737,7 @@ const make = Effect.gen(function* () { |
| 1831 | 1737 | event.type === "request.opened" |
| 1832 | 1738 | ? "assistant-delta-finalize-on-request-opened" |
| 1833 | 1739 | : "assistant-delta-finalize-on-user-input-requested", |
| 1834 | hasProjectedMessage: | |
| 1835 | detailedThread !== null && | |
| 1836 | hasAssistantMessageForTurn(detailedThread.messages, pauseForUserTurnId, { | |
| 1837 | streamingOnly: true, | |
| 1838 | }), | |
| 1740 | hasProjectedMessage, | |
| 1839 | 1741 | flushedMessageIds, |
| 1840 | 1742 | }); |
| 1841 | 1743 | } |
| ... | ... | @@ -1864,19 +1766,24 @@ const make = Effect.gen(function* () { |
| 1864 | 1766 | : undefined; |
| 1865 | 1767 | |
| 1866 | 1768 | if (assistantCompletion) { |
| 1867 | const detailedThread = yield* getLoadedThreadDetail(); | |
| 1868 | const messages = detailedThread?.messages ?? []; | |
| 1869 | 1769 | const turnId = toTurnId(event.turnId); |
| 1870 | 1770 | const activeAssistantMessageId = turnId |
| 1871 | 1771 | ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) |
| 1872 | 1772 | : Option.none<MessageId>(); |
| 1873 | const hasAssistantMessagesForTurn = | |
| 1874 | turnId !== undefined ? hasAssistantMessageForTurn(messages, turnId) : false; | |
| 1875 | 1773 | const assistantMessageId = Option.getOrElse( |
| 1876 | 1774 | activeAssistantMessageId, |
| 1877 | 1775 | () => assistantCompletion.messageId, |
| 1878 | 1776 | ); |
| 1879 | const existingAssistantMessage = findMessageById(messages, assistantMessageId); | |
| 1777 | const [existingAssistantMessage, hasAssistantMessagesForTurn] = yield* Effect.all([ | |
| 1778 | getThreadMessageById(thread.id, assistantMessageId), | |
| 1779 | turnId === undefined | |
| 1780 | ? Effect.succeed(false) | |
| 1781 | : projectionThreadMessages.hasAssistantMessageForTurn({ | |
| 1782 | threadId: thread.id, | |
| 1783 | turnId, | |
| 1784 | streamingOnly: false, | |
| 1785 | }), | |
| 1786 | ]); | |
| 1880 | 1787 | const shouldApplyFallbackCompletionText = |
| 1881 | 1788 | !existingAssistantMessage || existingAssistantMessage.text.length === 0; |
| 1882 | 1789 | |
| ... | ... | @@ -1916,11 +1823,9 @@ const make = Effect.gen(function* () { |
| 1916 | 1823 | } |
| 1917 | 1824 | |
| 1918 | 1825 | if (proposedPlanCompletion) { |
| 1919 | const detailedThread = yield* getLoadedThreadDetail(); | |
| 1920 | 1826 | yield* finalizeBufferedProposedPlan({ |
| 1921 | 1827 | event, |
| 1922 | 1828 | threadId: thread.id, |
| 1923 | threadProposedPlans: detailedThread?.proposedPlans ?? [], | |
| 1924 | 1829 | planId: proposedPlanCompletion.planId, |
| 1925 | 1830 | ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), |
| 1926 | 1831 | fallbackMarkdown: proposedPlanCompletion.planMarkdown, |
| ... | ... | @@ -1929,9 +1834,6 @@ const make = Effect.gen(function* () { |
| 1929 | 1834 | } |
| 1930 | 1835 | |
| 1931 | 1836 | if (isTerminalTurn) { |
| 1932 | const detailedThread = yield* getLoadedThreadDetail(); | |
| 1933 | const messages = detailedThread?.messages ?? []; | |
| 1934 | const proposedPlans = detailedThread?.proposedPlans ?? []; | |
| 1935 | 1837 | const turnId = toTurnId(event.turnId); |
| 1936 | 1838 | if (turnId) { |
| 1937 | 1839 | const userInputActivities = |
| ... | ... | @@ -1979,16 +1881,20 @@ const make = Effect.gen(function* () { |
| 1979 | 1881 | yield* Effect.forEach( |
| 1980 | 1882 | assistantMessageIds, |
| 1981 | 1883 | (assistantMessageId) => |
| 1982 | finalizeAssistantMessage({ | |
| 1983 | event, | |
| 1984 | threadId: thread.id, | |
| 1985 | messageId: assistantMessageId, | |
| 1986 | turnId, | |
| 1987 | createdAt: now, | |
| 1988 | commandTag: "assistant-complete-finalize", | |
| 1989 | finalDeltaCommandTag: "assistant-delta-finalize-fallback", | |
| 1990 | hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined, | |
| 1991 | }), | |
| 1884 | getThreadMessageById(thread.id, assistantMessageId).pipe( | |
| 1885 | Effect.flatMap((existingMessage) => | |
| 1886 | finalizeAssistantMessage({ | |
| 1887 | event, | |
| 1888 | threadId: thread.id, | |
| 1889 | messageId: assistantMessageId, | |
| 1890 | turnId, | |
| 1891 | createdAt: now, | |
| 1892 | commandTag: "assistant-complete-finalize", | |
| 1893 | finalDeltaCommandTag: "assistant-delta-finalize-fallback", | |
| 1894 | hasProjectedMessage: existingMessage !== undefined, | |
| 1895 | }), | |
| 1896 | ), | |
| 1897 | ), | |
| 1992 | 1898 | { concurrency: 1 }, |
| 1993 | 1899 | ).pipe(Effect.asVoid); |
| 1994 | 1900 | yield* clearAssistantMessageIdsForTurn(thread.id, turnId); |
| ... | ... | @@ -1997,7 +1903,6 @@ const make = Effect.gen(function* () { |
| 1997 | 1903 | yield* finalizeBufferedProposedPlan({ |
| 1998 | 1904 | event, |
| 1999 | 1905 | threadId: thread.id, |
| 2000 | threadProposedPlans: proposedPlans, | |
| 2001 | 1906 | planId: proposedPlanIdForTurn(thread.id, turnId), |
| 2002 | 1907 | turnId, |
| 2003 | 1908 | updatedAt: now, |
| ... | ... | @@ -2153,8 +2058,17 @@ const make = Effect.gen(function* () { |
| 2153 | 2058 | if (event.type === "task.completed") { |
| 2154 | 2059 | taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); |
| 2155 | 2060 | if (!taskTitle) { |
| 2156 | const threadDetail = yield* resolveThreadDetail(thread.id, TASK_TITLE_ACTIVITY_KINDS); | |
| 2157 | taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); | |
| 2061 | const taskActivity = yield* projectionThreadActivityRepository.getLatestTaskActivity({ | |
| 2062 | threadId: thread.id, | |
| 2063 | taskId: event.payload.taskId, | |
| 2064 | }); | |
| 2065 | taskTitle = findTaskTitleInActivities( | |
| 2066 | Option.match(taskActivity, { | |
| 2067 | onNone: () => undefined, | |
| 2068 | onSome: (activity) => [activity], | |
| 2069 | }), | |
| 2070 | event.payload.taskId, | |
| 2071 | ); | |
| 2158 | 2072 | } |
| 2159 | 2073 | } |
| 2160 | 2074 | |
| ... | ... | @@ -2172,8 +2086,9 @@ const make = Effect.gen(function* () { |
| 2172 | 2086 | DateTime.makeUnsafe(pendingTurnStart.value.requestedAt), |
| 2173 | 2087 | ) |
| 2174 | 2088 | ) { |
| 2175 | const pendingMessage = (yield* getLoadedThreadDetail())?.messages.find( | |
| 2176 | (message) => message.id === pendingTurnStart.value.messageId, | |
| 2089 | const pendingMessage = yield* getThreadMessageById( | |
| 2090 | thread.id, | |
| 2091 | pendingTurnStart.value.messageId, | |
| 2177 | 2092 | ); |
| 2178 | 2093 | if ( |
| 2179 | 2094 | pendingMessage?.role === "user" && |
| ... | ... | @@ -2192,11 +2107,13 @@ const make = Effect.gen(function* () { |
| 2192 | 2107 | (activityEvent.payload.beforeTokens === undefined || |
| 2193 | 2108 | activityEvent.payload.afterTokens === undefined) |
| 2194 | 2109 | ) { |
| 2195 | const threadDetail = yield* resolveThreadDetail(thread.id, [ | |
| 2196 | "context-window.updated", | |
| 2197 | "context-compaction", | |
| 2198 | ]); | |
| 2199 | const tokenCounts = compactedTokenCountsFromActivities(threadDetail?.activities); | |
| 2110 | const activities = yield* projectionThreadActivityRepository.listByThreadId({ | |
| 2111 | threadId: thread.id, | |
| 2112 | activityKinds: ["context-window.updated", "context-compaction"], | |
| 2113 | // Preserve the previous thread-detail read's context-history bound. | |
| 2114 | limit: 500, | |
| 2115 | }); | |
| 2116 | const tokenCounts = compactedTokenCountsFromActivities(activities); | |
| 2200 | 2117 | if (tokenCounts) { |
| 2201 | 2118 | activityEvent = { |
| 2202 | 2119 | ...activityEvent, |
| ... | ... | @@ -2274,6 +2191,8 @@ export const ProviderRuntimeIngestionLive = Layer.effect( |
| 2274 | 2191 | ProviderRuntimeIngestionService, |
| 2275 | 2192 | make, |
| 2276 | 2193 | ).pipe( |
| 2277 | Layer.provide(ProjectionTurnRepositoryLive), | |
| 2278 | 2194 | Layer.provide(ProjectionThreadActivityRepositoryLive), |
| 2195 | Layer.provide(ProjectionThreadMessageRepositoryLive), | |
| 2196 | Layer.provide(ProjectionThreadProposedPlanRepositoryLive), | |
| 2197 | Layer.provide(ProjectionTurnRepositoryLive), | |
| 2279 | 2198 | ); |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+47-1| ... | ... | @@ -1,4 +1,10 @@ |
| 1 | import { ProjectId, ThreadId, TurnId, ProviderInstanceId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | ProjectId, | |
| 3 | ThreadId, | |
| 4 | TurnId, | |
| 5 | ProviderInstanceId, | |
| 6 | OrchestrationProposedPlanId, | |
| 7 | } from "@t3tools/contracts"; | |
| 2 | 8 | import { assert, it } from "@effect/vitest"; |
| 3 | 9 | import * as Effect from "effect/Effect"; |
| 4 | 10 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -229,6 +235,46 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 229 | 235 | }), |
| 230 | 236 | ); |
| 231 | 237 | |
| 238 | it.effect("reads only the requested plan in its thread", () => | |
| 239 | Effect.gen(function* () { | |
| 240 | const plans = yield* ProjectionThreadProposedPlanRepository; | |
| 241 | const sql = yield* SqlClient.SqlClient; | |
| 242 | const threadId = ThreadId.make("plan-query-thread"); | |
| 243 | const planId = OrchestrationProposedPlanId.make("plan-query-target"); | |
| 244 | yield* plans.upsert({ | |
| 245 | planId, | |
| 246 | threadId, | |
| 247 | turnId: null, | |
| 248 | planMarkdown: "Keep this plan", | |
| 249 | implementedAt: "2026-03-01T00:01:00.000Z", | |
| 250 | implementationThreadId: ThreadId.make("implementation-thread"), | |
| 251 | createdAt: "2026-03-01T00:00:00.000Z", | |
| 252 | updatedAt: "2026-03-01T00:01:00.000Z", | |
| 253 | }); | |
| 254 | // An unrelated old row must not be loaded or decoded by the exact lookup. | |
| 255 | yield* sql` | |
| 256 | INSERT INTO projection_thread_proposed_plans ( | |
| 257 | plan_id, thread_id, turn_id, plan_markdown, implemented_at, | |
| 258 | implementation_thread_id, created_at, updated_at | |
| 259 | ) VALUES ( | |
| 260 | 'unrelated-plan', ${threadId}, NULL, '', NULL, NULL, | |
| 261 | '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z' | |
| 262 | ) | |
| 263 | `; | |
| 264 | const plan = Option.getOrThrow(yield* plans.getByPlanId({ threadId, planId })); | |
| 265 | assert.equal(plan.planMarkdown, "Keep this plan"); | |
| 266 | assert.equal(plan.implementedAt, "2026-03-01T00:01:00.000Z"); | |
| 267 | assert.isTrue( | |
| 268 | Option.isNone( | |
| 269 | yield* plans.getByPlanId({ | |
| 270 | threadId: ThreadId.make("another-thread"), | |
| 271 | planId, | |
| 272 | }), | |
| 273 | ), | |
| 274 | ); | |
| 275 | }), | |
| 276 | ); | |
| 277 | ||
| 232 | 278 | it.effect("stores SQL NULL for missing project model options", () => |
| 233 | 279 | Effect.gen(function* () { |
| 234 | 280 | const projects = yield* ProjectionProjectRepository; |
apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | import { EventId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 6 | ||
| 7 | import { ProjectionThreadActivityRepository } from "../Services/ProjectionThreadActivities.ts"; | |
| 8 | import { ProjectionThreadActivityRepositoryLive } from "./ProjectionThreadActivities.ts"; | |
| 9 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; | |
| 10 | ||
| 11 | const layer = it.layer( | |
| 12 | ProjectionThreadActivityRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), | |
| 13 | ); | |
| 14 | ||
| 15 | layer("ProjectionThreadActivityRepository", (it) => { | |
| 16 | it.effect("reads only the latest matching task activity", () => | |
| 17 | Effect.gen(function* () { | |
| 18 | const repository = yield* ProjectionThreadActivityRepository; | |
| 19 | const sql = yield* SqlClient.SqlClient; | |
| 20 | const threadId = ThreadId.make("thread-latest-task-activity"); | |
| 21 | ||
| 22 | yield* sql` | |
| 23 | INSERT INTO projection_thread_activities ( | |
| 24 | activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at | |
| 25 | ) | |
| 26 | VALUES | |
| 27 | ( | |
| 28 | 'activity-task-unrelated-tool', ${threadId}, NULL, 'tool', 'tool.completed', | |
| 29 | 'large tool output', 'not-json', 1, '2026-03-01T00:00:00.000Z' | |
| 30 | ), | |
| 31 | ( | |
| 32 | 'activity-task-started', ${threadId}, NULL, 'info', 'task.started', | |
| 33 | 'started', '{"taskId":"task-1","title":"Initial title"}', 2, | |
| 34 | '2026-03-01T00:00:01.000Z' | |
| 35 | ), | |
| 36 | ( | |
| 37 | 'activity-task-progress', ${threadId}, NULL, 'info', 'task.progress', | |
| 38 | 'progress', '{"taskId":"task-1","title":"Updated title"}', 3, | |
| 39 | '2026-03-01T00:00:02.000Z' | |
| 40 | ), | |
| 41 | ( | |
| 42 | 'activity-task-other', ${threadId}, NULL, 'info', 'task.progress', | |
| 43 | 'other', '{"taskId":"task-2","title":"Other title"}', 4, | |
| 44 | '2026-03-01T00:00:03.000Z' | |
| 45 | ) | |
| 46 | `; | |
| 47 | ||
| 48 | yield* repository.upsert({ | |
| 49 | activityId: EventId.make("activity-task-untitled"), | |
| 50 | threadId, | |
| 51 | turnId: null, | |
| 52 | tone: "info", | |
| 53 | kind: "task.progress", | |
| 54 | summary: "Still running", | |
| 55 | payload: { taskId: "task-1" }, | |
| 56 | sequence: 5, | |
| 57 | createdAt: "2026-03-01T00:00:04.000Z", | |
| 58 | }); | |
| 59 | yield* repository.upsert({ | |
| 60 | activityId: EventId.make("activity-task-blank-title"), | |
| 61 | threadId, | |
| 62 | turnId: null, | |
| 63 | tone: "info", | |
| 64 | kind: "task.progress", | |
| 65 | summary: "Still running", | |
| 66 | payload: { taskId: "task-1", title: " \t\n\u00a0" }, | |
| 67 | sequence: 6, | |
| 68 | createdAt: "2026-03-01T00:00:05.000Z", | |
| 69 | }); | |
| 70 | ||
| 71 | const recent = yield* repository.listByThreadId({ | |
| 72 | threadId, | |
| 73 | activityKinds: ["task.progress"], | |
| 74 | limit: 2, | |
| 75 | }); | |
| 76 | assert.deepEqual( | |
| 77 | recent.map((entry) => entry.activityId), | |
| 78 | ["activity-task-untitled", "activity-task-blank-title"], | |
| 79 | ); | |
| 80 | ||
| 81 | const activity = yield* repository.getLatestTaskActivity({ | |
| 82 | threadId, | |
| 83 | taskId: "task-1", | |
| 84 | }); | |
| 85 | assert.equal(activity._tag, "Some"); | |
| 86 | if (activity._tag === "Some") { | |
| 87 | assert.equal(activity.value.activityId, EventId.make("activity-task-progress")); | |
| 88 | assert.deepEqual(activity.value.payload, { | |
| 89 | taskId: "task-1", | |
| 90 | title: "Updated title", | |
| 91 | }); | |
| 92 | } | |
| 93 | ||
| 94 | assert.equal( | |
| 95 | (yield* repository.getLatestTaskActivity({ threadId, taskId: "missing" }))._tag, | |
| 96 | "None", | |
| 97 | ); | |
| 98 | }), | |
| 99 | ); | |
| 100 | }); |
apps/server/src/persistence/Layers/ProjectionThreadActivities.ts+71-10| ... | ... | @@ -3,6 +3,7 @@ import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 3 | 3 | import { NonNegativeInt } from "@t3tools/contracts"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | |
| 6 | 7 | import * as Schema from "effect/Schema"; |
| 7 | 8 | import * as Struct from "effect/Struct"; |
| 8 | 9 | |
| ... | ... | @@ -11,6 +12,7 @@ import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; |
| 11 | 12 | import { |
| 12 | 13 | DeleteProjectionThreadActivitiesInput, |
| 13 | 14 | ListProjectionThreadActivitiesInput, |
| 15 | GetLatestProjectionThreadTaskActivityInput, | |
| 14 | 16 | ProjectionThreadActivity, |
| 15 | 17 | ProjectionThreadActivityRepository, |
| 16 | 18 | type ProjectionThreadActivityRepositoryShape, |
| ... | ... | @@ -23,10 +25,10 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( |
| 23 | 25 | }), |
| 24 | 26 | ); |
| 25 | 27 | |
| 26 | const mapActivityRows = ( | |
| 27 | rows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>>, | |
| 28 | ): ReadonlyArray<ProjectionThreadActivity> => | |
| 29 | rows.map((row) => ({ | |
| 28 | function toProjectionThreadActivity( | |
| 29 | row: Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>, | |
| 30 | ): ProjectionThreadActivity { | |
| 31 | return { | |
| 30 | 32 | activityId: row.activityId, |
| 31 | 33 | threadId: row.threadId, |
| 32 | 34 | turnId: row.turnId, |
| ... | ... | @@ -36,7 +38,8 @@ const mapActivityRows = ( |
| 36 | 38 | payload: row.payload, |
| 37 | 39 | ...(row.sequence !== null ? { sequence: row.sequence } : {}), |
| 38 | 40 | createdAt: row.createdAt, |
| 39 | })); | |
| 41 | }; | |
| 42 | } | |
| 40 | 43 | |
| 41 | 44 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { |
| 42 | 45 | return (cause: unknown) => |
| ... | ... | @@ -45,6 +48,10 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st |
| 45 | 48 | : toPersistenceSqlError(sqlOperation)(cause); |
| 46 | 49 | } |
| 47 | 50 | |
| 51 | // Match String.trim so blank saved titles cannot hide an earlier task name. | |
| 52 | const taskTitleWhitespace = | |
| 53 | "\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"; | |
| 54 | ||
| 48 | 55 | const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 49 | 56 | const sql = yield* SqlClient.SqlClient; |
| 50 | 57 | |
| ... | ... | @@ -90,7 +97,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 90 | 97 | const listProjectionThreadActivityRows = SqlSchema.findAll({ |
| 91 | 98 | Request: ListProjectionThreadActivitiesInput, |
| 92 | 99 | Result: ProjectionThreadActivityDbRowSchema, |
| 93 | execute: ({ threadId }) => | |
| 100 | execute: ({ threadId, activityKinds, limit }) => | |
| 94 | 101 | sql` |
| 95 | 102 | SELECT |
| 96 | 103 | activity_id AS "activityId", |
| ... | ... | @@ -102,8 +109,14 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 102 | 109 | payload_json AS "payload", |
| 103 | 110 | sequence, |
| 104 | 111 | created_at AS "createdAt" |
| 105 | FROM projection_thread_activities | |
| 106 | WHERE thread_id = ${threadId} | |
| 112 | FROM ( | |
| 113 | SELECT * | |
| 114 | FROM projection_thread_activities | |
| 115 | WHERE thread_id = ${threadId} | |
| 116 | ${activityKinds === undefined ? sql`` : sql`AND ${sql.in("kind", activityKinds)}`} | |
| 117 | ORDER BY sequence DESC, created_at DESC, activity_id DESC | |
| 118 | ${limit === undefined ? sql`` : sql`LIMIT ${limit}`} | |
| 119 | ) AS recent_activities | |
| 107 | 120 | ORDER BY |
| 108 | 121 | CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, |
| 109 | 122 | sequence ASC, |
| ... | ... | @@ -142,6 +155,40 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 142 | 155 | `, |
| 143 | 156 | }); |
| 144 | 157 | |
| 158 | const getLatestProjectionThreadTaskActivityRow = SqlSchema.findOneOption({ | |
| 159 | Request: GetLatestProjectionThreadTaskActivityInput, | |
| 160 | Result: ProjectionThreadActivityDbRowSchema, | |
| 161 | execute: ({ threadId, taskId }) => | |
| 162 | sql` | |
| 163 | SELECT | |
| 164 | activity_id AS "activityId", | |
| 165 | thread_id AS "threadId", | |
| 166 | turn_id AS "turnId", | |
| 167 | tone, | |
| 168 | kind, | |
| 169 | summary, | |
| 170 | payload_json AS "payload", | |
| 171 | sequence, | |
| 172 | created_at AS "createdAt" | |
| 173 | FROM projection_thread_activities | |
| 174 | WHERE thread_id = ${threadId} | |
| 175 | AND kind IN ('task.started', 'task.progress') | |
| 176 | AND json_extract(payload_json, '$.taskId') = ${taskId} | |
| 177 | AND length(trim( | |
| 178 | CASE | |
| 179 | WHEN json_type(payload_json, '$.title') = 'text' | |
| 180 | THEN json_extract(payload_json, '$.title') | |
| 181 | WHEN kind = 'task.started' AND json_type(payload_json, '$.detail') = 'text' | |
| 182 | THEN json_extract(payload_json, '$.detail') | |
| 183 | ELSE '' | |
| 184 | END, | |
| 185 | ${taskTitleWhitespace} | |
| 186 | )) > 0 | |
| 187 | ORDER BY sequence DESC, created_at DESC, activity_id DESC | |
| 188 | LIMIT 1 | |
| 189 | `, | |
| 190 | }); | |
| 191 | ||
| 145 | 192 | const deleteProjectionThreadActivityRows = SqlSchema.void({ |
| 146 | 193 | Request: DeleteProjectionThreadActivitiesInput, |
| 147 | 194 | execute: ({ threadId }) => |
| ... | ... | @@ -169,7 +216,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 169 | 216 | "ProjectionThreadActivityRepository.listByThreadId:decodeRows", |
| 170 | 217 | ), |
| 171 | 218 | ), |
| 172 | Effect.map(mapActivityRows), | |
| 219 | Effect.map((rows) => rows.map(toProjectionThreadActivity)), | |
| 173 | 220 | ); |
| 174 | 221 | |
| 175 | 222 | const listUserInputLifecycleByThreadId: ProjectionThreadActivityRepositoryShape["listUserInputLifecycleByThreadId"] = |
| ... | ... | @@ -181,9 +228,22 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 181 | 228 | "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:decodeRows", |
| 182 | 229 | ), |
| 183 | 230 | ), |
| 184 | Effect.map(mapActivityRows), | |
| 231 | Effect.map((rows) => rows.map(toProjectionThreadActivity)), | |
| 185 | 232 | ); |
| 186 | 233 | |
| 234 | const getLatestTaskActivity: ProjectionThreadActivityRepositoryShape["getLatestTaskActivity"] = ( | |
| 235 | input, | |
| 236 | ) => | |
| 237 | getLatestProjectionThreadTaskActivityRow(input).pipe( | |
| 238 | Effect.mapError( | |
| 239 | toPersistenceSqlOrDecodeError( | |
| 240 | "ProjectionThreadActivityRepository.getLatestTaskActivity:query", | |
| 241 | "ProjectionThreadActivityRepository.getLatestTaskActivity:decodeRow", | |
| 242 | ), | |
| 243 | ), | |
| 244 | Effect.map(Option.map(toProjectionThreadActivity)), | |
| 245 | ); | |
| 246 | ||
| 187 | 247 | const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => |
| 188 | 248 | deleteProjectionThreadActivityRows(input).pipe( |
| 189 | 249 | Effect.mapError( |
| ... | ... | @@ -195,6 +255,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 195 | 255 | upsert, |
| 196 | 256 | listByThreadId, |
| 197 | 257 | listUserInputLifecycleByThreadId, |
| 258 | getLatestTaskActivity, | |
| 198 | 259 | deleteByThreadId, |
| 199 | 260 | } satisfies ProjectionThreadActivityRepositoryShape; |
| 200 | 261 | }); |
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts+46-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { MessageId, ThreadId } from "@t3tools/contracts"; | |
| 1 | import { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 2 | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -231,4 +231,49 @@ layer("ProjectionThreadMessageRepository", (it) => { |
| 231 | 231 | assert.deepEqual(rows[0]?.attachments, []); |
| 232 | 232 | }), |
| 233 | 233 | ); |
| 234 | ||
| 235 | it.effect("checks assistant turn state without hydrating message text", () => | |
| 236 | Effect.gen(function* () { | |
| 237 | const repository = yield* ProjectionThreadMessageRepository; | |
| 238 | const threadId = ThreadId.make("thread-assistant-turn-state"); | |
| 239 | const turnId = TurnId.make("turn-assistant-state"); | |
| 240 | const createdAt = "2026-03-01T00:00:00.000Z"; | |
| 241 | ||
| 242 | yield* repository.upsert({ | |
| 243 | messageId: MessageId.make("message-assistant-turn-state"), | |
| 244 | threadId, | |
| 245 | turnId, | |
| 246 | role: "assistant", | |
| 247 | text: "large text that the existence query must not select", | |
| 248 | isStreaming: false, | |
| 249 | createdAt, | |
| 250 | updatedAt: createdAt, | |
| 251 | }); | |
| 252 | ||
| 253 | assert.equal( | |
| 254 | yield* repository.hasAssistantMessageForTurn({ | |
| 255 | threadId, | |
| 256 | turnId, | |
| 257 | streamingOnly: false, | |
| 258 | }), | |
| 259 | true, | |
| 260 | ); | |
| 261 | assert.equal( | |
| 262 | yield* repository.hasAssistantMessageForTurn({ | |
| 263 | threadId, | |
| 264 | turnId, | |
| 265 | streamingOnly: true, | |
| 266 | }), | |
| 267 | false, | |
| 268 | ); | |
| 269 | assert.equal( | |
| 270 | yield* repository.hasAssistantMessageForTurn({ | |
| 271 | threadId, | |
| 272 | turnId: TurnId.make("turn-assistant-state-missing"), | |
| 273 | streamingOnly: false, | |
| 274 | }), | |
| 275 | false, | |
| 276 | ); | |
| 277 | }), | |
| 278 | ); | |
| 234 | 279 | }); |
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+31| ... | ... | @@ -11,6 +11,7 @@ import { toPersistenceSqlError } from "../Errors.ts"; |
| 11 | 11 | import { |
| 12 | 12 | AppendStreamingProjectionThreadMessage, |
| 13 | 13 | GetProjectionThreadMessageInput, |
| 14 | HasProjectionThreadAssistantMessageInput, | |
| 14 | 15 | ProjectionThreadMessageRepository, |
| 15 | 16 | type ProjectionThreadMessageRepositoryShape, |
| 16 | 17 | DeleteProjectionThreadMessagesInput, |
| ... | ... | @@ -24,6 +25,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( |
| 24 | 25 | attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), |
| 25 | 26 | }), |
| 26 | 27 | ); |
| 28 | const ProjectionThreadMessageExistsDbRowSchema = Schema.Struct({ exists: Schema.Number }); | |
| 27 | 29 | |
| 28 | 30 | function toProjectionThreadMessage( |
| 29 | 31 | row: Schema.Schema.Type<typeof ProjectionThreadMessageDbRowSchema>, |
| ... | ... | @@ -161,6 +163,23 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 161 | 163 | `, |
| 162 | 164 | }); |
| 163 | 165 | |
| 166 | const hasProjectionThreadAssistantMessageRow = SqlSchema.findOne({ | |
| 167 | Request: HasProjectionThreadAssistantMessageInput, | |
| 168 | Result: ProjectionThreadMessageExistsDbRowSchema, | |
| 169 | execute: ({ threadId, turnId, streamingOnly }) => | |
| 170 | sql` | |
| 171 | SELECT EXISTS ( | |
| 172 | SELECT 1 | |
| 173 | FROM projection_thread_messages | |
| 174 | WHERE thread_id = ${threadId} | |
| 175 | AND turn_id = ${turnId} | |
| 176 | AND role = 'assistant' | |
| 177 | AND (${streamingOnly ? 1 : 0} = 0 OR is_streaming = 1) | |
| 178 | LIMIT 1 | |
| 179 | ) AS "exists" | |
| 180 | `, | |
| 181 | }); | |
| 182 | ||
| 164 | 183 | const listProjectionThreadMessageRows = SqlSchema.findAll({ |
| 165 | 184 | Request: ListProjectionThreadMessagesInput, |
| 166 | 185 | Result: ProjectionThreadMessageDbRowSchema, |
| ... | ... | @@ -224,6 +243,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 224 | 243 | Effect.map(Option.map(toProjectionThreadMessage)), |
| 225 | 244 | ); |
| 226 | 245 | |
| 246 | const hasAssistantMessageForTurn: ProjectionThreadMessageRepositoryShape["hasAssistantMessageForTurn"] = | |
| 247 | (input) => | |
| 248 | hasProjectionThreadAssistantMessageRow(input).pipe( | |
| 249 | Effect.mapError( | |
| 250 | toPersistenceSqlError( | |
| 251 | "ProjectionThreadMessageRepository.hasAssistantMessageForTurn:query", | |
| 252 | ), | |
| 253 | ), | |
| 254 | Effect.map((row) => row.exists === 1), | |
| 255 | ); | |
| 256 | ||
| 227 | 257 | const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => |
| 228 | 258 | listProjectionThreadMessageRows(input).pipe( |
| 229 | 259 | Effect.mapError( |
| ... | ... | @@ -253,6 +283,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 253 | 283 | upsert, |
| 254 | 284 | appendStreaming, |
| 255 | 285 | getByMessageId, |
| 286 | hasAssistantMessageForTurn, | |
| 256 | 287 | listByThreadId, |
| 257 | 288 | getLatestUserMessageAt, |
| 258 | 289 | deleteByThreadId, |
apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts+27| ... | ... | @@ -8,6 +8,7 @@ import { toPersistenceSqlError } from "../Errors.ts"; |
| 8 | 8 | import { |
| 9 | 9 | DeleteProjectionThreadProposedPlansInput, |
| 10 | 10 | HasActionableProjectionThreadProposedPlanInput, |
| 11 | GetProjectionThreadProposedPlanInput, | |
| 11 | 12 | ListProjectionThreadProposedPlansInput, |
| 12 | 13 | ProjectionThreadProposedPlan, |
| 13 | 14 | ProjectionThreadProposedPlanRepository, |
| ... | ... | @@ -52,6 +53,24 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { |
| 52 | 53 | `, |
| 53 | 54 | }); |
| 54 | 55 | |
| 56 | const getProjectionThreadProposedPlanRow = SqlSchema.findOneOption({ | |
| 57 | Request: GetProjectionThreadProposedPlanInput, | |
| 58 | Result: ProjectionThreadProposedPlan, | |
| 59 | execute: ({ threadId, planId }) => sql` | |
| 60 | SELECT | |
| 61 | plan_id AS "planId", | |
| 62 | thread_id AS "threadId", | |
| 63 | turn_id AS "turnId", | |
| 64 | plan_markdown AS "planMarkdown", | |
| 65 | implemented_at AS "implementedAt", | |
| 66 | implementation_thread_id AS "implementationThreadId", | |
| 67 | created_at AS "createdAt", | |
| 68 | updated_at AS "updatedAt" | |
| 69 | FROM projection_thread_proposed_plans | |
| 70 | WHERE thread_id = ${threadId} AND plan_id = ${planId} | |
| 71 | `, | |
| 72 | }); | |
| 73 | ||
| 55 | 74 | const listProjectionThreadProposedPlanRows = SqlSchema.findAll({ |
| 56 | 75 | Request: ListProjectionThreadProposedPlansInput, |
| 57 | 76 | Result: ProjectionThreadProposedPlan, |
| ... | ... | @@ -133,6 +152,13 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { |
| 133 | 152 | Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), |
| 134 | 153 | ); |
| 135 | 154 | |
| 155 | const getByPlanId: ProjectionThreadProposedPlanRepositoryShape["getByPlanId"] = (input) => | |
| 156 | getProjectionThreadProposedPlanRow(input).pipe( | |
| 157 | Effect.mapError( | |
| 158 | toPersistenceSqlError("ProjectionThreadProposedPlanRepository.getByPlanId:query"), | |
| 159 | ), | |
| 160 | ); | |
| 161 | ||
| 136 | 162 | const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) => |
| 137 | 163 | listProjectionThreadProposedPlanRows(input).pipe( |
| 138 | 164 | Effect.mapError( |
| ... | ... | @@ -153,6 +179,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { |
| 153 | 179 | upsert, |
| 154 | 180 | listByThreadId, |
| 155 | 181 | hasActionableByThreadId, |
| 182 | getByPlanId, | |
| 156 | 183 | deleteByThreadId, |
| 157 | 184 | } satisfies ProjectionThreadProposedPlanRepositoryShape; |
| 158 | 185 | }); |
apps/server/src/persistence/Services/ProjectionThreadActivities.ts+18-1| ... | ... | @@ -17,6 +17,7 @@ import { |
| 17 | 17 | import * as Schema from "effect/Schema"; |
| 18 | 18 | import * as Context from "effect/Context"; |
| 19 | 19 | import type * as Effect from "effect/Effect"; |
| 20 | import type * as Option from "effect/Option"; | |
| 20 | 21 | |
| 21 | 22 | import type { ProjectionRepositoryError } from "../Errors.ts"; |
| 22 | 23 | |
| ... | ... | @@ -35,9 +36,18 @@ export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; |
| 35 | 36 | |
| 36 | 37 | export const ListProjectionThreadActivitiesInput = Schema.Struct({ |
| 37 | 38 | threadId: ThreadId, |
| 39 | activityKinds: Schema.optional(Schema.Array(Schema.String)), | |
| 40 | limit: Schema.optional(NonNegativeInt), | |
| 38 | 41 | }); |
| 39 | 42 | export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; |
| 40 | 43 | |
| 44 | export const GetLatestProjectionThreadTaskActivityInput = Schema.Struct({ | |
| 45 | threadId: ThreadId, | |
| 46 | taskId: Schema.String, | |
| 47 | }); | |
| 48 | export type GetLatestProjectionThreadTaskActivityInput = | |
| 49 | typeof GetLatestProjectionThreadTaskActivityInput.Type; | |
| 50 | ||
| 41 | 51 | export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ |
| 42 | 52 | threadId: ThreadId, |
| 43 | 53 | }); |
| ... | ... | @@ -61,7 +71,7 @@ export interface ProjectionThreadActivityRepositoryShape { |
| 61 | 71 | * List projected thread activity rows for a thread. |
| 62 | 72 | * |
| 63 | 73 | * Returned in ascending runtime sequence order (or creation order when |
| 64 | * sequence is unavailable). | |
| 74 | * sequence is unavailable). A limit selects the newest matching rows. | |
| 65 | 75 | */ |
| 66 | 76 | readonly listByThreadId: ( |
| 67 | 77 | input: ListProjectionThreadActivitiesInput, |
| ... | ... | @@ -76,6 +86,13 @@ export interface ProjectionThreadActivityRepositoryShape { |
| 76 | 86 | input: ListProjectionThreadActivitiesInput, |
| 77 | 87 | ) => Effect.Effect<ReadonlyArray<ProjectionThreadActivity>, ProjectionRepositoryError>; |
| 78 | 88 | |
| 89 | /** | |
| 90 | * Read the latest task-start or task-progress activity with a usable title. | |
| 91 | */ | |
| 92 | readonly getLatestTaskActivity: ( | |
| 93 | input: GetLatestProjectionThreadTaskActivityInput, | |
| 94 | ) => Effect.Effect<Option.Option<ProjectionThreadActivity>, ProjectionRepositoryError>; | |
| 95 | ||
| 79 | 96 | /** |
| 80 | 97 | * Delete projected thread activity rows by thread. |
| 81 | 98 | */ |
apps/server/src/persistence/Services/ProjectionThreadMessages.ts+15| ... | ... | @@ -51,6 +51,14 @@ export const GetProjectionThreadMessageInput = Schema.Struct({ |
| 51 | 51 | }); |
| 52 | 52 | export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type; |
| 53 | 53 | |
| 54 | export const HasProjectionThreadAssistantMessageInput = Schema.Struct({ | |
| 55 | threadId: ThreadId, | |
| 56 | turnId: TurnId, | |
| 57 | streamingOnly: Schema.Boolean, | |
| 58 | }); | |
| 59 | export type HasProjectionThreadAssistantMessageInput = | |
| 60 | typeof HasProjectionThreadAssistantMessageInput.Type; | |
| 61 | ||
| 54 | 62 | export const DeleteProjectionThreadMessagesInput = Schema.Struct({ |
| 55 | 63 | threadId: ThreadId, |
| 56 | 64 | }); |
| ... | ... | @@ -81,6 +89,13 @@ export interface ProjectionThreadMessageRepositoryShape { |
| 81 | 89 | input: GetProjectionThreadMessageInput, |
| 82 | 90 | ) => Effect.Effect<Option.Option<ProjectionThreadMessage>, ProjectionRepositoryError>; |
| 83 | 91 | |
| 92 | /** | |
| 93 | * Check for an assistant message in a turn without hydrating message text. | |
| 94 | */ | |
| 95 | readonly hasAssistantMessageForTurn: ( | |
| 96 | input: HasProjectionThreadAssistantMessageInput, | |
| 97 | ) => Effect.Effect<boolean, ProjectionRepositoryError>; | |
| 98 | ||
| 84 | 99 | /** |
| 85 | 100 | * List projected thread messages for a thread. |
| 86 | 101 | * |
apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts+11| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | import * as Schema from "effect/Schema"; |
| 9 | 9 | import * as Context from "effect/Context"; |
| 10 | 10 | import type * as Effect from "effect/Effect"; |
| 11 | import type * as Option from "effect/Option"; | |
| 11 | 12 | |
| 12 | 13 | import type { ProjectionRepositoryError } from "../Errors.ts"; |
| 13 | 14 | |
| ... | ... | @@ -36,6 +37,12 @@ export const HasActionableProjectionThreadProposedPlanInput = Schema.Struct({ |
| 36 | 37 | export type HasActionableProjectionThreadProposedPlanInput = |
| 37 | 38 | typeof HasActionableProjectionThreadProposedPlanInput.Type; |
| 38 | 39 | |
| 40 | export const GetProjectionThreadProposedPlanInput = Schema.Struct({ | |
| 41 | threadId: ThreadId, | |
| 42 | planId: OrchestrationProposedPlanId, | |
| 43 | }); | |
| 44 | export type GetProjectionThreadProposedPlanInput = typeof GetProjectionThreadProposedPlanInput.Type; | |
| 45 | ||
| 39 | 46 | export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ |
| 40 | 47 | threadId: ThreadId, |
| 41 | 48 | }); |
| ... | ... | @@ -46,6 +53,10 @@ export interface ProjectionThreadProposedPlanRepositoryShape { |
| 46 | 53 | readonly upsert: ( |
| 47 | 54 | proposedPlan: ProjectionThreadProposedPlan, |
| 48 | 55 | ) => Effect.Effect<void, ProjectionRepositoryError>; |
| 56 | /** Read one plan without loading the thread's other plans. */ | |
| 57 | readonly getByPlanId: ( | |
| 58 | input: GetProjectionThreadProposedPlanInput, | |
| 59 | ) => Effect.Effect<Option.Option<ProjectionThreadProposedPlan>, ProjectionRepositoryError>; | |
| 49 | 60 | readonly listByThreadId: ( |
| 50 | 61 | input: ListProjectionThreadProposedPlansInput, |
| 51 | 62 | ) => Effect.Effect<ReadonlyArray<ProjectionThreadProposedPlan>, ProjectionRepositoryError>; |