| 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,9 +286,20 @@ describe("ProviderRuntimeIngestion", () => { |
| 286 | Layer.provide(RepositoryIdentityResolver.layer), | 286 | Layer.provide(RepositoryIdentityResolver.layer), |
| 287 | Layer.provide(SqlitePersistenceMemory), | 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 | const layer = ProviderRuntimeIngestionLive.pipe( | 300 | const layer = ProviderRuntimeIngestionLive.pipe( |
| 290 | Layer.provideMerge(orchestrationLayer), | 301 | Layer.provideMerge(orchestrationLayer), |
| 291 | Layer.provideMerge(projectionSnapshotLayer), | 302 | Layer.provideMerge(ingestionProjectionSnapshotLayer), |
| 292 | // Single shared liveness instance across ingestion (writer), the | 303 | // Single shared liveness instance across ingestion (writer), the |
| 293 | // engine, and the snapshot query (reader). | 304 | // engine, and the snapshot query (reader). |
| 294 | Layer.provideMerge(ThreadBackgroundLiveness.layer), | 305 | Layer.provideMerge(ThreadBackgroundLiveness.layer), |
| ... | @@ -483,6 +494,89 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -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 | it.each([ | 580 | it.each([ |
| 487 | { source: "the previous turn", turnId: asTurnId("opencode-stopped-turn") }, | 581 | { source: "the previous turn", turnId: asTurnId("opencode-stopped-turn") }, |
| 488 | { source: "an unspecified turn", turnId: undefined }, | 582 | { source: "an unspecified turn", turnId: undefined }, |
| ... | @@ -1690,6 +1784,31 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1690,6 +1784,31 @@ describe("ProviderRuntimeIngestion", () => { |
| 1690 | ).toMatchObject({ | 1784 | ).toMatchObject({ |
| 1691 | implementationThreadId: "thread-implement", | 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 | it("does not mark the source proposed plan implemented for a rejected turn.started event", async () => { | 1814 | it("does not mark the source proposed plan implemented for a rejected turn.started event", async () => { |
| ... | @@ -2120,7 +2239,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2120,7 +2239,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2120 | type: "turn.proposed.delta", | 2239 | type: "turn.proposed.delta", |
| 2121 | eventId: asEventId("evt-plan-delta-1"), | 2240 | eventId: asEventId("evt-plan-delta-1"), |
| 2122 | provider: ProviderDriverKind.make("codex"), | 2241 | provider: ProviderDriverKind.make("codex"), |
| 2123 | createdAt: now, | 2242 | createdAt: "", |
| 2124 | threadId: asThreadId("thread-1"), | 2243 | threadId: asThreadId("thread-1"), |
| 2125 | turnId: asTurnId("turn-plan-buffer"), | 2244 | turnId: asTurnId("turn-plan-buffer"), |
| 2126 | payload: { | 2245 | payload: { |
| ... | @@ -2131,7 +2250,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2131,7 +2250,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2131 | type: "turn.proposed.delta", | 2250 | type: "turn.proposed.delta", |
| 2132 | eventId: asEventId("evt-plan-delta-2"), | 2251 | eventId: asEventId("evt-plan-delta-2"), |
| 2133 | provider: ProviderDriverKind.make("codex"), | 2252 | provider: ProviderDriverKind.make("codex"), |
| 2134 | createdAt: now, | 2253 | createdAt: "", |
| 2135 | threadId: asThreadId("thread-1"), | 2254 | threadId: asThreadId("thread-1"), |
| 2136 | turnId: asTurnId("turn-plan-buffer"), | 2255 | turnId: asTurnId("turn-plan-buffer"), |
| 2137 | payload: { | 2256 | payload: { |
| ... | @@ -2161,6 +2280,42 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2161,6 +2280,42 @@ describe("ProviderRuntimeIngestion", () => { |
| 2161 | entry.id === "plan:thread-1:turn:turn-plan-buffer", | 2280 | entry.id === "plan:thread-1:turn:turn-plan-buffer", |
| 2162 | ); | 2281 | ); |
| 2163 | expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second"); | 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 | it("buffers assistant deltas with one lifecycle query per event until completion", async () => { | 2321 | it("buffers assistant deltas with one lifecycle query per event until completion", async () => { |
| ... | @@ -4037,71 +4192,65 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -4037,71 +4192,65 @@ describe("ProviderRuntimeIngestion", () => { |
| 4037 | expect(completedPayload?.title).toBe("wait for codex review to finish"); | 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 | const harness = await createHarness(); | 4196 | const harness = await createHarness(); |
| 4042 | const now = "2026-01-01T00:00:00.000Z"; | 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({ | 4202 | await harness.emitAndDrain([ |
| 4045 | type: "task.progress", | 4203 | { |
| 4046 | eventId: asEventId("evt-swept-task-progress"), | 4204 | type: "task.started", |
| 4047 | provider: ProviderDriverKind.make("claudeAgent"), | 4205 | eventId: asEventId("evt-swept-task-started"), |
| 4048 | createdAt: now, | 4206 | provider, |
| 4049 | threadId: asThreadId("thread-1"), | 4207 | createdAt: now, |
| 4050 | turnId: asTurnId("turn-swept-task"), | 4208 | threadId, |
| 4051 | payload: { | 4209 | turnId, |
| 4052 | taskId: "swept-task-1", | 4210 | payload: { taskId: "swept-task-1", description: "Watch round-3 CI and bots" }, |
| 4053 | 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 | summary: "Polling CI checks.", | 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 | 4229 | await harness.emitAndDrain([ | |
| 4058 | await waitForThread(harness.readModel, (entry) => | 4230 | { |
| 4059 | entry.activities.some( | 4231 | type: "session.exited", |
| 4060 | (activity: ProviderRuntimeTestActivity) => | 4232 | eventId: asEventId("evt-swept-task-session-exited"), |
| 4061 | activity.id === "task-progress:thread-1:swept-task-1", | 4233 | provider, |
| 4062 | ), | 4234 | createdAt: "2026-01-01T00:00:02.000Z", |
| 4063 | ); | 4235 | threadId, |
| 4064 | 4236 | payload: {}, | |
| 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.", | ||
| 4087 | }, | 4237 | }, |
| 4088 | }); | 4238 | { |
| 4089 | 4239 | type: "task.completed", | |
| 4090 | const thread = await waitForThread(harness.readModel, (entry) => | 4240 | eventId: asEventId("evt-swept-task-completed"), |
| 4091 | entry.activities.some( | 4241 | provider, |
| 4092 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", | 4242 | createdAt: "2026-01-01T00:00:03.000Z", |
| 4093 | ), | 4243 | threadId, |
| 4094 | ); | 4244 | turnId, |
| 4245 | payload: { taskId: "swept-task-1", status: "completed", summary: "CI is green." }, | ||
| 4246 | }, | ||
| 4247 | ]); | ||
| 4095 | 4248 | ||
| 4096 | const completed = thread.activities.find( | 4249 | const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); |
| 4097 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", | 4250 | const completed = thread?.activities.find( |
| 4251 | (activity) => activity.id === "evt-swept-task-completed", | ||
| 4098 | ); | 4252 | ); |
| 4099 | const completedPayload = | 4253 | expect(completed?.payload).toMatchObject({ title: "Watch round-3 CI and bots" }); |
| 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"); | ||
| 4105 | }); | 4254 | }); |
| 4106 | 4255 | ||
| 4107 | it("projects structured user input request and resolution as thread activities", async () => { | 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,8 +4,7 @@ import { |
| 4 | CommandId, | 4 | CommandId, |
| 5 | MessageId, | 5 | MessageId, |
| 6 | type OrchestrationEvent, | 6 | type OrchestrationEvent, |
| 7 | type OrchestrationMessage, | 7 | OrchestrationProposedPlanId, |
| 8 | type OrchestrationProposedPlanId, | ||
| 9 | CheckpointRef, | 8 | CheckpointRef, |
| 10 | classifyTaskAgentKind, | 9 | classifyTaskAgentKind, |
| 11 | EventId, | 10 | EventId, |
| ... | @@ -14,8 +13,6 @@ import { | ... | @@ -14,8 +13,6 @@ import { |
| 14 | type ThreadTokenUsageSnapshot, | 13 | type ThreadTokenUsageSnapshot, |
| 15 | TurnId, | 14 | TurnId, |
| 16 | type OrchestrationCheckpointSummary, | 15 | type OrchestrationCheckpointSummary, |
| 17 | type OrchestrationProposedPlan, | ||
| 18 | type OrchestrationThread, | ||
| 19 | type OrchestrationThreadActivity, | 16 | type OrchestrationThreadActivity, |
| 20 | type ProviderRuntimeEvent, | 17 | type ProviderRuntimeEvent, |
| 21 | RuntimeRequestId, | 18 | RuntimeRequestId, |
| ... | @@ -39,6 +36,10 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio | ... | @@ -39,6 +36,10 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio |
| 39 | import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; | 36 | import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; |
| 40 | import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; | 37 | import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; |
| 41 | import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; | 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 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 43 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 43 | import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; | 44 | import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; |
| 44 | import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; | 45 | import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; |
| ... | @@ -54,13 +55,12 @@ import { canReplaceThreadTitle } from "../threadTitles.ts"; | ... | @@ -54,13 +55,12 @@ import { canReplaceThreadTitle } from "../threadTitles.ts"; |
| 54 | 55 | ||
| 55 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; | 56 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; |
| 56 | const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; | 57 | const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; |
| 57 | const TASK_TITLE_ACTIVITY_KINDS = ["task.started", "task.progress"] as const; | ||
| 58 | 58 | ||
| 59 | // Fallback when the in-memory description cache no longer has the task name | 59 | // Fallback when the in-memory description cache no longer has the task name |
| 60 | // (server restart, session-exit sweep, TTL/capacity eviction): earlier | 60 | // (server restart, session-exit sweep, TTL/capacity eviction): earlier |
| 61 | // task.started/task.progress activities for the task are persisted with it. | 61 | // task.started/task.progress activities for the task are persisted with it. |
| 62 | function findTaskTitleInActivities( | 62 | function findTaskTitleInActivities( |
| 63 | activities: ReadonlyArray<OrchestrationThreadActivity> | undefined, | 63 | activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }> | undefined, |
| 64 | taskId: string, | 64 | taskId: string, |
| 65 | ): string | undefined { | 65 | ): string | undefined { |
| 66 | if (!activities) { | 66 | if (!activities) { |
| ... | @@ -138,57 +138,6 @@ function sameId(left: string | null | undefined, right: string | null | undefine | ... | @@ -138,57 +138,6 @@ function sameId(left: string | null | undefined, right: string | null | undefine |
| 138 | return left === right; | 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 | function hasCheckpointForTurn( | 141 | function hasCheckpointForTurn( |
| 193 | checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, | 142 | checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, |
| 194 | turnId: TurnId, | 143 | turnId: TurnId, |
| ... | @@ -264,16 +213,15 @@ function buildContextWindowActivityPayload( | ... | @@ -264,16 +213,15 @@ function buildContextWindowActivityPayload( |
| 264 | } | 213 | } |
| 265 | 214 | ||
| 266 | function compactedTokenCountsFromActivities( | 215 | function compactedTokenCountsFromActivities( |
| 267 | activities: ReadonlyArray<OrchestrationThreadActivity> | undefined, | 216 | activities: ReadonlyArray< |
| 217 | Pick<OrchestrationThreadActivity, "kind" | "payload" | "sequence" | "createdAt"> | ||
| 218 | >, | ||
| 268 | ): { readonly beforeTokens: number; readonly afterTokens: number } | undefined { | 219 | ): { readonly beforeTokens: number; readonly afterTokens: number } | undefined { |
| 269 | const lastCompactionIndex = activities?.findLastIndex( | 220 | const lastCompactionIndex = activities.findLastIndex( |
| 270 | (activity) => activity.kind === "context-compaction", | 221 | (activity) => activity.kind === "context-compaction", |
| 271 | ); | 222 | ); |
| 272 | const lastCompaction = | 223 | const lastCompaction = activities[lastCompactionIndex]; |
| 273 | lastCompactionIndex !== undefined && lastCompactionIndex >= 0 | 224 | const activitiesSinceLastCompaction = activities.slice(lastCompactionIndex + 1); |
| 274 | ? activities?.[lastCompactionIndex] | ||
| 275 | : undefined; | ||
| 276 | const activitiesSinceLastCompaction = activities?.slice((lastCompactionIndex ?? -1) + 1) ?? []; | ||
| 277 | const usedTokens = activitiesSinceLastCompaction.flatMap((activity) => { | 225 | const usedTokens = activitiesSinceLastCompaction.flatMap((activity) => { |
| 278 | if (activity.kind !== "context-window.updated") return []; | 226 | if (activity.kind !== "context-window.updated") return []; |
| 279 | if (lastCompaction !== undefined) { | 227 | if (lastCompaction !== undefined) { |
| ... | @@ -956,6 +904,8 @@ const make = Effect.gen(function* () { | ... | @@ -956,6 +904,8 @@ const make = Effect.gen(function* () { |
| 956 | const orchestrationEngine = yield* OrchestrationEngineService; | 904 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 957 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | 905 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 958 | const providerService = yield* ProviderService; | 906 | const providerService = yield* ProviderService; |
| 907 | const projectionThreadMessages = yield* ProjectionThreadMessageRepository; | ||
| 908 | const projectionThreadProposedPlans = yield* ProjectionThreadProposedPlanRepository; | ||
| 959 | const projectionTurnRepository = yield* ProjectionTurnRepository; | 909 | const projectionTurnRepository = yield* ProjectionTurnRepository; |
| 960 | const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; | 910 | const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; |
| 961 | const serverSettingsService = yield* ServerSettingsService; | 911 | const serverSettingsService = yield* ServerSettingsService; |
| ... | @@ -1013,21 +963,22 @@ const make = Effect.gen(function* () { | ... | @@ -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 | threadId: ThreadId, | 967 | threadId: ThreadId, |
| 1018 | activityKinds: ReadonlyArray<string> = [], | ||
| 1019 | ) { | 968 | ) { |
| 1020 | return yield* projectionSnapshotQuery | 969 | return yield* projectionSnapshotQuery |
| 1021 | .getThreadDetailById(threadId, { activityKinds }) | 970 | .getThreadRuntimeContext(threadId) |
| 1022 | .pipe(Effect.map(Option.getOrUndefined)); | 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 | threadId: ThreadId, | 975 | threadId: ThreadId, |
| 976 | messageId: MessageId, | ||
| 1027 | ) { | 977 | ) { |
| 1028 | return yield* projectionSnapshotQuery | 978 | const message = yield* projectionThreadMessages.getByMessageId({ messageId }); |
| 1029 | .getThreadRuntimeContext(threadId) | 979 | return Option.filter(message, (entry) => entry.threadId === threadId).pipe( |
| 1030 | .pipe(Effect.map(Option.getOrUndefined)); | 980 | Option.getOrUndefined, |
| 981 | ); | ||
| 1031 | }); | 982 | }); |
| 1032 | 983 | ||
| 1033 | const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => | 984 | const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => |
| ... | @@ -1195,15 +1146,6 @@ const make = Effect.gen(function* () { | ... | @@ -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 | const clearBufferedProposedPlan = (planId: string) => | 1149 | const clearBufferedProposedPlan = (planId: string) => |
| 1208 | Cache.invalidate(bufferedProposedPlanById, planId); | 1150 | Cache.invalidate(bufferedProposedPlanById, planId); |
| 1209 | 1151 | ||
| ... | @@ -1357,83 +1299,45 @@ const make = Effect.gen(function* () { | ... | @@ -1357,83 +1299,45 @@ const make = Effect.gen(function* () { |
| 1357 | } | 1299 | } |
| 1358 | }); | 1300 | }); |
| 1359 | 1301 | ||
| 1360 | const upsertProposedPlan = (input: { | 1302 | const finalizeBufferedProposedPlan = Effect.fn("finalizeBufferedProposedPlan")(function* (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: { | ||
| 1400 | event: ProviderRuntimeEvent; | 1303 | event: ProviderRuntimeEvent; |
| 1401 | threadId: ThreadId; | 1304 | threadId: ThreadId; |
| 1402 | threadProposedPlans: ReadonlyArray<{ | ||
| 1403 | id: string; | ||
| 1404 | createdAt: string; | ||
| 1405 | implementedAt: string | null; | ||
| 1406 | implementationThreadId: ThreadId | null; | ||
| 1407 | }>; | ||
| 1408 | planId: string; | 1305 | planId: string; |
| 1409 | turnId?: TurnId; | 1306 | turnId?: TurnId; |
| 1410 | fallbackMarkdown?: string; | 1307 | fallbackMarkdown?: string; |
| 1411 | updatedAt: string; | 1308 | updatedAt: string; |
| 1412 | }) => | 1309 | }) { |
| 1413 | Effect.gen(function* () { | 1310 | const bufferedPlan = Option.getOrUndefined( |
| 1414 | const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); | 1311 | yield* Cache.getOption(bufferedProposedPlanById, input.planId), |
| 1415 | const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); | 1312 | ); |
| 1416 | const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); | 1313 | const planMarkdown = |
| 1417 | const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; | 1314 | normalizeProposedPlanMarkdown(bufferedPlan?.text) ?? |
| 1418 | if (!planMarkdown) { | 1315 | normalizeProposedPlanMarkdown(input.fallbackMarkdown); |
| 1419 | return; | 1316 | if (!planMarkdown) return yield* clearBufferedProposedPlan(input.planId); |
| 1420 | } | ||
| 1421 | 1317 | ||
| 1422 | yield* upsertProposedPlan({ | 1318 | const existingPlan = Option.getOrUndefined( |
| 1423 | event: input.event, | 1319 | yield* projectionThreadProposedPlans.getByPlanId({ |
| 1424 | threadId: input.threadId, | 1320 | threadId: input.threadId, |
| 1425 | threadProposedPlans: input.threadProposedPlans, | 1321 | planId: OrchestrationProposedPlanId.make(input.planId), |
| 1426 | planId: input.planId, | 1322 | }), |
| 1427 | ...(input.turnId ? { turnId: input.turnId } : {}), | 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 | planMarkdown, | 1331 | planMarkdown, |
| 1429 | createdAt: | 1332 | implementedAt: existingPlan?.implementedAt ?? null, |
| 1430 | bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0 | 1333 | implementationThreadId: existingPlan?.implementationThreadId ?? null, |
| 1431 | ? bufferedPlan.createdAt | 1334 | createdAt: existingPlan?.createdAt ?? (bufferedPlan?.createdAt || input.updatedAt), |
| 1432 | : input.updatedAt, | ||
| 1433 | updatedAt: input.updatedAt, | 1335 | updatedAt: input.updatedAt, |
| 1434 | }); | 1336 | }, |
| 1435 | yield* clearBufferedProposedPlan(input.planId); | 1337 | createdAt: input.updatedAt, |
| 1436 | }); | 1338 | }); |
| 1339 | yield* clearBufferedProposedPlan(input.planId); | ||
| 1340 | }); | ||
| 1437 | 1341 | ||
| 1438 | const clearTurnStateForSession = (threadId: ThreadId) => | 1342 | const clearTurnStateForSession = (threadId: ThreadId) => |
| 1439 | Effect.gen(function* () { | 1343 | Effect.gen(function* () { |
| ... | @@ -1538,8 +1442,13 @@ const make = Effect.gen(function* () { | ... | @@ -1538,8 +1442,13 @@ const make = Effect.gen(function* () { |
| 1538 | implementationThreadId: ThreadId, | 1442 | implementationThreadId: ThreadId, |
| 1539 | implementedAt: string, | 1443 | implementedAt: string, |
| 1540 | ) { | 1444 | ) { |
| 1541 | const sourceThread = yield* resolveThreadDetail(sourceThreadId); | 1445 | const sourceThread = yield* resolveThreadRuntimeContext(sourceThreadId); |
| 1542 | const sourcePlan = sourceThread?.proposedPlans.find((entry) => entry.id === sourcePlanId); | 1446 | const sourcePlan = Option.getOrUndefined( |
| 1447 | yield* projectionThreadProposedPlans.getByPlanId({ | ||
| 1448 | threadId: sourceThreadId, | ||
| 1449 | planId: sourcePlanId, | ||
| 1450 | }), | ||
| 1451 | ); | ||
| 1543 | if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) { | 1452 | if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) { |
| 1544 | return; | 1453 | return; |
| 1545 | } | 1454 | } |
| ... | @@ -1550,9 +1459,12 @@ const make = Effect.gen(function* () { | ... | @@ -1550,9 +1459,12 @@ const make = Effect.gen(function* () { |
| 1550 | commandId: CommandId.make( | 1459 | commandId: CommandId.make( |
| 1551 | `provider:source-proposed-plan-implemented:${implementationThreadId}:${commandUuid}`, | 1460 | `provider:source-proposed-plan-implemented:${implementationThreadId}:${commandUuid}`, |
| 1552 | ), | 1461 | ), |
| 1553 | threadId: sourceThread.id, | 1462 | threadId: sourceThreadId, |
| 1554 | proposedPlan: { | 1463 | proposedPlan: { |
| 1555 | ...sourcePlan, | 1464 | id: sourcePlan.planId, |
| 1465 | turnId: sourcePlan.turnId, | ||
| 1466 | planMarkdown: sourcePlan.planMarkdown, | ||
| 1467 | createdAt: sourcePlan.createdAt, | ||
| 1556 | implementedAt, | 1468 | implementedAt, |
| 1557 | implementationThreadId, | 1469 | implementationThreadId, |
| 1558 | updatedAt: implementedAt, | 1470 | updatedAt: implementedAt, |
| ... | @@ -1571,16 +1483,6 @@ const make = Effect.gen(function* () { | ... | @@ -1571,16 +1483,6 @@ const make = Effect.gen(function* () { |
| 1571 | const thread = yield* resolveThreadRuntimeContext(event.threadId); | 1483 | const thread = yield* resolveThreadRuntimeContext(event.threadId); |
| 1572 | if (!thread) return; | 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 | const now = event.createdAt; | 1486 | const now = event.createdAt; |
| 1585 | const eventTurnId = toTurnId(event.turnId); | 1487 | const eventTurnId = toTurnId(event.turnId); |
| 1586 | const activeTurnId = thread.session?.activeTurnId ?? null; | 1488 | const activeTurnId = thread.session?.activeTurnId ?? null; |
| ... | @@ -1800,7 +1702,11 @@ const make = Effect.gen(function* () { | ... | @@ -1800,7 +1702,11 @@ const make = Effect.gen(function* () { |
| 1800 | ? toTurnId(event.turnId) | 1702 | ? toTurnId(event.turnId) |
| 1801 | : undefined; | 1703 | : undefined; |
| 1802 | if (pauseForUserTurnId) { | 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 | const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( | 1710 | const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( |
| 1805 | serverSettingsService.getSettings, | 1711 | serverSettingsService.getSettings, |
| 1806 | (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), | 1712 | (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), |
| ... | @@ -1831,11 +1737,7 @@ const make = Effect.gen(function* () { | ... | @@ -1831,11 +1737,7 @@ const make = Effect.gen(function* () { |
| 1831 | event.type === "request.opened" | 1737 | event.type === "request.opened" |
| 1832 | ? "assistant-delta-finalize-on-request-opened" | 1738 | ? "assistant-delta-finalize-on-request-opened" |
| 1833 | : "assistant-delta-finalize-on-user-input-requested", | 1739 | : "assistant-delta-finalize-on-user-input-requested", |
| 1834 | hasProjectedMessage: | 1740 | hasProjectedMessage, |
| 1835 | detailedThread !== null && | ||
| 1836 | hasAssistantMessageForTurn(detailedThread.messages, pauseForUserTurnId, { | ||
| 1837 | streamingOnly: true, | ||
| 1838 | }), | ||
| 1839 | flushedMessageIds, | 1741 | flushedMessageIds, |
| 1840 | }); | 1742 | }); |
| 1841 | } | 1743 | } |
| ... | @@ -1864,19 +1766,24 @@ const make = Effect.gen(function* () { | ... | @@ -1864,19 +1766,24 @@ const make = Effect.gen(function* () { |
| 1864 | : undefined; | 1766 | : undefined; |
| 1865 | 1767 | ||
| 1866 | if (assistantCompletion) { | 1768 | if (assistantCompletion) { |
| 1867 | const detailedThread = yield* getLoadedThreadDetail(); | ||
| 1868 | const messages = detailedThread?.messages ?? []; | ||
| 1869 | const turnId = toTurnId(event.turnId); | 1769 | const turnId = toTurnId(event.turnId); |
| 1870 | const activeAssistantMessageId = turnId | 1770 | const activeAssistantMessageId = turnId |
| 1871 | ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) | 1771 | ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) |
| 1872 | : Option.none<MessageId>(); | 1772 | : Option.none<MessageId>(); |
| 1873 | const hasAssistantMessagesForTurn = | ||
| 1874 | turnId !== undefined ? hasAssistantMessageForTurn(messages, turnId) : false; | ||
| 1875 | const assistantMessageId = Option.getOrElse( | 1773 | const assistantMessageId = Option.getOrElse( |
| 1876 | activeAssistantMessageId, | 1774 | activeAssistantMessageId, |
| 1877 | () => assistantCompletion.messageId, | 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 | const shouldApplyFallbackCompletionText = | 1787 | const shouldApplyFallbackCompletionText = |
| 1881 | !existingAssistantMessage || existingAssistantMessage.text.length === 0; | 1788 | !existingAssistantMessage || existingAssistantMessage.text.length === 0; |
| 1882 | 1789 | ||
| ... | @@ -1916,11 +1823,9 @@ const make = Effect.gen(function* () { | ... | @@ -1916,11 +1823,9 @@ const make = Effect.gen(function* () { |
| 1916 | } | 1823 | } |
| 1917 | 1824 | ||
| 1918 | if (proposedPlanCompletion) { | 1825 | if (proposedPlanCompletion) { |
| 1919 | const detailedThread = yield* getLoadedThreadDetail(); | ||
| 1920 | yield* finalizeBufferedProposedPlan({ | 1826 | yield* finalizeBufferedProposedPlan({ |
| 1921 | event, | 1827 | event, |
| 1922 | threadId: thread.id, | 1828 | threadId: thread.id, |
| 1923 | threadProposedPlans: detailedThread?.proposedPlans ?? [], | ||
| 1924 | planId: proposedPlanCompletion.planId, | 1829 | planId: proposedPlanCompletion.planId, |
| 1925 | ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), | 1830 | ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), |
| 1926 | fallbackMarkdown: proposedPlanCompletion.planMarkdown, | 1831 | fallbackMarkdown: proposedPlanCompletion.planMarkdown, |
| ... | @@ -1929,9 +1834,6 @@ const make = Effect.gen(function* () { | ... | @@ -1929,9 +1834,6 @@ const make = Effect.gen(function* () { |
| 1929 | } | 1834 | } |
| 1930 | 1835 | ||
| 1931 | if (isTerminalTurn) { | 1836 | if (isTerminalTurn) { |
| 1932 | const detailedThread = yield* getLoadedThreadDetail(); | ||
| 1933 | const messages = detailedThread?.messages ?? []; | ||
| 1934 | const proposedPlans = detailedThread?.proposedPlans ?? []; | ||
| 1935 | const turnId = toTurnId(event.turnId); | 1837 | const turnId = toTurnId(event.turnId); |
| 1936 | if (turnId) { | 1838 | if (turnId) { |
| 1937 | const userInputActivities = | 1839 | const userInputActivities = |
| ... | @@ -1979,16 +1881,20 @@ const make = Effect.gen(function* () { | ... | @@ -1979,16 +1881,20 @@ const make = Effect.gen(function* () { |
| 1979 | yield* Effect.forEach( | 1881 | yield* Effect.forEach( |
| 1980 | assistantMessageIds, | 1882 | assistantMessageIds, |
| 1981 | (assistantMessageId) => | 1883 | (assistantMessageId) => |
| 1982 | finalizeAssistantMessage({ | 1884 | getThreadMessageById(thread.id, assistantMessageId).pipe( |
| 1983 | event, | 1885 | Effect.flatMap((existingMessage) => |
| 1984 | threadId: thread.id, | 1886 | finalizeAssistantMessage({ |
| 1985 | messageId: assistantMessageId, | 1887 | event, |
| 1986 | turnId, | 1888 | threadId: thread.id, |
| 1987 | createdAt: now, | 1889 | messageId: assistantMessageId, |
| 1988 | commandTag: "assistant-complete-finalize", | 1890 | turnId, |
| 1989 | finalDeltaCommandTag: "assistant-delta-finalize-fallback", | 1891 | createdAt: now, |
| 1990 | hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined, | 1892 | commandTag: "assistant-complete-finalize", |
| 1991 | }), | 1893 | finalDeltaCommandTag: "assistant-delta-finalize-fallback", |
| 1894 | hasProjectedMessage: existingMessage !== undefined, | ||
| 1895 | }), | ||
| 1896 | ), | ||
| 1897 | ), | ||
| 1992 | { concurrency: 1 }, | 1898 | { concurrency: 1 }, |
| 1993 | ).pipe(Effect.asVoid); | 1899 | ).pipe(Effect.asVoid); |
| 1994 | yield* clearAssistantMessageIdsForTurn(thread.id, turnId); | 1900 | yield* clearAssistantMessageIdsForTurn(thread.id, turnId); |
| ... | @@ -1997,7 +1903,6 @@ const make = Effect.gen(function* () { | ... | @@ -1997,7 +1903,6 @@ const make = Effect.gen(function* () { |
| 1997 | yield* finalizeBufferedProposedPlan({ | 1903 | yield* finalizeBufferedProposedPlan({ |
| 1998 | event, | 1904 | event, |
| 1999 | threadId: thread.id, | 1905 | threadId: thread.id, |
| 2000 | threadProposedPlans: proposedPlans, | ||
| 2001 | planId: proposedPlanIdForTurn(thread.id, turnId), | 1906 | planId: proposedPlanIdForTurn(thread.id, turnId), |
| 2002 | turnId, | 1907 | turnId, |
| 2003 | updatedAt: now, | 1908 | updatedAt: now, |
| ... | @@ -2153,8 +2058,17 @@ const make = Effect.gen(function* () { | ... | @@ -2153,8 +2058,17 @@ const make = Effect.gen(function* () { |
| 2153 | if (event.type === "task.completed") { | 2058 | if (event.type === "task.completed") { |
| 2154 | taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); | 2059 | taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); |
| 2155 | if (!taskTitle) { | 2060 | if (!taskTitle) { |
| 2156 | const threadDetail = yield* resolveThreadDetail(thread.id, TASK_TITLE_ACTIVITY_KINDS); | 2061 | const taskActivity = yield* projectionThreadActivityRepository.getLatestTaskActivity({ |
| 2157 | taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); | 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,8 +2086,9 @@ const make = Effect.gen(function* () { |
| 2172 | DateTime.makeUnsafe(pendingTurnStart.value.requestedAt), | 2086 | DateTime.makeUnsafe(pendingTurnStart.value.requestedAt), |
| 2173 | ) | 2087 | ) |
| 2174 | ) { | 2088 | ) { |
| 2175 | const pendingMessage = (yield* getLoadedThreadDetail())?.messages.find( | 2089 | const pendingMessage = yield* getThreadMessageById( |
| 2176 | (message) => message.id === pendingTurnStart.value.messageId, | 2090 | thread.id, |
| 2091 | pendingTurnStart.value.messageId, | ||
| 2177 | ); | 2092 | ); |
| 2178 | if ( | 2093 | if ( |
| 2179 | pendingMessage?.role === "user" && | 2094 | pendingMessage?.role === "user" && |
| ... | @@ -2192,11 +2107,13 @@ const make = Effect.gen(function* () { | ... | @@ -2192,11 +2107,13 @@ const make = Effect.gen(function* () { |
| 2192 | (activityEvent.payload.beforeTokens === undefined || | 2107 | (activityEvent.payload.beforeTokens === undefined || |
| 2193 | activityEvent.payload.afterTokens === undefined) | 2108 | activityEvent.payload.afterTokens === undefined) |
| 2194 | ) { | 2109 | ) { |
| 2195 | const threadDetail = yield* resolveThreadDetail(thread.id, [ | 2110 | const activities = yield* projectionThreadActivityRepository.listByThreadId({ |
| 2196 | "context-window.updated", | 2111 | threadId: thread.id, |
| 2197 | "context-compaction", | 2112 | activityKinds: ["context-window.updated", "context-compaction"], |
| 2198 | ]); | 2113 | // Preserve the previous thread-detail read's context-history bound. |
| 2199 | const tokenCounts = compactedTokenCountsFromActivities(threadDetail?.activities); | 2114 | limit: 500, |
| 2115 | }); | ||
| 2116 | const tokenCounts = compactedTokenCountsFromActivities(activities); | ||
| 2200 | if (tokenCounts) { | 2117 | if (tokenCounts) { |
| 2201 | activityEvent = { | 2118 | activityEvent = { |
| 2202 | ...activityEvent, | 2119 | ...activityEvent, |
| ... | @@ -2274,6 +2191,8 @@ export const ProviderRuntimeIngestionLive = Layer.effect( | ... | @@ -2274,6 +2191,8 @@ export const ProviderRuntimeIngestionLive = Layer.effect( |
| 2274 | ProviderRuntimeIngestionService, | 2191 | ProviderRuntimeIngestionService, |
| 2275 | make, | 2192 | make, |
| 2276 | ).pipe( | 2193 | ).pipe( |
| 2277 | Layer.provide(ProjectionTurnRepositoryLive), | ||
| 2278 | Layer.provide(ProjectionThreadActivityRepositoryLive), | 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,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 | import { assert, it } from "@effect/vitest"; | 8 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| ... | @@ -229,6 +235,46 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -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 | it.effect("stores SQL NULL for missing project model options", () => | 278 | it.effect("stores SQL NULL for missing project model options", () => |
| 233 | Effect.gen(function* () { | 279 | Effect.gen(function* () { |
| 234 | const projects = yield* ProjectionProjectRepository; | 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,6 +3,7 @@ import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 3 | import { NonNegativeInt } from "@t3tools/contracts"; | 3 | import { NonNegativeInt } from "@t3tools/contracts"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | ||
| 6 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 7 | import * as Struct from "effect/Struct"; | 8 | import * as Struct from "effect/Struct"; |
| 8 | 9 | ||
| ... | @@ -11,6 +12,7 @@ import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; | ... | @@ -11,6 +12,7 @@ import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; |
| 11 | import { | 12 | import { |
| 12 | DeleteProjectionThreadActivitiesInput, | 13 | DeleteProjectionThreadActivitiesInput, |
| 13 | ListProjectionThreadActivitiesInput, | 14 | ListProjectionThreadActivitiesInput, |
| 15 | GetLatestProjectionThreadTaskActivityInput, | ||
| 14 | ProjectionThreadActivity, | 16 | ProjectionThreadActivity, |
| 15 | ProjectionThreadActivityRepository, | 17 | ProjectionThreadActivityRepository, |
| 16 | type ProjectionThreadActivityRepositoryShape, | 18 | type ProjectionThreadActivityRepositoryShape, |
| ... | @@ -23,10 +25,10 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( | ... | @@ -23,10 +25,10 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( |
| 23 | }), | 25 | }), |
| 24 | ); | 26 | ); |
| 25 | 27 | ||
| 26 | const mapActivityRows = ( | 28 | function toProjectionThreadActivity( |
| 27 | rows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>>, | 29 | row: Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>, |
| 28 | ): ReadonlyArray<ProjectionThreadActivity> => | 30 | ): ProjectionThreadActivity { |
| 29 | rows.map((row) => ({ | 31 | return { |
| 30 | activityId: row.activityId, | 32 | activityId: row.activityId, |
| 31 | threadId: row.threadId, | 33 | threadId: row.threadId, |
| 32 | turnId: row.turnId, | 34 | turnId: row.turnId, |
| ... | @@ -36,7 +38,8 @@ const mapActivityRows = ( | ... | @@ -36,7 +38,8 @@ const mapActivityRows = ( |
| 36 | payload: row.payload, | 38 | payload: row.payload, |
| 37 | ...(row.sequence !== null ? { sequence: row.sequence } : {}), | 39 | ...(row.sequence !== null ? { sequence: row.sequence } : {}), |
| 38 | createdAt: row.createdAt, | 40 | createdAt: row.createdAt, |
| 39 | })); | 41 | }; |
| 42 | } | ||
| 40 | 43 | ||
| 41 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | 44 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { |
| 42 | return (cause: unknown) => | 45 | return (cause: unknown) => |
| ... | @@ -45,6 +48,10 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st | ... | @@ -45,6 +48,10 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st |
| 45 | : toPersistenceSqlError(sqlOperation)(cause); | 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 | const makeProjectionThreadActivityRepository = Effect.gen(function* () { | 55 | const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 49 | const sql = yield* SqlClient.SqlClient; | 56 | const sql = yield* SqlClient.SqlClient; |
| 50 | 57 | ||
| ... | @@ -90,7 +97,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { | ... | @@ -90,7 +97,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 90 | const listProjectionThreadActivityRows = SqlSchema.findAll({ | 97 | const listProjectionThreadActivityRows = SqlSchema.findAll({ |
| 91 | Request: ListProjectionThreadActivitiesInput, | 98 | Request: ListProjectionThreadActivitiesInput, |
| 92 | Result: ProjectionThreadActivityDbRowSchema, | 99 | Result: ProjectionThreadActivityDbRowSchema, |
| 93 | execute: ({ threadId }) => | 100 | execute: ({ threadId, activityKinds, limit }) => |
| 94 | sql` | 101 | sql` |
| 95 | SELECT | 102 | SELECT |
| 96 | activity_id AS "activityId", | 103 | activity_id AS "activityId", |
| ... | @@ -102,8 +109,14 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { | ... | @@ -102,8 +109,14 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 102 | payload_json AS "payload", | 109 | payload_json AS "payload", |
| 103 | sequence, | 110 | sequence, |
| 104 | created_at AS "createdAt" | 111 | created_at AS "createdAt" |
| 105 | FROM projection_thread_activities | 112 | FROM ( |
| 106 | WHERE thread_id = ${threadId} | 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 | ORDER BY | 120 | ORDER BY |
| 108 | CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, | 121 | CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, |
| 109 | sequence ASC, | 122 | sequence ASC, |
| ... | @@ -142,6 +155,40 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { | ... | @@ -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 | const deleteProjectionThreadActivityRows = SqlSchema.void({ | 192 | const deleteProjectionThreadActivityRows = SqlSchema.void({ |
| 146 | Request: DeleteProjectionThreadActivitiesInput, | 193 | Request: DeleteProjectionThreadActivitiesInput, |
| 147 | execute: ({ threadId }) => | 194 | execute: ({ threadId }) => |
| ... | @@ -169,7 +216,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { | ... | @@ -169,7 +216,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 169 | "ProjectionThreadActivityRepository.listByThreadId:decodeRows", | 216 | "ProjectionThreadActivityRepository.listByThreadId:decodeRows", |
| 170 | ), | 217 | ), |
| 171 | ), | 218 | ), |
| 172 | Effect.map(mapActivityRows), | 219 | Effect.map((rows) => rows.map(toProjectionThreadActivity)), |
| 173 | ); | 220 | ); |
| 174 | 221 | ||
| 175 | const listUserInputLifecycleByThreadId: ProjectionThreadActivityRepositoryShape["listUserInputLifecycleByThreadId"] = | 222 | const listUserInputLifecycleByThreadId: ProjectionThreadActivityRepositoryShape["listUserInputLifecycleByThreadId"] = |
| ... | @@ -181,9 +228,22 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { | ... | @@ -181,9 +228,22 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 181 | "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:decodeRows", | 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 | const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => | 247 | const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => |
| 188 | deleteProjectionThreadActivityRows(input).pipe( | 248 | deleteProjectionThreadActivityRows(input).pipe( |
| 189 | Effect.mapError( | 249 | Effect.mapError( |
| ... | @@ -195,6 +255,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { | ... | @@ -195,6 +255,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 195 | upsert, | 255 | upsert, |
| 196 | listByThreadId, | 256 | listByThreadId, |
| 197 | listUserInputLifecycleByThreadId, | 257 | listUserInputLifecycleByThreadId, |
| 258 | getLatestTaskActivity, | ||
| 198 | deleteByThreadId, | 259 | deleteByThreadId, |
| 199 | } satisfies ProjectionThreadActivityRepositoryShape; | 260 | } satisfies ProjectionThreadActivityRepositoryShape; |
| 200 | }); | 261 | }); |
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts+46-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { MessageId, ThreadId } from "@t3tools/contracts"; | 1 | import { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 2 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| ... | @@ -231,4 +231,49 @@ layer("ProjectionThreadMessageRepository", (it) => { | ... | @@ -231,4 +231,49 @@ layer("ProjectionThreadMessageRepository", (it) => { |
| 231 | assert.deepEqual(rows[0]?.attachments, []); | 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,6 +11,7 @@ import { toPersistenceSqlError } from "../Errors.ts"; |
| 11 | import { | 11 | import { |
| 12 | AppendStreamingProjectionThreadMessage, | 12 | AppendStreamingProjectionThreadMessage, |
| 13 | GetProjectionThreadMessageInput, | 13 | GetProjectionThreadMessageInput, |
| 14 | HasProjectionThreadAssistantMessageInput, | ||
| 14 | ProjectionThreadMessageRepository, | 15 | ProjectionThreadMessageRepository, |
| 15 | type ProjectionThreadMessageRepositoryShape, | 16 | type ProjectionThreadMessageRepositoryShape, |
| 16 | DeleteProjectionThreadMessagesInput, | 17 | DeleteProjectionThreadMessagesInput, |
| ... | @@ -24,6 +25,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | ... | @@ -24,6 +25,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( |
| 24 | attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), | 25 | attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), |
| 25 | }), | 26 | }), |
| 26 | ); | 27 | ); |
| 28 | const ProjectionThreadMessageExistsDbRowSchema = Schema.Struct({ exists: Schema.Number }); | ||
| 27 | 29 | ||
| 28 | function toProjectionThreadMessage( | 30 | function toProjectionThreadMessage( |
| 29 | row: Schema.Schema.Type<typeof ProjectionThreadMessageDbRowSchema>, | 31 | row: Schema.Schema.Type<typeof ProjectionThreadMessageDbRowSchema>, |
| ... | @@ -161,6 +163,23 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -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 | const listProjectionThreadMessageRows = SqlSchema.findAll({ | 183 | const listProjectionThreadMessageRows = SqlSchema.findAll({ |
| 165 | Request: ListProjectionThreadMessagesInput, | 184 | Request: ListProjectionThreadMessagesInput, |
| 166 | Result: ProjectionThreadMessageDbRowSchema, | 185 | Result: ProjectionThreadMessageDbRowSchema, |
| ... | @@ -224,6 +243,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -224,6 +243,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 224 | Effect.map(Option.map(toProjectionThreadMessage)), | 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 | const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => | 257 | const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => |
| 228 | listProjectionThreadMessageRows(input).pipe( | 258 | listProjectionThreadMessageRows(input).pipe( |
| 229 | Effect.mapError( | 259 | Effect.mapError( |
| ... | @@ -253,6 +283,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -253,6 +283,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 253 | upsert, | 283 | upsert, |
| 254 | appendStreaming, | 284 | appendStreaming, |
| 255 | getByMessageId, | 285 | getByMessageId, |
| 286 | hasAssistantMessageForTurn, | ||
| 256 | listByThreadId, | 287 | listByThreadId, |
| 257 | getLatestUserMessageAt, | 288 | getLatestUserMessageAt, |
| 258 | deleteByThreadId, | 289 | deleteByThreadId, |
apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts+27| ... | @@ -8,6 +8,7 @@ import { toPersistenceSqlError } from "../Errors.ts"; | ... | @@ -8,6 +8,7 @@ import { toPersistenceSqlError } from "../Errors.ts"; |
| 8 | import { | 8 | import { |
| 9 | DeleteProjectionThreadProposedPlansInput, | 9 | DeleteProjectionThreadProposedPlansInput, |
| 10 | HasActionableProjectionThreadProposedPlanInput, | 10 | HasActionableProjectionThreadProposedPlanInput, |
| 11 | GetProjectionThreadProposedPlanInput, | ||
| 11 | ListProjectionThreadProposedPlansInput, | 12 | ListProjectionThreadProposedPlansInput, |
| 12 | ProjectionThreadProposedPlan, | 13 | ProjectionThreadProposedPlan, |
| 13 | ProjectionThreadProposedPlanRepository, | 14 | ProjectionThreadProposedPlanRepository, |
| ... | @@ -52,6 +53,24 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { | ... | @@ -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 | const listProjectionThreadProposedPlanRows = SqlSchema.findAll({ | 74 | const listProjectionThreadProposedPlanRows = SqlSchema.findAll({ |
| 56 | Request: ListProjectionThreadProposedPlansInput, | 75 | Request: ListProjectionThreadProposedPlansInput, |
| 57 | Result: ProjectionThreadProposedPlan, | 76 | Result: ProjectionThreadProposedPlan, |
| ... | @@ -133,6 +152,13 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { | ... | @@ -133,6 +152,13 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { |
| 133 | Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), | 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 | const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) => | 162 | const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) => |
| 137 | listProjectionThreadProposedPlanRows(input).pipe( | 163 | listProjectionThreadProposedPlanRows(input).pipe( |
| 138 | Effect.mapError( | 164 | Effect.mapError( |
| ... | @@ -153,6 +179,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { | ... | @@ -153,6 +179,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { |
| 153 | upsert, | 179 | upsert, |
| 154 | listByThreadId, | 180 | listByThreadId, |
| 155 | hasActionableByThreadId, | 181 | hasActionableByThreadId, |
| 182 | getByPlanId, | ||
| 156 | deleteByThreadId, | 183 | deleteByThreadId, |
| 157 | } satisfies ProjectionThreadProposedPlanRepositoryShape; | 184 | } satisfies ProjectionThreadProposedPlanRepositoryShape; |
| 158 | }); | 185 | }); |
apps/server/src/persistence/Services/ProjectionThreadActivities.ts+18-1| ... | @@ -17,6 +17,7 @@ import { | ... | @@ -17,6 +17,7 @@ import { |
| 17 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 18 | import * as Context from "effect/Context"; | 18 | import * as Context from "effect/Context"; |
| 19 | import type * as Effect from "effect/Effect"; | 19 | import type * as Effect from "effect/Effect"; |
| 20 | import type * as Option from "effect/Option"; | ||
| 20 | 21 | ||
| 21 | import type { ProjectionRepositoryError } from "../Errors.ts"; | 22 | import type { ProjectionRepositoryError } from "../Errors.ts"; |
| 22 | 23 | ||
| ... | @@ -35,9 +36,18 @@ export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; | ... | @@ -35,9 +36,18 @@ export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; |
| 35 | 36 | ||
| 36 | export const ListProjectionThreadActivitiesInput = Schema.Struct({ | 37 | export const ListProjectionThreadActivitiesInput = Schema.Struct({ |
| 37 | threadId: ThreadId, | 38 | threadId: ThreadId, |
| 39 | activityKinds: Schema.optional(Schema.Array(Schema.String)), | ||
| 40 | limit: Schema.optional(NonNegativeInt), | ||
| 38 | }); | 41 | }); |
| 39 | export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; | 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 | export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ | 51 | export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ |
| 42 | threadId: ThreadId, | 52 | threadId: ThreadId, |
| 43 | }); | 53 | }); |
| ... | @@ -61,7 +71,7 @@ export interface ProjectionThreadActivityRepositoryShape { | ... | @@ -61,7 +71,7 @@ export interface ProjectionThreadActivityRepositoryShape { |
| 61 | * List projected thread activity rows for a thread. | 71 | * List projected thread activity rows for a thread. |
| 62 | * | 72 | * |
| 63 | * Returned in ascending runtime sequence order (or creation order when | 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 | readonly listByThreadId: ( | 76 | readonly listByThreadId: ( |
| 67 | input: ListProjectionThreadActivitiesInput, | 77 | input: ListProjectionThreadActivitiesInput, |
| ... | @@ -76,6 +86,13 @@ export interface ProjectionThreadActivityRepositoryShape { | ... | @@ -76,6 +86,13 @@ export interface ProjectionThreadActivityRepositoryShape { |
| 76 | input: ListProjectionThreadActivitiesInput, | 86 | input: ListProjectionThreadActivitiesInput, |
| 77 | ) => Effect.Effect<ReadonlyArray<ProjectionThreadActivity>, ProjectionRepositoryError>; | 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 | * Delete projected thread activity rows by thread. | 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,6 +51,14 @@ export const GetProjectionThreadMessageInput = Schema.Struct({ |
| 51 | }); | 51 | }); |
| 52 | export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type; | 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 | export const DeleteProjectionThreadMessagesInput = Schema.Struct({ | 62 | export const DeleteProjectionThreadMessagesInput = Schema.Struct({ |
| 55 | threadId: ThreadId, | 63 | threadId: ThreadId, |
| 56 | }); | 64 | }); |
| ... | @@ -81,6 +89,13 @@ export interface ProjectionThreadMessageRepositoryShape { | ... | @@ -81,6 +89,13 @@ export interface ProjectionThreadMessageRepositoryShape { |
| 81 | input: GetProjectionThreadMessageInput, | 89 | input: GetProjectionThreadMessageInput, |
| 82 | ) => Effect.Effect<Option.Option<ProjectionThreadMessage>, ProjectionRepositoryError>; | 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 | * List projected thread messages for a thread. | 100 | * List projected thread messages for a thread. |
| 86 | * | 101 | * |
apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts+11| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as Context from "effect/Context"; | 9 | import * as Context from "effect/Context"; |
| 10 | import type * as Effect from "effect/Effect"; | 10 | import type * as Effect from "effect/Effect"; |
| 11 | import type * as Option from "effect/Option"; | ||
| 11 | 12 | ||
| 12 | import type { ProjectionRepositoryError } from "../Errors.ts"; | 13 | import type { ProjectionRepositoryError } from "../Errors.ts"; |
| 13 | 14 | ||
| ... | @@ -36,6 +37,12 @@ export const HasActionableProjectionThreadProposedPlanInput = Schema.Struct({ | ... | @@ -36,6 +37,12 @@ export const HasActionableProjectionThreadProposedPlanInput = Schema.Struct({ |
| 36 | export type HasActionableProjectionThreadProposedPlanInput = | 37 | export type HasActionableProjectionThreadProposedPlanInput = |
| 37 | typeof HasActionableProjectionThreadProposedPlanInput.Type; | 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 | export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ | 46 | export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ |
| 40 | threadId: ThreadId, | 47 | threadId: ThreadId, |
| 41 | }); | 48 | }); |
| ... | @@ -46,6 +53,10 @@ export interface ProjectionThreadProposedPlanRepositoryShape { | ... | @@ -46,6 +53,10 @@ export interface ProjectionThreadProposedPlanRepositoryShape { |
| 46 | readonly upsert: ( | 53 | readonly upsert: ( |
| 47 | proposedPlan: ProjectionThreadProposedPlan, | 54 | proposedPlan: ProjectionThreadProposedPlan, |
| 48 | ) => Effect.Effect<void, ProjectionRepositoryError>; | 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 | readonly listByThreadId: ( | 60 | readonly listByThreadId: ( |
| 50 | input: ListProjectionThreadProposedPlansInput, | 61 | input: ListProjectionThreadProposedPlansInput, |
| 51 | ) => Effect.Effect<ReadonlyArray<ProjectionThreadProposedPlan>, ProjectionRepositoryError>; | 62 | ) => Effect.Effect<ReadonlyArray<ProjectionThreadProposedPlan>, ProjectionRepositoryError>; |