| author | |
| committer | |
| log | 344e04b62351d005fbf973c1f6384fc162878db1 |
| tree | d0076e52357b1f5f0701078c7ec64a4651bdd736 |
| parent | 7b71e234e0c7d9e937cad940a6a2474d060ca3c6 |
- allow Claude/Cursor adapters to accept an injected native event logger
- make native event writes effectful/non-blocking so logging cannot break message handling
- update adapter tests to assert native observability events via in-memory loggers
- fix runtime session ID conversion when resolving canonical event thread IDs5 files changed, 149 insertions(+), 91 deletions(-)
apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts+35-36| ... | ... | @@ -1,7 +1,3 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | 1 | import type { |
| 6 | 2 | Options as ClaudeQueryOptions, |
| 7 | 3 | PermissionMode, |
| ... | ... | @@ -113,7 +109,10 @@ interface Harness { |
| 113 | 109 | | undefined; |
| 114 | 110 | } |
| 115 | 111 | |
| 116 | function makeHarness(config?: { readonly nativeEventLogPath?: string }): Harness { | |
| 112 | function makeHarness(config?: { | |
| 113 | readonly nativeEventLogPath?: string; | |
| 114 | readonly nativeEventLogger?: ClaudeCodeAdapterLiveOptions["nativeEventLogger"]; | |
| 115 | }): Harness { | |
| 117 | 116 | const query = new FakeClaudeQuery(); |
| 118 | 117 | let createInput: |
| 119 | 118 | | { |
| ... | ... | @@ -127,6 +126,11 @@ function makeHarness(config?: { readonly nativeEventLogPath?: string }): Harness |
| 127 | 126 | createInput = input; |
| 128 | 127 | return query; |
| 129 | 128 | }, |
| 129 | ...(config?.nativeEventLogger | |
| 130 | ? { | |
| 131 | nativeEventLogger: config.nativeEventLogger, | |
| 132 | } | |
| 133 | : {}), | |
| 130 | 134 | ...(config?.nativeEventLogPath |
| 131 | 135 | ? { |
| 132 | 136 | nativeEventLogPath: config.nativeEventLogPath, |
| ... | ... | @@ -815,9 +819,24 @@ describe("ClaudeCodeAdapterLive", () => { |
| 815 | 819 | }); |
| 816 | 820 | |
| 817 | 821 | it.effect("writes provider-native observability records when enabled", () => { |
| 818 | const nativeLogDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-native-events-")); | |
| 819 | const nativeEventLogPath = path.join(nativeLogDir, "provider-native.ndjson"); | |
| 820 | const harness = makeHarness({ nativeEventLogPath }); | |
| 822 | const nativeEvents: Array<{ | |
| 823 | event?: { | |
| 824 | provider?: string; | |
| 825 | method?: string; | |
| 826 | sessionId?: string; | |
| 827 | turnId?: string; | |
| 828 | }; | |
| 829 | }> = []; | |
| 830 | const harness = makeHarness({ | |
| 831 | nativeEventLogger: { | |
| 832 | filePath: "memory://claude-native-events", | |
| 833 | write: (event) => { | |
| 834 | nativeEvents.push(event as (typeof nativeEvents)[number]); | |
| 835 | return Effect.void; | |
| 836 | }, | |
| 837 | close: () => Effect.void, | |
| 838 | }, | |
| 839 | }); | |
| 821 | 840 | return Effect.gen(function* () { |
| 822 | 841 | const adapter = yield* ClaudeCodeAdapter; |
| 823 | 842 | |
| ... | ... | @@ -862,40 +881,20 @@ describe("ClaudeCodeAdapterLive", () => { |
| 862 | 881 | const turnCompleted = yield* Fiber.join(turnCompletedFiber); |
| 863 | 882 | assert.equal(turnCompleted._tag, "Some"); |
| 864 | 883 | |
| 865 | const lines = fs | |
| 866 | .readFileSync(nativeEventLogPath, "utf8") | |
| 867 | .split("\n") | |
| 868 | .map((line) => line.trim()) | |
| 869 | .filter((line) => line.length > 0); | |
| 870 | assert.equal(lines.length > 0, true); | |
| 871 | ||
| 872 | const records = lines.map((line) => JSON.parse(line) as { | |
| 873 | event?: { | |
| 874 | provider?: string; | |
| 875 | method?: string; | |
| 876 | sessionId?: string; | |
| 877 | turnId?: string; | |
| 878 | }; | |
| 879 | }); | |
| 880 | ||
| 881 | assert.equal(records.some((record) => record.event?.provider === "claudeCode"), true); | |
| 882 | assert.equal(records.some((record) => record.event?.sessionId === session.sessionId), true); | |
| 883 | assert.equal(records.some((record) => String(record.event?.turnId) === String(turn.turnId)), true); | |
| 884 | assert.equal(nativeEvents.length > 0, true); | |
| 885 | assert.equal(nativeEvents.some((record) => record.event?.provider === "claudeCode"), true); | |
| 886 | assert.equal(nativeEvents.some((record) => record.event?.sessionId === session.sessionId), true); | |
| 887 | assert.equal( | |
| 888 | nativeEvents.some((record) => String(record.event?.turnId) === String(turn.turnId)), | |
| 889 | true, | |
| 890 | ); | |
| 884 | 891 | assert.equal( |
| 885 | records.some( | |
| 892 | nativeEvents.some( | |
| 886 | 893 | (record) => record.event?.method === "claude/stream_event/content_block_delta/text_delta", |
| 887 | 894 | ), |
| 888 | 895 | true, |
| 889 | 896 | ); |
| 890 | 897 | }).pipe( |
| 891 | Effect.ensuring( | |
| 892 | Effect.sync(() => { | |
| 893 | fs.rmSync(nativeLogDir, { | |
| 894 | recursive: true, | |
| 895 | force: true, | |
| 896 | }); | |
| 897 | }), | |
| 898 | ), | |
| 899 | 898 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 900 | 899 | Effect.provide(harness.layer), |
| 901 | 900 | ); |
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+31-23| ... | ... | @@ -48,7 +48,7 @@ import { |
| 48 | 48 | type ProviderAdapterError, |
| 49 | 49 | } from "../Errors.ts"; |
| 50 | 50 | import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; |
| 51 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 51 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 52 | 52 | |
| 53 | 53 | const PROVIDER = "claudeCode" as const; |
| 54 | 54 | |
| ... | ... | @@ -125,6 +125,7 @@ export interface ClaudeCodeAdapterLiveOptions { |
| 125 | 125 | readonly options: ClaudeQueryOptions; |
| 126 | 126 | }) => ClaudeQueryRuntime; |
| 127 | 127 | readonly nativeEventLogPath?: string; |
| 128 | readonly nativeEventLogger?: EventNdjsonLogger; | |
| 128 | 129 | } |
| 129 | 130 | |
| 130 | 131 | function isUuid(value: string): boolean { |
| ... | ... | @@ -453,9 +454,12 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { |
| 453 | 454 | function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 454 | 455 | return Effect.gen(function* () { |
| 455 | 456 | const nativeEventLogger = |
| 456 | options?.nativeEventLogPath !== undefined | |
| 457 | ? makeEventNdjsonLogger(options.nativeEventLogPath) | |
| 458 | : undefined; | |
| 457 | options?.nativeEventLogger ?? | |
| 458 | (options?.nativeEventLogPath !== undefined | |
| 459 | ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { | |
| 460 | stream: "native", | |
| 461 | }) | |
| 462 | : undefined); | |
| 459 | 463 | |
| 460 | 464 | const createQuery = |
| 461 | 465 | options?.createQuery ?? |
| ... | ... | @@ -478,7 +482,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 478 | 482 | context: ClaudeSessionContext, |
| 479 | 483 | message: SDKMessage, |
| 480 | 484 | ): Effect.Effect<void> => |
| 481 | Effect.sync(() => { | |
| 485 | Effect.gen(function* () { | |
| 482 | 486 | if (!nativeEventLogger) { |
| 483 | 487 | return; |
| 484 | 488 | } |
| ... | ... | @@ -486,24 +490,28 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 486 | 490 | const observedAt = new Date().toISOString(); |
| 487 | 491 | const itemId = sdkNativeItemId(message); |
| 488 | 492 | |
| 489 | nativeEventLogger.write({ | |
| 490 | observedAt, | |
| 491 | event: { | |
| 492 | id: | |
| 493 | "uuid" in message && typeof message.uuid === "string" | |
| 494 | ? message.uuid | |
| 495 | : crypto.randomUUID(), | |
| 496 | kind: "notification", | |
| 497 | provider: PROVIDER, | |
| 498 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 499 | createdAt: observedAt, | |
| 500 | method: sdkNativeMethod(message), | |
| 501 | ...(typeof message.session_id === "string" ? { threadId: message.session_id } : {}), | |
| 502 | ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), | |
| 503 | ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), | |
| 504 | payload: message, | |
| 505 | }, | |
| 506 | }); | |
| 493 | yield* nativeEventLogger | |
| 494 | .write( | |
| 495 | { | |
| 496 | observedAt, | |
| 497 | event: { | |
| 498 | id: | |
| 499 | "uuid" in message && typeof message.uuid === "string" | |
| 500 | ? message.uuid | |
| 501 | : crypto.randomUUID(), | |
| 502 | kind: "notification", | |
| 503 | provider: PROVIDER, | |
| 504 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 505 | createdAt: observedAt, | |
| 506 | method: sdkNativeMethod(message), | |
| 507 | ...(typeof message.session_id === "string" ? { threadId: message.session_id } : {}), | |
| 508 | ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), | |
| 509 | ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), | |
| 510 | payload: message, | |
| 511 | }, | |
| 512 | }, | |
| 513 | null, | |
| 514 | ); | |
| 507 | 515 | }); |
| 508 | 516 | |
| 509 | 517 | const snapshotThread = ( |
apps/server/src/provider/Layers/CursorAdapter.test.ts+35| ... | ... | @@ -359,6 +359,41 @@ describe("CursorAdapterLive", () => { |
| 359 | 359 | }).pipe(Effect.provide(layer)); |
| 360 | 360 | }); |
| 361 | 361 | |
| 362 | it.effect("writes provider-native observability records when enabled", () => { | |
| 363 | const nativeEvents: Array<{ | |
| 364 | event?: { | |
| 365 | provider?: string; | |
| 366 | method?: string; | |
| 367 | sessionId?: string; | |
| 368 | }; | |
| 369 | }> = []; | |
| 370 | const fake = new FakeCursorAcpProcess(); | |
| 371 | const layer = makeCursorAdapterLive({ | |
| 372 | createProcess: () => fake as never, | |
| 373 | nativeEventLogger: { | |
| 374 | filePath: "memory://cursor-native-events", | |
| 375 | write: (event) => { | |
| 376 | nativeEvents.push(event as (typeof nativeEvents)[number]); | |
| 377 | return Effect.void; | |
| 378 | }, | |
| 379 | close: () => Effect.void, | |
| 380 | }, | |
| 381 | }); | |
| 382 | ||
| 383 | return Effect.gen(function* () { | |
| 384 | const adapter = yield* CursorAdapter; | |
| 385 | const session = yield* adapter.startSession({ | |
| 386 | provider: "cursor", | |
| 387 | cwd: "/tmp/project", | |
| 388 | }); | |
| 389 | ||
| 390 | assert.equal(nativeEvents.length > 0, true); | |
| 391 | assert.equal(nativeEvents.some((record) => record.event?.provider === "cursor"), true); | |
| 392 | assert.equal(nativeEvents.some((record) => record.event?.sessionId === session.sessionId), true); | |
| 393 | assert.equal(nativeEvents.some((record) => record.event?.method === "cursor/acp/response"), true); | |
| 394 | }).pipe(Effect.provide(layer)); | |
| 395 | }); | |
| 396 | ||
| 362 | 397 | it.effect("resumes ACP session using resumeCursor.acpSessionId", () => { |
| 363 | 398 | const fake = new FakeCursorAcpProcess(); |
| 364 | 399 | const layer = makeCursorAdapterLive({ |
apps/server/src/provider/Layers/CursorAdapter.ts+45-31| ... | ... | @@ -48,7 +48,7 @@ import { |
| 48 | 48 | CursorAcpSessionPromptResult, |
| 49 | 49 | CursorAcpSessionUpdateNotification, |
| 50 | 50 | } from "../Services/CursorAdapter.ts"; |
| 51 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 51 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 52 | 52 | |
| 53 | 53 | const PROVIDER = "cursor" as const; |
| 54 | 54 | const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; |
| ... | ... | @@ -103,6 +103,7 @@ export interface CursorAdapterLiveOptions { |
| 103 | 103 | readonly model?: string; |
| 104 | 104 | }) => ChildProcessWithoutNullStreams; |
| 105 | 105 | readonly nativeEventLogPath?: string; |
| 106 | readonly nativeEventLogger?: EventNdjsonLogger; | |
| 106 | 107 | } |
| 107 | 108 | |
| 108 | 109 | function toMessage(cause: unknown, fallback: string): string { |
| ... | ... | @@ -323,9 +324,12 @@ function writeCursorMessage(context: CursorSessionContext, message: unknown): vo |
| 323 | 324 | function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 324 | 325 | return Effect.gen(function* () { |
| 325 | 326 | const nativeEventLogger = |
| 326 | options?.nativeEventLogPath !== undefined | |
| 327 | ? makeEventNdjsonLogger(options.nativeEventLogPath) | |
| 328 | : undefined; | |
| 327 | options?.nativeEventLogger ?? | |
| 328 | (options?.nativeEventLogPath !== undefined | |
| 329 | ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { | |
| 330 | stream: "native", | |
| 331 | }) | |
| 332 | : undefined); | |
| 329 | 333 | |
| 330 | 334 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 331 | 335 | const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); |
| ... | ... | @@ -822,33 +826,43 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 822 | 826 | } |
| 823 | 827 | |
| 824 | 828 | if (nativeEventLogger) { |
| 825 | const nativeMethod = | |
| 826 | typeof message.method === "string" | |
| 827 | ? message.method | |
| 828 | : typeof message.id === "string" || typeof message.id === "number" | |
| 829 | ? "cursor/acp/response" | |
| 830 | : "cursor/acp/message"; | |
| 831 | const nativeKind = | |
| 832 | typeof message.method === "string" && | |
| 833 | (typeof message.id === "string" || typeof message.id === "number") | |
| 834 | ? "request" | |
| 835 | : typeof message.method === "string" | |
| 836 | ? "notification" | |
| 837 | : "session"; | |
| 838 | nativeEventLogger.write({ | |
| 839 | observedAt: new Date().toISOString(), | |
| 840 | event: { | |
| 841 | id: EventId.makeUnsafe(randomUUID()), | |
| 842 | kind: nativeKind, | |
| 843 | provider: PROVIDER, | |
| 844 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 845 | createdAt: new Date().toISOString(), | |
| 846 | method: nativeMethod, | |
| 847 | ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), | |
| 848 | ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), | |
| 849 | payload: message, | |
| 850 | }, | |
| 851 | }); | |
| 829 | try { | |
| 830 | const nativeMethod = | |
| 831 | typeof message.method === "string" | |
| 832 | ? message.method | |
| 833 | : typeof message.id === "string" || typeof message.id === "number" | |
| 834 | ? "cursor/acp/response" | |
| 835 | : "cursor/acp/message"; | |
| 836 | const nativeKind = | |
| 837 | typeof message.method === "string" && | |
| 838 | (typeof message.id === "string" || typeof message.id === "number") | |
| 839 | ? "request" | |
| 840 | : typeof message.method === "string" | |
| 841 | ? "notification" | |
| 842 | : "session"; | |
| 843 | Effect.runFork( | |
| 844 | nativeEventLogger | |
| 845 | .write( | |
| 846 | { | |
| 847 | observedAt: new Date().toISOString(), | |
| 848 | event: { | |
| 849 | id: EventId.makeUnsafe(randomUUID()), | |
| 850 | kind: nativeKind, | |
| 851 | provider: PROVIDER, | |
| 852 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 853 | createdAt: new Date().toISOString(), | |
| 854 | method: nativeMethod, | |
| 855 | ...(context.session.threadId ? { threadId: context.session.threadId } : {}), | |
| 856 | ...(context.turnState ? { turnId: String(context.turnState.turnId) } : {}), | |
| 857 | payload: message, | |
| 858 | }, | |
| 859 | }, | |
| 860 | null, | |
| 861 | ), | |
| 862 | ); | |
| 863 | } catch { | |
| 864 | // Native logging must never block or break protocol handling. | |
| 865 | } | |
| 852 | 866 | } |
| 853 | 867 | |
| 854 | 868 | if ( |
apps/server/src/provider/Layers/ProviderService.ts+3-1| ... | ... | @@ -126,6 +126,8 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 126 | 126 | ); |
| 127 | 127 | const asRuntimeSessionId = (sessionId: ProviderSessionId): RuntimeSessionId => |
| 128 | 128 | RuntimeSessionId.makeUnsafe(sessionId); |
| 129 | const asProviderSessionId = (sessionId: RuntimeSessionId): ProviderSessionId => | |
| 130 | ProviderSessionId.makeUnsafe(sessionId); | |
| 129 | 131 | |
| 130 | 132 | const canonicalizeRuntimeEventSession = ( |
| 131 | 133 | event: ProviderRuntimeEvent, |
| ... | ... | @@ -150,7 +152,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 150 | 152 | canonicalEventLogger |
| 151 | 153 | ? Effect.flatMap( |
| 152 | 154 | Effect.catch( |
| 153 | directory.getThreadId(canonicalEvent.sessionId).pipe( | |
| 155 | directory.getThreadId(asProviderSessionId(canonicalEvent.sessionId)).pipe( | |
| 154 | 156 | Effect.map((threadIdOption) => |
| 155 | 157 | Option.isSome(threadIdOption) ? threadIdOption.value : null, |
| 156 | 158 | ), |