Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1711,6 +1711,133 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("consumes undeclared and UX-internal system subtypes without warning rows", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
const runtimeEvents: Array<ProviderRuntimeEvent> = [];
const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
Effect.sync(() => runtimeEvents.push(event)),
).pipe(Effect.forkChild);

yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});

// Undeclared wire-only roster snapshot + every typed UX-internal
// subtype and top-level type consumed silently: none may surface as
// unknown-subtype warnings.
for (const message of [
{
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "t1", task_type: "local_agent", description: "Say hi" }],
session_id: "session",
uuid: "roster",
},
{
type: "system",
subtype: "task_updated",
task_id: "t1",
patch: { status: "running" },
session_id: "session",
uuid: "tu",
},
{ type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" },
{ type: "system", subtype: "model_refusal_fallback", session_id: "session", uuid: "mrf" },
{ type: "system", subtype: "local_command_output", session_id: "session", uuid: "lco" },
{ type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" },
{ type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" },
{ type: "system", subtype: "elicitation_complete", session_id: "session", uuid: "ec" },
{ type: "prompt_suggestion", suggestion: "try this", session_id: "session", uuid: "ps" },
{
type: "system",
subtype: "notification",
key: "context",
text: "low priority note",
priority: "low",
session_id: "session",
uuid: "notif",
},
]) {
harness.query.emit(message as unknown as SDKMessage);
}
// High-priority notifications DO surface as a warning row.
harness.query.emit({
type: "system",
subtype: "notification",
key: "limit",
text: "context window nearly full",
priority: "high",
session_id: "session",
uuid: "notif-high",
} as unknown as SDKMessage);
// session_state_changed maps to the matching session states.
for (const [state, uuid] of [
["running", "ssc-run"],
["requires_action", "ssc-req"],
["idle", "ssc-idle"],
]) {
harness.query.emit({
type: "system",
subtype: "session_state_changed",
state,
session_id: "session",
uuid,
} as unknown as SDKMessage);
}
// api_retry maps to a session heartbeat, not a warning row.
harness.query.emit({
type: "system",
subtype: "api_retry",
attempt: 3,
max_retries: 10,
retry_delay_ms: 1000,
error_status: 502,
error: { type: "api_error" },
session_id: "session",
uuid: "retry",
} as unknown as SDKMessage);
yield* Effect.yieldNow;
yield* Effect.yieldNow;

const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning");
// Exactly one warning: the high-priority notification. Nothing else.
assert.deepEqual(
warnings.map((event) => event.payload.message),
["context window nearly full"],
);
const sessionStates = runtimeEvents
.filter((event) => event.type === "session.state.changed")
.map((event) =>
event.type === "session.state.changed"
? `${event.payload.state}:${event.payload.reason ?? ""}`
: "",
)
.filter(
(entry) => entry.startsWith("running:session_state") || entry.includes("session_state"),
);
assert.deepEqual(sessionStates, [
"running:session_state:running",
"waiting:session_state:requires_action",
"ready:session_state:idle",
]);
const heartbeat = runtimeEvents.find(
(event) =>
event.type === "session.state.changed" &&
typeof event.payload.reason === "string" &&
event.payload.reason.startsWith("api_retry:"),
);
assert.equal(heartbeat?.type, "session.state.changed");
runtimeEventsFiber.interruptUnsafe();
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("emits thread token usage updates from Claude task progress", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
86 changes: 82 additions & 4 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2585,6 +2585,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
},
};

// Undeclared-but-real subtypes (absent from the SDK's union, so they can't
// be switch cases): consumed intentionally without emitting, otherwise
// they fall through to the unknown-subtype warning and surface as spurious
// error rows in client work logs. `background_tasks_changed` is a roster
// snapshot ({tasks: [...]}) — the task_* lifecycle events carry the
// authoritative per-agent data and the typed background_tasks control
// request is the reconciliation source.
if ((message.subtype as string) === "background_tasks_changed") {
return;
}

switch (message.subtype) {
case "init":
yield* offerRuntimeEvent({
Expand Down Expand Up @@ -2697,6 +2708,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
},
});
return;
// Task state patch (status/backgrounded/end_time). No runtime mapping
// yet — the terminal task_notification reports the outcome — but it
// must not surface as an unknown-subtype warning row.
case "task_updated":
return;
case "task_notification":
yield* emitThreadTokenUsage(
context,
Expand Down Expand Up @@ -2741,6 +2757,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
return;
case "thinking_tokens":
return;
case "api_retry":
// Transport-level retry heartbeat. Surfacing each attempt as a
// warning row spammed the work log (10 rows during a 502 storm);
// the terminal result/error path reports the actual failure. Keep
// the session visibly alive instead.
yield* offerRuntimeEvent({
...base,
type: "session.state.changed",
payload: {
state: "running",
reason: `api_retry:${message.attempt}/${message.max_retries}`,
},
});
return;
case "session_state_changed":
// Authoritative turn-over signal from the CLI.
yield* offerRuntimeEvent({
...base,
type: "session.state.changed",
payload: {
state:
message.state === "running"
? "running"
: message.state === "requires_action"
? "waiting"
: "ready",
reason: `session_state:${message.state}`,
},
});
return;
case "notification":
// User-facing CLI notification (e.g. context-limit warnings). Only
// high-priority ones warrant a work-log row.
if (message.priority === "high" || message.priority === "immediate") {
yield* emitRuntimeWarning(context, message.text, message);
}
return;
// Inner protocol/UX details with no T3 surface today — consumed
// deliberately so they don't masquerade as unknown-subtype warnings.
case "model_refusal_fallback":
case "local_command_output":
case "plugin_install":
case "commands_changed":
case "memory_recall":
case "elicitation_complete":
return;
case "permission_denied":
yield* offerRuntimeEvent({
...base,
Expand All @@ -2760,13 +2822,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
message,
);
return;
default:
default: {
// Exhaustiveness guard: every subtype in the SDK's typed union is
// handled above, so `message` narrows to never here — a new SDK
// release adding a subtype fails this typecheck instead of silently
// warning at runtime. The runtime fallback still catches undeclared
// wire-only subtypes (like background_tasks_changed used to be).
message satisfies never;
const unknownMessage = message as never as { subtype: string };
yield* emitRuntimeWarning(
context,
describeUnknownSdkMessage(`Claude system message '${message.subtype}'`, message),
describeUnknownSdkMessage(`Claude system message '${unknownMessage.subtype}'`, message),
message,
);
return;
}
}
});

Expand Down Expand Up @@ -2874,13 +2944,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
case "rate_limit_event":
yield* handleSdkTelemetryMessage(context, message);
return;
default:
// Composer prompt suggestions have no T3 surface; consumed deliberately.
case "prompt_suggestion":
return;
default: {
// Exhaustiveness guard (see handleSystemMessage): new SDK top-level
// message types fail typecheck here instead of warning at runtime.
message satisfies never;
const unknownMessage = message as never as { type: string };
yield* emitRuntimeWarning(
context,
describeUnknownSdkMessage(`Claude SDK message '${message.type}'`, message),
describeUnknownSdkMessage(`Claude SDK message '${unknownMessage.type}'`, message),
message,
);
return;
}
}
});

Expand Down
Loading