authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 02:56:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 02:56:42-07:00
loge4e9fa9a0b1a77e393ab6cffd75ec22dbb258bdc
tree10bd68fad603b5cc257dc165080fa58ed89c5605
parente63ddb48e2fd23854a0b4a480b32cbf33e601981
signature Signed by PGP key B5690EEEBB952194

perf(server): finish runtime messages without full thread reads (#10120)

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), the303 // 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 });
485496
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 });
16941813
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 });
21652320
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 });
40394194
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");
40434201
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 });
40574229 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,
40644236 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 {
40894239 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 ]);
40954248
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 });
41064255
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
39import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts";36import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts";
40import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts";37import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts";
41import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";38import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";
39import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts";
40import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts";
41import { ProjectionThreadProposedPlanRepository } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
42import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts";
42import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";43import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
43import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";44import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
44import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";45import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";
...@@ -54,13 +55,12 @@ import { canReplaceThreadTitle } from "../threadTitles.ts";...@@ -54,13 +55,12 @@ import { canReplaceThreadTitle } from "../threadTitles.ts";
5455
55const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;56const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
56const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`;57const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`;
57const TASK_TITLE_ACTIVITY_KINDS = ["task.started", "task.progress"] as const;
5858
59// Fallback when the in-memory description cache no longer has the task name59// Fallback when the in-memory description cache no longer has the task name
60// (server restart, session-exit sweep, TTL/capacity eviction): earlier60// (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.
62function findTaskTitleInActivities(62function 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}
140140
141function 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
162function 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
175function 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
192function hasCheckpointForTurn(141function 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}
265214
266function compactedTokenCountsFromActivities(215function 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 >= 0224 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 );
1015965
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* projectionSnapshotQuery969 return yield* projectionSnapshotQuery
1021 .getThreadDetailById(threadId, { activityKinds })970 .getThreadRuntimeContext(threadId)
1022 .pipe(Effect.map(Option.getOrUndefined));971 .pipe(Effect.map(Option.getOrUndefined));
1023 });972 });
1024973
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* projectionSnapshotQuery978 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 });
1032983
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 );
11971148
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);
12091151
...@@ -1357,83 +1299,45 @@ const make = Effect.gen(function* () {...@@ -1357,83 +1299,45 @@ const make = Effect.gen(function* () {
1357 }1299 }
1358 });1300 });
13591301
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 }
14211317
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 > 01333 implementationThreadId: existingPlan?.implementationThreadId ?? null,
1431 ? bufferedPlan.createdAt1334 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 });
14371341
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;
15731485
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;
18651767
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 = turnId1770 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;
18821789
...@@ -1916,11 +1823,9 @@ const make = Effect.gen(function* () {...@@ -1916,11 +1823,9 @@ const make = Effect.gen(function* () {
1916 }1823 }
19171824
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 }
19301835
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 }
21602074
...@@ -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 @@
1import { ProjectId, ThreadId, TurnId, ProviderInstanceId } from "@t3tools/contracts";1import {
2 ProjectId,
3 ThreadId,
4 TurnId,
5 ProviderInstanceId,
6 OrchestrationProposedPlanId,
7} from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";8import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";10import * 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 );
231237
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 @@
1import { EventId, ThreadId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as SqlClient from "effect/unstable/sql/SqlClient";
6
7import { ProjectionThreadActivityRepository } from "../Services/ProjectionThreadActivities.ts";
8import { ProjectionThreadActivityRepositoryLive } from "./ProjectionThreadActivities.ts";
9import { SqlitePersistenceMemory } from "./Sqlite.ts";
10
11const layer = it.layer(
12 ProjectionThreadActivityRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
13);
14
15layer("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";
3import { NonNegativeInt } from "@t3tools/contracts";3import { NonNegativeInt } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
7import * as Struct from "effect/Struct";8import * as Struct from "effect/Struct";
89
...@@ -11,6 +12,7 @@ import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";...@@ -11,6 +12,7 @@ import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
11import {12import {
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);
2527
26const mapActivityRows = (28function 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}
4043
41function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {44function 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}
4750
51// Match String.trim so blank saved titles cannot hide an earlier task name.
52const 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
48const makeProjectionThreadActivityRepository = Effect.gen(function* () {55const makeProjectionThreadActivityRepository = Effect.gen(function* () {
49 const sql = yield* SqlClient.SqlClient;56 const sql = yield* SqlClient.SqlClient;
5057
...@@ -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 SELECT102 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_activities112 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 BY120 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 });
144157
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 );
174221
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 );
186233
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 @@
1import { MessageId, ThreadId } from "@t3tools/contracts";1import { MessageId, ThreadId, TurnId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * 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";
11import {11import {
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);
28const ProjectionThreadMessageExistsDbRowSchema = Schema.Struct({ exists: Schema.Number });
2729
28function toProjectionThreadMessage(30function 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 });
163165
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 );
226245
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";
8import {8import {
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 });
5455
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 );
135154
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 {
17import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
18import * as Context from "effect/Context";18import * as Context from "effect/Context";
19import type * as Effect from "effect/Effect";19import type * as Effect from "effect/Effect";
20import type * as Option from "effect/Option";
2021
21import type { ProjectionRepositoryError } from "../Errors.ts";22import type { ProjectionRepositoryError } from "../Errors.ts";
2223
...@@ -35,9 +36,18 @@ export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type;...@@ -35,9 +36,18 @@ export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type;
3536
36export const ListProjectionThreadActivitiesInput = Schema.Struct({37export 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});
39export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type;42export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type;
4043
44export const GetLatestProjectionThreadTaskActivityInput = Schema.Struct({
45 threadId: ThreadId,
46 taskId: Schema.String,
47});
48export type GetLatestProjectionThreadTaskActivityInput =
49 typeof GetLatestProjectionThreadTaskActivityInput.Type;
50
41export const DeleteProjectionThreadActivitiesInput = Schema.Struct({51export 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 when73 * 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>;
7888
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});
52export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type;52export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type;
5353
54export const HasProjectionThreadAssistantMessageInput = Schema.Struct({
55 threadId: ThreadId,
56 turnId: TurnId,
57 streamingOnly: Schema.Boolean,
58});
59export type HasProjectionThreadAssistantMessageInput =
60 typeof HasProjectionThreadAssistantMessageInput.Type;
61
54export const DeleteProjectionThreadMessagesInput = Schema.Struct({62export 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>;
8391
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 {
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import * as Context from "effect/Context";9import * as Context from "effect/Context";
10import type * as Effect from "effect/Effect";10import type * as Effect from "effect/Effect";
11import type * as Option from "effect/Option";
1112
12import type { ProjectionRepositoryError } from "../Errors.ts";13import type { ProjectionRepositoryError } from "../Errors.ts";
1314
...@@ -36,6 +37,12 @@ export const HasActionableProjectionThreadProposedPlanInput = Schema.Struct({...@@ -36,6 +37,12 @@ export const HasActionableProjectionThreadProposedPlanInput = Schema.Struct({
36export type HasActionableProjectionThreadProposedPlanInput =37export type HasActionableProjectionThreadProposedPlanInput =
37 typeof HasActionableProjectionThreadProposedPlanInput.Type;38 typeof HasActionableProjectionThreadProposedPlanInput.Type;
3839
40export const GetProjectionThreadProposedPlanInput = Schema.Struct({
41 threadId: ThreadId,
42 planId: OrchestrationProposedPlanId,
43});
44export type GetProjectionThreadProposedPlanInput = typeof GetProjectionThreadProposedPlanInput.Type;
45
39export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({46export 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>;