1import { assert, it } from "@effect/vitest";
2import {
3 NodeId,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 ProviderSessionId,
7 ProviderSetupError,
8 ProviderThreadId,
9 ProviderTurnId,
10 RunId,
11 ThreadId,
12} from "@t3tools/contracts";
13import * as Cause from "effect/Cause";
14import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";
16
17import {
18 knownProviderFailureMessage,
19 makeProviderFailure,
20 makeProviderFailureTurnItem,
21 MAX_PROVIDER_FAILURE_MESSAGE_LENGTH,
22 ContextHandoffBudgetError,
23 ContextHandoffBackgroundWorkError,
24} from "./failure.ts";
25import * as IdAllocator from "./IdAllocator.ts";
26import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
27
28it("redacts credentials and URL secrets from provider failures", () => {
29 const failure = makeProviderFailure({
30 message:
31 'request failed: Authorization: Bearer bearer-secret https://user:pass@example.test/path?access_token=url-secret#fragment {"token":"json-secret"} api_key=key-secret sk-abcdefghijklmnop',
32 code: "provider_rejected",
33 class: "provider_error",
34 });
35
36 assert.equal(failure.class, "provider_error");
37 assert.equal(failure.code, "provider_rejected");
38 assert.include(failure.message, "[REDACTED]");
39 assert.include(failure.message, "https://example.test/path");
40 assert.notInclude(failure.message, "bearer-secret");
41 assert.notInclude(failure.message, "user:pass");
42 assert.notInclude(failure.message, "url-secret");
43 assert.notInclude(failure.message, "json-secret");
44 assert.notInclude(failure.message, "key-secret");
45 assert.notInclude(failure.message, "sk-abcdefghijklmnop");
46});
47
48it("redacts credentials whose key names end in a credential word", () => {
49 const failure = makeProviderFailure({
50 code: '{"refresh_token":"code value"}',
51 message:
52 'oauth refresh failed: client_secret=client-value refresh_token=refresh-value {"client_secret":"json-value"} clientSecret: camel-value X-Auth-Token: header-value max_tokens=4096',
53 });
54
55 assert.equal(failure.code, '{"refresh_token":"[REDACTED]"}');
56 assert.equal(
57 failure.message,
58 'oauth refresh failed: client_secret=[REDACTED] refresh_token=[REDACTED] {"client_secret":"[REDACTED]"} clientSecret: [REDACTED] X-Auth-Token: [REDACTED] max_tokens=4096',
59 );
60});
61
62it("does not split a surrogate pair at the truncation boundary", () => {
63 const failure = makeProviderFailure({
64 message: `${"a".repeat(MAX_PROVIDER_FAILURE_MESSAGE_LENGTH - 2)}🚀tail`,
65 });
66
67 assert.equal(failure.message.length, MAX_PROVIDER_FAILURE_MESSAGE_LENGTH - 1);
68 assert.equal(failure.message.at(-1), "…");
69 assert.notMatch(failure.message.slice(0, -1), /[\uD800-\uDBFF]$/u);
70});
71
72it("does not expose arbitrary cause messages and preserves explicit messages", () => {
73 const cause = new Error("Adapter failed", {
74 cause: new Error("Session expired. Sign in again."),
75 });
76 for (const value of [
77 cause,
78 Cause.fail(cause),
79 "Session expired. Sign in again.",
80 { message: "Session expired. Sign in again." },
81 ]) {
82 assert.equal(makeProviderFailure({ cause: value }).message, "Provider turn failed.");
83 assert.equal(
84 makeProviderFailure({ cause: value, message: "Provider connection closed." }).message,
85 "Provider connection closed.",
86 );
87 }
88});
89
90it("preserves actionable T3 errors wrapped by turn startup", () => {
91 for (const error of [
92 new ContextHandoffBudgetError(),
93 new ContextHandoffBackgroundWorkError(),
94 new ProviderSetupError({
95 instanceId: ProviderInstanceId.make("codex"),
96 operation: "runtime",
97 detail: "Codex 0.144.3 does not work with this T3 Code release.",
98 }),
99 ]) {
100 const cause = new ProviderAdapter.ProviderAdapterTurnStartError({
101 driver: ProviderDriverKind.make("codex"),
102 threadId: ThreadId.make("thread:handoff-error"),
103 providerThreadId: ProviderThreadId.make("provider-thread:handoff-error"),
104 runId: RunId.make("run:handoff-error"),
105 cause: error,
106 });
107 assert.equal(makeProviderFailure({ cause: Cause.fail(cause) }).message, error.message);
108 }
109});
110
111it("names how the provider process exited and the stage it failed in, without its output", () => {
112 for (const [exit, status] of [
113 [{ signal: "SIGILL" }, " after signal SIGILL"],
114 [{ code: 134 }, " with code 134"],
115 [{}, ""],
116 [{ signal: "SIGILL Bearer signal-secret" }, ""],
117 ] as const) {
118 const sessionOpenFailure = {
119 _tag: "ProviderSessionOpenError",
120 cause: new ProviderAdapter.ProviderAdapterOpenSessionError({
121 driver: ProviderDriverKind.make("antigravity"),
122 providerSessionId: ProviderSessionId.make("provider-session:private-id"),
123 cause: {
124 _tag: "AcpProcessExitedError",
125 ...exit,
126 pid: 4242,
127 stderr: "*** SIGILL *** token=stderr-secret",
128 cause: new Error("Process interrupted with private-detail"),
129 },
130 }),
131 };
132 const expected = `The provider session could not be opened: the provider process exited${status}. Check the server logs for the provider's output, then retry the turn.`;
133 assert.equal(knownProviderFailureMessage(sessionOpenFailure), expected);
134 assert.equal(makeProviderFailure({ cause: Cause.fail(sessionOpenFailure) }).message, expected);
135 }
136 assert.equal(
137 makeProviderFailure({ cause: { _tag: "AcpProcessExitedError", code: 1 } }).message,
138 "The provider process exited with code 1. Check the server logs for the provider's output, then retry the turn.",
139 );
140});
141
142it("keeps unrecognized reasons and provider text out of category messages", () => {
143 for (const cause of [
144 "Private launch command",
145 { _tag: "ProviderAdapterProtocolError", detail: "Pi model 'private-config' is invalid" },
146 { _tag: "ProviderAdapterProtocolError", reason: "private-config" },
147 { _tag: "ProviderAdapterProtocolError", reason: "constructor" },
148 { _tag: "constructor", cause: "private-config" },
149 {
150 _tag: "CodexAppServerRequestError",
151 method: "thread/goal/set",
152 message: "thread not found: private-thread",
153 },
154 {
155 _tag: "CodexAppServerRequestError",
156 method: "turn/start",
157 message: "goal objective must be at most 4000 characters",
158 },
159 ]) {
160 assert.equal(
161 makeProviderFailure({ cause: { _tag: "ProviderAdapterTurnStartError", cause } }).message,
162 "The provider could not start this turn. Retry the turn; if it keeps failing, check the provider setup and server logs.",
163 );
164 }
165 assert.equal(
166 makeProviderFailure({
167 cause: { _tag: "ProviderAdapterProtocolError", reason: "previous_turn_active" },
168 }).message,
169 "Provider turn failed.",
170 );
171});
172
173it("names a managed Codex policy that refuses the thread's access mode", () => {
174 const message = makeProviderFailure({
175 cause: {
176 _tag: "ProviderAdapterTurnStartError",
177 cause: {
178 _tag: "CodexAppServerRequestError",
179 method: "turn/start",
180 code: -32600,
181 message:
182 "invalid thread settings override: invalid value for `sandbox_mode`: `DangerFullAccess` is not in the allowed set [ReadOnly, WorkspaceWrite] (set by /etc/codex/requirements.toml)",
183 },
184 },
185 }).message;
186 assert.notInclude(message, "Retry the turn");
187 assert.match(message, /requirements|policy/iu);
188});
189
190it("uses the innermost category without carrying an outer reason into it", () => {
191 assert.equal(
192 makeProviderFailure({
193 cause: {
194 _tag: "ProviderAdapterTurnStartError",
195 cause: {
196 _tag: "ProviderAdapterProtocolError",
197 reason: "previous_turn_active",
198 cause: Cause.fail({ _tag: "ProviderAdapterOpenSessionError" }),
199 },
200 },
201 }).message,
202 "The provider session could not be opened. Check that the provider is installed and signed in, then retry the turn.",
203 );
204});
205
206it("explains a native thread another client is writing to", () => {
207 const inUse = new ProviderAdapter.ProviderAdapterThreadInUseError({
208 driver: ProviderDriverKind.make("codex"),
209 providerThreadId: ProviderThreadId.make("provider-thread:in-use"),
210 detail: "This conversation is open in another Codex client.",
211 cause: new Error("thread provider-thread:in-use already has an active writer"),
212 });
213 const cause = new ProviderAdapter.ProviderAdapterTurnStartError({
214 driver: ProviderDriverKind.make("codex"),
215 threadId: ThreadId.make("thread:in-use"),
216 providerThreadId: ProviderThreadId.make("provider-thread:in-use"),
217 runId: RunId.make("run:in-use"),
218 cause: inUse,
219 });
220 assert.equal(makeProviderFailure({ cause: Cause.fail(cause) }).message, inUse.message);
221});
222
223it("does not expose defect text nested inside a known error category", () => {
224 const failure = makeProviderFailure({
225 cause: {
226 _tag: "ProviderAdapterEventStreamError",
227 cause: new Error(
228 `Session rejected: Bearer nested-secret https://user:pass@example.test/path?token=secret ${"x".repeat(5000)}`,
229 ),
230 },
231 });
232 assert.include(failure.message, "provider event stream closed unexpectedly");
233 assert.notInclude(failure.message, "Session rejected:");
234 assert.notInclude(failure.message, "nested-secret");
235 assert.notInclude(failure.message, "user:pass");
236 assert.notInclude(failure.message, "token=secret");
237 assert.isBelow(failure.message.length, MAX_PROVIDER_FAILURE_MESSAGE_LENGTH);
238});
239
240it("handles cyclic causes and throwing accessors", () => {
241 const cyclic: { message: string; cause?: unknown } = {
242 message: "Provider disconnected. Retry the turn.",
243 };
244 cyclic.cause = cyclic;
245 assert.equal(makeProviderFailure({ cause: cyclic }).message, "Provider turn failed.");
246 assert.equal(
247 makeProviderFailure({
248 cause: {
249 get message() {
250 throw new Error("unreadable");
251 },
252 get cause() {
253 throw new Error("unreadable");
254 },
255 },
256 }).message,
257 "Provider turn failed.",
258 );
259});
260
261it("does not serialize arbitrary provider causes", () => {
262 const failure = makeProviderFailure({
263 cause: {
264 payload: { authorization: "Bearer nested-secret" },
265 stack: "private provider stack",
266 },
267 class: "transport_error",
268 });
269
270 assert.deepEqual(failure, {
271 class: "transport_error",
272 message: "Provider turn failed.",
273 code: null,
274 retryable: null,
275 });
276});
277
278it.effect("keys terminal failure items by provider turn across retries and fallback paths", () =>
279 Effect.gen(function* () {
280 const idAllocator = yield* IdAllocator.IdAllocatorV2;
281 const driver = ProviderDriverKind.make("codex");
282 const runId = RunId.make("run:provider-failure-id");
283 const base = {
284 idAllocator,
285 driver,
286 threadId: ThreadId.make("thread:provider-failure-id"),
287 runId,
288 nodeId: NodeId.make("node:provider-failure-id"),
289 providerThreadId: ProviderThreadId.make("provider-thread:provider-failure-id"),
290 itemOrdinal: 101,
291 failure: makeProviderFailure({ message: "Provider failed" }),
292 occurredAt: DateTime.makeUnsafe("2026-06-22T12:00:00.000Z"),
293 } as const;
294 const firstTurnId = ProviderTurnId.make("provider-turn:provider-failure-id:first");
295 const secondTurnId = ProviderTurnId.make("provider-turn:provider-failure-id:second");
296
297 const firstAttempt = makeProviderFailureTurnItem({
298 ...base,
299 providerTurnId: firstTurnId,
300 });
301 const retriedAttempt = makeProviderFailureTurnItem({
302 ...base,
303 providerTurnId: secondTurnId,
304 });
305 const ingestorFallback = makeProviderFailureTurnItem({
306 ...base,
307 runId: null,
308 nodeId: null,
309 providerTurnId: firstTurnId,
310 });
311
312 assert.notEqual(firstAttempt.id, retriedAttempt.id);
313 assert.equal(firstAttempt.id, ingestorFallback.id);
314 assert.equal(firstAttempt.ordinal, 101);
315 }).pipe(Effect.provide(IdAllocator.layer)),
316);