| ... | @@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Path from "effect/Path"; | 9 | import * as Path from "effect/Path"; |
| 10 | import * as Queue from "effect/Queue"; | 10 | import * as Queue from "effect/Queue"; |
| | 11 | import * as Ref from "effect/Ref"; |
| 11 | import * as Stream from "effect/Stream"; | 12 | import * as Stream from "effect/Stream"; |
| 12 | | 13 | |
| 13 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; | 14 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; |
| ... | @@ -54,35 +55,58 @@ interface IntegrationFixture { | ... | @@ -54,35 +55,58 @@ interface IntegrationFixture { |
| 54 | readonly layer: Layer.Layer<ProviderService, unknown, never>; | 55 | readonly layer: Layer.Layer<ProviderService, unknown, never>; |
| 55 | } | 56 | } |
| 56 | | 57 | |
| 57 | const makeIntegrationFixture = Effect.gen(function* () { | 58 | interface RecordedAnalyticsEvent { |
| 58 | const cwd = yield* makeWorkspaceDirectory; | 59 | readonly event: string; |
| 59 | const harness = yield* makeTestProviderAdapterHarness(); | 60 | readonly properties: Readonly<Record<string, unknown>> | undefined; |
| 60 | | 61 | } |
| 61 | const registry = makeAdapterRegistryMock({ | | |
| 62 | [ProviderDriverKind.make("codex")]: harness.adapter, | | |
| 63 | }); | | |
| 64 | | 62 | |
| 65 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | 63 | /** |
| 66 | Layer.provide(ProviderSessionRuntime.layer), | 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 | | 77 | return { layer, get: Ref.get(recorded) } as const; |
| 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; | | |
| 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 | const collectEventsDuring = <A, E, R>( | 110 | const collectEventsDuring = <A, E, R>( |
| 87 | stream: Stream.Stream<ProviderRuntimeEvent>, | 111 | stream: Stream.Stream<ProviderRuntimeEvent>, |
| 88 | count: number, | 112 | count: number, |
| ... | @@ -126,7 +150,7 @@ const runTurn = (input: { | ... | @@ -126,7 +150,7 @@ const runTurn = (input: { |
| 126 | | 150 | |
| 127 | it.live("replays typed runtime fixture events", () => | 151 | it.live("replays typed runtime fixture events", () => |
| 128 | Effect.gen(function* () { | 152 | Effect.gen(function* () { |
| 129 | const fixture = yield* makeIntegrationFixture; | 153 | const fixture = yield* makeIntegrationFixture(); |
| 130 | | 154 | |
| 131 | yield* Effect.gen(function* () { | 155 | yield* Effect.gen(function* () { |
| 132 | const provider = yield* ProviderService; | 156 | const provider = yield* ProviderService; |
| ... | @@ -161,7 +185,7 @@ it.live("replays typed runtime fixture events", () => | ... | @@ -161,7 +185,7 @@ it.live("replays typed runtime fixture events", () => |
| 161 | | 185 | |
| 162 | it.live("replays file-changing fixture turn events", () => | 186 | it.live("replays file-changing fixture turn events", () => |
| 163 | Effect.gen(function* () { | 187 | Effect.gen(function* () { |
| 164 | const fixture = yield* makeIntegrationFixture; | 188 | const fixture = yield* makeIntegrationFixture(); |
| 165 | const { join } = yield* Path.Path; | 189 | const { join } = yield* Path.Path; |
| 166 | const { writeFileString } = yield* FileSystem.FileSystem; | 190 | const { writeFileString } = yield* FileSystem.FileSystem; |
| 167 | | 191 | |
| ... | @@ -198,7 +222,7 @@ it.live("replays file-changing fixture turn events", () => | ... | @@ -198,7 +222,7 @@ it.live("replays file-changing fixture turn events", () => |
| 198 | | 222 | |
| 199 | it.live("runs multi-turn tool/approval flow", () => | 223 | it.live("runs multi-turn tool/approval flow", () => |
| 200 | Effect.gen(function* () { | 224 | Effect.gen(function* () { |
| 201 | const fixture = yield* makeIntegrationFixture; | 225 | const fixture = yield* makeIntegrationFixture(); |
| 202 | const { join } = yield* Path.Path; | 226 | const { join } = yield* Path.Path; |
| 203 | const { writeFileString } = yield* FileSystem.FileSystem; | 227 | const { writeFileString } = yield* FileSystem.FileSystem; |
| 204 | | 228 | |
| ... | @@ -250,7 +274,7 @@ it.live("runs multi-turn tool/approval flow", () => | ... | @@ -250,7 +274,7 @@ it.live("runs multi-turn tool/approval flow", () => |
| 250 | | 274 | |
| 251 | it.live("rolls back provider conversation state only", () => | 275 | it.live("rolls back provider conversation state only", () => |
| 252 | Effect.gen(function* () { | 276 | Effect.gen(function* () { |
| 253 | const fixture = yield* makeIntegrationFixture; | 277 | const fixture = yield* makeIntegrationFixture(); |
| 254 | const { join } = yield* Path.Path; | 278 | const { join } = yield* Path.Path; |
| 255 | const { writeFileString, readFileString } = yield* FileSystem.FileSystem; | 279 | const { writeFileString, readFileString } = yield* FileSystem.FileSystem; |
| 256 | | 280 | |
| ... | @@ -302,3 +326,59 @@ it.live("rolls back provider conversation state only", () => | ... | @@ -302,3 +326,59 @@ it.live("rolls back provider conversation state only", () => |
| 302 | }).pipe(Effect.provide(fixture.layer)); | 326 | }).pipe(Effect.provide(fixture.layer)); |
| 303 | }).pipe(Effect.provide(NodeServices.layer)), | 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 | ); |