Unified · Split
| @@ -286,9 +286,20 @@ describe("ProviderRuntimeIngestion", () => { · 285 unmodified lines | ||
| 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", () => { · 188 unmodified lines | ||
| 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", () => { · 1201 unmodified lines | ||
| 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", () => { · 424 unmodified lines | ||
| 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", () => { · 4 unmodified lines | ||
| 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", () => { · 23 unmodified lines | ||
| 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", () => { · 1870 unmodified lines | ||
| 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 () => { |
| @@ -4,8 +4,7 @@ import { · 3 unmodified lines | ||
| 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 { · 2 unmodified lines | ||
| 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 · 17 unmodified lines | ||
| 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"; · 9 unmodified lines | ||
| 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 · 71 unmodified lines | ||
| 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( · 69 unmodified lines | ||
| 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* () { · 676 unmodified lines | ||
| 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* () { · 51 unmodified lines | ||
| 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* () { · 161 unmodified lines | ||
| 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* () { · 147 unmodified lines | ||
| 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* () { · 98 unmodified lines | ||
| 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* () { · 4 unmodified lines | ||
| 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* () { · 12 unmodified lines | ||
| 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* () { · 213 unmodified lines | ||
| 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* () { · 24 unmodified lines | ||
| 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* () { · 22 unmodified lines | ||
| 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* () { · 33 unmodified lines | ||
| 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* () { · 2 unmodified lines | ||
| 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* () { · 41 unmodified lines | ||
| 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* () { · 2 unmodified lines | ||
| 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* () { · 149 unmodified lines | ||
| 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* () { · 11 unmodified lines | ||
| 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* () { · 12 unmodified lines | ||
| 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( · 71 unmodified lines | ||
| 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 | ); |
| @@ -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) => { · 224 unmodified lines | ||
| 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; |
| @@ -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 | +}); | |
| @@ -3,6 +3,7 @@ import * as SqlSchema from "effect/unstable/sql/SqlSchema"; · 2 unmodified lines | ||
| 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"; · 2 unmodified lines | ||
| 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( · 6 unmodified lines | ||
| 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 = ( · 3 unmodified lines | ||
| 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 · 2 unmodified lines | ||
| 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* () { · 39 unmodified lines | ||
| 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* () { · 5 unmodified lines | ||
| 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* () { · 32 unmodified lines | ||
| 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* () { · 21 unmodified lines | ||
| 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* () { · 5 unmodified lines | ||
| 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* () { · 5 unmodified lines | ||
| 195 | 255 | upsert, |
| 196 | 256 | listByThreadId, |
| 197 | 257 | listUserInputLifecycleByThreadId, |
| 258 | + getLatestTaskActivity, | |
| 198 | 259 | deleteByThreadId, |
| 199 | 260 | } satisfies ProjectionThreadActivityRepositoryShape; |
| 200 | 261 | }); |
| @@ -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) => { · 226 unmodified lines | ||
| 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 | }); |
| @@ -11,6 +11,7 @@ import { toPersistenceSqlError } from "../Errors.ts"; · 10 unmodified lines | ||
| 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( · 7 unmodified lines | ||
| 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* () { · 131 unmodified lines | ||
| 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* () { · 57 unmodified lines | ||
| 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* () { · 23 unmodified lines | ||
| 253 | 283 | upsert, |
| 254 | 284 | appendStreaming, |
| 255 | 285 | getByMessageId, |
| 286 | + hasAssistantMessageForTurn, | |
| 256 | 287 | listByThreadId, |
| 257 | 288 | getLatestUserMessageAt, |
| 258 | 289 | deleteByThreadId, |
| @@ -8,6 +8,7 @@ import { toPersistenceSqlError } from "../Errors.ts"; · 7 unmodified lines | ||
| 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* () { · 38 unmodified lines | ||
| 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* () { · 75 unmodified lines | ||
| 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* () { · 14 unmodified lines | ||
| 153 | 179 | upsert, |
| 154 | 180 | listByThreadId, |
| 155 | 181 | hasActionableByThreadId, |
| 182 | + getByPlanId, | |
| 156 | 183 | deleteByThreadId, |
| 157 | 184 | } satisfies ProjectionThreadProposedPlanRepositoryShape; |
| 158 | 185 | }); |
| @@ -17,6 +17,7 @@ import { · 16 unmodified lines | ||
| 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; · 12 unmodified lines | ||
| 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 { · 17 unmodified lines | ||
| 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 { · 8 unmodified lines | ||
| 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 | */ |
| @@ -51,6 +51,14 @@ export const GetProjectionThreadMessageInput = Schema.Struct({ · 50 unmodified lines | ||
| 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 { · 24 unmodified lines | ||
| 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 | * |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 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({ · 22 unmodified lines | ||
| 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 { · 4 unmodified lines | ||
| 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>; |