| 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,7 +1,3 @@ |
| 1 | import fs from "node:fs"; | ||
| 2 | import os from "node:os"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | import type { | 1 | import 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 | } |
| 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 | 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?.nativeEventLogPath | 134 | ...(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 | }); |
| 816 | 820 | ||
| 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; |
| 823 | 842 | ||
| ... | @@ -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"); |
| 864 | 883 | ||
| 865 | const lines = fs | 884 | 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, |
| 871 | 890 | ); | |
| 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"; |
| 50 | import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; | 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 | const PROVIDER = "claudeCode" as const; | 53 | const PROVIDER = "claudeCode" as const; |
| 54 | 54 | ||
| ... | @@ -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 | } |
| 129 | 130 | ||
| 130 | function isUuid(value: string): boolean { | 131 | function isUuid(value: string): boolean { |
| ... | @@ -453,9 +454,12 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { | ... | @@ -453,9 +454,12 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { |
| 453 | function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { | 454 | function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 454 | return Effect.gen(function* () { | 455 | return Effect.gen(function* () { |
| 455 | const nativeEventLogger = | 456 | const nativeEventLogger = |
| 456 | options?.nativeEventLogPath !== undefined | 457 | options?.nativeEventLogger ?? |
| 457 | ? makeEventNdjsonLogger(options.nativeEventLogPath) | 458 | (options?.nativeEventLogPath !== undefined |
| 458 | : undefined; | 459 | ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { |
| 460 | stream: "native", | ||
| 461 | }) | ||
| 462 | : undefined); | ||
| 459 | 463 | ||
| 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); |
| 488 | 492 | ||
| 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.uuid | 498 | 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 | }); |
| 508 | 516 | ||
| 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 | }); |
| 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 | 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"; |
| 51 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 51 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 52 | 52 | ||
| 53 | const PROVIDER = "cursor" as const; | 53 | const PROVIDER = "cursor" as const; |
| 54 | const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; | 54 | const 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 | } |
| 107 | 108 | ||
| 108 | function toMessage(cause: unknown, fallback: string): string { | 109 | function 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 |
| 323 | function makeCursorAdapter(options?: CursorAdapterLiveOptions) { | 324 | function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 324 | return Effect.gen(function* () { | 325 | return Effect.gen(function* () { |
| 325 | const nativeEventLogger = | 326 | const nativeEventLogger = |
| 326 | options?.nativeEventLogPath !== undefined | 327 | options?.nativeEventLogger ?? |
| 327 | ? makeEventNdjsonLogger(options.nativeEventLogPath) | 328 | (options?.nativeEventLogPath !== undefined |
| 328 | : undefined; | 329 | ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { |
| 330 | stream: "native", | ||
| 331 | }) | ||
| 332 | : undefined); | ||
| 329 | 333 | ||
| 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 | } |
| 823 | 827 | ||
| 824 | if (nativeEventLogger) { | 828 | if (nativeEventLogger) { |
| 825 | const nativeMethod = | 829 | try { |
| 826 | typeof message.method === "string" | 830 | const nativeMethod = |
| 827 | ? message.method | 831 | 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 | } |
| 853 | 867 | ||
| 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); | ||
| 129 | 131 | ||
| 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 | canonicalEventLogger | 152 | 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 | ), |