authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 00:38:27-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:21-08:00
log8f42d34db921fa623715b9524aa3894919dec8bb
tree1748f1f8e228514d0fc1a8e6697e211a921886ea
parent70857ce299941ac18a4509b10c725aba7e15a6b5

Inject test ServerConfig across orchestration and git layers

- add `ServerConfig.layerTest(cwd, stateDir)` to standardize test wiring - require `ServerConfig` in `CodexTextGeneration` attachment path resolution - update orchestration/projection tests and harness to use managed runtimes with explicit config

11 files changed, 586 insertions(+), 624 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+9-13
......@@ -56,6 +56,7 @@ import {
5656 makeTestProviderAdapterHarness,
5757 type TestProviderAdapterHarness,
5858} from "./TestProviderAdapter.integration.ts";
59import { ServerConfig } from "../src/config.ts";
5960
6061function runGit(cwd: string, args: ReadonlyArray<string>) {
6162 return execFileSync("git", args, {
......@@ -229,19 +230,13 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
229230 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
230231 Layer.provideMerge(runtimeServicesLayer),
231232 );
232 const gitCoreLayer = Layer.succeed(
233 GitCore,
234 {
235 renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) =>
236 Effect.succeed({ branch: input.newBranch }),
237 } as unknown as GitCoreShape,
238 );
239 const textGenerationLayer = Layer.succeed(
240 TextGeneration,
241 {
242 generateBranchName: () => Effect.succeed({ branch: null }),
243 } as unknown as TextGenerationShape,
244 );
233 const gitCoreLayer = Layer.succeed(GitCore, {
234 renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) =>
235 Effect.succeed({ branch: input.newBranch }),
236 } as unknown as GitCoreShape);
237 const textGenerationLayer = Layer.succeed(TextGeneration, {
238 generateBranchName: () => Effect.succeed({ branch: null }),
239 } as unknown as TextGenerationShape);
245240 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
246241 Layer.provideMerge(runtimeServicesLayer),
247242 Layer.provideMerge(gitCoreLayer),
......@@ -257,6 +252,7 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
257252 );
258253 const layer = orchestrationReactorLayer.pipe(
259254 Layer.provide(persistenceLayer),
255 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),
260256 Layer.provideMerge(NodeServices.layer),
261257 );
262258
apps/server/src/config.ts+23-1
......@@ -36,7 +36,29 @@ export interface ServerConfigShape {
3636 */
3737export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()(
3838 "t3/config/ServerConfig",
39) {}
39) {
40 static readonly layerTest = (cwd: string, statedir: string) =>
41 Layer.effect(
42 ServerConfig,
43 Effect.gen(function* () {
44 const path = yield* Path.Path;
45 return {
46 cwd,
47 stateDir: statedir,
48 mode: "web",
49 autoBootstrapProjectFromCwd: false,
50 logWebSocketEvents: false,
51 port: 0,
52 host: undefined,
53 authToken: undefined,
54 keybindingsConfigPath: path.join(statedir, "keybindings.json"),
55 staticDir: undefined,
56 devUrl: undefined,
57 noBrowser: false,
58 };
59 }),
60 );
61}
4062
4163// Helpers
4264
apps/server/src/git/Layers/CodexTextGeneration.test.ts+47-59
......@@ -3,27 +3,16 @@ import { it } from "@effect/vitest";
33import { Effect, FileSystem, Layer, Path } from "effect";
44import { expect } from "vitest";
55
6import { type ServerConfigShape, ServerConfig } from "../../config.ts";
6import { ServerConfig } from "../../config.ts";
77import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
88import { TextGenerationError } from "../Errors.ts";
99import { TextGeneration } from "../Services/TextGeneration.ts";
1010
11function makeTestServerConfig(stateDir: string): ServerConfigShape {
12 return {
13 mode: "web",
14 port: 0,
15 host: undefined,
16 cwd: process.cwd(),
17 keybindingsConfigPath: "",
18 stateDir,
19 staticDir: undefined,
20 devUrl: undefined,
21 noBrowser: true,
22 authToken: undefined,
23 autoBootstrapProjectFromCwd: false,
24 logWebSocketEvents: false,
25 };
26}
11const makeCodexTextGenerationTestLayer = (stateDir: string) =>
12 CodexTextGenerationLive.pipe(
13 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)),
14 Layer.provideMerge(NodeServices.layer),
15 );
2716
2817function makeFakeCodexBinary(dir: string) {
2918 return Effect.gen(function* () {
......@@ -197,10 +186,7 @@ function withFakeCodexEnv<A, E, R>(
197186 );
198187}
199188
200const CodexTextGenerationTestLayer = Layer.provideMerge(
201 CodexTextGenerationLive,
202 NodeServices.layer,
203);
189const CodexTextGenerationTestLayer = makeCodexTextGenerationTestLayer(process.cwd());
204190
205191it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
206192 it.effect("generates and sanitizes commit messages", () =>
......@@ -340,14 +326,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
340326 Effect.gen(function* () {
341327 const fs = yield* FileSystem.FileSystem;
342328 const path = yield* Path.Path;
343 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-state-textgen-" });
344 const imagePath = path.join(
345 stateDir,
346 "attachments",
347 "thread-1",
348 "message-1-0.png",
349 );
350 yield* fs.makeDirectory(path.join(stateDir, "attachments", "thread-1"), {
329 const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
330 const imageFileName = `message-1-${uniqueSuffix}.png`;
331 const imagePath = path.join(process.cwd(), "attachments", "thread-1", imageFileName);
332 yield* fs.makeDirectory(path.join(process.cwd(), "attachments", "thread-1"), {
351333 recursive: true,
352334 });
353335 yield* fs.writeFile(imagePath, Buffer.from("hello"));
......@@ -363,46 +345,52 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
363345 name: "bug.png",
364346 mimeType: "image/png",
365347 sizeBytes: 5,
366 dataUrl: "/attachments/thread-1/message-1-0.png",
348 dataUrl: `/attachments/thread-1/${imageFileName}`,
367349 },
368350 ],
369351 })
370 .pipe(Effect.provideService(ServerConfig, makeTestServerConfig(stateDir)));
352 .pipe(
353 Effect.ensuring(
354 fs.remove(imagePath).pipe(Effect.catch(() => Effect.void)),
355 ),
356 );
371357
372358 expect(generated.branch).toBe("fix/ui-regression");
373359 }),
374360 ),
375361 );
376362
377 it.effect("fails with typed TextGenerationError when codex returns wrong branch payload shape", () =>
378 withFakeCodexEnv(
379 {
380 output: JSON.stringify({
381 title: "This is not a branch payload",
363 it.effect(
364 "fails with typed TextGenerationError when codex returns wrong branch payload shape",
365 () =>
366 withFakeCodexEnv(
367 {
368 output: JSON.stringify({
369 title: "This is not a branch payload",
370 }),
371 },
372 Effect.gen(function* () {
373 const textGeneration = yield* TextGeneration;
374
375 const result = yield* textGeneration
376 .generateBranchName({
377 cwd: process.cwd(),
378 message: "Fix websocket reconnect flake",
379 })
380 .pipe(
381 Effect.match({
382 onFailure: (error) => ({ _tag: "Left" as const, left: error }),
383 onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
384 }),
385 );
386
387 expect(result._tag).toBe("Left");
388 if (result._tag === "Left") {
389 expect(result.left).toBeInstanceOf(TextGenerationError);
390 expect(result.left.message).toContain("Codex returned invalid structured output");
391 }
382392 }),
383 },
384 Effect.gen(function* () {
385 const textGeneration = yield* TextGeneration;
386
387 const result = yield* textGeneration
388 .generateBranchName({
389 cwd: process.cwd(),
390 message: "Fix websocket reconnect flake",
391 })
392 .pipe(
393 Effect.match({
394 onFailure: (error) => ({ _tag: "Left" as const, left: error }),
395 onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
396 }),
397 );
398
399 expect(result._tag).toBe("Left");
400 if (result._tag === "Left") {
401 expect(result.left).toBeInstanceOf(TextGenerationError);
402 expect(result.left.message).toContain("Codex returned invalid structured output");
403 }
404 }),
405 ),
393 ),
406394 );
407395
408396 it.effect("returns typed TextGenerationError when codex exits non-zero", () =>
apps/server/src/git/Layers/CodexTextGeneration.ts+5-7
......@@ -105,6 +105,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
105105 const fileSystem = yield* FileSystem.FileSystem;
106106 const path = yield* Path.Path;
107107 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
108 const serverConfig = yield* Effect.service(ServerConfig);
108109
109110 const readStreamAsString = <E>(
110111 operation: string,
......@@ -180,7 +181,6 @@ const makeCodexTextGeneration = Effect.gen(function* () {
180181 if (!attachments || attachments.length === 0) {
181182 return [];
182183 }
183 const serverConfig = yield* Effect.serviceOption(ServerConfig);
184184
185185 const imagePaths: string[] = [];
186186 for (const [index, attachment] of attachments.entries()) {
......@@ -209,12 +209,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
209209 continue;
210210 }
211211
212 const persistedPath = Option.isSome(serverConfig)
213 ? resolveAttachmentRoutePath({
214 stateDir: serverConfig.value.stateDir,
215 dataUrl: attachment.dataUrl,
216 })
217 : null;
212 const persistedPath = resolveAttachmentRoutePath({
213 stateDir: serverConfig.stateDir,
214 dataUrl: attachment.dataUrl,
215 });
218216 const localPath = persistedPath ?? attachment.dataUrl;
219217 if (!path.isAbsolute(localPath)) {
220218 continue;
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+2
......@@ -37,6 +37,7 @@ import {
3737 type ProviderServiceShape,
3838} from "../../provider/Services/ProviderService.ts";
3939import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
40import { ServerConfig } from "../../config.ts";
4041
4142const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
4243const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
......@@ -238,6 +239,7 @@ describe("CheckpointReactor", () => {
238239 Layer.provideMerge(orchestrationLayer),
239240 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
240241 Layer.provideMerge(CheckpointStoreLive),
242 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
241243 Layer.provideMerge(NodeServices.layer),
242244 );
243245
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+6
......@@ -25,6 +25,8 @@ import {
2525 OrchestrationProjectionPipeline,
2626 type OrchestrationProjectionPipelineShape,
2727} from "../Services/ProjectionPipeline.ts";
28import { ServerConfig } from "../../config.ts";
29import * as NodeServices from "@effect/platform-node/NodeServices";
2830
2931const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
3032const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
......@@ -37,6 +39,8 @@ async function createOrchestrationSystem() {
3739 Layer.provide(OrchestrationEventStoreLive),
3840 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
3941 Layer.provide(SqlitePersistenceMemory),
42 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
43 Layer.provideMerge(NodeServices.layer),
4044 );
4145 const runtime = ManagedRuntime.make(orchestrationLayer);
4246 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
......@@ -310,6 +314,8 @@ describe("OrchestrationEngine", () => {
310314 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
311315 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
312316 Layer.provide(SqlitePersistenceMemory),
317 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
318 Layer.provideMerge(NodeServices.layer),
313319 ),
314320 );
315321 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+404-446
......@@ -12,7 +12,7 @@ import {
1212} from "@t3tools/contracts";
1313import * as NodeServices from "@effect/platform-node/NodeServices";
1414import { assert, it } from "@effect/vitest";
15import { Effect, Layer } from "effect";
15import { Effect, Layer, ManagedRuntime } from "effect";
1616import fs from "node:fs";
1717import os from "node:os";
1818import path from "node:path";
......@@ -32,13 +32,32 @@ import {
3232} from "./ProjectionPipeline.ts";
3333import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3434import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
35import { ServerConfig, type ServerConfigShape } from "../../config.ts";
35import { ServerConfig } from "../../config.ts";
3636
37const projectionLayer = it.layer(
37const makeProjectionPipelineTestLayer = (stateDir: string) =>
3838 OrchestrationProjectionPipelineLive.pipe(
3939 Layer.provideMerge(OrchestrationEventStoreLive),
40 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)),
4041 Layer.provideMerge(SqlitePersistenceMemory),
41 ),
42 Layer.provideMerge(NodeServices.layer),
43 );
44
45const runWithProjectionPipelineLayer = <A, E>(
46 stateDir: string,
47 effect: Effect.Effect<
48 A,
49 E,
50 OrchestrationProjectionPipeline | OrchestrationEventStore | SqlClient.SqlClient
51 >,
52) =>
53 Effect.acquireUseRelease(
54 Effect.sync(() => ManagedRuntime.make(makeProjectionPipelineTestLayer(stateDir))),
55 (runtime) => Effect.promise(() => runtime.runPromise(effect)),
56 (runtime) => Effect.promise(() => runtime.dispose()),
57 );
58
59const projectionLayer = it.layer(
60 makeProjectionPipelineTestLayer(process.cwd()),
4261);
4362
4463projectionLayer("OrchestrationProjectionPipeline", (it) => {
......@@ -160,137 +179,147 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
160179 );
161180
162181 it.effect("materializes message image attachments into stateDir and stores URL references", () =>
163 Effect.gen(function* () {
164 const projectionPipeline = yield* OrchestrationProjectionPipeline;
165 const eventStore = yield* OrchestrationEventStore;
166 const sql = yield* SqlClient.SqlClient;
167 const now = new Date().toISOString();
168 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"));
169
170 const serverConfig = {
171 mode: "web",
172 port: 0,
173 host: undefined,
174 cwd: "/tmp/project-attachments",
175 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
176 stateDir,
177 staticDir: undefined,
178 devUrl: undefined,
179 noBrowser: true,
180 authToken: undefined,
181 autoBootstrapProjectFromCwd: false,
182 logWebSocketEvents: false,
183 } satisfies ServerConfigShape;
184
185 yield* eventStore.append({
186 type: "thread.message-sent",
187 eventId: EventId.makeUnsafe("evt-attachments"),
188 aggregateKind: "thread",
189 aggregateId: ThreadId.makeUnsafe("thread-attachments"),
190 occurredAt: now,
191 commandId: CommandId.makeUnsafe("cmd-attachments"),
192 causationEventId: null,
193 correlationId: CommandId.makeUnsafe("cmd-attachments"),
194 metadata: {},
195 payload: {
196 threadId: ThreadId.makeUnsafe("thread-attachments"),
197 messageId: MessageId.makeUnsafe("message-attachments"),
198 role: "user",
199 text: "Inspect this",
200 attachments: [
182 Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe(
183 Effect.flatMap((stateDir) =>
184 Effect.gen(function* () {
185 const projectionPipeline = yield* OrchestrationProjectionPipeline;
186 const eventStore = yield* OrchestrationEventStore;
187 const sql = yield* SqlClient.SqlClient;
188 const now = new Date().toISOString();
189
190 yield* eventStore.append({
191 type: "thread.message-sent",
192 eventId: EventId.makeUnsafe("evt-attachments"),
193 aggregateKind: "thread",
194 aggregateId: ThreadId.makeUnsafe("thread-attachments"),
195 occurredAt: now,
196 commandId: CommandId.makeUnsafe("cmd-attachments"),
197 causationEventId: null,
198 correlationId: CommandId.makeUnsafe("cmd-attachments"),
199 metadata: {},
200 payload: {
201 threadId: ThreadId.makeUnsafe("thread-attachments"),
202 messageId: MessageId.makeUnsafe("message-attachments"),
203 role: "user",
204 text: "Inspect this",
205 attachments: [
206 {
207 type: "image",
208 name: "example.png",
209 mimeType: "image/png",
210 sizeBytes: 5,
211 dataUrl: "data:image/png;base64,SGVsbG8=",
212 },
213 ],
214 turnId: null,
215 streaming: false,
216 createdAt: now,
217 updatedAt: now,
218 },
219 });
220
221 yield* projectionPipeline.bootstrap;
222
223 const rows = yield* sql<{
224 readonly attachmentsJson: string | null;
225 }>`
226 SELECT
227 attachments_json AS "attachmentsJson"
228 FROM projection_thread_messages
229 WHERE message_id = 'message-attachments'
230 `;
231 assert.equal(rows.length, 1);
232 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
201233 {
202234 type: "image",
203235 name: "example.png",
204236 mimeType: "image/png",
205237 sizeBytes: 5,
206 dataUrl: "data:image/png;base64,SGVsbG8=",
238 dataUrl: "/attachments/thread-attachments/message-attachments-0.png",
207239 },
208 ],
209 turnId: null,
210 streaming: false,
211 createdAt: now,
212 updatedAt: now,
213 },
214 });
215
216 yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig));
217
218 const rows = yield* sql<{
219 readonly attachmentsJson: string | null;
220 }>`
221 SELECT
222 attachments_json AS "attachmentsJson"
223 FROM projection_thread_messages
224 WHERE message_id = 'message-attachments'
225 `;
226 assert.equal(rows.length, 1);
227 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
228 {
229 type: "image",
230 name: "example.png",
231 mimeType: "image/png",
232 sizeBytes: 5,
233 dataUrl: "/attachments/thread-attachments/message-attachments-0.png",
234 },
235 ]);
236
237 const attachmentPath = path.join(
238 stateDir,
239 "attachments",
240 "thread-attachments",
241 "message-attachments-0.png",
242 );
243 assert.equal(fs.existsSync(attachmentPath), true);
244 assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("SGVsbG8=", "base64"));
245 fs.rmSync(stateDir, { recursive: true, force: true });
246 }),
240 ]);
241
242 const attachmentPath = path.join(
243 stateDir,
244 "attachments",
245 "thread-attachments",
246 "message-attachments-0.png",
247 );
248 assert.equal(fs.existsSync(attachmentPath), true);
249 assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("SGVsbG8=", "base64"));
250 }).pipe(
251 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
252 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
253 ),
254 ),
255 ),
247256 );
248257
249258 it.effect("materializes only image data URLs and uses a safe extension whitelist", () =>
250 Effect.gen(function* () {
251 const projectionPipeline = yield* OrchestrationProjectionPipeline;
252 const eventStore = yield* OrchestrationEventStore;
253 const sql = yield* SqlClient.SqlClient;
254 const now = new Date().toISOString();
255 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"));
256
257 const serverConfig = {
258 mode: "web",
259 port: 0,
260 host: undefined,
261 cwd: "/tmp/project-attachments",
262 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
263 stateDir,
264 staticDir: undefined,
265 devUrl: undefined,
266 noBrowser: true,
267 authToken: undefined,
268 autoBootstrapProjectFromCwd: false,
269 logWebSocketEvents: false,
270 } satisfies ServerConfigShape;
271
272 yield* eventStore.append({
273 type: "thread.message-sent",
274 eventId: EventId.makeUnsafe("evt-attachments-safe"),
275 aggregateKind: "thread",
276 aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"),
277 occurredAt: now,
278 commandId: CommandId.makeUnsafe("cmd-attachments-safe"),
279 causationEventId: null,
280 correlationId: CommandId.makeUnsafe("cmd-attachments-safe"),
281 metadata: {},
282 payload: {
283 threadId: ThreadId.makeUnsafe("thread-attachments-safe"),
284 messageId: MessageId.makeUnsafe("message-attachments-safe"),
285 role: "user",
286 text: "Inspect this",
287 attachments: [
259 Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe(
260 Effect.flatMap((stateDir) =>
261 Effect.gen(function* () {
262 const projectionPipeline = yield* OrchestrationProjectionPipeline;
263 const eventStore = yield* OrchestrationEventStore;
264 const sql = yield* SqlClient.SqlClient;
265 const now = new Date().toISOString();
266
267 yield* eventStore.append({
268 type: "thread.message-sent",
269 eventId: EventId.makeUnsafe("evt-attachments-safe"),
270 aggregateKind: "thread",
271 aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"),
272 occurredAt: now,
273 commandId: CommandId.makeUnsafe("cmd-attachments-safe"),
274 causationEventId: null,
275 correlationId: CommandId.makeUnsafe("cmd-attachments-safe"),
276 metadata: {},
277 payload: {
278 threadId: ThreadId.makeUnsafe("thread-attachments-safe"),
279 messageId: MessageId.makeUnsafe("message-attachments-safe"),
280 role: "user",
281 text: "Inspect this",
282 attachments: [
283 {
284 type: "image",
285 name: "untrusted.exe",
286 mimeType: "image/x-unknown",
287 sizeBytes: 5,
288 dataUrl: "data:image/x-unknown;base64,SGVsbG8=",
289 },
290 {
291 type: "image",
292 name: "not-image.png",
293 mimeType: "image/png",
294 sizeBytes: 5,
295 dataUrl: "data:text/plain;base64,SGVsbG8=",
296 },
297 ],
298 turnId: null,
299 streaming: false,
300 createdAt: now,
301 updatedAt: now,
302 },
303 });
304
305 yield* projectionPipeline.bootstrap;
306
307 const rows = yield* sql<{
308 readonly attachmentsJson: string | null;
309 }>`
310 SELECT
311 attachments_json AS "attachmentsJson"
312 FROM projection_thread_messages
313 WHERE message_id = 'message-attachments-safe'
314 `;
315 assert.equal(rows.length, 1);
316 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
288317 {
289318 type: "image",
290319 name: "untrusted.exe",
291320 mimeType: "image/x-unknown",
292321 sizeBytes: 5,
293 dataUrl: "data:image/x-unknown;base64,SGVsbG8=",
322 dataUrl: "/attachments/thread-attachments-safe/message-attachments-safe-0.bin",
294323 },
295324 {
296325 type: "image",
......@@ -299,60 +328,30 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
299328 sizeBytes: 5,
300329 dataUrl: "data:text/plain;base64,SGVsbG8=",
301330 },
302 ],
303 turnId: null,
304 streaming: false,
305 createdAt: now,
306 updatedAt: now,
307 },
308 });
309
310 yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig));
311
312 const rows = yield* sql<{
313 readonly attachmentsJson: string | null;
314 }>`
315 SELECT
316 attachments_json AS "attachmentsJson"
317 FROM projection_thread_messages
318 WHERE message_id = 'message-attachments-safe'
319 `;
320 assert.equal(rows.length, 1);
321 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
322 {
323 type: "image",
324 name: "untrusted.exe",
325 mimeType: "image/x-unknown",
326 sizeBytes: 5,
327 dataUrl: "/attachments/thread-attachments-safe/message-attachments-safe-0.bin",
328 },
329 {
330 type: "image",
331 name: "not-image.png",
332 mimeType: "image/png",
333 sizeBytes: 5,
334 dataUrl: "data:text/plain;base64,SGVsbG8=",
335 },
336 ]);
337
338 const firstAttachmentPath = path.join(
339 stateDir,
340 "attachments",
341 "thread-attachments-safe",
342 "message-attachments-safe-0.bin",
343 );
344 assert.equal(fs.existsSync(firstAttachmentPath), true);
345 assert.deepEqual(fs.readFileSync(firstAttachmentPath), Buffer.from("SGVsbG8=", "base64"));
346
347 const secondAttachmentPath = path.join(
348 stateDir,
349 "attachments",
350 "thread-attachments-safe",
351 "message-attachments-safe-1.png",
352 );
353 assert.equal(fs.existsSync(secondAttachmentPath), false);
354 fs.rmSync(stateDir, { recursive: true, force: true });
355 }),
331 ]);
332
333 const firstAttachmentPath = path.join(
334 stateDir,
335 "attachments",
336 "thread-attachments-safe",
337 "message-attachments-safe-0.bin",
338 );
339 assert.equal(fs.existsSync(firstAttachmentPath), true);
340 assert.deepEqual(fs.readFileSync(firstAttachmentPath), Buffer.from("SGVsbG8=", "base64"));
341
342 const secondAttachmentPath = path.join(
343 stateDir,
344 "attachments",
345 "thread-attachments-safe",
346 "message-attachments-safe-1.png",
347 );
348 assert.equal(fs.existsSync(secondAttachmentPath), false);
349 }).pipe(
350 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
351 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
352 ),
353 ),
354 ),
356355 );
357356
358357 it.effect(
......@@ -480,31 +479,17 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
480479 it.effect(
481480 "overwrites attachment file bytes when a message updates the same attachment index",
482481 () =>
483 Effect.gen(function* () {
484 const projectionPipeline = yield* OrchestrationProjectionPipeline;
485 const eventStore = yield* OrchestrationEventStore;
486 const stateDir = fs.mkdtempSync(
487 path.join(os.tmpdir(), "t3-projection-attachments-overwrite-"),
488 );
489 const now = new Date().toISOString();
490 const later = new Date(Date.now() + 1_000).toISOString();
491
492 const serverConfig = {
493 mode: "web",
494 port: 0,
495 host: undefined,
496 cwd: "/tmp/project-attachments",
497 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
498 stateDir,
499 staticDir: undefined,
500 devUrl: undefined,
501 noBrowser: true,
502 authToken: undefined,
503 autoBootstrapProjectFromCwd: false,
504 logWebSocketEvents: false,
505 } satisfies ServerConfigShape;
506
507 yield* eventStore.append({
482 Effect.sync(() =>
483 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")),
484 ).pipe(
485 Effect.flatMap((stateDir) =>
486 Effect.gen(function* () {
487 const projectionPipeline = yield* OrchestrationProjectionPipeline;
488 const eventStore = yield* OrchestrationEventStore;
489 const now = new Date().toISOString();
490 const later = new Date(Date.now() + 1_000).toISOString();
491
492 yield* eventStore.append({
508493 type: "project.created",
509494 eventId: EventId.makeUnsafe("evt-overwrite-1"),
510495 aggregateKind: "project",
......@@ -609,95 +594,86 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
609594 },
610595 });
611596
612 yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig));
613
614 const attachmentPath = path.join(
615 stateDir,
616 "attachments",
617 "thread-overwrite",
618 "message-overwrite-0.png",
619 );
620 assert.equal(fs.existsSync(attachmentPath), true);
621 assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("V29ybGQ=", "base64"));
622 fs.rmSync(stateDir, { recursive: true, force: true });
623 }),
597 yield* projectionPipeline.bootstrap;
598
599 const attachmentPath = path.join(
600 stateDir,
601 "attachments",
602 "thread-overwrite",
603 "message-overwrite-0.png",
604 );
605 assert.equal(fs.existsSync(attachmentPath), true);
606 assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("V29ybGQ=", "base64"));
607 }).pipe(
608 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
609 Effect.ensuring(
610 Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true })),
611 ),
612 ),
613 ),
614 ),
624615 );
625616
626617 it.effect("does not persist attachment files when projector transaction rolls back", () =>
627 Effect.gen(function* () {
628 const projectionPipeline = yield* OrchestrationProjectionPipeline;
629 const eventStore = yield* OrchestrationEventStore;
630 const sql = yield* SqlClient.SqlClient;
631 const stateDir = fs.mkdtempSync(
632 path.join(os.tmpdir(), "t3-projection-attachments-rollback-"),
633 );
634 const now = new Date().toISOString();
635
636 const serverConfig = {
637 mode: "web",
638 port: 0,
639 host: undefined,
640 cwd: "/tmp/project-attachments",
641 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
642 stateDir,
643 staticDir: undefined,
644 devUrl: undefined,
645 noBrowser: true,
646 authToken: undefined,
647 autoBootstrapProjectFromCwd: false,
648 logWebSocketEvents: false,
649 } satisfies ServerConfigShape;
650
651 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
652 eventStore.append(event).pipe(
653 Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)),
654 Effect.provideService(ServerConfig, serverConfig),
655 );
656
657 yield* appendAndProject({
658 type: "project.created",
659 eventId: EventId.makeUnsafe("evt-rollback-1"),
660 aggregateKind: "project",
661 aggregateId: ProjectId.makeUnsafe("project-rollback"),
662 occurredAt: now,
663 commandId: CommandId.makeUnsafe("cmd-rollback-1"),
664 causationEventId: null,
665 correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"),
666 metadata: {},
667 payload: {
668 projectId: ProjectId.makeUnsafe("project-rollback"),
669 title: "Project Rollback",
670 workspaceRoot: "/tmp/project-rollback",
671 defaultModel: null,
672 scripts: [],
673 createdAt: now,
674 updatedAt: now,
675 },
676 });
677
678 yield* appendAndProject({
679 type: "thread.created",
680 eventId: EventId.makeUnsafe("evt-rollback-2"),
681 aggregateKind: "thread",
682 aggregateId: ThreadId.makeUnsafe("thread-rollback"),
683 occurredAt: now,
684 commandId: CommandId.makeUnsafe("cmd-rollback-2"),
685 causationEventId: null,
686 correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"),
687 metadata: {},
688 payload: {
689 threadId: ThreadId.makeUnsafe("thread-rollback"),
690 projectId: ProjectId.makeUnsafe("project-rollback"),
691 title: "Thread Rollback",
692 model: "gpt-5-codex",
693 branch: null,
694 worktreePath: null,
695 createdAt: now,
696 updatedAt: now,
697 },
698 });
618 Effect.sync(() =>
619 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-rollback-")),
620 ).pipe(
621 Effect.flatMap((stateDir) =>
622 Effect.gen(function* () {
623 const projectionPipeline = yield* OrchestrationProjectionPipeline;
624 const eventStore = yield* OrchestrationEventStore;
625 const sql = yield* SqlClient.SqlClient;
626 const now = new Date().toISOString();
627
628 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
629 eventStore
630 .append(event)
631 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
632
633 yield* appendAndProject({
634 type: "project.created",
635 eventId: EventId.makeUnsafe("evt-rollback-1"),
636 aggregateKind: "project",
637 aggregateId: ProjectId.makeUnsafe("project-rollback"),
638 occurredAt: now,
639 commandId: CommandId.makeUnsafe("cmd-rollback-1"),
640 causationEventId: null,
641 correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"),
642 metadata: {},
643 payload: {
644 projectId: ProjectId.makeUnsafe("project-rollback"),
645 title: "Project Rollback",
646 workspaceRoot: "/tmp/project-rollback",
647 defaultModel: null,
648 scripts: [],
649 createdAt: now,
650 updatedAt: now,
651 },
652 });
653
654 yield* appendAndProject({
655 type: "thread.created",
656 eventId: EventId.makeUnsafe("evt-rollback-2"),
657 aggregateKind: "thread",
658 aggregateId: ThreadId.makeUnsafe("thread-rollback"),
659 occurredAt: now,
660 commandId: CommandId.makeUnsafe("cmd-rollback-2"),
661 causationEventId: null,
662 correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"),
663 metadata: {},
664 payload: {
665 threadId: ThreadId.makeUnsafe("thread-rollback"),
666 projectId: ProjectId.makeUnsafe("project-rollback"),
667 title: "Thread Rollback",
668 model: "gpt-5-codex",
669 branch: null,
670 worktreePath: null,
671 createdAt: now,
672 updatedAt: now,
673 },
674 });
699675
700 yield* sql`
676 yield* sql`
701677 CREATE TRIGGER fail_thread_messages_projection_state_update
702678 BEFORE UPDATE ON projection_state
703679 WHEN NEW.projector = 'projection.thread-messages'
......@@ -706,90 +682,81 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
706682 END;
707683 `;
708684
709 const result = yield* Effect.result(
710 appendAndProject({
711 type: "thread.message-sent",
712 eventId: EventId.makeUnsafe("evt-rollback-3"),
713 aggregateKind: "thread",
714 aggregateId: ThreadId.makeUnsafe("thread-rollback"),
715 occurredAt: now,
716 commandId: CommandId.makeUnsafe("cmd-rollback-3"),
717 causationEventId: null,
718 correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"),
719 metadata: {},
720 payload: {
721 threadId: ThreadId.makeUnsafe("thread-rollback"),
722 messageId: MessageId.makeUnsafe("message-rollback"),
723 role: "user",
724 text: "Rollback me",
725 attachments: [
726 {
727 type: "image",
728 name: "rollback.png",
729 mimeType: "image/png",
730 sizeBytes: 5,
731 dataUrl: "data:image/png;base64,SGVsbG8=",
685 const result = yield* Effect.result(
686 appendAndProject({
687 type: "thread.message-sent",
688 eventId: EventId.makeUnsafe("evt-rollback-3"),
689 aggregateKind: "thread",
690 aggregateId: ThreadId.makeUnsafe("thread-rollback"),
691 occurredAt: now,
692 commandId: CommandId.makeUnsafe("cmd-rollback-3"),
693 causationEventId: null,
694 correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"),
695 metadata: {},
696 payload: {
697 threadId: ThreadId.makeUnsafe("thread-rollback"),
698 messageId: MessageId.makeUnsafe("message-rollback"),
699 role: "user",
700 text: "Rollback me",
701 attachments: [
702 {
703 type: "image",
704 name: "rollback.png",
705 mimeType: "image/png",
706 sizeBytes: 5,
707 dataUrl: "data:image/png;base64,SGVsbG8=",
708 },
709 ],
710 turnId: null,
711 streaming: false,
712 createdAt: now,
713 updatedAt: now,
732714 },
733 ],
734 turnId: null,
735 streaming: false,
736 createdAt: now,
737 updatedAt: now,
738 },
739 }),
740 );
741 assert.equal(result._tag, "Failure");
715 }),
716 );
717 assert.equal(result._tag, "Failure");
742718
743 const rows = yield* sql<{
744 readonly count: number;
745 }>`
719 const rows = yield* sql<{
720 readonly count: number;
721 }>`
746722 SELECT COUNT(*) AS "count"
747723 FROM projection_thread_messages
748724 WHERE message_id = 'message-rollback'
749725 `;
750 assert.equal(rows[0]?.count ?? 0, 0);
751
752 const attachmentPath = path.join(
753 stateDir,
754 "attachments",
755 "thread-rollback",
756 "message-rollback-0.png",
757 );
758 assert.equal(fs.existsSync(attachmentPath), false);
759 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;
760 fs.rmSync(stateDir, { recursive: true, force: true });
761 }),
726 assert.equal(rows[0]?.count ?? 0, 0);
727
728 const attachmentPath = path.join(
729 stateDir,
730 "attachments",
731 "thread-rollback",
732 "message-rollback-0.png",
733 );
734 assert.equal(fs.existsSync(attachmentPath), false);
735 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;
736 }).pipe(
737 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
738 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
739 ),
740 ),
741 ),
762742 );
763743
764744 it.effect("removes unreferenced attachment files when a thread is reverted", () =>
765 Effect.gen(function* () {
766 const projectionPipeline = yield* OrchestrationProjectionPipeline;
767 const eventStore = yield* OrchestrationEventStore;
768 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-"));
769 const now = new Date().toISOString();
770
771 const serverConfig = {
772 mode: "web",
773 port: 0,
774 host: undefined,
775 cwd: "/tmp/project-attachments",
776 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
777 stateDir,
778 staticDir: undefined,
779 devUrl: undefined,
780 noBrowser: true,
781 authToken: undefined,
782 autoBootstrapProjectFromCwd: false,
783 logWebSocketEvents: false,
784 } satisfies ServerConfigShape;
785
786 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
787 eventStore.append(event).pipe(
788 Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)),
789 Effect.provideService(ServerConfig, serverConfig),
790 );
791
792 yield* appendAndProject({
745 Effect.sync(() =>
746 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-")),
747 ).pipe(
748 Effect.flatMap((stateDir) =>
749 Effect.gen(function* () {
750 const projectionPipeline = yield* OrchestrationProjectionPipeline;
751 const eventStore = yield* OrchestrationEventStore;
752 const now = new Date().toISOString();
753
754 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
755 eventStore
756 .append(event)
757 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
758
759 yield* appendAndProject({
793760 type: "project.created",
794761 eventId: EventId.makeUnsafe("evt-revert-files-1"),
795762 aggregateKind: "project",
......@@ -969,41 +936,32 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
969936 },
970937 });
971938
972 assert.equal(fs.existsSync(keepPath), true);
973 assert.equal(fs.existsSync(removePath), false);
974 fs.rmSync(stateDir, { recursive: true, force: true });
975 }),
939 assert.equal(fs.existsSync(keepPath), true);
940 assert.equal(fs.existsSync(removePath), false);
941 }).pipe(
942 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
943 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
944 ),
945 ),
946 ),
976947 );
977948
978949 it.effect("removes thread attachment directory when thread is deleted", () =>
979 Effect.gen(function* () {
980 const projectionPipeline = yield* OrchestrationProjectionPipeline;
981 const eventStore = yield* OrchestrationEventStore;
982 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-"));
983 const now = new Date().toISOString();
984
985 const serverConfig = {
986 mode: "web",
987 port: 0,
988 host: undefined,
989 cwd: "/tmp/project-attachments",
990 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
991 stateDir,
992 staticDir: undefined,
993 devUrl: undefined,
994 noBrowser: true,
995 authToken: undefined,
996 autoBootstrapProjectFromCwd: false,
997 logWebSocketEvents: false,
998 } satisfies ServerConfigShape;
999
1000 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
1001 eventStore.append(event).pipe(
1002 Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)),
1003 Effect.provideService(ServerConfig, serverConfig),
1004 );
1005
1006 yield* appendAndProject({
950 Effect.sync(() =>
951 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-")),
952 ).pipe(
953 Effect.flatMap((stateDir) =>
954 Effect.gen(function* () {
955 const projectionPipeline = yield* OrchestrationProjectionPipeline;
956 const eventStore = yield* OrchestrationEventStore;
957 const now = new Date().toISOString();
958
959 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
960 eventStore
961 .append(event)
962 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
963
964 yield* appendAndProject({
1007965 type: "project.created",
1008966 eventId: EventId.makeUnsafe("evt-delete-files-1"),
1009967 aggregateKind: "project",
......@@ -1096,62 +1054,56 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
10961054 },
10971055 });
10981056
1099 assert.equal(fs.existsSync(threadAttachmentDir), false);
1100 fs.rmSync(stateDir, { recursive: true, force: true });
1101 }),
1057 assert.equal(fs.existsSync(threadAttachmentDir), false);
1058 }).pipe(
1059 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
1060 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
1061 ),
1062 ),
1063 ),
11021064 );
11031065
11041066 it.effect("ignores unsafe thread ids for attachment cleanup paths", () =>
1105 Effect.gen(function* () {
1106 const projectionPipeline = yield* OrchestrationProjectionPipeline;
1107 const eventStore = yield* OrchestrationEventStore;
1108 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-"));
1109 const now = new Date().toISOString();
1110 const attachmentsRootDir = path.join(stateDir, "attachments");
1111 const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt");
1112 const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt");
1113 fs.mkdirSync(attachmentsRootDir, { recursive: true });
1114 fs.writeFileSync(attachmentsSentinelPath, "keep-attachments-root", "utf8");
1115 fs.writeFileSync(stateDirSentinelPath, "keep-state-dir", "utf8");
1116
1117 const serverConfig = {
1118 mode: "web",
1119 port: 0,
1120 host: undefined,
1121 cwd: "/tmp/project-attachments",
1122 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
1123 stateDir,
1124 staticDir: undefined,
1125 devUrl: undefined,
1126 noBrowser: true,
1127 authToken: undefined,
1128 autoBootstrapProjectFromCwd: false,
1129 logWebSocketEvents: false,
1130 } satisfies ServerConfigShape;
1131
1132 yield* eventStore.append({
1133 type: "thread.deleted",
1134 eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"),
1135 aggregateKind: "thread",
1136 aggregateId: ThreadId.makeUnsafe(".."),
1137 occurredAt: now,
1138 commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"),
1139 causationEventId: null,
1140 correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"),
1141 metadata: {},
1142 payload: {
1143 threadId: ThreadId.makeUnsafe(".."),
1144 deletedAt: now,
1145 },
1146 });
1147
1148 yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig));
1149
1150 assert.equal(fs.existsSync(attachmentsRootDir), true);
1151 assert.equal(fs.existsSync(attachmentsSentinelPath), true);
1152 assert.equal(fs.existsSync(stateDirSentinelPath), true);
1153 fs.rmSync(stateDir, { recursive: true, force: true });
1154 }),
1067 Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-"))).pipe(
1068 Effect.flatMap((stateDir) =>
1069 Effect.gen(function* () {
1070 const projectionPipeline = yield* OrchestrationProjectionPipeline;
1071 const eventStore = yield* OrchestrationEventStore;
1072 const now = new Date().toISOString();
1073 const attachmentsRootDir = path.join(stateDir, "attachments");
1074 const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt");
1075 const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt");
1076 fs.mkdirSync(attachmentsRootDir, { recursive: true });
1077 fs.writeFileSync(attachmentsSentinelPath, "keep-attachments-root", "utf8");
1078 fs.writeFileSync(stateDirSentinelPath, "keep-state-dir", "utf8");
1079
1080 yield* eventStore.append({
1081 type: "thread.deleted",
1082 eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"),
1083 aggregateKind: "thread",
1084 aggregateId: ThreadId.makeUnsafe(".."),
1085 occurredAt: now,
1086 commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"),
1087 causationEventId: null,
1088 correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"),
1089 metadata: {},
1090 payload: {
1091 threadId: ThreadId.makeUnsafe(".."),
1092 deletedAt: now,
1093 },
1094 });
1095
1096 yield* projectionPipeline.bootstrap;
1097
1098 assert.equal(fs.existsSync(attachmentsRootDir), true);
1099 assert.equal(fs.existsSync(attachmentsSentinelPath), true);
1100 assert.equal(fs.existsSync(stateDirSentinelPath), true);
1101 }).pipe(
1102 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
1103 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
1104 ),
1105 ),
1106 ),
11551107 );
11561108
11571109 it.effect("resumes from projector last_applied_sequence without replaying older events", () =>
......@@ -1868,7 +1820,11 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
18681820 ]);
18691821
18701822 fs.rmSync(tempDir, { recursive: true, force: true });
1871 }).pipe(Effect.provide(NodeServices.layer)),
1823 }).pipe(
1824 Effect.provide(
1825 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd()), NodeServices.layer),
1826 ),
1827 ),
18721828);
18731829
18741830const engineLayer = it.layer(
......@@ -1877,6 +1833,8 @@ const engineLayer = it.layer(
18771833 Layer.provide(OrchestrationEventStoreLive),
18781834 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
18791835 Layer.provideMerge(SqlitePersistenceMemory),
1836 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
1837 Layer.provideMerge(NodeServices.layer),
18801838 ),
18811839);
18821840
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+32-32
......@@ -96,11 +96,10 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: {
9696}) {
9797 if (input.attachments.length === 0) return [];
9898
99 const serverConfig = yield* Effect.serviceOption(ServerConfig);
100 if (Option.isNone(serverConfig)) return input.attachments;
99 const serverConfig = yield* Effect.service(ServerConfig);
101100
102101 const path = yield* Path.Path;
103 const attachmentsRootDir = path.join(serverConfig.value.stateDir, "attachments");
102 const attachmentsRootDir = path.join(serverConfig.stateDir, "attachments");
104103 const threadSegment = toSafeThreadAttachmentSegment(input.threadId);
105104 if (!threadSegment) {
106105 yield* Effect.logWarning("skipping attachment materialization for unsafe thread id", {
......@@ -289,20 +288,15 @@ function collectThreadAttachmentRelativePaths(
289288 return relativePaths;
290289}
291290
292const runAttachmentSideEffects = Effect.fn(function* (input: {
293 readonly sideEffects: AttachmentSideEffects;
294 readonly fileSystem: FileSystem.FileSystem;
295 readonly path: Path.Path;
296}) {
297 const serverConfig = yield* Effect.serviceOption(ServerConfig);
298 if (Option.isNone(serverConfig)) {
299 return;
300 }
291const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSideEffects) {
292 const serverConfig = yield* Effect.service(ServerConfig);
293 const fileSystem = yield* Effect.service(FileSystem.FileSystem);
294 const path = yield* Effect.service(Path.Path);
301295
302 const attachmentsRootDir = input.path.join(serverConfig.value.stateDir, "attachments");
296 const attachmentsRootDir = path.join(serverConfig.stateDir, "attachments");
303297
304298 yield* Effect.forEach(
305 input.sideEffects.deletedThreadIds,
299 sideEffects.deletedThreadIds,
306300 (threadId) =>
307301 Effect.gen(function* () {
308302 const threadSegment = toSafeThreadAttachmentSegment(threadId);
......@@ -312,16 +306,16 @@ const runAttachmentSideEffects = Effect.fn(function* (input: {
312306 });
313307 return;
314308 }
315 const threadDir = input.path.join(attachmentsRootDir, threadSegment);
316 yield* input.fileSystem.remove(threadDir, { recursive: true, force: true });
309 const threadDir = path.join(attachmentsRootDir, threadSegment);
310 yield* fileSystem.remove(threadDir, { recursive: true, force: true });
317311 }),
318312 { concurrency: 1 },
319313 );
320314
321315 yield* Effect.forEach(
322 input.sideEffects.prunedThreadRelativePaths.entries(),
316 sideEffects.prunedThreadRelativePaths.entries(),
323317 ([threadId, keptThreadRelativePaths]) => {
324 if (input.sideEffects.deletedThreadIds.has(threadId)) {
318 if (sideEffects.deletedThreadIds.has(threadId)) {
325319 return Effect.void;
326320 }
327321 return Effect.gen(function* () {
......@@ -330,8 +324,8 @@ const runAttachmentSideEffects = Effect.fn(function* (input: {
330324 yield* Effect.logWarning("skipping attachment prune for unsafe thread id", { threadId });
331325 return;
332326 }
333 const threadDir = input.path.join(attachmentsRootDir, threadSegment);
334 const entries = yield* input.fileSystem
327 const threadDir = path.join(attachmentsRootDir, threadSegment);
328 const entries = yield* fileSystem
335329 .readDirectory(threadDir, { recursive: true })
336330 .pipe(Effect.catch(() => Effect.succeed([] as Array<string>)));
337331 yield* Effect.forEach(
......@@ -343,8 +337,8 @@ const runAttachmentSideEffects = Effect.fn(function* (input: {
343337 return;
344338 }
345339
346 const absolutePath = input.path.join(threadDir, threadRelativePath);
347 const fileInfo = yield* input.fileSystem
340 const absolutePath = path.join(threadDir, threadRelativePath);
341 const fileInfo = yield* fileSystem
348342 .stat(absolutePath)
349343 .pipe(Effect.catch(() => Effect.succeed(null)));
350344 if (!fileInfo || fileInfo.type !== "File") {
......@@ -352,7 +346,7 @@ const runAttachmentSideEffects = Effect.fn(function* (input: {
352346 }
353347
354348 if (!keptThreadRelativePaths.has(threadRelativePath)) {
355 yield* input.fileSystem.remove(absolutePath, { force: true });
349 yield* fileSystem.remove(absolutePath, { force: true });
356350 }
357351 }),
358352 { concurrency: 1 },
......@@ -363,7 +357,7 @@ const runAttachmentSideEffects = Effect.fn(function* (input: {
363357 );
364358
365359 const writesByPath = new Map<string, Uint8Array>();
366 for (const pendingWrite of input.sideEffects.writes) {
360 for (const pendingWrite of sideEffects.writes) {
367361 // Last write wins for a path so updated attachment contents replace stale bytes.
368362 writesByPath.set(pendingWrite.absolutePath, pendingWrite.bytes);
369363 }
......@@ -371,10 +365,10 @@ const runAttachmentSideEffects = Effect.fn(function* (input: {
371365 writesByPath.entries(),
372366 ([absolutePath, bytes]) =>
373367 Effect.gen(function* () {
374 yield* input.fileSystem.makeDirectory(input.path.dirname(absolutePath), {
368 yield* fileSystem.makeDirectory(path.dirname(absolutePath), {
375369 recursive: true,
376370 });
377 yield* input.fileSystem.writeFile(absolutePath, bytes);
371 yield* fileSystem.writeFile(absolutePath, bytes);
378372 }),
379373 { concurrency: 1 },
380374 );
......@@ -394,6 +388,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
394388
395389 const fileSystem = yield* FileSystem.FileSystem;
396390 const path = yield* Path.Path;
391 const serverConfig = yield* ServerConfig;
397392
398393 const applyProjectsProjection: ProjectorDefinition["apply"] = (event, _attachmentSideEffects) =>
399394 Effect.gen(function* () {
......@@ -600,7 +595,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
600595 stageFileWrite: (pendingWrite) => {
601596 attachmentSideEffects.writes.push(pendingWrite);
602597 },
603 }).pipe(Effect.provideService(Path.Path, path))
598 }).pipe(
599 Effect.provideService(ServerConfig, serverConfig),
600 Effect.provideService(Path.Path, path),
601 )
604602 : existingMessage?.attachments;
605603 yield* projectionThreadMessageRepository.upsert({
606604 messageId: event.payload.messageId,
......@@ -1108,11 +1106,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
11081106 ),
11091107 );
11101108
1111 yield* runAttachmentSideEffects({
1112 sideEffects: attachmentSideEffects,
1113 fileSystem,
1114 path,
1115 }).pipe(
1109 yield* runAttachmentSideEffects(attachmentSideEffects).pipe(
11161110 Effect.catch((cause) =>
11171111 Effect.logWarning("failed to apply projected attachment side-effects", {
11181112 projector: projector.name,
......@@ -1144,6 +1138,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
11441138 Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), {
11451139 concurrency: 1,
11461140 }).pipe(
1141 Effect.provideService(FileSystem.FileSystem, fileSystem),
1142 Effect.provideService(Path.Path, path),
1143 Effect.provideService(ServerConfig, serverConfig),
11471144 Effect.asVoid,
11481145 Effect.catchTag("SqlError", (sqlError) =>
11491146 Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)),
......@@ -1155,6 +1152,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
11551152 bootstrapProjector,
11561153 { concurrency: 1 },
11571154 ).pipe(
1155 Effect.provideService(FileSystem.FileSystem, fileSystem),
1156 Effect.provideService(Path.Path, path),
1157 Effect.provideService(ServerConfig, serverConfig),
11581158 Effect.asVoid,
11591159 Effect.tap(() =>
11601160 Effect.log("orchestration projection pipeline bootstrapped").pipe(
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+21-27
......@@ -17,7 +17,7 @@ import {
1717import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
1818import { afterEach, describe, expect, it, vi } from "vitest";
1919
20import { type ServerConfigShape, ServerConfig } from "../../config.ts";
20import { ServerConfig } from "../../config.ts";
2121import { TextGenerationError } from "../../git/Errors.ts";
2222import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
2323import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
......@@ -33,6 +33,7 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3333import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";
3434import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3535import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
36import * as NodeServices from "@effect/platform-node/NodeServices";
3637
3738const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
3839const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
......@@ -42,23 +43,6 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>
4243const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
4344const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4445
45function makeTestServerConfig(stateDir: string): ServerConfigShape {
46 return {
47 mode: "web",
48 port: 0,
49 host: undefined,
50 cwd: process.cwd(),
51 keybindingsConfigPath: "",
52 stateDir,
53 staticDir: undefined,
54 devUrl: undefined,
55 noBrowser: true,
56 authToken: undefined,
57 autoBootstrapProjectFromCwd: false,
58 logWebSocketEvents: false,
59 };
60}
61
6246async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
6347 const deadline = Date.now() + timeoutMs;
6448 const poll = async (): Promise<void> => {
......@@ -81,6 +65,7 @@ describe("ProviderCommandReactor", () => {
8165 unknown
8266 > | null = null;
8367 let scope: Scope.Closeable | null = null;
68 const createdStateDirs = new Set<string>();
8469
8570 afterEach(async () => {
8671 if (scope) {
......@@ -91,10 +76,16 @@ describe("ProviderCommandReactor", () => {
9176 await runtime.dispose();
9277 }
9378 runtime = null;
79 for (const stateDir of createdStateDirs) {
80 fs.rmSync(stateDir, { recursive: true, force: true });
81 }
82 createdStateDirs.clear();
9483 });
9584
9685 async function createHarness(input?: { readonly stateDir?: string }) {
9786 const now = new Date().toISOString();
87 const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));
88 createdStateDirs.add(stateDir);
9889 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
9990 let nextSessionIndex = 1;
10091 const startSession = vi.fn((_: unknown, __: unknown) => {
......@@ -147,21 +138,18 @@ describe("ProviderCommandReactor", () => {
147138 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
148139 Layer.provide(SqlitePersistenceMemory),
149140 );
150 const liveLayer = ProviderCommandReactorLive.pipe(
141 const layer = ProviderCommandReactorLive.pipe(
151142 Layer.provideMerge(orchestrationLayer),
152143 Layer.provideMerge(Layer.succeed(ProviderService, service)),
153144 Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),
154145 Layer.provideMerge(
155146 Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape),
156147 ),
148 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)),
149 Layer.provideMerge(NodeServices.layer),
157150 );
158 const layer =
159 input?.stateDir !== undefined
160 ? liveLayer.pipe(
161 Layer.provideMerge(Layer.succeed(ServerConfig, makeTestServerConfig(input.stateDir))),
162 )
163 : liveLayer;
164 runtime = ManagedRuntime.make(layer);
151 const runtime = ManagedRuntime.make(layer);
152
165153 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
166154 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
167155 scope = await Effect.runPromise(Scope.make("sequential"));
......@@ -202,6 +190,7 @@ describe("ProviderCommandReactor", () => {
202190 stopSession,
203191 renameBranch,
204192 generateBranchName,
193 stateDir,
205194 };
206195 }
207196
......@@ -294,7 +283,12 @@ describe("ProviderCommandReactor", () => {
294283 name: "bug.png",
295284 mimeType: "image/png",
296285 sizeBytes: 5,
297 dataUrl: "data:image/png;base64,SGVsbG8=",
286 dataUrl: path.join(
287 harness.stateDir,
288 "attachments",
289 "thread-1",
290 "user-message-worktree-rename-0.png",
291 ),
298292 },
299293 ],
300294 });
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+4-9
......@@ -111,7 +111,7 @@ const make = Effect.gen(function* () {
111111 const providerService = yield* ProviderService;
112112 const git = yield* GitCore;
113113 const textGeneration = yield* TextGeneration;
114 const serverConfig = yield* Effect.serviceOption(ServerConfig);
114 const serverConfig = yield* Effect.service(ServerConfig);
115115 const handledTurnStartKeys = yield* Cache.make<string, true>({
116116 capacity: HANDLED_TURN_START_KEY_MAX,
117117 timeToLive: HANDLED_TURN_START_KEY_TTL,
......@@ -301,20 +301,15 @@ const make = Effect.gen(function* () {
301301 return input.attachments ?? [];
302302 }
303303
304 if (Option.isNone(serverConfig)) {
305 return input.attachments;
306 }
307304 const threadSegment = encodeURIComponent(input.threadId);
308305 const messageSegment = encodeURIComponent(input.messageId);
309306
310307 return yield* Effect.forEach(Array.from(input.attachments.entries()), ([index, attachment]) =>
311308 Effect.gen(function* () {
312 if (attachment.type !== "image") {
313 return attachment;
314 }
309 if (attachment.type !== "image") return attachment;
315310
316311 const resolvedRoutePath = resolveAttachmentRoutePath({
317 stateDir: serverConfig.value.stateDir,
312 stateDir: serverConfig.stateDir,
318313 dataUrl: attachment.dataUrl,
319314 });
320315 const resolvedMaterializedPath =
......@@ -329,7 +324,7 @@ const make = Effect.gen(function* () {
329324 fileName: attachment.name,
330325 });
331326 return resolveAttachmentRelativePath({
332 stateDir: serverConfig.value.stateDir,
327 stateDir: serverConfig.stateDir,
333328 relativePath: `${threadSegment}/${messageSegment}-${index}${extension}`,
334329 });
335330 })();
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+33-30
......@@ -28,11 +28,12 @@ import {
2828 type OrchestrationEngineShape,
2929} from "../Services/OrchestrationEngine.ts";
3030import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
31import { ServerConfig } from "../../config.ts";
32import * as NodeServices from "@effect/platform-node/NodeServices";
3133
3234const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
3335const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
34const asProviderThreadId = (value: string): ProviderThreadId =>
35 ProviderThreadId.makeUnsafe(value);
36const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
3637const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
3738const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
3839const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
......@@ -122,6 +123,8 @@ describe("ProviderRuntimeIngestion", () => {
122123 const layer = ProviderRuntimeIngestionLive.pipe(
123124 Layer.provideMerge(orchestrationLayer),
124125 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
126 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
127 Layer.provideMerge(NodeServices.layer),
125128 );
126129 runtime = ManagedRuntime.make(layer);
127130 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
......@@ -255,7 +258,9 @@ describe("ProviderRuntimeIngestion", () => {
255258
256259 await Effect.runPromise(Effect.sleep("40 millis"));
257260 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
258 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
261 const midThread = midReadModel.threads.find(
262 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
263 );
259264 expect(midThread?.session?.status).toBe("running");
260265 expect(midThread?.session?.activeTurnId).toBe("turn-primary");
261266
......@@ -308,7 +313,9 @@ describe("ProviderRuntimeIngestion", () => {
308313
309314 await Effect.runPromise(Effect.sleep("40 millis"));
310315 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
311 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
316 const midThread = midReadModel.threads.find(
317 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
318 );
312319 expect(midThread?.session?.status).toBe("running");
313320 expect(midThread?.session?.activeTurnId).toBe("turn-guarded-main");
314321
......@@ -401,7 +408,9 @@ describe("ProviderRuntimeIngestion", () => {
401408
402409 await Effect.runPromise(Effect.sleep("30 millis"));
403410 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
404 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
411 const midThread = midReadModel.threads.find(
412 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
413 );
405414 expect(midThread?.messages.some((message) => message.id === "assistant:item-buffered")).toBe(
406415 false,
407416 );
......@@ -416,10 +425,10 @@ describe("ProviderRuntimeIngestion", () => {
416425 itemId: asItemId("item-buffered"),
417426 });
418427
419 const thread = await waitForThread(
420 harness.engine,
421 (entry) =>
422 entry.messages.some((message) => message.id === "assistant:item-buffered" && !message.streaming),
428 const thread = await waitForThread(harness.engine, (entry) =>
429 entry.messages.some(
430 (message) => message.id === "assistant:item-buffered" && !message.streaming,
431 ),
423432 );
424433 const message = thread.messages.find((entry) => entry.id === "assistant:item-buffered");
425434 expect(message?.text).toBe("buffer me");
......@@ -475,15 +484,13 @@ describe("ProviderRuntimeIngestion", () => {
475484 delta: "hello live",
476485 });
477486
478 const liveThread = await waitForThread(
479 harness.engine,
480 (entry) =>
481 entry.messages.some(
482 (message) =>
483 message.id === "assistant:item-streaming-mode" &&
484 message.streaming &&
485 message.text === "hello live",
486 ),
487 const liveThread = await waitForThread(harness.engine, (entry) =>
488 entry.messages.some(
489 (message) =>
490 message.id === "assistant:item-streaming-mode" &&
491 message.streaming &&
492 message.text === "hello live",
493 ),
487494 );
488495 const liveMessage = liveThread.messages.find(
489496 (entry) => entry.id === "assistant:item-streaming-mode",
......@@ -500,12 +507,10 @@ describe("ProviderRuntimeIngestion", () => {
500507 itemId: asItemId("item-streaming-mode"),
501508 });
502509
503 const finalThread = await waitForThread(
504 harness.engine,
505 (entry) =>
506 entry.messages.some(
507 (message) => message.id === "assistant:item-streaming-mode" && !message.streaming,
508 ),
510 const finalThread = await waitForThread(harness.engine, (entry) =>
511 entry.messages.some(
512 (message) => message.id === "assistant:item-streaming-mode" && !message.streaming,
513 ),
509514 );
510515 const finalMessage = finalThread.messages.find(
511516 (entry) => entry.id === "assistant:item-streaming-mode",
......@@ -554,12 +559,10 @@ describe("ProviderRuntimeIngestion", () => {
554559 itemId: asItemId("item-buffer-spill"),
555560 });
556561
557 const thread = await waitForThread(
558 harness.engine,
559 (entry) =>
560 entry.messages.some(
561 (message) => message.id === "assistant:item-buffer-spill" && !message.streaming,
562 ),
562 const thread = await waitForThread(harness.engine, (entry) =>
563 entry.messages.some(
564 (message) => message.id === "assistant:item-buffer-spill" && !message.streaming,
565 ),
563566 );
564567 const message = thread.messages.find((entry) => entry.id === "assistant:item-buffer-spill");
565568 expect(message?.text.length).toBe(oversizedText.length);