authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 19:03:27-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log344e04b62351d005fbf973c1f6384fc162878db1
treed0076e52357b1f5f0701078c7ec64a4651bdd736
parent7b71e234e0c7d9e937cad940a6a2474d060ca3c6

Inject native event loggers into provider adapters

- 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 IDs

5 files changed, 149 insertions(+), 91 deletions(-)

apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts+35-36
...@@ -1,7 +1,3 @@...@@ -1,7 +1,3 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import type {1import type {
6 Options as ClaudeQueryOptions,2 Options as ClaudeQueryOptions,
7 PermissionMode,3 PermissionMode,
...@@ -113,7 +109,10 @@ interface Harness {...@@ -113,7 +109,10 @@ interface Harness {
113 | undefined;109 | undefined;
114}110}
115111
116function makeHarness(config?: { readonly nativeEventLogPath?: string }): Harness {112function makeHarness(config?: {
113 readonly nativeEventLogPath?: string;
114 readonly nativeEventLogger?: ClaudeCodeAdapterLiveOptions["nativeEventLogger"];
115}): Harness {
117 const query = new FakeClaudeQuery();116 const query = new FakeClaudeQuery();
118 let createInput:117 let createInput:
119 | {118 | {
...@@ -127,6 +126,11 @@ function makeHarness(config?: { readonly nativeEventLogPath?: string }): Harness...@@ -127,6 +126,11 @@ function makeHarness(config?: { readonly nativeEventLogPath?: string }): Harness
127 createInput = input;126 createInput = input;
128 return query;127 return query;
129 },128 },
129 ...(config?.nativeEventLogger
130 ? {
131 nativeEventLogger: config.nativeEventLogger,
132 }
133 : {}),
130 ...(config?.nativeEventLogPath134 ...(config?.nativeEventLogPath
131 ? {135 ? {
132 nativeEventLogPath: config.nativeEventLogPath,136 nativeEventLogPath: config.nativeEventLogPath,
...@@ -815,9 +819,24 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -815,9 +819,24 @@ describe("ClaudeCodeAdapterLive", () => {
815 });819 });
816820
817 it.effect("writes provider-native observability records when enabled", () => {821 it.effect("writes provider-native observability records when enabled", () => {
818 const nativeLogDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-native-events-"));822 const nativeEvents: Array<{
819 const nativeEventLogPath = path.join(nativeLogDir, "provider-native.ndjson");823 event?: {
820 const harness = makeHarness({ nativeEventLogPath });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 return Effect.gen(function* () {840 return Effect.gen(function* () {
822 const adapter = yield* ClaudeCodeAdapter;841 const adapter = yield* ClaudeCodeAdapter;
823842
...@@ -862,40 +881,20 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -862,40 +881,20 @@ describe("ClaudeCodeAdapterLive", () => {
862 const turnCompleted = yield* Fiber.join(turnCompletedFiber);881 const turnCompleted = yield* Fiber.join(turnCompletedFiber);
863 assert.equal(turnCompleted._tag, "Some");882 assert.equal(turnCompleted._tag, "Some");
864883
865 const lines = fs884 assert.equal(nativeEvents.length > 0, true);
866 .readFileSync(nativeEventLogPath, "utf8")885 assert.equal(nativeEvents.some((record) => record.event?.provider === "claudeCode"), true);
867 .split("\n")886 assert.equal(nativeEvents.some((record) => record.event?.sessionId === session.sessionId), true);
868 .map((line) => line.trim())887 assert.equal(
869 .filter((line) => line.length > 0);888 nativeEvents.some((record) => String(record.event?.turnId) === String(turn.turnId)),
870 assert.equal(lines.length > 0, true);889 true,
871890 );
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(891 assert.equal(
885 records.some(892 nativeEvents.some(
886 (record) => record.event?.method === "claude/stream_event/content_block_delta/text_delta",893 (record) => record.event?.method === "claude/stream_event/content_block_delta/text_delta",
887 ),894 ),
888 true,895 true,
889 );896 );
890 }).pipe(897 }).pipe(
891 Effect.ensuring(
892 Effect.sync(() => {
893 fs.rmSync(nativeLogDir, {
894 recursive: true,
895 force: true,
896 });
897 }),
898 ),
899 Effect.provideService(Random.Random, makeDeterministicRandomService()),898 Effect.provideService(Random.Random, makeDeterministicRandomService()),
900 Effect.provide(harness.layer),899 Effect.provide(harness.layer),
901 );900 );
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+31-23
...@@ -48,7 +48,7 @@ import {...@@ -48,7 +48,7 @@ import {
48 type ProviderAdapterError,48 type ProviderAdapterError,
49} from "../Errors.ts";49} from "../Errors.ts";
50import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts";50import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts";
51import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";51import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
5252
53const PROVIDER = "claudeCode" as const;53const PROVIDER = "claudeCode" as const;
5454
...@@ -125,6 +125,7 @@ export interface ClaudeCodeAdapterLiveOptions {...@@ -125,6 +125,7 @@ export interface ClaudeCodeAdapterLiveOptions {
125 readonly options: ClaudeQueryOptions;125 readonly options: ClaudeQueryOptions;
126 }) => ClaudeQueryRuntime;126 }) => ClaudeQueryRuntime;
127 readonly nativeEventLogPath?: string;127 readonly nativeEventLogPath?: string;
128 readonly nativeEventLogger?: EventNdjsonLogger;
128}129}
129130
130function isUuid(value: string): boolean {131function isUuid(value: string): boolean {
...@@ -453,9 +454,12 @@ function sdkNativeItemId(message: SDKMessage): string | undefined {...@@ -453,9 +454,12 @@ function sdkNativeItemId(message: SDKMessage): string | undefined {
453function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {454function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
454 return Effect.gen(function* () {455 return Effect.gen(function* () {
455 const nativeEventLogger =456 const nativeEventLogger =
456 options?.nativeEventLogPath !== undefined457 options?.nativeEventLogger ??
457 ? makeEventNdjsonLogger(options.nativeEventLogPath)458 (options?.nativeEventLogPath !== undefined
458 : undefined;459 ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
460 stream: "native",
461 })
462 : undefined);
459463
460 const createQuery =464 const createQuery =
461 options?.createQuery ??465 options?.createQuery ??
...@@ -478,7 +482,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -478,7 +482,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
478 context: ClaudeSessionContext,482 context: ClaudeSessionContext,
479 message: SDKMessage,483 message: SDKMessage,
480 ): Effect.Effect<void> =>484 ): Effect.Effect<void> =>
481 Effect.sync(() => {485 Effect.gen(function* () {
482 if (!nativeEventLogger) {486 if (!nativeEventLogger) {
483 return;487 return;
484 }488 }
...@@ -486,24 +490,28 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -486,24 +490,28 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
486 const observedAt = new Date().toISOString();490 const observedAt = new Date().toISOString();
487 const itemId = sdkNativeItemId(message);491 const itemId = sdkNativeItemId(message);
488492
489 nativeEventLogger.write({493 yield* nativeEventLogger
490 observedAt,494 .write(
491 event: {495 {
492 id:496 observedAt,
493 "uuid" in message && typeof message.uuid === "string"497 event: {
494 ? message.uuid498 id:
495 : crypto.randomUUID(),499 "uuid" in message && typeof message.uuid === "string"
496 kind: "notification",500 ? message.uuid
497 provider: PROVIDER,501 : crypto.randomUUID(),
498 sessionId: asRuntimeSessionId(context.session.sessionId),502 kind: "notification",
499 createdAt: observedAt,503 provider: PROVIDER,
500 method: sdkNativeMethod(message),504 sessionId: asRuntimeSessionId(context.session.sessionId),
501 ...(typeof message.session_id === "string" ? { threadId: message.session_id } : {}),505 createdAt: observedAt,
502 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),506 method: sdkNativeMethod(message),
503 ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}),507 ...(typeof message.session_id === "string" ? { threadId: message.session_id } : {}),
504 payload: message,508 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
505 },509 ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}),
506 });510 payload: message,
511 },
512 },
513 null,
514 );
507 });515 });
508516
509 const snapshotThread = (517 const snapshotThread = (
apps/server/src/provider/Layers/CursorAdapter.test.ts+35
...@@ -359,6 +359,41 @@ describe("CursorAdapterLive", () => {...@@ -359,6 +359,41 @@ describe("CursorAdapterLive", () => {
359 }).pipe(Effect.provide(layer));359 }).pipe(Effect.provide(layer));
360 });360 });
361361
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 it.effect("resumes ACP session using resumeCursor.acpSessionId", () => {397 it.effect("resumes ACP session using resumeCursor.acpSessionId", () => {
363 const fake = new FakeCursorAcpProcess();398 const fake = new FakeCursorAcpProcess();
364 const layer = makeCursorAdapterLive({399 const layer = makeCursorAdapterLive({
apps/server/src/provider/Layers/CursorAdapter.ts+45-31
...@@ -48,7 +48,7 @@ import {...@@ -48,7 +48,7 @@ import {
48 CursorAcpSessionPromptResult,48 CursorAcpSessionPromptResult,
49 CursorAcpSessionUpdateNotification,49 CursorAcpSessionUpdateNotification,
50} from "../Services/CursorAdapter.ts";50} from "../Services/CursorAdapter.ts";
51import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";51import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
5252
53const PROVIDER = "cursor" as const;53const PROVIDER = "cursor" as const;
54const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;54const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
...@@ -103,6 +103,7 @@ export interface CursorAdapterLiveOptions {...@@ -103,6 +103,7 @@ export interface CursorAdapterLiveOptions {
103 readonly model?: string;103 readonly model?: string;
104 }) => ChildProcessWithoutNullStreams;104 }) => ChildProcessWithoutNullStreams;
105 readonly nativeEventLogPath?: string;105 readonly nativeEventLogPath?: string;
106 readonly nativeEventLogger?: EventNdjsonLogger;
106}107}
107108
108function toMessage(cause: unknown, fallback: string): string {109function toMessage(cause: unknown, fallback: string): string {
...@@ -323,9 +324,12 @@ function writeCursorMessage(context: CursorSessionContext, message: unknown): vo...@@ -323,9 +324,12 @@ function writeCursorMessage(context: CursorSessionContext, message: unknown): vo
323function makeCursorAdapter(options?: CursorAdapterLiveOptions) {324function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
324 return Effect.gen(function* () {325 return Effect.gen(function* () {
325 const nativeEventLogger =326 const nativeEventLogger =
326 options?.nativeEventLogPath !== undefined327 options?.nativeEventLogger ??
327 ? makeEventNdjsonLogger(options.nativeEventLogPath)328 (options?.nativeEventLogPath !== undefined
328 : undefined;329 ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
330 stream: "native",
331 })
332 : undefined);
329333
330 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);334 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
331 const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id));335 const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id));
...@@ -822,33 +826,43 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -822,33 +826,43 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
822 }826 }
823827
824 if (nativeEventLogger) {828 if (nativeEventLogger) {
825 const nativeMethod =829 try {
826 typeof message.method === "string"830 const nativeMethod =
827 ? message.method831 typeof message.method === "string"
828 : typeof message.id === "string" || typeof message.id === "number"832 ? message.method
829 ? "cursor/acp/response"833 : typeof message.id === "string" || typeof message.id === "number"
830 : "cursor/acp/message";834 ? "cursor/acp/response"
831 const nativeKind =835 : "cursor/acp/message";
832 typeof message.method === "string" &&836 const nativeKind =
833 (typeof message.id === "string" || typeof message.id === "number")837 typeof message.method === "string" &&
834 ? "request"838 (typeof message.id === "string" || typeof message.id === "number")
835 : typeof message.method === "string"839 ? "request"
836 ? "notification"840 : typeof message.method === "string"
837 : "session";841 ? "notification"
838 nativeEventLogger.write({842 : "session";
839 observedAt: new Date().toISOString(),843 Effect.runFork(
840 event: {844 nativeEventLogger
841 id: EventId.makeUnsafe(randomUUID()),845 .write(
842 kind: nativeKind,846 {
843 provider: PROVIDER,847 observedAt: new Date().toISOString(),
844 sessionId: asRuntimeSessionId(context.session.sessionId),848 event: {
845 createdAt: new Date().toISOString(),849 id: EventId.makeUnsafe(randomUUID()),
846 method: nativeMethod,850 kind: nativeKind,
847 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),851 provider: PROVIDER,
848 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),852 sessionId: asRuntimeSessionId(context.session.sessionId),
849 payload: message,853 createdAt: new Date().toISOString(),
850 },854 method: nativeMethod,
851 });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 }
853867
854 if (868 if (
apps/server/src/provider/Layers/ProviderService.ts+3-1
...@@ -126,6 +126,8 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -126,6 +126,8 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
126 );126 );
127 const asRuntimeSessionId = (sessionId: ProviderSessionId): RuntimeSessionId =>127 const asRuntimeSessionId = (sessionId: ProviderSessionId): RuntimeSessionId =>
128 RuntimeSessionId.makeUnsafe(sessionId);128 RuntimeSessionId.makeUnsafe(sessionId);
129 const asProviderSessionId = (sessionId: RuntimeSessionId): ProviderSessionId =>
130 ProviderSessionId.makeUnsafe(sessionId);
129131
130 const canonicalizeRuntimeEventSession = (132 const canonicalizeRuntimeEventSession = (
131 event: ProviderRuntimeEvent,133 event: ProviderRuntimeEvent,
...@@ -150,7 +152,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -150,7 +152,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
150 canonicalEventLogger152 canonicalEventLogger
151 ? Effect.flatMap(153 ? Effect.flatMap(
152 Effect.catch(154 Effect.catch(
153 directory.getThreadId(canonicalEvent.sessionId).pipe(155 directory.getThreadId(asProviderSessionId(canonicalEvent.sessionId)).pipe(
154 Effect.map((threadIdOption) =>156 Effect.map((threadIdOption) =>
155 Option.isSome(threadIdOption) ? threadIdOption.value : null,157 Option.isSome(threadIdOption) ? threadIdOption.value : null,
156 ),158 ),