authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:22:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:22:57-07:00
log5ca7676661325b13ee2a557b28f0d0d3ce8ab695
tree8c7b0848320fed54f1be378d516461f378726d5b
parentc3e3e26844a591f2bb21e617db3c5494e700fbcc
signature Signed by PGP key B5690EEEBB952194

[codex] Structure Claude adapter failures (#3406)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 143 insertions(+), 68 deletions(-)

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+99-17
......@@ -35,7 +35,7 @@ import * as TestClock from "effect/testing/TestClock";
3535import { attachmentRelativePath } from "../../attachmentStore.ts";
3636import { ServerConfig } from "../../config.ts";
3737import { ServerSettingsService } from "../../serverSettings.ts";
38import { ProviderAdapterValidationError } from "../Errors.ts";
38import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts";
3939import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
4040import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
4141const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
......@@ -298,6 +298,44 @@ describe("ClaudeAdapterLive", () => {
298298 );
299299 });
300300
301 it.effect("retains Claude session startup causes without exposing their messages", () => {
302 const cause = new Error("credential material that must remain in the cause chain");
303 const layer = Layer.effect(
304 ClaudeAdapter,
305 Effect.gen(function* () {
306 const claudeConfig = decodeClaudeSettings({});
307 return yield* makeClaudeAdapter(claudeConfig, {
308 createQuery: () => {
309 throw cause;
310 },
311 });
312 }),
313 ).pipe(
314 Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")),
315 Layer.provideMerge(ServerSettingsService.layerTest()),
316 Layer.provideMerge(NodeServices.layer),
317 );
318
319 return Effect.gen(function* () {
320 const adapter = yield* ClaudeAdapter;
321 const error = yield* adapter
322 .startSession({
323 threadId: THREAD_ID,
324 provider: ProviderDriverKind.make("claudeAgent"),
325 runtimeMode: "full-access",
326 })
327 .pipe(Effect.flip);
328
329 assert.instanceOf(error, ProviderAdapterProcessError);
330 assert.equal(error.detail, "Failed to start Claude runtime session.");
331 assert.strictEqual(error.cause, cause);
332 assert.notMatch(error.message, /credential material/u);
333 }).pipe(
334 Effect.provideService(Random.Random, makeDeterministicRandomService()),
335 Effect.provide(layer),
336 );
337 });
338
301339 it.effect("derives bypass permission mode from full-access runtime policy", () => {
302340 const harness = makeHarness();
303341 return Effect.gen(function* () {
......@@ -1365,19 +1403,14 @@ describe("ClaudeAdapterLive", () => {
13651403 it.effect("closes the session when the Claude stream aborts after a turn starts", () => {
13661404 const harness = makeHarness();
13671405 return Effect.gen(function* () {
1368 const context = yield* Effect.context<never>();
1369 const runFork = Effect.runForkWith(context);
1370
13711406 const adapter = yield* ClaudeAdapter;
13721407 const runtimeEvents: Array<ProviderRuntimeEvent> = [];
13731408
1374 const runtimeEventsFiber = runFork(
1375 Stream.runForEach(adapter.streamEvents, (event) =>
1376 Effect.sync(() => {
1377 runtimeEvents.push(event);
1378 }),
1379 ),
1380 );
1409 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
1410 Effect.sync(() => {
1411 runtimeEvents.push(event);
1412 }),
1413 ).pipe(Effect.forkChild);
13811414
13821415 yield* adapter.startSession({
13831416 threadId: THREAD_ID,
......@@ -1430,6 +1463,57 @@ describe("ClaudeAdapterLive", () => {
14301463 );
14311464 });
14321465
1466 it.effect("keeps Claude stream failure events structural", () => {
1467 const harness = makeHarness();
1468 return Effect.gen(function* () {
1469 const adapter = yield* ClaudeAdapter;
1470 const runtimeEvents: Array<ProviderRuntimeEvent> = [];
1471 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
1472 Effect.sync(() => {
1473 runtimeEvents.push(event);
1474 }),
1475 ).pipe(Effect.forkChild);
1476
1477 yield* adapter.startSession({
1478 threadId: THREAD_ID,
1479 provider: ProviderDriverKind.make("claudeAgent"),
1480 runtimeMode: "full-access",
1481 });
1482 yield* adapter.sendTurn({
1483 threadId: THREAD_ID,
1484 input: "hello",
1485 attachments: [],
1486 });
1487
1488 harness.query.fail(new Error("credential material that must stay in the cause chain"));
1489
1490 yield* Effect.yieldNow;
1491 yield* Effect.yieldNow;
1492 yield* Effect.yieldNow;
1493 runtimeEventsFiber.interruptUnsafe();
1494
1495 const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error");
1496 assert.equal(runtimeError?.type, "runtime.error");
1497 if (runtimeError?.type === "runtime.error") {
1498 assert.equal(runtimeError.payload.message, "Claude runtime stream failed.");
1499 assert.deepEqual(runtimeError.payload.detail, {
1500 failureCount: 1,
1501 failureTags: ["ProviderAdapterProcessError"],
1502 });
1503 }
1504
1505 const completed = runtimeEvents.find((event) => event.type === "turn.completed");
1506 assert.equal(completed?.type, "turn.completed");
1507 if (completed?.type === "turn.completed") {
1508 assert.equal(completed.payload.state, "failed");
1509 assert.equal(completed.payload.errorMessage, "Claude runtime stream failed.");
1510 }
1511 }).pipe(
1512 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1513 Effect.provide(harness.layer),
1514 );
1515 });
1516
14331517 it.effect("closes the previous session before replacing an existing thread session", () => {
14341518 const queries: FakeClaudeQuery[] = [];
14351519 const layer = Layer.effect(
......@@ -1542,14 +1626,12 @@ describe("ClaudeAdapterLive", () => {
15421626 );
15431627
15441628 return Effect.gen(function* () {
1545 const context = yield* Effect.context<never>();
1546 const runFork = Effect.runForkWith(context);
1547
15481629 const adapter = yield* ClaudeAdapter;
15491630
1550 const runtimeEventsFiber = runFork(
1551 Stream.runForEach(adapter.streamEvents, () => Effect.void),
1552 );
1631 const runtimeEventsFiber = yield* Stream.runForEach(
1632 adapter.streamEvents,
1633 () => Effect.void,
1634 ).pipe(Effect.forkChild);
15531635
15541636 yield* adapter.startSession({
15551637 threadId: THREAD_ID,
apps/server/src/provider/Layers/ClaudeAdapter.ts+44-51
......@@ -249,21 +249,8 @@ function toMessage(cause: unknown, fallback: string): string {
249249 return fallback;
250250}
251251
252function toProcessError(
253 cause: unknown,
254 fallback: string,
255 threadId: ThreadId,
256): ProviderAdapterProcessError {
257 return new ProviderAdapterProcessError({
258 provider: PROVIDER,
259 threadId,
260 detail: toMessage(cause, fallback),
261 cause,
262 });
263}
264
265252function normalizeClaudeStreamMessages(
266 cause: Cause.Cause<{ readonly message: string }>,
253 cause: Cause.Cause<ProviderAdapterProcessError>,
267254): ReadonlyArray<string> {
268255 const errors: Array<string> = [];
269256 for (const error of Cause.prettyErrors(cause)) {
......@@ -297,27 +284,17 @@ function isClaudeInterruptedMessage(message: string): boolean {
297284 );
298285}
299286
300function isClaudeInterruptedCause(cause: Cause.Cause<{ readonly message: string }>): boolean {
287function isClaudeInterruptedCause(cause: Cause.Cause<ProviderAdapterProcessError>): boolean {
301288 return (
302289 Cause.hasInterruptsOnly(cause) ||
303 normalizeClaudeStreamMessages(cause).some(isClaudeInterruptedMessage)
290 normalizeClaudeStreamMessages(cause).some(isClaudeInterruptedMessage) ||
291 cause.reasons.some(
292 (reason) =>
293 Cause.isFailReason(reason) && isClaudeInterruptedMessage(toMessage(reason.error.cause, "")),
294 )
304295 );
305296}
306297
307function messageFromClaudeStreamCause(
308 cause: Cause.Cause<{ readonly message: string }>,
309 fallback: string,
310): string {
311 return normalizeClaudeStreamMessages(cause)[0] ?? fallback;
312}
313
314function interruptionMessageFromClaudeCause(
315 cause: Cause.Cause<{ readonly message: string }>,
316): string {
317 const message = messageFromClaudeStreamCause(cause, "Claude runtime interrupted.");
318 return isClaudeInterruptedMessage(message) ? "Claude runtime interrupted." : message;
319}
320
321298function resultErrorsText(result: SDKResultMessage): string {
322299 return "errors" in result && Array.isArray(result.errors)
323300 ? result.errors.join(" ").toLowerCase()
......@@ -1004,7 +981,7 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
1004981 new ProviderAdapterRequestError({
1005982 provider: PROVIDER,
1006983 method: "turn/start",
1007 detail: toMessage(cause, "Failed to read attachment file."),
984 detail: "Failed to read attachment file.",
1008985 cause,
1009986 }),
1010987 ),
......@@ -1242,7 +1219,7 @@ function toRequestError(threadId: ThreadId, method: string, cause: unknown): Pro
12421219 return new ProviderAdapterRequestError({
12431220 provider: PROVIDER,
12441221 method,
1245 detail: toMessage(cause, `${method} failed`),
1222 detail: `${method} failed`,
12461223 cause,
12471224 });
12481225}
......@@ -2910,18 +2887,27 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
29102887 const runSdkStream = (
29112888 context: ClaudeSessionContext,
29122889 ): Effect.Effect<void, ProviderAdapterProcessError> =>
2913 Stream.fromAsyncIterable(context.query, (cause) =>
2914 toProcessError(cause, "Claude runtime stream failed.", context.session.threadId),
2890 Stream.fromAsyncIterable(
2891 context.query,
2892 (cause) =>
2893 new ProviderAdapterProcessError({
2894 provider: PROVIDER,
2895 threadId: context.session.threadId,
2896 detail: "Claude runtime stream failed.",
2897 cause,
2898 }),
29152899 ).pipe(
29162900 Stream.takeWhile(() => !context.stopped),
29172901 Stream.runForEach((message) =>
29182902 handleSdkMessage(context, message).pipe(
2919 Effect.mapError((cause) =>
2920 toProcessError(
2921 cause,
2922 "Failed to process Claude runtime event.",
2923 context.session.threadId,
2924 ),
2903 Effect.mapError(
2904 (cause) =>
2905 new ProviderAdapterProcessError({
2906 provider: PROVIDER,
2907 threadId: context.session.threadId,
2908 detail: "Failed to process Claude runtime event.",
2909 cause,
2910 }),
29252911 ),
29262912 ),
29272913 ),
......@@ -2938,15 +2924,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
29382924 if (Exit.isFailure(exit)) {
29392925 if (isClaudeInterruptedCause(exit.cause)) {
29402926 if (context.turnState) {
2941 yield* completeTurn(
2942 context,
2943 "interrupted",
2944 interruptionMessageFromClaudeCause(exit.cause),
2945 );
2927 yield* completeTurn(context, "interrupted", "Claude runtime interrupted.");
29462928 }
29472929 } else {
2948 const message = messageFromClaudeStreamCause(exit.cause, "Claude runtime stream failed.");
2949 yield* emitRuntimeError(context, message, Cause.pretty(exit.cause));
2930 const failures = exit.cause.reasons.flatMap((reason) =>
2931 Cause.isFailReason(reason) ? [reason.error] : [],
2932 );
2933 const message = failures[0]?.detail ?? "Claude runtime stream failed.";
2934 yield* emitRuntimeError(context, message, {
2935 failureCount: failures.length,
2936 failureTags: failures.map((failure) => failure._tag),
2937 });
29502938 yield* completeTurn(context, "failed", message);
29512939 }
29522940 } else if (context.turnState) {
......@@ -3004,12 +2992,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
30042992 new ProviderAdapterProcessError({
30052993 provider: PROVIDER,
30062994 threadId: context.session.threadId,
3007 detail: toMessage(cause, "Failed to close Claude runtime query."),
2995 detail: "Failed to close Claude runtime query.",
30082996 cause,
30092997 }),
30102998 }).pipe(
3011 Effect.catch((cause) =>
3012 emitRuntimeError(context, "Failed to close Claude runtime query.", cause),
2999 Effect.catch((error) =>
3000 emitRuntimeError(context, "Failed to close Claude runtime query.", {
3001 errorTag: error._tag,
3002 provider: error.provider,
3003 threadId: error.threadId,
3004 detail: error.detail,
3005 }),
30133006 ),
30143007 );
30153008
......@@ -3522,7 +3515,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
35223515 new ProviderAdapterProcessError({
35233516 provider: PROVIDER,
35243517 threadId,
3525 detail: toMessage(cause, "Failed to start Claude runtime session."),
3518 detail: "Failed to start Claude runtime session.",
35263519 cause,
35273520 }),
35283521 });