| ... | ... | @@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 8 | 8 | import * as Layer from "effect/Layer"; |
| 9 | 9 | import * as Path from "effect/Path"; |
| 10 | 10 | import * as Queue from "effect/Queue"; |
| 11 | import * as Ref from "effect/Ref"; |
| 11 | 12 | import * as Stream from "effect/Stream"; |
| 12 | 13 | |
| 13 | 14 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; |
| ... | ... | @@ -54,35 +55,58 @@ interface IntegrationFixture { |
| 54 | 55 | readonly layer: Layer.Layer<ProviderService, unknown, never>; |
| 55 | 56 | } |
| 56 | 57 | |
| 57 | | const makeIntegrationFixture = Effect.gen(function* () { |
| 58 | | const cwd = yield* makeWorkspaceDirectory; |
| 59 | | const harness = yield* makeTestProviderAdapterHarness(); |
| 60 | | |
| 61 | | const registry = makeAdapterRegistryMock({ |
| 62 | | [ProviderDriverKind.make("codex")]: harness.adapter, |
| 63 | | }); |
| 58 | interface RecordedAnalyticsEvent { |
| 59 | readonly event: string; |
| 60 | readonly properties: Readonly<Record<string, unknown>> | undefined; |
| 61 | } |
| 64 | 62 | |
| 65 | | const directoryLayer = ProviderSessionDirectoryLive.pipe( |
| 66 | | Layer.provide(ProviderSessionRuntime.layer), |
| 63 | /** |
| 64 | * Analytics layer that keeps captured events in memory so tests can assert on |
| 65 | * telemetry payloads. `AnalyticsService.layerTest` discards them. |
| 66 | */ |
| 67 | const makeRecordingAnalytics = Effect.gen(function* () { |
| 68 | const recorded = yield* Ref.make<ReadonlyArray<RecordedAnalyticsEvent>>([]); |
| 69 | const layer = Layer.succeed( |
| 70 | AnalyticsService, |
| 71 | AnalyticsService.of({ |
| 72 | record: (event, properties) => |
| 73 | Ref.update(recorded, (current) => [...current, { event, properties }]), |
| 74 | flush: Effect.void, |
| 75 | }), |
| 67 | 76 | ); |
| 68 | | |
| 69 | | const shared = Layer.mergeAll( |
| 70 | | directoryLayer, |
| 71 | | Layer.succeed(ProviderAdapterRegistry, registry), |
| 72 | | ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), |
| 73 | | AnalyticsService.layerTest, |
| 74 | | Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), |
| 75 | | ).pipe(Layer.provide(SqlitePersistenceMemory)); |
| 76 | | |
| 77 | | const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); |
| 78 | | |
| 79 | | return { |
| 80 | | cwd, |
| 81 | | harness, |
| 82 | | layer, |
| 83 | | } satisfies IntegrationFixture; |
| 77 | return { layer, get: Ref.get(recorded) } as const; |
| 84 | 78 | }); |
| 85 | 79 | |
| 80 | const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer<AnalyticsService> }) => |
| 81 | Effect.gen(function* () { |
| 82 | const cwd = yield* makeWorkspaceDirectory; |
| 83 | const harness = yield* makeTestProviderAdapterHarness(); |
| 84 | |
| 85 | const registry = makeAdapterRegistryMock({ |
| 86 | [ProviderDriverKind.make("codex")]: harness.adapter, |
| 87 | }); |
| 88 | |
| 89 | const directoryLayer = ProviderSessionDirectoryLive.pipe( |
| 90 | Layer.provide(ProviderSessionRuntime.layer), |
| 91 | ); |
| 92 | |
| 93 | const shared = Layer.mergeAll( |
| 94 | directoryLayer, |
| 95 | Layer.succeed(ProviderAdapterRegistry, registry), |
| 96 | ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), |
| 97 | options?.analytics ?? AnalyticsService.layerTest, |
| 98 | Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), |
| 99 | ).pipe(Layer.provide(SqlitePersistenceMemory)); |
| 100 | |
| 101 | const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); |
| 102 | |
| 103 | return { |
| 104 | cwd, |
| 105 | harness, |
| 106 | layer, |
| 107 | } satisfies IntegrationFixture; |
| 108 | }); |
| 109 | |
| 86 | 110 | const collectEventsDuring = <A, E, R>( |
| 87 | 111 | stream: Stream.Stream<ProviderRuntimeEvent>, |
| 88 | 112 | count: number, |
| ... | ... | @@ -126,7 +150,7 @@ const runTurn = (input: { |
| 126 | 150 | |
| 127 | 151 | it.live("replays typed runtime fixture events", () => |
| 128 | 152 | Effect.gen(function* () { |
| 129 | | const fixture = yield* makeIntegrationFixture; |
| 153 | const fixture = yield* makeIntegrationFixture(); |
| 130 | 154 | |
| 131 | 155 | yield* Effect.gen(function* () { |
| 132 | 156 | const provider = yield* ProviderService; |
| ... | ... | @@ -161,7 +185,7 @@ it.live("replays typed runtime fixture events", () => |
| 161 | 185 | |
| 162 | 186 | it.live("replays file-changing fixture turn events", () => |
| 163 | 187 | Effect.gen(function* () { |
| 164 | | const fixture = yield* makeIntegrationFixture; |
| 188 | const fixture = yield* makeIntegrationFixture(); |
| 165 | 189 | const { join } = yield* Path.Path; |
| 166 | 190 | const { writeFileString } = yield* FileSystem.FileSystem; |
| 167 | 191 | |
| ... | ... | @@ -198,7 +222,7 @@ it.live("replays file-changing fixture turn events", () => |
| 198 | 222 | |
| 199 | 223 | it.live("runs multi-turn tool/approval flow", () => |
| 200 | 224 | Effect.gen(function* () { |
| 201 | | const fixture = yield* makeIntegrationFixture; |
| 225 | const fixture = yield* makeIntegrationFixture(); |
| 202 | 226 | const { join } = yield* Path.Path; |
| 203 | 227 | const { writeFileString } = yield* FileSystem.FileSystem; |
| 204 | 228 | |
| ... | ... | @@ -250,7 +274,7 @@ it.live("runs multi-turn tool/approval flow", () => |
| 250 | 274 | |
| 251 | 275 | it.live("rolls back provider conversation state only", () => |
| 252 | 276 | Effect.gen(function* () { |
| 253 | | const fixture = yield* makeIntegrationFixture; |
| 277 | const fixture = yield* makeIntegrationFixture(); |
| 254 | 278 | const { join } = yield* Path.Path; |
| 255 | 279 | const { writeFileString, readFileString } = yield* FileSystem.FileSystem; |
| 256 | 280 | |
| ... | ... | @@ -302,3 +326,59 @@ it.live("rolls back provider conversation state only", () => |
| 302 | 326 | }).pipe(Effect.provide(fixture.layer)); |
| 303 | 327 | }).pipe(Effect.provide(NodeServices.layer)), |
| 304 | 328 | ); |
| 329 | |
| 330 | it.live("reports runtime mode per turn and on mode transitions", () => |
| 331 | Effect.gen(function* () { |
| 332 | const analytics = yield* makeRecordingAnalytics; |
| 333 | const fixture = yield* makeIntegrationFixture({ analytics: analytics.layer }); |
| 334 | const threadId = ThreadId.make("thread-integration-runtime-mode"); |
| 335 | |
| 336 | yield* Effect.gen(function* () { |
| 337 | const provider = yield* ProviderService; |
| 338 | const startSession = (runtimeMode: "approval-required" | "full-access") => |
| 339 | provider.startSession(threadId, { |
| 340 | threadId, |
| 341 | provider: ProviderDriverKind.make("codex"), |
| 342 | providerInstanceId: codexInstanceId, |
| 343 | cwd: fixture.cwd, |
| 344 | runtimeMode, |
| 345 | }); |
| 346 | |
| 347 | yield* startSession("approval-required"); |
| 348 | yield* runTurn({ |
| 349 | provider, |
| 350 | harness: fixture.harness, |
| 351 | threadId, |
| 352 | userText: "supervised turn", |
| 353 | response: { events: codexTurnTextFixture }, |
| 354 | }); |
| 355 | |
| 356 | // Toggling the mode restarts the session, which is the only place the |
| 357 | // transition is observable. |
| 358 | yield* startSession("full-access"); |
| 359 | yield* runTurn({ |
| 360 | provider, |
| 361 | harness: fixture.harness, |
| 362 | threadId, |
| 363 | userText: "full access turn", |
| 364 | response: { events: codexTurnTextFixture }, |
| 365 | }); |
| 366 | |
| 367 | const recorded = yield* analytics.get; |
| 368 | |
| 369 | assert.deepEqual( |
| 370 | recorded |
| 371 | .filter((entry) => entry.event === "provider.turn.sent") |
| 372 | .map((entry) => entry.properties?.runtimeMode), |
| 373 | ["approval-required", "full-access"], |
| 374 | ); |
| 375 | |
| 376 | assert.deepEqual( |
| 377 | recorded |
| 378 | .filter((entry) => entry.event === "provider.runtime_mode.changed") |
| 379 | .map((entry) => [entry.properties?.from, entry.properties?.to]), |
| 380 | [["approval-required", "full-access"]], |
| 381 | ); |
| 382 | }).pipe(Effect.provide(fixture.layer)); |
| 383 | }).pipe(Effect.provide(NodeServices.layer)), |
| 384 | ); |