| author | |
| committer | |
| log | 1542b00db21f7586354adcc2ffd4f1e2a20bd02b |
| tree | f703fe82747ac87c57f27eb8a93b12cee64ff625 |
| parent | f79f8bac8072f4c634144cca2b7380a3020f7fec |
- Preserve active turn state when `session.started`/`thread.started` arrive mid-turn
- Emit `message.delta` from assistant text when stream deltas are missing, then complete the message
- Add Claude native SDK NDJSON observability logging and wire its log path in server layers
- Expand ingestion/adapter tests to cover mid-turn lifecycle and delta fallback behavior5 files changed, 489 insertions(+), 28 deletions(-)
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+61| ... | ... | @@ -225,6 +225,67 @@ describe("ProviderRuntimeIngestion", () => { |
| 225 | 225 | expect(thread.session?.lastError).toBe("turn failed"); |
| 226 | 226 | }); |
| 227 | 227 | |
| 228 | it("does not clear active turn when session/thread started arrives mid-turn", async () => { | |
| 229 | const harness = await createHarness(); | |
| 230 | const now = new Date().toISOString(); | |
| 231 | ||
| 232 | harness.emit({ | |
| 233 | type: "turn.started", | |
| 234 | eventId: asEventId("evt-turn-started-midturn-lifecycle"), | |
| 235 | provider: "codex", | |
| 236 | sessionId: asSessionId("sess-1"), | |
| 237 | createdAt: now, | |
| 238 | threadId: asProviderThreadId("provider-thread-1"), | |
| 239 | turnId: asProviderTurnId("turn-midturn-lifecycle"), | |
| 240 | }); | |
| 241 | ||
| 242 | await waitForThread( | |
| 243 | harness.engine, | |
| 244 | (thread) => | |
| 245 | thread.session?.status === "running" && | |
| 246 | thread.session?.activeTurnId === "turn-midturn-lifecycle", | |
| 247 | ); | |
| 248 | ||
| 249 | harness.emit({ | |
| 250 | type: "thread.started", | |
| 251 | eventId: asEventId("evt-thread-started-midturn-lifecycle"), | |
| 252 | provider: "codex", | |
| 253 | sessionId: asSessionId("sess-1"), | |
| 254 | createdAt: new Date().toISOString(), | |
| 255 | threadId: asProviderThreadId("provider-thread-1"), | |
| 256 | }); | |
| 257 | harness.emit({ | |
| 258 | type: "session.started", | |
| 259 | eventId: asEventId("evt-session-started-midturn-lifecycle"), | |
| 260 | provider: "codex", | |
| 261 | sessionId: asSessionId("sess-1"), | |
| 262 | createdAt: new Date().toISOString(), | |
| 263 | threadId: asProviderThreadId("provider-thread-1"), | |
| 264 | }); | |
| 265 | ||
| 266 | await Effect.runPromise(Effect.sleep("40 millis")); | |
| 267 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 268 | const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 269 | expect(midThread?.session?.status).toBe("running"); | |
| 270 | expect(midThread?.session?.activeTurnId).toBe("turn-midturn-lifecycle"); | |
| 271 | ||
| 272 | harness.emit({ | |
| 273 | type: "turn.completed", | |
| 274 | eventId: asEventId("evt-turn-completed-midturn-lifecycle"), | |
| 275 | provider: "codex", | |
| 276 | sessionId: asSessionId("sess-1"), | |
| 277 | createdAt: new Date().toISOString(), | |
| 278 | threadId: asProviderThreadId("provider-thread-1"), | |
| 279 | turnId: asProviderTurnId("turn-midturn-lifecycle"), | |
| 280 | status: "completed", | |
| 281 | }); | |
| 282 | ||
| 283 | await waitForThread( | |
| 284 | harness.engine, | |
| 285 | (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, | |
| 286 | ); | |
| 287 | }); | |
| 288 | ||
| 228 | 289 | it("ignores auxiliary turn completions from a different provider thread", async () => { |
| 229 | 290 | const harness = await createHarness(); |
| 230 | 291 | const now = new Date().toISOString(); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+23-11| ... | ... | @@ -407,8 +407,8 @@ const make = Effect.gen(function* () { |
| 407 | 407 | if (activeTurnId !== null && eventTurnId !== undefined) { |
| 408 | 408 | return sameId(activeTurnId, eventTurnId); |
| 409 | 409 | } |
| 410 | // Without an active turn, only accept completion when no thread mismatch signal exists. | |
| 411 | return eventProviderThreadId === null || scopedSessionProviderThreadId === null; | |
| 410 | // If no active turn is tracked, accept completion scoped to this thread. | |
| 411 | return true; | |
| 412 | 412 | default: |
| 413 | 413 | return true; |
| 414 | 414 | } |
| ... | ... | @@ -421,7 +421,12 @@ const make = Effect.gen(function* () { |
| 421 | 421 | event.type === "turn.started" || |
| 422 | 422 | event.type === "turn.completed" |
| 423 | 423 | ) { |
| 424 | const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) : null; | |
| 424 | const nextActiveTurnId = | |
| 425 | event.type === "turn.started" | |
| 426 | ? (eventTurnId ?? null) | |
| 427 | : event.type === "turn.completed" || event.type === "session.exited" | |
| 428 | ? null | |
| 429 | : activeTurnId; | |
| 425 | 430 | const providerThreadIdFromEvent = |
| 426 | 431 | event.type === "thread.started" |
| 427 | 432 | ? ProviderThreadId.makeUnsafe(event.threadId) |
| ... | ... | @@ -430,14 +435,21 @@ const make = Effect.gen(function* () { |
| 430 | 435 | : null; |
| 431 | 436 | const providerThreadId = |
| 432 | 437 | providerThreadIdFromEvent ?? scopedSessionProviderThreadId ?? null; |
| 433 | const status = | |
| 434 | event.type === "turn.started" | |
| 435 | ? "running" | |
| 436 | : event.type === "session.exited" | |
| 437 | ? "stopped" | |
| 438 | : event.type === "turn.completed" && event.status === "failed" | |
| 439 | ? "error" | |
| 440 | : "ready"; | |
| 438 | const status = (() => { | |
| 439 | switch (event.type) { | |
| 440 | case "turn.started": | |
| 441 | return "running"; | |
| 442 | case "session.exited": | |
| 443 | return "stopped"; | |
| 444 | case "turn.completed": | |
| 445 | return event.status === "failed" ? "error" : "ready"; | |
| 446 | case "session.started": | |
| 447 | case "thread.started": | |
| 448 | // Provider thread/session start notifications can arrive during an | |
| 449 | // active turn; preserve turn-running state in that case. | |
| 450 | return activeTurnId !== null ? "running" : "ready"; | |
| 451 | } | |
| 452 | })(); | |
| 441 | 453 | const lastError = |
| 442 | 454 | event.type === "turn.completed" && event.status === "failed" |
| 443 | 455 | ? (event.errorMessage ?? thread.session?.lastError ?? "Turn failed") |
apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts+248-3| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 1 | 5 | import type { |
| 2 | 6 | Options as ClaudeQueryOptions, |
| 3 | 7 | PermissionMode, |
| ... | ... | @@ -109,7 +113,7 @@ interface Harness { |
| 109 | 113 | | undefined; |
| 110 | 114 | } |
| 111 | 115 | |
| 112 | function makeHarness(): Harness { | |
| 116 | function makeHarness(config?: { readonly nativeEventLogPath?: string }): Harness { | |
| 113 | 117 | const query = new FakeClaudeQuery(); |
| 114 | 118 | let createInput: |
| 115 | 119 | | { |
| ... | ... | @@ -118,15 +122,20 @@ function makeHarness(): Harness { |
| 118 | 122 | } |
| 119 | 123 | | undefined; |
| 120 | 124 | |
| 121 | const options: ClaudeCodeAdapterLiveOptions = { | |
| 125 | const adapterOptions: ClaudeCodeAdapterLiveOptions = { | |
| 122 | 126 | createQuery: (input) => { |
| 123 | 127 | createInput = input; |
| 124 | 128 | return query; |
| 125 | 129 | }, |
| 130 | ...(config?.nativeEventLogPath | |
| 131 | ? { | |
| 132 | nativeEventLogPath: config.nativeEventLogPath, | |
| 133 | } | |
| 134 | : {}), | |
| 126 | 135 | }; |
| 127 | 136 | |
| 128 | 137 | return { |
| 129 | layer: makeClaudeCodeAdapterLive(options), | |
| 138 | layer: makeClaudeCodeAdapterLive(adapterOptions), | |
| 130 | 139 | query, |
| 131 | 140 | getLastCreateQueryInput: () => createInput, |
| 132 | 141 | }; |
| ... | ... | @@ -305,6 +314,155 @@ describe("ClaudeCodeAdapterLive", () => { |
| 305 | 314 | ); |
| 306 | 315 | }); |
| 307 | 316 | |
| 317 | it.effect("emits completion only after turn result when assistant frames arrive before deltas", () => { | |
| 318 | const harness = makeHarness(); | |
| 319 | return Effect.gen(function* () { | |
| 320 | const adapter = yield* ClaudeCodeAdapter; | |
| 321 | ||
| 322 | const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( | |
| 323 | Stream.runCollect, | |
| 324 | Effect.forkChild, | |
| 325 | ); | |
| 326 | ||
| 327 | const session = yield* adapter.startSession({ | |
| 328 | provider: "claudeCode", | |
| 329 | }); | |
| 330 | ||
| 331 | const turn = yield* adapter.sendTurn({ | |
| 332 | sessionId: session.sessionId, | |
| 333 | input: "hello", | |
| 334 | attachments: [], | |
| 335 | }); | |
| 336 | ||
| 337 | harness.query.emit({ | |
| 338 | type: "assistant", | |
| 339 | session_id: "sdk-session-early-assistant", | |
| 340 | uuid: "assistant-early", | |
| 341 | parent_tool_use_id: null, | |
| 342 | message: { | |
| 343 | id: "assistant-message-early", | |
| 344 | content: [{ type: "tool_use", id: "tool-early", name: "Read", input: { path: "a.ts" } }], | |
| 345 | }, | |
| 346 | } as unknown as SDKMessage); | |
| 347 | ||
| 348 | harness.query.emit({ | |
| 349 | type: "stream_event", | |
| 350 | session_id: "sdk-session-early-assistant", | |
| 351 | uuid: "stream-early", | |
| 352 | parent_tool_use_id: null, | |
| 353 | event: { | |
| 354 | type: "content_block_delta", | |
| 355 | index: 0, | |
| 356 | delta: { | |
| 357 | type: "text_delta", | |
| 358 | text: "Late text", | |
| 359 | }, | |
| 360 | }, | |
| 361 | } as unknown as SDKMessage); | |
| 362 | ||
| 363 | harness.query.emit({ | |
| 364 | type: "result", | |
| 365 | subtype: "success", | |
| 366 | is_error: false, | |
| 367 | errors: [], | |
| 368 | session_id: "sdk-session-early-assistant", | |
| 369 | uuid: "result-early", | |
| 370 | } as unknown as SDKMessage); | |
| 371 | ||
| 372 | const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); | |
| 373 | assert.deepEqual( | |
| 374 | runtimeEvents.map((event) => event.type), | |
| 375 | [ | |
| 376 | "session.started", | |
| 377 | "turn.started", | |
| 378 | "thread.started", | |
| 379 | "message.delta", | |
| 380 | "message.completed", | |
| 381 | "turn.completed", | |
| 382 | ], | |
| 383 | ); | |
| 384 | ||
| 385 | const deltaIndex = runtimeEvents.findIndex((event) => event.type === "message.delta"); | |
| 386 | const completedIndex = runtimeEvents.findIndex((event) => event.type === "message.completed"); | |
| 387 | assert.equal(deltaIndex >= 0 && completedIndex >= 0 && deltaIndex < completedIndex, true); | |
| 388 | ||
| 389 | const deltaEvent = runtimeEvents[deltaIndex]; | |
| 390 | assert.equal(deltaEvent?.type, "message.delta"); | |
| 391 | if (deltaEvent?.type === "message.delta") { | |
| 392 | assert.equal(deltaEvent.delta, "Late text"); | |
| 393 | assert.equal(deltaEvent.turnId, turn.turnId); | |
| 394 | } | |
| 395 | }).pipe( | |
| 396 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 397 | Effect.provide(harness.layer), | |
| 398 | ); | |
| 399 | }); | |
| 400 | ||
| 401 | it.effect("falls back to assistant payload text when stream deltas are absent", () => { | |
| 402 | const harness = makeHarness(); | |
| 403 | return Effect.gen(function* () { | |
| 404 | const adapter = yield* ClaudeCodeAdapter; | |
| 405 | ||
| 406 | const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( | |
| 407 | Stream.runCollect, | |
| 408 | Effect.forkChild, | |
| 409 | ); | |
| 410 | ||
| 411 | const session = yield* adapter.startSession({ | |
| 412 | provider: "claudeCode", | |
| 413 | }); | |
| 414 | ||
| 415 | const turn = yield* adapter.sendTurn({ | |
| 416 | sessionId: session.sessionId, | |
| 417 | input: "hello", | |
| 418 | attachments: [], | |
| 419 | }); | |
| 420 | ||
| 421 | harness.query.emit({ | |
| 422 | type: "assistant", | |
| 423 | session_id: "sdk-session-fallback-text", | |
| 424 | uuid: "assistant-fallback", | |
| 425 | parent_tool_use_id: null, | |
| 426 | message: { | |
| 427 | id: "assistant-message-fallback", | |
| 428 | content: [{ type: "text", text: "Fallback hello" }], | |
| 429 | }, | |
| 430 | } as unknown as SDKMessage); | |
| 431 | ||
| 432 | harness.query.emit({ | |
| 433 | type: "result", | |
| 434 | subtype: "success", | |
| 435 | is_error: false, | |
| 436 | errors: [], | |
| 437 | session_id: "sdk-session-fallback-text", | |
| 438 | uuid: "result-fallback", | |
| 439 | } as unknown as SDKMessage); | |
| 440 | ||
| 441 | const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); | |
| 442 | assert.deepEqual( | |
| 443 | runtimeEvents.map((event) => event.type), | |
| 444 | [ | |
| 445 | "session.started", | |
| 446 | "turn.started", | |
| 447 | "thread.started", | |
| 448 | "message.delta", | |
| 449 | "message.completed", | |
| 450 | "turn.completed", | |
| 451 | ], | |
| 452 | ); | |
| 453 | ||
| 454 | const deltaEvent = runtimeEvents.find((event) => event.type === "message.delta"); | |
| 455 | assert.equal(deltaEvent?.type, "message.delta"); | |
| 456 | if (deltaEvent?.type === "message.delta") { | |
| 457 | assert.equal(deltaEvent.delta, "Fallback hello"); | |
| 458 | assert.equal(deltaEvent.turnId, turn.turnId); | |
| 459 | } | |
| 460 | }).pipe( | |
| 461 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 462 | Effect.provide(harness.layer), | |
| 463 | ); | |
| 464 | }); | |
| 465 | ||
| 308 | 466 | it.effect("bridges approval request/response lifecycle through canUseTool", () => { |
| 309 | 467 | const harness = makeHarness(); |
| 310 | 468 | return Effect.gen(function* () { |
| ... | ... | @@ -527,4 +685,91 @@ describe("ClaudeCodeAdapterLive", () => { |
| 527 | 685 | Effect.provide(harness.layer), |
| 528 | 686 | ); |
| 529 | 687 | }); |
| 688 | ||
| 689 | it.effect("writes provider-native observability records when enabled", () => { | |
| 690 | const nativeLogDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-native-events-")); | |
| 691 | const nativeEventLogPath = path.join(nativeLogDir, "provider-native.ndjson"); | |
| 692 | const harness = makeHarness({ nativeEventLogPath }); | |
| 693 | return Effect.gen(function* () { | |
| 694 | const adapter = yield* ClaudeCodeAdapter; | |
| 695 | ||
| 696 | const session = yield* adapter.startSession({ | |
| 697 | provider: "claudeCode", | |
| 698 | }); | |
| 699 | const turn = yield* adapter.sendTurn({ | |
| 700 | sessionId: session.sessionId, | |
| 701 | input: "hello", | |
| 702 | attachments: [], | |
| 703 | }); | |
| 704 | ||
| 705 | const turnCompletedFiber = yield* Stream.filter( | |
| 706 | adapter.streamEvents, | |
| 707 | (event) => event.type === "turn.completed", | |
| 708 | ).pipe(Stream.runHead, Effect.forkChild); | |
| 709 | ||
| 710 | harness.query.emit({ | |
| 711 | type: "stream_event", | |
| 712 | session_id: "sdk-session-native-log", | |
| 713 | uuid: "stream-native-log", | |
| 714 | parent_tool_use_id: null, | |
| 715 | event: { | |
| 716 | type: "content_block_delta", | |
| 717 | index: 0, | |
| 718 | delta: { | |
| 719 | type: "text_delta", | |
| 720 | text: "hi", | |
| 721 | }, | |
| 722 | }, | |
| 723 | } as unknown as SDKMessage); | |
| 724 | ||
| 725 | harness.query.emit({ | |
| 726 | type: "result", | |
| 727 | subtype: "success", | |
| 728 | is_error: false, | |
| 729 | errors: [], | |
| 730 | session_id: "sdk-session-native-log", | |
| 731 | uuid: "result-native-log", | |
| 732 | } as unknown as SDKMessage); | |
| 733 | ||
| 734 | const turnCompleted = yield* Fiber.join(turnCompletedFiber); | |
| 735 | assert.equal(turnCompleted._tag, "Some"); | |
| 736 | ||
| 737 | const lines = fs | |
| 738 | .readFileSync(nativeEventLogPath, "utf8") | |
| 739 | .split("\n") | |
| 740 | .map((line) => line.trim()) | |
| 741 | .filter((line) => line.length > 0); | |
| 742 | assert.equal(lines.length > 0, true); | |
| 743 | ||
| 744 | const records = lines.map((line) => JSON.parse(line) as { | |
| 745 | event?: { | |
| 746 | provider?: string; | |
| 747 | method?: string; | |
| 748 | sessionId?: string; | |
| 749 | turnId?: string; | |
| 750 | }; | |
| 751 | }); | |
| 752 | ||
| 753 | assert.equal(records.some((record) => record.event?.provider === "claudeCode"), true); | |
| 754 | assert.equal(records.some((record) => record.event?.sessionId === session.sessionId), true); | |
| 755 | assert.equal(records.some((record) => record.event?.turnId === turn.turnId), true); | |
| 756 | assert.equal( | |
| 757 | records.some( | |
| 758 | (record) => record.event?.method === "claude/stream_event/content_block_delta/text_delta", | |
| 759 | ), | |
| 760 | true, | |
| 761 | ); | |
| 762 | }).pipe( | |
| 763 | Effect.ensuring( | |
| 764 | Effect.sync(() => { | |
| 765 | fs.rmSync(nativeLogDir, { | |
| 766 | recursive: true, | |
| 767 | force: true, | |
| 768 | }); | |
| 769 | }), | |
| 770 | ), | |
| 771 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 772 | Effect.provide(harness.layer), | |
| 773 | ); | |
| 774 | }); | |
| 530 | 775 | }); |
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+154-13| ... | ... | @@ -42,6 +42,7 @@ import { |
| 42 | 42 | type ProviderAdapterError, |
| 43 | 43 | } from "../Errors.ts"; |
| 44 | 44 | import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; |
| 45 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 45 | 46 | |
| 46 | 47 | const PROVIDER = "claudeCode" as const; |
| 47 | 48 | |
| ... | ... | @@ -67,6 +68,8 @@ interface ClaudeTurnState { |
| 67 | 68 | readonly startedAt: string; |
| 68 | 69 | readonly items: Array<unknown>; |
| 69 | 70 | readonly messageCompleted: boolean; |
| 71 | readonly emittedTextDelta: boolean; | |
| 72 | readonly fallbackAssistantText: string; | |
| 70 | 73 | } |
| 71 | 74 | |
| 72 | 75 | interface PendingApproval { |
| ... | ... | @@ -114,6 +117,7 @@ export interface ClaudeCodeAdapterLiveOptions { |
| 114 | 117 | readonly prompt: AsyncIterable<SDKUserMessage>; |
| 115 | 118 | readonly options: ClaudeQueryOptions; |
| 116 | 119 | }) => ClaudeQueryRuntime; |
| 120 | readonly nativeEventLogPath?: string; | |
| 117 | 121 | } |
| 118 | 122 | |
| 119 | 123 | function isUuid(value: string): boolean { |
| ... | ... | @@ -272,6 +276,30 @@ function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStat |
| 272 | 276 | return "failed"; |
| 273 | 277 | } |
| 274 | 278 | |
| 279 | function extractAssistantText(message: SDKMessage): string { | |
| 280 | if (message.type !== "assistant") { | |
| 281 | return ""; | |
| 282 | } | |
| 283 | ||
| 284 | const content = (message.message as { content?: unknown } | undefined)?.content; | |
| 285 | if (!Array.isArray(content)) { | |
| 286 | return ""; | |
| 287 | } | |
| 288 | ||
| 289 | const fragments: string[] = []; | |
| 290 | for (const block of content) { | |
| 291 | if (!block || typeof block !== "object") { | |
| 292 | continue; | |
| 293 | } | |
| 294 | const candidate = block as { type?: unknown; text?: unknown }; | |
| 295 | if (candidate.type === "text" && typeof candidate.text === "string" && candidate.text.length > 0) { | |
| 296 | fragments.push(candidate.text); | |
| 297 | } | |
| 298 | } | |
| 299 | ||
| 300 | return fragments.join(""); | |
| 301 | } | |
| 302 | ||
| 275 | 303 | function toSessionError( |
| 276 | 304 | sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>, |
| 277 | 305 | cause: unknown, |
| ... | ... | @@ -311,8 +339,72 @@ function toRequestError( |
| 311 | 339 | }); |
| 312 | 340 | } |
| 313 | 341 | |
| 342 | function sdkMessageType(value: unknown): string | undefined { | |
| 343 | if (!value || typeof value !== "object") { | |
| 344 | return undefined; | |
| 345 | } | |
| 346 | const record = value as { type?: unknown }; | |
| 347 | return typeof record.type === "string" ? record.type : undefined; | |
| 348 | } | |
| 349 | ||
| 350 | function sdkMessageSubtype(value: unknown): string | undefined { | |
| 351 | if (!value || typeof value !== "object") { | |
| 352 | return undefined; | |
| 353 | } | |
| 354 | const record = value as { subtype?: unknown }; | |
| 355 | return typeof record.subtype === "string" ? record.subtype : undefined; | |
| 356 | } | |
| 357 | ||
| 358 | function sdkNativeMethod(message: SDKMessage): string { | |
| 359 | const subtype = sdkMessageSubtype(message); | |
| 360 | if (subtype) { | |
| 361 | return `claude/${message.type}/${subtype}`; | |
| 362 | } | |
| 363 | ||
| 364 | if (message.type === "stream_event") { | |
| 365 | const streamType = sdkMessageType(message.event); | |
| 366 | if (streamType) { | |
| 367 | const deltaType = | |
| 368 | streamType === "content_block_delta" ? sdkMessageType((message.event as { delta?: unknown }).delta) : undefined; | |
| 369 | if (deltaType) { | |
| 370 | return `claude/${message.type}/${streamType}/${deltaType}`; | |
| 371 | } | |
| 372 | return `claude/${message.type}/${streamType}`; | |
| 373 | } | |
| 374 | } | |
| 375 | ||
| 376 | return `claude/${message.type}`; | |
| 377 | } | |
| 378 | ||
| 379 | function sdkNativeItemId(message: SDKMessage): string | undefined { | |
| 380 | if (message.type === "assistant") { | |
| 381 | const maybeId = (message.message as { id?: unknown }).id; | |
| 382 | if (typeof maybeId === "string") { | |
| 383 | return maybeId; | |
| 384 | } | |
| 385 | return undefined; | |
| 386 | } | |
| 387 | ||
| 388 | if (message.type === "stream_event") { | |
| 389 | const event = message.event as { | |
| 390 | type?: unknown; | |
| 391 | content_block?: { id?: unknown }; | |
| 392 | }; | |
| 393 | if (event.type === "content_block_start" && typeof event.content_block?.id === "string") { | |
| 394 | return event.content_block.id; | |
| 395 | } | |
| 396 | } | |
| 397 | ||
| 398 | return undefined; | |
| 399 | } | |
| 400 | ||
| 314 | 401 | function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 315 | 402 | return Effect.gen(function* () { |
| 403 | const nativeEventLogger = | |
| 404 | options?.nativeEventLogPath !== undefined | |
| 405 | ? makeEventNdjsonLogger(options.nativeEventLogPath) | |
| 406 | : undefined; | |
| 407 | ||
| 316 | 408 | const createQuery = |
| 317 | 409 | options?.createQuery ?? |
| 318 | 410 | ((input: { |
| ... | ... | @@ -330,6 +422,38 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 330 | 422 | const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 331 | 423 | Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); |
| 332 | 424 | |
| 425 | const logNativeSdkMessage = ( | |
| 426 | context: ClaudeSessionContext, | |
| 427 | message: SDKMessage, | |
| 428 | ): Effect.Effect<void> => | |
| 429 | Effect.sync(() => { | |
| 430 | if (!nativeEventLogger) { | |
| 431 | return; | |
| 432 | } | |
| 433 | ||
| 434 | const observedAt = new Date().toISOString(); | |
| 435 | const itemId = sdkNativeItemId(message); | |
| 436 | ||
| 437 | nativeEventLogger.write({ | |
| 438 | observedAt, | |
| 439 | event: { | |
| 440 | id: | |
| 441 | "uuid" in message && typeof message.uuid === "string" | |
| 442 | ? message.uuid | |
| 443 | : crypto.randomUUID(), | |
| 444 | kind: "notification", | |
| 445 | provider: PROVIDER, | |
| 446 | sessionId: context.session.sessionId, | |
| 447 | createdAt: observedAt, | |
| 448 | method: sdkNativeMethod(message), | |
| 449 | ...(typeof message.session_id === "string" ? { threadId: message.session_id } : {}), | |
| 450 | ...(context.turnState ? { turnId: context.turnState.turnId } : {}), | |
| 451 | ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), | |
| 452 | payload: message, | |
| 453 | }, | |
| 454 | }); | |
| 455 | }); | |
| 456 | ||
| 333 | 457 | const snapshotThread = ( |
| 334 | 458 | context: ClaudeSessionContext, |
| 335 | 459 | ): Effect.Effect<{ |
| ... | ... | @@ -450,6 +574,21 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 450 | 574 | } |
| 451 | 575 | |
| 452 | 576 | if (!turnState.messageCompleted) { |
| 577 | if (!turnState.emittedTextDelta && turnState.fallbackAssistantText.length > 0) { | |
| 578 | const deltaStamp = yield* makeEventStamp(); | |
| 579 | yield* offerRuntimeEvent({ | |
| 580 | type: "message.delta", | |
| 581 | eventId: deltaStamp.eventId, | |
| 582 | provider: PROVIDER, | |
| 583 | sessionId: context.session.sessionId, | |
| 584 | createdAt: deltaStamp.createdAt, | |
| 585 | ...(context.session.threadId ? { threadId: context.session.threadId } : {}), | |
| 586 | turnId: turnState.turnId, | |
| 587 | itemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), | |
| 588 | delta: turnState.fallbackAssistantText, | |
| 589 | }); | |
| 590 | } | |
| 591 | ||
| 453 | 592 | const stamp = yield* makeEventStamp(); |
| 454 | 593 | yield* offerRuntimeEvent({ |
| 455 | 594 | type: "message.completed", |
| ... | ... | @@ -510,6 +649,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 510 | 649 | event.delta.text.length > 0 && |
| 511 | 650 | context.turnState |
| 512 | 651 | ) { |
| 652 | if (!context.turnState.emittedTextDelta) { | |
| 653 | context.turnState = { | |
| 654 | ...context.turnState, | |
| 655 | emittedTextDelta: true, | |
| 656 | }; | |
| 657 | } | |
| 513 | 658 | const stamp = yield* makeEventStamp(); |
| 514 | 659 | yield* offerRuntimeEvent({ |
| 515 | 660 | type: "message.delta", |
| ... | ... | @@ -606,22 +751,15 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 606 | 751 | |
| 607 | 752 | if (context.turnState) { |
| 608 | 753 | context.turnState.items.push(message.message); |
| 609 | if (!context.turnState.messageCompleted) { | |
| 754 | const fallbackAssistantText = extractAssistantText(message); | |
| 755 | if ( | |
| 756 | fallbackAssistantText.length > 0 && | |
| 757 | fallbackAssistantText !== context.turnState.fallbackAssistantText | |
| 758 | ) { | |
| 610 | 759 | context.turnState = { |
| 611 | 760 | ...context.turnState, |
| 612 | messageCompleted: true, | |
| 761 | fallbackAssistantText, | |
| 613 | 762 | }; |
| 614 | const stamp = yield* makeEventStamp(); | |
| 615 | yield* offerRuntimeEvent({ | |
| 616 | type: "message.completed", | |
| 617 | eventId: stamp.eventId, | |
| 618 | provider: PROVIDER, | |
| 619 | sessionId: context.session.sessionId, | |
| 620 | createdAt: stamp.createdAt, | |
| 621 | itemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), | |
| 622 | ...(context.session.threadId ? { threadId: context.session.threadId } : {}), | |
| 623 | turnId: context.turnState.turnId, | |
| 624 | }); | |
| 625 | 763 | } |
| 626 | 764 | } |
| 627 | 765 | |
| ... | ... | @@ -653,6 +791,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 653 | 791 | message: SDKMessage, |
| 654 | 792 | ): Effect.Effect<void> => |
| 655 | 793 | Effect.gen(function* () { |
| 794 | yield* logNativeSdkMessage(context, message); | |
| 656 | 795 | yield* ensureThreadId(context, message); |
| 657 | 796 | |
| 658 | 797 | switch (message.type) { |
| ... | ... | @@ -1014,6 +1153,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1014 | 1153 | startedAt: yield* nowIso, |
| 1015 | 1154 | items: [], |
| 1016 | 1155 | messageCompleted: false, |
| 1156 | emittedTextDelta: false, | |
| 1157 | fallbackAssistantText: "", | |
| 1017 | 1158 | }; |
| 1018 | 1159 | |
| 1019 | 1160 | const updatedAt = yield* nowIso; |
apps/server/src/serverLayers.ts+3-1| ... | ... | @@ -57,7 +57,9 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 57 | 57 | const codexAdapterLayer = makeCodexAdapterLive( |
| 58 | 58 | nativeEventLogger ? { nativeEventLogger } : undefined, |
| 59 | 59 | ); |
| 60 | const claudeAdapterLayer = makeClaudeCodeAdapterLive(); | |
| 60 | const claudeAdapterLayer = makeClaudeCodeAdapterLive({ | |
| 61 | nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), | |
| 62 | }); | |
| 61 | 63 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( |
| 62 | 64 | Layer.provide(Layer.mergeAll(codexAdapterLayer, claudeAdapterLayer)), |
| 63 | 65 | Layer.provideMerge(providerSessionDirectoryLayer), |