| ... | ... | @@ -1,13 +1,20 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { EnvironmentNotRegisteredError } from "@t3tools/client-runtime/connection"; |
| 3 | import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; |
| 4 | import { EnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc"; |
| 2 | 5 | import { |
| 3 | 6 | CommandId, |
| 7 | EnvironmentAuthorizationError, |
| 4 | 8 | EnvironmentId, |
| 5 | 9 | MessageId, |
| 10 | OrchestrationDispatchCommandError, |
| 6 | 11 | ProjectId, |
| 7 | 12 | ProviderInstanceId, |
| 8 | 13 | ThreadId, |
| 9 | 14 | } from "@t3tools/contracts"; |
| 10 | 15 | import { AtomRegistry } from "effect/unstable/reactivity"; |
| 16 | import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; |
| 17 | import * as Socket from "effect/unstable/socket/Socket"; |
| 11 | 18 | import { onTestFinished, vi } from "vite-plus/test"; |
| 12 | 19 | |
| 13 | 20 | const outboxFiles = vi.hoisted(() => new Map<string, string | Error>()); |
| ... | ... | @@ -1399,6 +1406,62 @@ describe("thread outbox", () => { |
| 1399 | 1406 | }), |
| 1400 | 1407 | ).toBe(true); |
| 1401 | 1408 | expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false); |
| 1409 | expect( |
| 1410 | shouldRetryThreadOutboxDelivery( |
| 1411 | new OrchestrationDispatchCommandError({ message: "Thread no longer exists" }), |
| 1412 | ), |
| 1413 | ).toBe(false); |
| 1414 | expect( |
| 1415 | shouldRetryThreadOutboxDelivery( |
| 1416 | new EnvironmentAuthorizationError({ |
| 1417 | message: "Missing scope", |
| 1418 | requiredScope: "orchestration:operate", |
| 1419 | }), |
| 1420 | ), |
| 1421 | ).toBe(false); |
| 1422 | }); |
| 1423 | |
| 1424 | // A pending task created offline drains the moment the phone reconnects, |
| 1425 | // which is exactly when the socket is most likely to drop again. Every way a |
| 1426 | // request can fail in flight must retry; a restore turns the pending task |
| 1427 | // into a draft and it disappears from the list. |
| 1428 | it("retries every in-flight transport failure by tag, not by message text", () => { |
| 1429 | const socketReasons = [ |
| 1430 | new Socket.SocketReadError({ cause: new Error("The network connection was lost.") }), |
| 1431 | new Socket.SocketWriteError({ cause: new Error("Broken pipe") }), |
| 1432 | new Socket.SocketCloseError({ code: 1006 }), |
| 1433 | new Socket.SocketOpenError({ kind: "Timeout", cause: new Error("timeout") }), |
| 1434 | ]; |
| 1435 | for (const reason of socketReasons) { |
| 1436 | const error = new RpcClientError.RpcClientError({ reason }); |
| 1437 | expect(isTransportConnectionErrorMessage(error.message)).toBe( |
| 1438 | reason._tag === "SocketCloseError" || reason._tag === "SocketOpenError", |
| 1439 | ); |
| 1440 | expect(shouldRetryThreadOutboxDelivery(error)).toBe(true); |
| 1441 | } |
| 1442 | expect( |
| 1443 | shouldRetryThreadOutboxDelivery( |
| 1444 | new RpcClientError.RpcClientError({ |
| 1445 | reason: new RpcClientError.RpcClientDefect({ |
| 1446 | message: "Error decoding message", |
| 1447 | cause: new Error("Unexpected end of JSON input"), |
| 1448 | }), |
| 1449 | }), |
| 1450 | ), |
| 1451 | ).toBe(true); |
| 1452 | expect( |
| 1453 | shouldRetryThreadOutboxDelivery( |
| 1454 | new EnvironmentRpcUnavailableError({ |
| 1455 | environmentId: "environment-1", |
| 1456 | message: "Home is not connected.", |
| 1457 | }), |
| 1458 | ), |
| 1459 | ).toBe(true); |
| 1460 | expect( |
| 1461 | shouldRetryThreadOutboxDelivery( |
| 1462 | new EnvironmentNotRegisteredError({ environmentId: EnvironmentId.make("environment-1") }), |
| 1463 | ), |
| 1464 | ).toBe(true); |
| 1402 | 1465 | }); |
| 1403 | 1466 | |
| 1404 | 1467 | it("retains queued messages when settings synchronization fails before startTurn", () => { |