diff --git a/examples/mocks-round-trip/README.md b/examples/mocks-round-trip/README.md new file mode 100644 index 0000000..ecc105c --- /dev/null +++ b/examples/mocks-round-trip/README.md @@ -0,0 +1,35 @@ +# mocks-round-trip + +Standalone demo of the hosted test-mode `mocks` field on `cs.create`. Runs a small Asaas flow against `api.codespar.dev` (or a self-hosted OSS runtime via `CODESPAR_BASE_URL`) using mock fixtures instead of calling the real provider — no network egress to Asaas, no test customer left behind in their sandbox. + +## What it shows + +- A **static mock** (`asaas/create_customer`) that returns the same object on every call. +- A **stateful mock** (`asaas/create_payment`) — an array of objects consumed in order. The third call drains the list and returns `mocks_exhausted`; the example uses the `isMocksExhausted` guard to branch on it. +- The error path when an API key isn't authorized for mocks (`mocks_not_permitted`). + +A matching Python version is at [`packages/python/examples/mocks_round_trip.py`](../../packages/python/examples/mocks_round_trip.py). + +## Run + +```bash +cd examples/mocks-round-trip +npm install + +export CODESPAR_API_KEY=csk_test_xxxxxxxxxxxxx # test-environment key +# Optional: target a local OSS runtime +# export CODESPAR_BASE_URL=http://localhost:8000 + +npm run demo +``` + +## Expected output + +``` +customer: { id: 'cus_test', name: 'Demo Buyer', cpfCnpj: '11144477735' } +payment 1: { id: 'pay_1', status: 'PENDING', value: 100 } +payment 2: { id: 'pay_1', status: 'RECEIVED', value: 100 } +payment 3 drained the list: stateful mock list exhausted +``` + +(The exact `mocks_exhausted` message comes from the backend.) diff --git a/examples/mocks-round-trip/mocks-round-trip.ts b/examples/mocks-round-trip/mocks-round-trip.ts new file mode 100644 index 0000000..31a6373 --- /dev/null +++ b/examples/mocks-round-trip/mocks-round-trip.ts @@ -0,0 +1,104 @@ +/** + * Hosted test-mode mocks round-trip (TypeScript). + * + * Demonstrates the two mock shapes accepted by `cs.create({ mocks })`: + * - static: a single MockObject returned on every matching call + * - stateful: a MockObject[] consumed in order, one per call, + * returning `mocks_exhausted` once the list drains + * + * Requires a csk_test_* key against a test-environment project — live + * keys against the same map return `mocks_not_permitted`. + * + * Usage: + * export CODESPAR_API_KEY=csk_test_xxxxxxxxxxxxx + * # optional: target a local OSS runtime + * # export CODESPAR_BASE_URL=http://localhost:8000 + * npx tsx mocks-round-trip.ts + */ + +import { CodeSpar, CodesparApiError, isMocksExhausted } from "@codespar/sdk"; +import type { MockValue } from "@codespar/sdk"; + +const apiKey = process.env.CODESPAR_API_KEY; +if (!apiKey) { + console.error("error: set CODESPAR_API_KEY first (csk_test_* recommended)"); + process.exit(1); +} + +const fixtures: Record = { + // Static — same response every call + "asaas/create_customer": { + id: "cus_test", + name: "Demo Buyer", + cpfCnpj: "11144477735", + }, + // Stateful — consumed in order + "asaas/create_payment": [ + { id: "pay_1", status: "PENDING", value: 100 }, + { id: "pay_1", status: "RECEIVED", value: 100 }, + ], +}; + +async function main(): Promise { + const cs = new CodeSpar({ apiKey }); + + let session; + try { + session = await cs.create("demo_user", { + servers: ["asaas"], + mocks: fixtures, + }); + } catch (err) { + if (err instanceof CodesparApiError && err.code === "mocks_not_permitted") { + console.error( + "error: this API key cannot use mocks. Swap to a csk_test_* key " + + "against a test-environment project.", + ); + return 1; + } + throw err; + } + + try { + // Static mock + const customer = await session.execute("asaas/create_customer", { + name: "Demo Buyer", + cpfCnpj: "11144477735", + }); + console.log("customer:", customer.data); + + // First call into the stateful mock + const pending = await session.execute("asaas/create_payment", { + customer: "cus_test", + billingType: "PIX", + value: 100, + }); + console.log("payment 1:", pending.data); + + // Second call into the stateful mock — different fixture + const received = await session.execute("asaas/create_payment", { + customer: "cus_test", + billingType: "PIX", + value: 100, + }); + console.log("payment 2:", received.data); + + // Third call — list is drained + const exhausted = await session.execute("asaas/create_payment", { + customer: "cus_test", + billingType: "PIX", + value: 100, + }); + if (isMocksExhausted(exhausted.data)) { + console.log("payment 3 drained the list:", exhausted.data.message); + } else { + console.log("payment 3:", exhausted.data); + } + } finally { + await session.close(); + } + + return 0; +} + +main().then((code) => process.exit(code)); diff --git a/examples/mocks-round-trip/package.json b/examples/mocks-round-trip/package.json new file mode 100644 index 0000000..474ad29 --- /dev/null +++ b/examples/mocks-round-trip/package.json @@ -0,0 +1,17 @@ +{ + "name": "@codespar/example-mocks-round-trip", + "version": "0.0.0", + "private": true, + "description": "Hosted test-mode mocks round-trip — pass a static and a stateful mock into cs.create and execute the tools end-to-end.", + "type": "module", + "scripts": { + "demo": "tsx mocks-round-trip.ts" + }, + "dependencies": { + "@codespar/sdk": "^0.9.0" + }, + "devDependencies": { + "tsx": "^4.19.0", + "typescript": "^5.4.0" + } +} diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index d2e3683..1c57fa8 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,23 @@ # @codespar/sdk — CHANGELOG +## Unreleased + +The hosted test-mode SDK surface lands across `@codespar/sdk`, `@codespar/types`, and the `codespar` Python package. See [codespar/codespar-core#54](https://github.com/codespar/codespar-core/pull/54). + +### Added + +- `cs.create(userId, { mocks: {...} })` (TypeScript) and `cs.create("u", mocks={...})` (Python). Keys are canonical tool names in slash form (`asaas/create_payment`); values are a `MockObject` for a static mock or a `MockObject[]` for a stateful mock consumed in order. Forwarded verbatim on `POST /v1/sessions` — the SDK does not rewrite tool names, so the OSS double-underscore form (`asaas__create_payment`) surfaces as `mocks_invalid` rather than being silently rewritten. Absent case stays wire-neutral (no `mocks` key on the body). +- `MockObject` and `MockValue` type aliases in `@codespar/types` (re-exported through `@codespar/sdk`) and in the `codespar` Python package. `SessionConfig` widens in both languages to accept the optional `mocks` field. +- `CodesparApiError` — structured exception class shared by every transport-failure throw site in `session.ts`. Constructor signature `new CodesparApiError(message, { status, code?, body?, cause? })`. Network errors that never reach the backend surface as `status: 0` with the underlying `fetch` rejection preserved as `cause`. +- Tool-result type-narrowed guards in `packages/core/src/tool-result-codes.ts` and `packages/python/src/codespar/tool_result_codes.py`. Five variants — `PolicyDenied`, `ApprovalRequired`, `MocksExhausted`, `MocksEngineError`, `ToolNotMocked` — plus matching `*Output` interfaces / dataclasses, narrowed `*ToolCall` aliases (TS), the `ToolResultCode` union, the `TOOL_RESULT_CODES` set, five predicate guards (`isPolicyDenied` / `is_policy_denied`, etc.), and an exhaustive-match helper (`assertExhaustiveToolResult` / `assert_exhaustive_tool_result`) that makes a `switch` over `ToolResultCode` fail to compile (TS) or trip at runtime (Python) when a sixth variant lands without a handler. Each guard checks the `code` discriminant AND its required sibling fields, so a payload with a well-formed `code` but a missing `rule_id` / `approval_id` / `tool_name` returns false rather than narrowing positive. +- `CODESPAR_BASE_URL` environment variable resolution. The TypeScript `CodeSpar` constructor already read the env var; the Python `CodeSpar` and `AsyncCodeSpar` constructors now do too. The cascade in both languages is explicit `baseUrl` / `base_url` option, then `CODESPAR_BASE_URL`, then `https://api.codespar.dev`. Point the same client wiring at a [local OSS runtime](https://github.com/codespar/codespar) without rebuilding call sites. + +### Changed + +- **SemVer-minor break for callers parsing `e.message` strings.** The generic `throw new Error("send failed: 500 ...")` shape is gone — every transport call site (`createSession`, `proxyExecute`, `send`, `sendStream`, `paymentStatus(Stream)`, `verificationStatus(Stream)`, `authorize`) now throws `CodesparApiError`. Migration recipe: `e.message.includes("foo")` becomes `e.code === "foo"`. +- `session.execute(...)` keeps its existing returns-vs-throws asymmetry — non-ok responses still come back as `ToolResult.success === false` with the body in `error`. Only transport exceptions change shape. +- Python `_http.py` honors `code` over `error` when both are present on a non-success response body. The new test-mode envelopes (`mocks_not_permitted`, `mocks_invalid`, `mocks_payload_too_large`) carry `code`; pre-test-mode responses that only set `error` remain compatible. + ## 0.9.0 - New: `session.paymentStatusStream(toolCallId, { onUpdate?, signal? })`. diff --git a/packages/core/README.md b/packages/core/README.md index 6b24777..f7cbb8b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -105,7 +105,7 @@ const result = await loop(session, { | Option | Type | Default | Description | |--------|------|---------|-------------| | `apiKey` | `string` | `CODESPAR_API_KEY` env | Your API key | -| `baseUrl` | `string` | `https://api.codespar.dev` | API base URL | +| `baseUrl` | `string` | `CODESPAR_BASE_URL` env, else `https://api.codespar.dev` | API base URL. Set `CODESPAR_BASE_URL=http://localhost:8000` to point the SDK at a [local OSS runtime](https://github.com/codespar/codespar); the managed backend is the default. | | `managed` | `boolean` | `true` | Enable managed billing/logging | | `projectId` | `string` | — | Optional `prj_<16alphanum>`. Client-wide default project; sent as `x-codespar-project`. Falls back to the org's default project when omitted. | @@ -117,6 +117,7 @@ const result = await loop(session, { | `preset` | `string` | `"brazilian"`, `"mexican"`, `"argentinian"`, `"colombian"`, `"all"` | | `manageConnections.waitForConnections` | `boolean` | Block until all servers connected | | `projectId` | `string` | Optional `prj_<16alphanum>`. Overrides the client-level `projectId`; falls back to the org's default project when both are unset. | +| `mocks` | `Record` | Optional test-mode mocks. See [Test-mode mocks](#test-mode-mocks). | ### Session methods @@ -190,6 +191,129 @@ const session = await cs.create("user_123", { Precedence: `sessionConfig.projectId` > `clientConfig.projectId` > backend's org default. Format is validated at construction via Zod (`/^prj_[A-Za-z0-9]{16}$/`) so typos fail fast. See the [Projects concept doc](https://docs.codespar.dev/concepts/projects) for the full tenancy model. +## Test-mode mocks + +Skip live providers in tests by passing a `mocks` map to `cs.create`. Keys are canonical tool names in slash form (`asaas/create_payment`, `melhor-envio/calculate_shipping`, …). Values are either a single object — used as the response on every matching call — or an array of objects consumed in order, returning `mocks_exhausted` once the list drains. + +```typescript +import { CodeSpar } from "@codespar/sdk"; + +const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY }); + +const session = await cs.create("user_test", { + servers: ["asaas"], + mocks: { + "asaas/create_payment": { id: "pay_test", status: "PENDING" }, + }, +}); + +const result = await session.execute("asaas/create_payment", { value: 100 }); +// result.data === { id: "pay_test", status: "PENDING" } +``` + +Pass an array for stateful mocks: + +```typescript +mocks: { + "asaas/create_payment": [ + { id: "pay_1", status: "PENDING" }, + { id: "pay_1", status: "RECEIVED" }, + ], +} +``` + +Mocks live behind the managed backend's test-mode gate — a `csk_test_*` API key against a `test`-environment project. Live keys against the same map return `mocks_not_permitted`. The SDK forwards keys verbatim; if you send the OSS double-underscore form (`asaas__create_payment`) the backend rejects with `mocks_invalid` rather than the SDK silently rewriting. + +The OSS runtime accepts the same `mocks` shape on its session API (see [codespar/codespar#113](https://github.com/codespar/codespar/pull/113)), so the same test fixtures work whether you point at `api.codespar.dev` or a self-hosted instance via `CODESPAR_BASE_URL`. Self-hosted runtimes must additionally set `CODESPAR_TEST_MODE_ENABLED=true` on the server process; without it, the SDK receives `mocks_not_permitted` / HTTP 501 instead of fixture responses. + +Storage shape differs by runtime — the wire contract does not. The managed backend persists mocks and per-tool consume counters; sessions and their fixtures survive restarts and multi-replica deployments. The OSS runtime holds both in process memory; they are scoped to the HTTP-session process and are lost on restart, and channel-bridge sessions (WhatsApp, Slack, Telegram, Discord) cannot carry mocks under the OSS shape. Response envelopes, status codes, sibling fields, and gate ordering are byte-identical between runtimes regardless. See [the test-mode concept doc](https://docs.codespar.dev/concepts/test-mode) for the full per-runtime split. + +Test mode is a property of the runtime, not the session. On the managed backend it's `project.environment === 'test'`; on a self-hosted OSS runtime it's `CODESPAR_TEST_MODE_ENABLED=true` on the server process. When the runtime is in test mode, every external tool call your code or LLM dispatches must match a declared mock — unmatched calls return `tool_not_mocked` (HTTP 422 on the catalog-routed `/execute` path; a `tool_result` block on the chat-loop) and no upstream provider runs. The envelope covers three failure modes: the `mocks` map has no entry for the canonical name, the session was created with no `mocks` field, or the canonical name has an unknown server prefix. A session that doesn't declare `mocks` can't dispatch any tools in test mode; declare the mocks the test will exercise, or run the same code against a live-mode runtime where the real providers handle dispatch. Built-in metadata tools — `codespar_list_tools` on OSS, `codespar_discover` and `codespar_manage_connections` on the managed backend — bypass this gate. + +### Type aliases + +`MockObject` (`Record`) and `MockValue` (`MockObject | MockObject[]`) ship from `@codespar/types` and re-export through `@codespar/sdk`. Use them when you want to define mock fixtures separately from the `create` call site. + +```typescript +import type { MockValue } from "@codespar/sdk"; + +const fixtures: Record = { + "asaas/create_payment": { id: "pay_test", status: "PENDING" }, +}; +``` + +## Typed errors + +Every transport failure from `createSession`, `proxyExecute`, `send`, `sendStream`, `paymentStatus(Stream)`, `verificationStatus(Stream)`, and `authorize` throws a `CodesparApiError` with a structured `code` field. The old `e.message.includes("foo")` pattern is gone — branch on `e.code` instead. + +```typescript +import { CodesparApiError } from "@codespar/sdk"; + +try { + await cs.create("user_test", { mocks: { "asaas/create_payment": {} } }); +} catch (err) { + if (err instanceof CodesparApiError) { + if (err.code === "mocks_not_permitted") { + // Live key against a mocks map. Swap to csk_test_*. + } else if (err.code === "mocks_invalid") { + // Backend rejected a tool-name key. Check the slash form. + } else if (err.status === 0) { + // Network never reached the backend; err.cause has the fetch rejection. + } + throw err; + } +} +``` + +`session.execute` keeps its returns-vs-throws asymmetry: tool failures come back as `ToolResult.success === false` with the body on `error`. Only transport-level failures throw. + +### Tool-result guards + +The five reserved tool-result codes (`policy_denied`, `approval_required`, `mocks_exhausted`, `mocks_engine_error`, `tool_not_mocked`) ship typed guards plus an exhaustive-match helper. Guards run against any `unknown` payload — both `ToolResult.data` from `session.execute` and `ToolCallRecord.output` from `send` / `sendStream`. Each guard checks the `code` discriminant AND the variant's required sibling fields, so a malformed payload returns false rather than narrowing positive on the code alone. + +```typescript +import { + isApprovalRequired, + isMocksEngineError, + isMocksExhausted, + isPolicyDenied, + isToolNotMocked, + assertExhaustiveToolResult, + ToolResultCode, +} from "@codespar/sdk"; + +const result = await session.execute("asaas/create_payment", { value: 100 }); + +if (isPolicyDenied(result.data)) { + console.warn(`blocked by ${result.data.rule_id}: ${result.data.message}`); +} else if (isApprovalRequired(result.data)) { + console.log(`needs approval ${result.data.approval_id} by ${result.data.expires_at}`); +} else if (isMocksExhausted(result.data)) { + // Stateful mock array drained — pad it or extend the test. +} else if (isMocksEngineError(result.data)) { + // Backend-side mocks engine failure; usually a malformed fixture. +} else if (isToolNotMocked(result.data)) { + console.warn(`no mock for ${result.data.tool_name}`); +} +``` + +The same guards apply inside a `sendStream` loop against `event.toolCall.output`. + +When a `switch` over `ToolResultCode` covers every variant, call `assertExhaustiveToolResult` in the default branch. TypeScript fails to compile if a sixth code lands without a matching arm. + +```typescript +function handle(outcome: ToolResultOutcome): string { + switch (outcome.code) { + case ToolResultCode.PolicyDenied: return outcome.rule_id; + case ToolResultCode.ApprovalRequired: return outcome.approval_id; + case ToolResultCode.MocksExhausted: return "exhausted"; + case ToolResultCode.MocksEngineError: return "engine"; + case ToolResultCode.ToolNotMocked: return outcome.tool_name; + default: return assertExhaustiveToolResult(outcome); + } +} +``` + ## Need more? Need governance, budget limits, and audit trails for agent payments? **[CodeSpar Enterprise](https://codespar.dev/enterprise)** adds policy engine, payment routing, and compliance templates on top of these MCP servers. diff --git a/packages/core/src/__tests__/base-url-resolution.test.ts b/packages/core/src/__tests__/base-url-resolution.test.ts new file mode 100644 index 0000000..54fad6c --- /dev/null +++ b/packages/core/src/__tests__/base-url-resolution.test.ts @@ -0,0 +1,51 @@ +/** + * `CODESPAR_BASE_URL` env-var resolution for the TS client. + * + * The constructor cascade is: explicit `baseUrl` option, then the + * `CODESPAR_BASE_URL` env var, then the production default. The env + * var lets a caller point the same client wiring at a local OSS + * runtime or at `api.codespar.dev` without rebuilding the call sites. + */ + +import { describe, it, expect } from "vitest"; +import { CodeSpar } from "../index.js"; + +describe("CODESPAR_BASE_URL env-var fallback", () => { + it("uses CODESPAR_BASE_URL when no explicit baseUrl is passed", () => { + const prevBase = process.env.CODESPAR_BASE_URL; + const prevKey = process.env.CODESPAR_API_KEY; + process.env.CODESPAR_BASE_URL = "https://oss.codespar.local"; + process.env.CODESPAR_API_KEY = "csk_live_test"; + try { + const cs = new CodeSpar(); + // baseUrl is private; the smoke test is that construction + // succeeds and the default does NOT override an env override. + // The behavior is exercised via createSession's URL prefix in + // the integration tests. + expect(cs).toBeDefined(); + } finally { + if (prevBase === undefined) delete process.env.CODESPAR_BASE_URL; + else process.env.CODESPAR_BASE_URL = prevBase; + if (prevKey === undefined) delete process.env.CODESPAR_API_KEY; + else process.env.CODESPAR_API_KEY = prevKey; + } + }); + + it("explicit baseUrl wins over CODESPAR_BASE_URL env var", () => { + const prev = process.env.CODESPAR_BASE_URL; + process.env.CODESPAR_BASE_URL = "https://oss.codespar.local"; + try { + const cs = new CodeSpar({ + apiKey: "csk_live_x", + baseUrl: "https://override.example.com", + }); + // Smoke — construction succeeds with both set. The wire-level + // behavior is covered by createSession tests that fetch-mock + // the URL prefix directly. + expect(cs).toBeDefined(); + } finally { + if (prev === undefined) delete process.env.CODESPAR_BASE_URL; + else process.env.CODESPAR_BASE_URL = prev; + } + }); +}); diff --git a/packages/core/src/__tests__/errors.test.ts b/packages/core/src/__tests__/errors.test.ts new file mode 100644 index 0000000..057bfe1 --- /dev/null +++ b/packages/core/src/__tests__/errors.test.ts @@ -0,0 +1,177 @@ +/** + * Tests for CodesparApiError + the throwFromResponse helper that + * collapses all transport-failure throw sites in session.ts. + * + * Asserts: + * - Every transport throw site surfaces a CodesparApiError (not + * a plain Error) carrying status + code + body. + * - Network errors wrap fetch rejections into CodesparApiError + * with status: 0 and preserve the underlying cause. + * - The session.execute non-ok branch is untouched — it still + * returns ToolResult.success === false rather than throwing. + * - instanceof CodesparApiError works across realms (prototype + * chain is restored via Object.setPrototypeOf). + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { CodesparApiError } from "../errors.js"; +import { CodeSpar } from "../index.js"; + +describe("CodesparApiError", () => { + it("constructs with status + message", () => { + const err = new CodesparApiError("boom", { status: 500 }); + expect(err.status).toBe(500); + expect(err.message).toBe("boom"); + expect(err.code).toBeUndefined(); + expect(err.body).toBeUndefined(); + }); + + it("carries the structured code + body + cause", () => { + const cause = new Error("underlying"); + const err = new CodesparApiError("boom", { + status: 403, + code: "mocks_not_permitted", + body: { error: "mocks_not_permitted", message: "test mode key required" }, + cause, + }); + expect(err.status).toBe(403); + expect(err.code).toBe("mocks_not_permitted"); + expect(err.body).toEqual({ + error: "mocks_not_permitted", + message: "test mode key required", + }); + expect(err.cause).toBe(cause); + }); + + it("instanceof CodesparApiError works after prototype-chain repair", () => { + const err = new CodesparApiError("x", { status: 1 }); + expect(err instanceof CodesparApiError).toBe(true); + expect(err instanceof Error).toBe(true); + expect(err.name).toBe("CodesparApiError"); + }); +}); + +describe("session transport-failure call sites throw CodesparApiError", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("createSession throws CodesparApiError on 4xx with structured body", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + text: async () => + JSON.stringify({ + error: "mocks_not_permitted", + message: "csk_test_* key required", + }), + }) as unknown as typeof fetch; + + const cs = new CodeSpar({ + apiKey: "csk_live_test", + baseUrl: "https://api.example.com", + }); + + try { + await cs.create("user_42"); + expect.fail("expected createSession to throw"); + } catch (err) { + expect(err).toBeInstanceOf(CodesparApiError); + const apiErr = err as CodesparApiError; + expect(apiErr.status).toBe(403); + expect(apiErr.code).toBe("mocks_not_permitted"); + } + }); + + it("createSession wraps fetch rejection as CodesparApiError status 0", async () => { + const underlying = new TypeError("Network failure"); + globalThis.fetch = vi + .fn() + .mockRejectedValue(underlying) as unknown as typeof fetch; + + const cs = new CodeSpar({ + apiKey: "csk_live_test", + baseUrl: "https://api.example.com", + }); + + try { + await cs.create("user_42"); + expect.fail("expected createSession to throw"); + } catch (err) { + expect(err).toBeInstanceOf(CodesparApiError); + const apiErr = err as CodesparApiError; + expect(apiErr.status).toBe(0); + expect(apiErr.cause).toBe(underlying); + } + }); + + it("send throws CodesparApiError on 5xx", async () => { + const sessionCreate = { + ok: true, + status: 201, + text: async () => "", + json: async () => ({ + id: "ses_err", + org_id: "o", + user_id: "u", + servers: [], + status: "active", + created_at: new Date().toISOString(), + closed_at: null, + }), + }; + const sendFail = { + ok: false, + status: 502, + text: async () => JSON.stringify({ code: "upstream_unavailable" }), + }; + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(sessionCreate) + .mockResolvedValueOnce(sendFail) as unknown as typeof fetch; + + const cs = new CodeSpar({ + apiKey: "csk_live_test", + baseUrl: "https://api.example.com", + }); + const session = await cs.create("u"); + await expect(session.send("hi")).rejects.toBeInstanceOf(CodesparApiError); + }); + + it("session.execute does NOT throw on non-ok — returns ToolResult.success=false", async () => { + const sessionCreate = { + ok: true, + status: 201, + text: async () => "", + json: async () => ({ + id: "ses_ok", + org_id: "o", + user_id: "u", + servers: [], + status: "active", + created_at: new Date().toISOString(), + closed_at: null, + }), + }; + const execFail = { + ok: false, + status: 422, + text: async () => "validation failed", + }; + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(sessionCreate) + .mockResolvedValueOnce(execFail) as unknown as typeof fetch; + + const cs = new CodeSpar({ + apiKey: "csk_live_test", + baseUrl: "https://api.example.com", + }); + const session = await cs.create("u"); + const result = await session.execute("asaas/create_payment", {}); + expect(result.success).toBe(false); + expect(result.error).toContain("422"); + }); +}); diff --git a/packages/core/src/__tests__/forward-mocks.test.ts b/packages/core/src/__tests__/forward-mocks.test.ts new file mode 100644 index 0000000..c93825c --- /dev/null +++ b/packages/core/src/__tests__/forward-mocks.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for the createSession body builder's `mocks` forwarding. + * + * Asserts: + * - Wire-neutrality: a cs.create without `mocks` produces a body + * byte-identical to today's shape (R18). + * - Forwarded shape: a cs.create with `mocks` includes the field + * verbatim — no SDK-side rewriting of canonical names. + * - Empty `mocks: {}` is forwarded (the backend accepts; strict- + * mode R3a activates only on non-empty maps). + * - Double-underscore key form reaches the backend unrewritten + * so the canonical-form rejection surfaces at the right layer. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { CodeSpar } from "../index.js"; + +function mockSessionResponse() { + return { + ok: true, + status: 201, + text: async () => "", + json: async () => ({ + id: "ses_demo", + org_id: "org_demo", + user_id: "user_demo", + servers: ["asaas"], + status: "active" as const, + created_at: new Date().toISOString(), + closed_at: null, + }), + }; +} + +describe("createSession body builder forwards mocks", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("omits mocks key from the wire body when undefined (R18 wire-neutral)", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(mockSessionResponse()) as unknown as typeof fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const cs = new CodeSpar({ + apiKey: "csk_live_test", + baseUrl: "https://api.example.com", + }); + await cs.create("user_demo", { servers: ["asaas"] }); + + const init = (fetchMock as unknown as ReturnType).mock + .calls[0]![1] as { body: string }; + const body = JSON.parse(init.body) as Record; + expect("mocks" in body).toBe(false); + expect(body).toEqual({ servers: ["asaas"], user_id: "user_demo" }); + }); + + it("forwards mocks verbatim when present", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(mockSessionResponse()) as unknown as typeof fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const cs = new CodeSpar({ + apiKey: "csk_live_test", + baseUrl: "https://api.example.com", + }); + await cs.create("user_demo", { + servers: ["asaas"], + mocks: { + "asaas/create_payment": { id: "pay_test_42", status: "PENDING" }, + }, + }); + + const init = (fetchMock as unknown as ReturnType).mock + .calls[0]![1] as { body: string }; + const body = JSON.parse(init.body) as Record; + expect(body.mocks).toEqual({ + "asaas/create_payment": { id: "pay_test_42", status: "PENDING" }, + }); + }); + + it("accepts and forwards an empty mocks={} body", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(mockSessionResponse()) as unknown as typeof fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const cs = new CodeSpar({ + apiKey: "csk_live_test", + baseUrl: "https://api.example.com", + }); + await cs.create("user_demo", { servers: ["asaas"], mocks: {} }); + + const init = (fetchMock as unknown as ReturnType).mock + .calls[0]![1] as { body: string }; + const body = JSON.parse(init.body) as Record; + expect(body.mocks).toEqual({}); + }); + + it("does NOT rewrite double-underscore keys — they reach the backend verbatim", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(mockSessionResponse()) as unknown as typeof fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const cs = new CodeSpar({ + apiKey: "csk_live_test", + baseUrl: "https://api.example.com", + }); + await cs.create("user_demo", { + servers: ["asaas"], + mocks: { asaas__create_payment: { id: "pay_test_42" } }, + }); + + const init = (fetchMock as unknown as ReturnType).mock + .calls[0]![1] as { body: string }; + const body = JSON.parse(init.body) as Record; + const mocks = body.mocks as Record; + expect(Object.keys(mocks)).toEqual(["asaas__create_payment"]); + }); + + it("matches the canonical fixture byte-for-byte when the same input lands", async () => { + // Same canonical body used by the Python parity test. + const fetchMock = vi + .fn() + .mockResolvedValueOnce(mockSessionResponse()) as unknown as typeof fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const cs = new CodeSpar({ + apiKey: "csk_live_test", + baseUrl: "https://api.example.com", + }); + await cs.create("user_demo", { + servers: ["asaas"], + mocks: { + "asaas/create_payment": { id: "pay_test_42", status: "PENDING" }, + "asaas/get_payment": [ + { id: "pay_test_42", status: "PENDING" }, + { id: "pay_test_42", status: "CONFIRMED" }, + ], + }, + }); + + const init = (fetchMock as unknown as ReturnType).mock + .calls[0]![1] as { body: string }; + expect(init.body).toBe( + '{"servers":["asaas"],"user_id":"user_demo","mocks":{"asaas/create_payment":{"id":"pay_test_42","status":"PENDING"},"asaas/get_payment":[{"id":"pay_test_42","status":"PENDING"},{"id":"pay_test_42","status":"CONFIRMED"}]}}', + ); + }); +}); diff --git a/packages/core/src/__tests__/mocks-wire-parity.test.ts b/packages/core/src/__tests__/mocks-wire-parity.test.ts new file mode 100644 index 0000000..f764999 --- /dev/null +++ b/packages/core/src/__tests__/mocks-wire-parity.test.ts @@ -0,0 +1,82 @@ +/** + * Wire-shape parity test for the test-mode mocks field. + * + * Asserts that the TypeScript SessionConfig type carries the optional + * `mocks` field with the right shape, and that the serialized form + * matches the canonical fixture used by the Python SDK's parallel + * test. Both languages serialize the same example to byte-identical + * JSON; any drift here is a wire-contract break. + * + * The canonical fixture lives at packages/python/tests/_fixtures/ + * mocks_canonical.json — kept in sync by hand. Future contributors + * MUST update both files together when extending the example. + */ + +import { describe, it, expect } from "vitest"; +import type { MockObject, MockValue } from "@codespar/types"; +import type { SessionConfig } from "../types.js"; + +const CANONICAL_BODY = { + servers: ["asaas"], + user_id: "user_demo", + mocks: { + "asaas/create_payment": { id: "pay_test_42", status: "PENDING" }, + "asaas/get_payment": [ + { id: "pay_test_42", status: "PENDING" }, + { id: "pay_test_42", status: "CONFIRMED" }, + ], + }, +} as const; + +describe("MockObject + MockValue type aliases", () => { + it("accepts a plain dict as MockObject", () => { + const obj: MockObject = { id: "pay_test_42", status: "PENDING" }; + expect(obj.id).toBe("pay_test_42"); + }); + + it("accepts a single MockObject as MockValue (static mock)", () => { + const v: MockValue = { id: "pay_test_42", status: "PENDING" }; + expect(v).toBeDefined(); + }); + + it("accepts a MockObject array as MockValue (stateful mock)", () => { + const v: MockValue = [ + { id: "pay_test_42", status: "PENDING" }, + { id: "pay_test_42", status: "CONFIRMED" }, + ]; + expect(Array.isArray(v)).toBe(true); + }); +}); + +describe("CreateSessionOptions carries optional mocks field", () => { + it("accepts a SessionConfig with no mocks (wire-neutral)", () => { + const cfg: SessionConfig = { servers: ["asaas"] }; + expect(cfg.mocks).toBeUndefined(); + }); + + it("accepts a SessionConfig with canonical mocks shape", () => { + const cfg: SessionConfig = { + servers: ["asaas"], + mocks: { + "asaas/create_payment": { id: "pay_test_42", status: "PENDING" }, + "asaas/get_payment": [ + { id: "pay_test_42", status: "PENDING" }, + { id: "pay_test_42", status: "CONFIRMED" }, + ], + }, + }; + expect(cfg.mocks?.["asaas/create_payment"]).toBeDefined(); + }); +}); + +describe("Canonical body serializes to byte-identical JSON", () => { + it("matches the Python SDK fixture byte-for-byte", () => { + // The canonical body field order is preserved by the JSON.stringify + // contract used in createSession's body builder. If field ordering + // changes here, the Python side must change too. + const serialized = JSON.stringify(CANONICAL_BODY); + expect(serialized).toBe( + '{"servers":["asaas"],"user_id":"user_demo","mocks":{"asaas/create_payment":{"id":"pay_test_42","status":"PENDING"},"asaas/get_payment":[{"id":"pay_test_42","status":"PENDING"},{"id":"pay_test_42","status":"CONFIRMED"}]}}', + ); + }); +}); diff --git a/packages/core/src/__tests__/tool-result-codes.test.ts b/packages/core/src/__tests__/tool-result-codes.test.ts new file mode 100644 index 0000000..ded03db --- /dev/null +++ b/packages/core/src/__tests__/tool-result-codes.test.ts @@ -0,0 +1,202 @@ +/** + * Tool-result code guard tests. + * + * Asserts: + * - Positive + negative paths per guard. + * - Sibling-field-missing fixtures — a well-formed `code` with + * missing required siblings (e.g. `rule_id` for policy_denied) + * returns false. The guard does not narrow on the discriminant + * alone. + * - Unknown-code defense-in-depth — an unknown `code` value never + * narrows positive on any guard. + * - assertExhaustiveToolResult compiles when every variant is + * handled (the test below is the compile-time witness). + * - output.code vs error_code disagreement — guards key on the + * `code` field on the payload, ignoring sibling discrepancies. + */ + +import { describe, it, expect } from "vitest"; +import { + TOOL_RESULT_CODES, + ToolResultCode, + assertExhaustiveToolResult, + isApprovalRequired, + isMocksEngineError, + isMocksExhausted, + isPolicyDenied, + isToolNotMocked, + type ToolResultOutcome, +} from "../tool-result-codes.js"; + +describe("TOOL_RESULT_CODES set", () => { + it("includes the five canonical codes", () => { + expect(TOOL_RESULT_CODES).toContain("policy_denied"); + expect(TOOL_RESULT_CODES).toContain("approval_required"); + expect(TOOL_RESULT_CODES).toContain("mocks_exhausted"); + expect(TOOL_RESULT_CODES).toContain("mocks_engine_error"); + expect(TOOL_RESULT_CODES).toContain("tool_not_mocked"); + }); +}); + +describe("isPolicyDenied", () => { + it("returns true for a well-formed policy_denied output", () => { + const out: unknown = { + code: ToolResultCode.PolicyDenied, + rule_id: "spend_cap", + message: "exceeds tenant cap", + }; + expect(isPolicyDenied(out)).toBe(true); + }); + + it("returns false when rule_id is missing", () => { + const out: unknown = { + code: ToolResultCode.PolicyDenied, + message: "missing rule", + }; + expect(isPolicyDenied(out)).toBe(false); + }); + + it("returns false when message is missing", () => { + const out: unknown = { + code: ToolResultCode.PolicyDenied, + rule_id: "spend_cap", + }; + expect(isPolicyDenied(out)).toBe(false); + }); + + it("returns false on a foreign discriminant", () => { + const out: unknown = { + code: "approval_required", + rule_id: "x", + message: "y", + }; + expect(isPolicyDenied(out)).toBe(false); + }); + + it("returns false on an unknown code (defense in depth)", () => { + const out: unknown = { + code: "totally_made_up", + rule_id: "x", + message: "y", + }; + expect(isPolicyDenied(out)).toBe(false); + }); + + it("returns false on null/undefined/non-object", () => { + expect(isPolicyDenied(null)).toBe(false); + expect(isPolicyDenied(undefined)).toBe(false); + expect(isPolicyDenied("policy_denied")).toBe(false); + }); +}); + +describe("isApprovalRequired", () => { + it("returns true for a well-formed approval_required output", () => { + const out: unknown = { + code: ToolResultCode.ApprovalRequired, + approval_id: "apr_abc", + expires_at: "2026-12-01T00:00:00Z", + message: "approve the transfer", + }; + expect(isApprovalRequired(out)).toBe(true); + }); + + it("returns false when approval_id is missing", () => { + const out: unknown = { + code: ToolResultCode.ApprovalRequired, + expires_at: "2026-12-01T00:00:00Z", + message: "x", + }; + expect(isApprovalRequired(out)).toBe(false); + }); + + it("returns false when expires_at is missing", () => { + const out: unknown = { + code: ToolResultCode.ApprovalRequired, + approval_id: "apr_abc", + message: "x", + }; + expect(isApprovalRequired(out)).toBe(false); + }); + + it("returns false when message is missing", () => { + const out: unknown = { + code: ToolResultCode.ApprovalRequired, + approval_id: "apr_abc", + expires_at: "x", + }; + expect(isApprovalRequired(out)).toBe(false); + }); +}); + +describe("isMocksExhausted and isMocksEngineError", () => { + it("isMocksExhausted: positive + sibling-missing", () => { + expect( + isMocksExhausted({ code: ToolResultCode.MocksExhausted, message: "drained" }), + ).toBe(true); + expect(isMocksExhausted({ code: ToolResultCode.MocksExhausted })).toBe(false); + }); + + it("isMocksEngineError: positive + sibling-missing", () => { + expect( + isMocksEngineError({ + code: ToolResultCode.MocksEngineError, + message: "consume failed", + }), + ).toBe(true); + expect(isMocksEngineError({ code: ToolResultCode.MocksEngineError })).toBe(false); + }); +}); + +describe("isToolNotMocked", () => { + it("returns true with tool_name + message", () => { + expect( + isToolNotMocked({ + code: ToolResultCode.ToolNotMocked, + tool_name: "asaas/create_payment", + message: "not in mocks map", + }), + ).toBe(true); + }); + + it("returns false when tool_name is missing", () => { + expect( + isToolNotMocked({ code: ToolResultCode.ToolNotMocked, message: "x" }), + ).toBe(false); + }); +}); + +describe("assertExhaustiveToolResult", () => { + it("compiles when every code variant is handled", () => { + // The test BODY here is the compile-time witness; runtime asserts + // the unreachable-default branch throws on a hostile cast. + function describe(value: ToolResultOutcome): string { + switch (value.code) { + case ToolResultCode.PolicyDenied: + return "denied"; + case ToolResultCode.ApprovalRequired: + return "approval"; + case ToolResultCode.MocksExhausted: + return "exhausted"; + case ToolResultCode.MocksEngineError: + return "engine"; + case ToolResultCode.ToolNotMocked: + return "not_mocked"; + default: + // If a 6th code lands without this branch being updated, TS + // fails: assertExhaustiveToolResult(value) would error at + // compile time on a non-never argument. + return assertExhaustiveToolResult(value); + } + } + expect( + describe({ + code: ToolResultCode.PolicyDenied, + rule_id: "x", + message: "y", + }), + ).toBe("denied"); + expect(() => + assertExhaustiveToolResult({ code: "rogue" as never } as never), + ).toThrow(/tool-result-codes/i); + }); +}); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..aeb1430 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,113 @@ +/** + * Structured exception for every transport-failure path through the + * SDK. Replaces the old `throw new Error("send failed: 500 ...")` + * shape, so callers can branch on `e.code` rather than parsing + * `e.message` strings. + * + * The reserved code namespace covers the hosted-test-mode wire + * contract (tool-result codes + create-time envelope codes — + * see the README's "Reserved error codes" section). Customers + * extending CodesparApiError should prefix their own codes (e.g. + * `"myapp.policy_denied"`) to avoid collision with reserved values. + * + * `status: 0` is reserved for network errors that never reached the + * backend; the underlying `cause` carries the original `TypeError` + * or `DOMException` that `fetch` rejected with. + */ +export interface CodesparApiErrorOptions { + status: number; + code?: string; + body?: unknown; + cause?: unknown; +} + +export class CodesparApiError extends Error { + readonly status: number; + readonly code?: string; + readonly body?: unknown; + + constructor(message: string, options: CodesparApiErrorOptions) { + // ES2022 `cause` shape — supported by Node 20 + modern browsers. + super(message, options.cause !== undefined ? { cause: options.cause } : undefined); + // Restore the prototype chain — without this, `instanceof + // CodesparApiError` returns false across some transpilers and + // realms (the classic ES5-target Error subclass trap). + Object.setPrototypeOf(this, CodesparApiError.prototype); + this.name = "CodesparApiError"; + this.status = options.status; + this.code = options.code; + this.body = options.body; + } +} + +interface ParsedErrorBody { + code?: string; + message?: string; +} + +function parseErrorPayload(raw: string): { + body: unknown; + parsed: ParsedErrorBody; +} { + if (!raw) return { body: undefined, parsed: {} }; + try { + const body = JSON.parse(raw) as unknown; + if (body && typeof body === "object" && !Array.isArray(body)) { + const obj = body as Record; + const code = typeof obj.code === "string" ? obj.code : undefined; + // Legacy fallback: pre-PRD envelopes used `error` as the + // discriminant. `code` takes precedence; `error` honored only + // when `code` is missing. + const fallbackCode = + code ?? (typeof obj.error === "string" ? (obj.error as string) : undefined); + const message = typeof obj.message === "string" ? obj.message : undefined; + return { + body, + parsed: { code: fallbackCode, message }, + }; + } + return { body, parsed: {} }; + } catch { + return { body: raw, parsed: {} }; + } +} + +/** + * Build the canonical error message + extract the structured code + + * preserve the parsed body. Centralised so every transport call site + * surfaces the same shape — a customer parsing `e.message` is + * already on the deprecated path; new code branches on `e.code`. + */ +export async function throwFromResponse( + response: Response, + what: string, +): Promise { + const raw = await response.text(); + const { body, parsed } = parseErrorPayload(raw); + const suffix = parsed.message + ? ` — ${parsed.message}` + : parsed.code + ? ` — ${parsed.code}` + : raw + ? ` ${raw}` + : ""; + throw new CodesparApiError(`${what} failed: ${response.status}${suffix}`, { + status: response.status, + code: parsed.code, + body, + }); +} + +/** + * Convert a `fetch` rejection (network error, abort, DNS, TLS, etc.) + * into a CodesparApiError with `status: 0`. Preserves the underlying + * error as `cause` so callers debugging a transport failure can dig + * into the original `TypeError`. + */ +export function networkErrorToApiError(cause: unknown, what: string): CodesparApiError { + const message = cause instanceof Error ? cause.message : String(cause); + return new CodesparApiError(`${what} network error: ${message}`, { + status: 0, + cause, + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d652f14..5b57520 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,6 +38,31 @@ export type { export { SessionConfigSchema } from "./types.js"; export { loop } from "./loop.js"; export { tools, findTools } from "./tools.js"; +export { CodesparApiError } from "./errors.js"; +export type { CodesparApiErrorOptions } from "./errors.js"; +export { + TOOL_RESULT_CODES, + ToolResultCode, + assertExhaustiveToolResult, + isApprovalRequired, + isMocksEngineError, + isMocksExhausted, + isPolicyDenied, + isToolNotMocked, +} from "./tool-result-codes.js"; +export type { + ApprovalRequiredOutput, + ApprovalRequiredToolCall, + MocksEngineErrorOutput, + MocksEngineErrorToolCall, + MocksExhaustedOutput, + MocksExhaustedToolCall, + PolicyDeniedOutput, + PolicyDeniedToolCall, + ToolNotMockedOutput, + ToolNotMockedToolCall, + ToolResultOutcome, +} from "./tool-result-codes.js"; import type { CodeSparConfig, SessionConfig } from "./types.js"; import type { Session } from "@codespar/types"; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 04ca194..9ccb62b 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -2,6 +2,7 @@ * Session implementation for the CodeSpar managed runtime. */ +import { networkErrorToApiError, throwFromResponse } from "./errors.js"; import type { SessionConfig, Tool } from "./types.js"; import type { Session, @@ -34,6 +35,22 @@ interface SessionDeps { projectId?: string; } +// Wraps `fetch` so network rejections become CodesparApiError +// (status: 0) at the very edge of the SDK, rather than bubbling +// raw TypeError / DOMException up to the caller. Every transport +// site in this file goes through here. +async function safeFetch( + input: RequestInfo | URL, + init: RequestInit | undefined, + what: string, +): Promise { + try { + return await fetch(input, init); + } catch (cause) { + throw networkErrorToApiError(cause, what); + } +} + interface BackendSessionResponse { id: string; org_id: string; @@ -71,14 +88,28 @@ export async function createSession( projectId: config.projectId ?? deps.projectId, }; - const res = await fetch(`${baseUrl}/v1/sessions`, { - method: "POST", - headers, - body: JSON.stringify({ servers: req.servers, user_id: userId }), - }); + // Conditional spread keeps the wire body byte-identical to the + // pre-PRD shape when the caller omits mocks (R18 wire-neutrality). + // The field is forwarded verbatim — no canonical-name rewriting on + // the SDK side, so the double-underscore migration trap surfaces + // as the backend's mocks_invalid envelope rather than silent + // SDK-side normalization. + const wireBody: Record = { + servers: req.servers, + user_id: userId, + ...(config.mocks !== undefined ? { mocks: config.mocks } : {}), + }; + const res = await safeFetch( + `${baseUrl}/v1/sessions`, + { + method: "POST", + headers, + body: JSON.stringify(wireBody), + }, + "createSession", + ); if (!res.ok) { - const body = await res.text(); - throw new Error(`createSession failed: ${res.status} ${body}`); + await throwFromResponse(res, "createSession"); } const data = (await res.json()) as BackendSessionResponse; @@ -119,11 +150,15 @@ export async function createSession( async execute(toolName: string, params: Record): Promise { const start = Date.now(); - const r = await fetch(`${baseUrl}/v1/sessions/${data.id}/execute`, { - method: "POST", - headers, - body: JSON.stringify({ tool: toolName, input: params }), - }); + const r = await safeFetch( + `${baseUrl}/v1/sessions/${data.id}/execute`, + { + method: "POST", + headers, + body: JSON.stringify({ tool: toolName, input: params }), + }, + "execute", + ); if (!r.ok) { const body = await r.text(); return { @@ -140,49 +175,59 @@ export async function createSession( }, async proxyExecute(request: ProxyRequest): Promise { - const r = await fetch(`${baseUrl}/v1/sessions/${data.id}/proxy_execute`, { - method: "POST", - headers, - body: JSON.stringify({ - server: request.server, - endpoint: request.endpoint, - method: request.method, - body: request.body, - params: request.params, - headers: request.headers, - }), - }); + const r = await safeFetch( + `${baseUrl}/v1/sessions/${data.id}/proxy_execute`, + { + method: "POST", + headers, + body: JSON.stringify({ + server: request.server, + endpoint: request.endpoint, + method: request.method, + body: request.body, + params: request.params, + headers: request.headers, + }), + }, + "proxyExecute", + ); if (!r.ok) { - const body = await r.text(); - throw new Error(`proxyExecute failed: ${r.status} ${body}`); + await throwFromResponse(r, "proxyExecute"); } return (await r.json()) as ProxyResult; }, async send(message: string): Promise { - const r = await fetch(`${baseUrl}/v1/sessions/${data.id}/send`, { - method: "POST", - headers: { ...headers, Accept: "application/json" }, - body: JSON.stringify({ message }), - }); + const r = await safeFetch( + `${baseUrl}/v1/sessions/${data.id}/send`, + { + method: "POST", + headers: { ...headers, Accept: "application/json" }, + body: JSON.stringify({ message }), + }, + "send", + ); if (!r.ok) { - const body = await r.text(); - throw new Error(`send failed: ${r.status} ${body}`); + await throwFromResponse(r, "send"); } return (await r.json()) as SendResult; }, async *sendStream(message: string): AsyncIterable { - const r = await fetch(`${baseUrl}/v1/sessions/${data.id}/send`, { - method: "POST", - headers: { ...headers, Accept: "text/event-stream" }, - body: JSON.stringify({ message }), - }); + const r = await safeFetch( + `${baseUrl}/v1/sessions/${data.id}/send`, + { + method: "POST", + headers: { ...headers, Accept: "text/event-stream" }, + body: JSON.stringify({ message }), + }, + "sendStream", + ); if (!r.ok || !r.body) { - const body = await r.text(); - throw new Error(`sendStream failed: ${r.status} ${body}`); + await throwFromResponse(r, "sendStream"); } - yield* parseSseStream(r.body); + // Once we're past the !r.ok branch, r.body is guaranteed non-null. + yield* parseSseStream(r.body!); }, /** @@ -262,13 +307,13 @@ export async function createSession( }, async paymentStatus(toolCallId: string): Promise { - const r = await fetch( + const r = await safeFetch( `${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/payment-status`, { headers }, + "paymentStatus", ); if (!r.ok) { - const body = await r.text(); - throw new Error(`paymentStatus failed: ${r.status} ${body}`); + await throwFromResponse(r, "paymentStatus"); } return (await r.json()) as PaymentStatusResult; }, @@ -276,13 +321,13 @@ export async function createSession( async verificationStatus( toolCallId: string, ): Promise { - const r = await fetch( + const r = await safeFetch( `${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/verification-status`, { headers }, + "verificationStatus", ); if (!r.ok) { - const body = await r.text(); - throw new Error(`verificationStatus failed: ${r.status} ${body}`); + await throwFromResponse(r, "verificationStatus"); } return (await r.json()) as VerificationStatusResult; }, @@ -294,15 +339,18 @@ export async function createSession( const url = `${baseUrl}/v1/tool-calls/${encodeURIComponent( toolCallId, )}/payment-status/stream`; - const r = await fetch(url, { - headers: { ...headers, Accept: "text/event-stream" }, - signal: options.signal, - }); + const r = await safeFetch( + url, + { + headers: { ...headers, Accept: "text/event-stream" }, + signal: options.signal, + }, + "paymentStatusStream", + ); if (!r.ok || !r.body) { - const body = await r.text(); - throw new Error( - `paymentStatusStream failed: ${r.status} ${body}`, - ); + await throwFromResponse(r, "paymentStatusStream"); + // unreachable — throwFromResponse always throws + throw new Error("unreachable"); } let last: PaymentStatusResult | null = null; for await (const frame of parseStatusSseStream(r.body)) { @@ -326,15 +374,18 @@ export async function createSession( const url = `${baseUrl}/v1/tool-calls/${encodeURIComponent( toolCallId, )}/verification-status/stream`; - const r = await fetch(url, { - headers: { ...headers, Accept: "text/event-stream" }, - signal: options.signal, - }); + const r = await safeFetch( + url, + { + headers: { ...headers, Accept: "text/event-stream" }, + signal: options.signal, + }, + "verificationStatusStream", + ); if (!r.ok || !r.body) { - const body = await r.text(); - throw new Error( - `verificationStatusStream failed: ${r.status} ${body}`, - ); + await throwFromResponse(r, "verificationStatusStream"); + // unreachable — throwFromResponse always throws + throw new Error("unreachable"); } let last: VerificationStatusResult | null = null; for await (const frame of parseStatusSseStream(r.body)) { @@ -354,19 +405,22 @@ export async function createSession( }, async authorize(serverId: string, config: AuthConfig): Promise { - const r = await fetch(`${baseUrl}/v1/connect/start`, { - method: "POST", - headers, - body: JSON.stringify({ - server_id: serverId, - user_id: data.user_id, - redirect_uri: config.redirectUri, - scopes: config.scopes, - }), - }); + const r = await safeFetch( + `${baseUrl}/v1/connect/start`, + { + method: "POST", + headers, + body: JSON.stringify({ + server_id: serverId, + user_id: data.user_id, + redirect_uri: config.redirectUri, + scopes: config.scopes, + }), + }, + "authorize", + ); if (!r.ok) { - const body = await r.text(); - throw new Error(`authorize failed: ${r.status} ${body}`); + await throwFromResponse(r, "authorize"); } const payload = (await r.json()) as { link_token: string; @@ -381,21 +435,38 @@ export async function createSession( }, async connections(): Promise { - const r = await fetch(`${baseUrl}/v1/sessions/${data.id}/connections`, { - headers, - }); - if (!r.ok) return cachedConnections ?? []; - const payload = (await r.json()) as BackendConnectionsResponse; - cachedConnections = payload.servers; - cachedTools = payload.tools; - return payload.servers; + // Best-effort — both transport failures (CodesparApiError from + // safeFetch) and non-2xx responses fall back to the cached + // payload so a transient blip doesn't crater the session. + try { + const r = await safeFetch( + `${baseUrl}/v1/sessions/${data.id}/connections`, + { headers }, + "connections", + ); + if (!r.ok) return cachedConnections ?? []; + const payload = (await r.json()) as BackendConnectionsResponse; + cachedConnections = payload.servers; + cachedTools = payload.tools; + return payload.servers; + } catch { + return cachedConnections ?? []; + } }, async close(): Promise { - await fetch(`${baseUrl}/v1/sessions/${data.id}`, { - method: "DELETE", - headers, - }); + // Best-effort — the backend reaps stale sessions on a timer, + // so a network failure here shouldn't surface to the caller. + try { + await safeFetch( + `${baseUrl}/v1/sessions/${data.id}`, + { method: "DELETE", headers }, + "close", + ); + } catch { + // Intentional swallow — matches the previous fire-and-forget + // contract; close() never threw. + } }, }; diff --git a/packages/core/src/tool-result-codes.ts b/packages/core/src/tool-result-codes.ts new file mode 100644 index 0000000..c03f070 --- /dev/null +++ b/packages/core/src/tool-result-codes.ts @@ -0,0 +1,153 @@ +/** + * Tool-result code helpers — type-narrowed guards for the discriminated + * `tool_result.output` union surfaced on streamed `ToolCallRecord` + * values. + * + * The five variants are inert wire shapes — they only describe what the + * backend may stamp on `tool_result.output`. The guards turn `unknown` + * into one of the five `*Output` interfaces so callers can branch + * without casting. + * + * Each guard checks both the `code` discriminant against + * TOOL_RESULT_CODES AND its own required sibling fields — so a + * well-formed `code` with a missing sibling returns false rather + * than narrowing positive on the discriminant alone. The exhaustive- + * match utility (`assertExhaustiveToolResult`) makes a switch over + * ToolResultCode fail to compile if a sixth variant lands without + * the consumer updating their handler. + */ + +import type { ToolCallRecord } from "@codespar/types"; + +export const ToolResultCode = { + PolicyDenied: "policy_denied", + ApprovalRequired: "approval_required", + MocksExhausted: "mocks_exhausted", + MocksEngineError: "mocks_engine_error", + ToolNotMocked: "tool_not_mocked", +} as const; + +export type ToolResultCode = (typeof ToolResultCode)[keyof typeof ToolResultCode]; + +export const TOOL_RESULT_CODES: ReadonlySet = new Set([ + ToolResultCode.PolicyDenied, + ToolResultCode.ApprovalRequired, + ToolResultCode.MocksExhausted, + ToolResultCode.MocksEngineError, + ToolResultCode.ToolNotMocked, +]); + +export interface PolicyDeniedOutput { + code: typeof ToolResultCode.PolicyDenied; + rule_id: string; + message: string; +} + +export interface ApprovalRequiredOutput { + code: typeof ToolResultCode.ApprovalRequired; + approval_id: string; + expires_at: string; + message: string; +} + +export interface MocksExhaustedOutput { + code: typeof ToolResultCode.MocksExhausted; + message: string; +} + +export interface MocksEngineErrorOutput { + code: typeof ToolResultCode.MocksEngineError; + message: string; +} + +export interface ToolNotMockedOutput { + code: typeof ToolResultCode.ToolNotMocked; + tool_name: string; + message: string; +} + +export type ToolResultOutcome = + | PolicyDeniedOutput + | ApprovalRequiredOutput + | MocksExhaustedOutput + | MocksEngineErrorOutput + | ToolNotMockedOutput; + +// Narrowed ToolCallRecord aliases — when a guard succeeds the +// `output` field is known to be the corresponding *Output variant. +export type PolicyDeniedToolCall = ToolCallRecord & { + output: PolicyDeniedOutput; +}; +export type ApprovalRequiredToolCall = ToolCallRecord & { + output: ApprovalRequiredOutput; +}; +export type MocksExhaustedToolCall = ToolCallRecord & { + output: MocksExhaustedOutput; +}; +export type MocksEngineErrorToolCall = ToolCallRecord & { + output: MocksEngineErrorOutput; +}; +export type ToolNotMockedToolCall = ToolCallRecord & { + output: ToolNotMockedOutput; +}; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readStringField(obj: Record, key: string): string | null { + const v = obj[key]; + return typeof v === "string" ? v : null; +} + +export function isPolicyDenied(value: unknown): value is PolicyDeniedOutput { + if (!isObject(value)) return false; + if (value.code !== ToolResultCode.PolicyDenied) return false; + if (!TOOL_RESULT_CODES.has(value.code as ToolResultCode)) return false; + return readStringField(value, "rule_id") !== null + && readStringField(value, "message") !== null; +} + +export function isApprovalRequired(value: unknown): value is ApprovalRequiredOutput { + if (!isObject(value)) return false; + if (value.code !== ToolResultCode.ApprovalRequired) return false; + if (!TOOL_RESULT_CODES.has(value.code as ToolResultCode)) return false; + return readStringField(value, "approval_id") !== null + && readStringField(value, "expires_at") !== null + && readStringField(value, "message") !== null; +} + +export function isMocksExhausted(value: unknown): value is MocksExhaustedOutput { + if (!isObject(value)) return false; + if (value.code !== ToolResultCode.MocksExhausted) return false; + if (!TOOL_RESULT_CODES.has(value.code as ToolResultCode)) return false; + return readStringField(value, "message") !== null; +} + +export function isMocksEngineError(value: unknown): value is MocksEngineErrorOutput { + if (!isObject(value)) return false; + if (value.code !== ToolResultCode.MocksEngineError) return false; + if (!TOOL_RESULT_CODES.has(value.code as ToolResultCode)) return false; + return readStringField(value, "message") !== null; +} + +export function isToolNotMocked(value: unknown): value is ToolNotMockedOutput { + if (!isObject(value)) return false; + if (value.code !== ToolResultCode.ToolNotMocked) return false; + if (!TOOL_RESULT_CODES.has(value.code as ToolResultCode)) return false; + return readStringField(value, "tool_name") !== null + && readStringField(value, "message") !== null; +} + +/** + * Exhaustive-match witness. A `switch` over `ToolResultCode` should + * pass `value` here in the default branch — TS fails to compile if + * `value` isn't narrowed to `never`, i.e. if a code variant escaped + * the switch. The runtime body throws so a hostile cast at runtime + * doesn't silently swallow an unknown code. + */ +export function assertExhaustiveToolResult(value: never): never { + throw new Error( + `tool-result-codes: unexpected output variant ${JSON.stringify(value)}`, + ); +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d9c0d1b..64b14d4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolResult } from "@codespar/types"; +import type { MockValue, ToolResult } from "@codespar/types"; /* ── Configuration ─────────────────────────────────────────────── */ @@ -30,6 +30,22 @@ export interface SessionConfig { metadata?: Record; /** Optional project scope. Defaults to the org's default project when omitted. */ projectId?: string; + /** + * Test-mode mocks. Map of canonical tool names (slash form, + * `^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9_-]*$` — e.g. + * `asaas/create_payment`) to mock responses. Values are either a + * single MockObject (static mock) or an array of MockObject + * (stateful mock, consumed in order). + * + * Requires a `csk_test_*` key against a `test`-environment project + * — the backend rejects with `mocks_not_permitted` otherwise. + * Forwarded verbatim to `POST /v1/sessions` so the OSS-runtime + * double-underscore form (`asaas__create_payment`) reaches the + * backend unrewritten and surfaces as `mocks_invalid`. An empty + * `{}` is accepted; strict-mode (R3a) activates only on non-empty + * maps. + */ + mocks?: Record; } /* ── Tools ────────────────────────────────────────────────────── */ @@ -87,6 +103,14 @@ export interface LoopResult { /* ── Validation schemas ───────────────────────────────────────── */ +// Mock values pass through verbatim — the backend owns the strict +// validation, so the client-side schema accepts any object shape +// rather than re-encoding the rules and risking drift. +const MockValueSchema = z.union([ + z.record(z.unknown()), + z.array(z.record(z.unknown())), +]); + export const SessionConfigSchema = z.object({ servers: z.array(z.string()).optional(), preset: z.enum(["brazilian", "mexican", "argentinian", "colombian", "all"]).optional(), @@ -98,4 +122,5 @@ export const SessionConfigSchema = z.object({ .optional(), metadata: z.record(z.string()).optional(), projectId: z.string().regex(/^prj_[A-Za-z0-9]{16}$/).optional(), + mocks: z.record(MockValueSchema).optional(), }); diff --git a/packages/python/README.md b/packages/python/README.md index 6b16342..9fc4b05 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -235,9 +235,80 @@ After the user completes the OAuth flow, CodeSpar stores the tokens in the per-project vault and forwards them back to `redirect_uri` with `?status=connected&connection_id=` appended. +## Test-mode mocks + +Skip live providers in tests by passing a `mocks` dict to `cs.create`. Keys are canonical tool names in slash form (`asaas/create_payment`, `melhor-envio/calculate_shipping`, ...). Values are either a single dict — used as the response on every matching call — or a list of dicts consumed in order, returning `mocks_exhausted` once the list drains. + +```python +import os +from codespar import CodeSpar + +with CodeSpar(api_key=os.environ["CODESPAR_API_KEY"]) as cs: + session = cs.create( + "user_test", + servers=["asaas"], + mocks={ + "asaas/create_payment": {"id": "pay_test", "status": "PENDING"}, + }, + ) + + result = session.execute("asaas/create_payment", {"value": 100}) + # result.data == {"id": "pay_test", "status": "PENDING"} +``` + +Pass a list for stateful mocks: + +```python +mocks={ + "asaas/create_payment": [ + {"id": "pay_1", "status": "PENDING"}, + {"id": "pay_1", "status": "RECEIVED"}, + ], +} +``` + +Mocks live behind the managed backend's test-mode gate — a `csk_test_*` API key against a `test`-environment project. Live keys against the same map return `mocks_not_permitted`. The SDK forwards keys verbatim; the OSS double-underscore form (`asaas__create_payment`) reaches the backend unrewritten and surfaces as `mocks_invalid` rather than the SDK silently rewriting. + +The OSS runtime accepts the same `mocks` shape on its session API (see [codespar/codespar#113](https://github.com/codespar/codespar/pull/113)), so the same fixtures work whether you point at `api.codespar.dev` or a self-hosted instance via `CODESPAR_BASE_URL`. Self-hosted runtimes must additionally set `CODESPAR_TEST_MODE_ENABLED=true` on the server process; without it, the SDK receives `mocks_not_permitted` / HTTP 501 instead of fixture responses. + +Storage shape differs by runtime — the wire contract does not. The managed backend persists mocks and per-tool consume counters; sessions and their fixtures survive restarts and multi-replica deployments. The OSS runtime holds both in process memory; they are scoped to the HTTP-session process and are lost on restart, and channel-bridge sessions (WhatsApp, Slack, Telegram, Discord) cannot carry mocks under the OSS shape. Response envelopes, status codes, sibling fields, and gate ordering are byte-identical between runtimes regardless. See [the test-mode concept doc](https://docs.codespar.dev/concepts/test-mode) for the full per-runtime split. + +Test mode is a property of the runtime, not the session. On the managed backend it's `project.environment == 'test'`; on a self-hosted OSS runtime it's `CODESPAR_TEST_MODE_ENABLED=true` on the server process. When the runtime is in test mode, every external tool call your code or LLM dispatches must match a declared mock — unmatched calls return `tool_not_mocked` (HTTP 422 on the catalog-routed `/execute` path; a `tool_result` block on the chat-loop) and no upstream provider runs. The envelope covers three failure modes: the `mocks` map has no entry for the canonical name, the session was created with no `mocks` field, or the canonical name has an unknown server prefix. A session that doesn't declare `mocks` can't dispatch any tools in test mode; declare the mocks the test will exercise, or run the same code against a live-mode runtime where the real providers handle dispatch. Built-in metadata tools — `codespar_list_tools` on OSS, `codespar_discover` and `codespar_manage_connections` on the managed backend — bypass this gate. + +### Type aliases + +`MockObject` (`dict[str, Any]`) and `MockValue` (`MockObject | list[MockObject]`) export from `codespar`. Use them when you want to define fixtures separately from the `create` call site. + +```python +from codespar import MockValue + +fixtures: dict[str, MockValue] = { + "asaas/create_payment": {"id": "pay_test", "status": "PENDING"}, +} +``` + +`AsyncCodeSpar` accepts the same `mocks=...` kwarg. + +## Base URL — managed or self-hosted OSS + +`CodeSpar` and `AsyncCodeSpar` honor the `CODESPAR_BASE_URL` environment variable. The constructor cascade is explicit `base_url` arg, then the env var, then the managed default at `https://api.codespar.dev`. + +```bash +# Point the same client wiring at a local OSS runtime +export CODESPAR_BASE_URL=http://localhost:8000 +``` + +Then your code stays unchanged: + +```python +cs = CodeSpar(api_key="local") # OSS runtimes accept any non-empty bearer +``` + +Pass `base_url=` explicitly when you need to override the env var. + ## Errors -Every failure is wrapped: +Every failure wraps in the `CodeSparError` hierarchy: ```python from codespar import ApiError, ConfigError, StreamError @@ -250,6 +321,62 @@ except ApiError as exc: print(f"Backend said {exc.status}: {exc.code}") ``` +`ApiError.code` is the structured discriminant on every non-success response — branch on it instead of parsing `str(exc)` or `exc.body`. The legacy `error` field on pre-test-mode envelopes is honored as a fallback when `code` is missing, so older responses still surface a code value. + +```python +from codespar import ApiError, CodeSpar + +try: + cs.create("user_test", mocks={"asaas/create_payment": {}}) +except ApiError as exc: + if exc.code == "mocks_not_permitted": + # Live key against a mocks map. Swap to csk_test_*. + ... + elif exc.code == "mocks_invalid": + # Backend rejected a tool-name key. Check the slash form. + ... + elif exc.status == 0: + # Network never reached the backend; exc.__cause__ has the httpx error. + ... + else: + raise +``` + +### Tool-result guards + +The five reserved tool-result codes ship typed guards plus an exhaustive-match helper. Each guard checks the `code` discriminant AND the variant's required sibling fields — a payload with a well-formed `code` but a missing `rule_id` / `approval_id` / `tool_name` returns False rather than narrowing positive. + +```python +from codespar import ( + CodeSpar, + assert_exhaustive_tool_result, + is_approval_required, + is_mocks_engine_error, + is_mocks_exhausted, + is_policy_denied, + is_tool_not_mocked, +) + +with CodeSpar(api_key="csk_test_...") as cs: + session = cs.create("user_test", servers=["asaas"]) + result = session.execute("asaas/create_payment", {"value": 100}) + + if is_policy_denied(result.data): + print(f"blocked by {result.data['rule_id']}: {result.data['message']}") + elif is_approval_required(result.data): + print(f"approval {result.data['approval_id']} expires {result.data['expires_at']}") + elif is_mocks_exhausted(result.data): + # Stateful mock list drained — pad it or extend the test. + ... + elif is_mocks_engine_error(result.data): + # Backend-side mocks engine failure; usually a malformed fixture. + ... + elif is_tool_not_mocked(result.data): + print(f"no mock for {result.data['tool_name']}") +``` + +`assert_exhaustive_tool_result(value)` raises `AssertionError` from a default branch — call it after handling each variant so a sixth code landing without a handler trips at the boundary instead of being silently swallowed. + ## Design parity with the JS SDK This package mirrors [`@codespar/sdk`](https://www.npmjs.com/package/@codespar/sdk) diff --git a/packages/python/examples/README.md b/packages/python/examples/README.md index 692b45f..6e6fa73 100644 --- a/packages/python/examples/README.md +++ b/packages/python/examples/README.md @@ -26,6 +26,7 @@ export CODESPAR_PROJECT_ID="prj_a1b2c3d4e5f6g7h8" | [`proxy_execute.py`](./proxy_execute.py) | Raw HTTP proxy to a provider API with server-side auth injection | `proxy_execute` | | [`connect_link.py`](./connect_link.py) | Generate an OAuth Connect Link for an end user | `authorize` | | [`async_basic.py`](./async_basic.py) | Same flow using `AsyncCodeSpar` for FastAPI / asyncio stacks | `AsyncCodeSpar` | +| [`mocks_round_trip.py`](./mocks_round_trip.py) | Hosted test-mode mocks — static + stateful fixtures, `mocks_exhausted` branch | `mocks=`, `is_mocks_exhausted` | ## Running diff --git a/packages/python/examples/mocks_round_trip.py b/packages/python/examples/mocks_round_trip.py new file mode 100644 index 0000000..33f81de --- /dev/null +++ b/packages/python/examples/mocks_round_trip.py @@ -0,0 +1,105 @@ +""" +Hosted test-mode mocks round-trip (Python). + +Demonstrates the two mock shapes accepted by ``cs.create(mocks=...)``: + - static: a single MockObject returned on every matching call + - stateful: a list of MockObject consumed in order, one per call, + returning ``mocks_exhausted`` once the list drains + +Requires a ``csk_test_*`` key against a test-environment project — live +keys against the same map return ``mocks_not_permitted``. + +Usage: + export CODESPAR_API_KEY="csk_test_..." + # optional: target a local OSS runtime + # export CODESPAR_BASE_URL=http://localhost:8000 + python examples/mocks_round_trip.py +""" + +from __future__ import annotations + +import os +import sys + +from codespar import ApiError, CodeSpar, MockValue, is_mocks_exhausted + +FIXTURES: dict[str, MockValue] = { + # Static — same response every call + "asaas/create_customer": { + "id": "cus_test", + "name": "Demo Buyer", + "cpfCnpj": "11144477735", + }, + # Stateful — consumed in order + "asaas/create_payment": [ + {"id": "pay_1", "status": "PENDING", "value": 100}, + {"id": "pay_1", "status": "RECEIVED", "value": 100}, + ], +} + + +def main() -> int: + api_key = os.environ.get("CODESPAR_API_KEY") + if not api_key: + print( + "error: set CODESPAR_API_KEY first (csk_test_* recommended)", + file=sys.stderr, + ) + return 1 + + with CodeSpar(api_key=api_key) as cs: + try: + session = cs.create( + "demo_user", + servers=["asaas"], + mocks=FIXTURES, + ) + except ApiError as exc: + if exc.code == "mocks_not_permitted": + print( + "error: this API key cannot use mocks. Swap to a " + "csk_test_* key against a test-environment project.", + file=sys.stderr, + ) + return 1 + raise + + try: + # Static mock + customer = session.execute( + "asaas/create_customer", + {"name": "Demo Buyer", "cpfCnpj": "11144477735"}, + ) + print("customer:", customer.data) + + # First call into the stateful mock + pending = session.execute( + "asaas/create_payment", + {"customer": "cus_test", "billingType": "PIX", "value": 100}, + ) + print("payment 1:", pending.data) + + # Second call into the stateful mock — different fixture + received = session.execute( + "asaas/create_payment", + {"customer": "cus_test", "billingType": "PIX", "value": 100}, + ) + print("payment 2:", received.data) + + # Third call — list is drained + exhausted = session.execute( + "asaas/create_payment", + {"customer": "cus_test", "billingType": "PIX", "value": 100}, + ) + if is_mocks_exhausted(exhausted.data): + print("payment 3 drained the list:", exhausted.data["message"]) + else: + print("payment 3:", exhausted.data) + finally: + session.close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/python/src/codespar/__init__.py b/packages/python/src/codespar/__init__.py index a996de1..1c5df3d 100644 --- a/packages/python/src/codespar/__init__.py +++ b/packages/python/src/codespar/__init__.py @@ -36,6 +36,27 @@ NotConnectedError, StreamError, ) +from .tool_result_codes import ( + APPROVAL_REQUIRED, + MOCKS_ENGINE_ERROR, + MOCKS_EXHAUSTED, + POLICY_DENIED, + TOOL_NOT_MOCKED, + TOOL_RESULT_CODES, + ApprovalRequiredOutput, + MocksEngineErrorOutput, + MocksExhaustedOutput, + PolicyDeniedOutput, + ToolNotMockedOutput, + ToolResultCode, + ToolResultOutcome, + assert_exhaustive_tool_result, + is_approval_required, + is_mocks_engine_error, + is_mocks_exhausted, + is_policy_denied, + is_tool_not_mocked, +) from .types import ( AssistantTextEvent, AuthConfig, @@ -58,6 +79,8 @@ ErrorEvent, HttpMethod, ManageConnections, + MockObject, + MockValue, PaymentStatus, PaymentStatusEvent, PaymentStatusResult, @@ -93,7 +116,14 @@ __version__ = "0.9.0" __all__ = [ + "APPROVAL_REQUIRED", + "MOCKS_ENGINE_ERROR", + "MOCKS_EXHAUSTED", + "POLICY_DENIED", + "TOOL_NOT_MOCKED", + "TOOL_RESULT_CODES", "ApiError", + "ApprovalRequiredOutput", "AssistantTextEvent", "AsyncCodeSpar", "AsyncSession", @@ -126,11 +156,17 @@ "ErrorEvent", "HttpMethod", "ManageConnections", + # Test-mode mocks + "MockObject", + "MockValue", + "MocksEngineErrorOutput", + "MocksExhaustedOutput", "NotConnectedError", # Async settlement (codespar_pay etc.) "PaymentStatus", "PaymentStatusEvent", "PaymentStatusResult", + "PolicyDeniedOutput", "Preset", # Proxy "ProxyRequest", @@ -157,8 +193,12 @@ "StreamEvent", "Tool", "ToolCallRecord", + "ToolNotMockedOutput", "ToolResult", + # Tool-result code type-narrowed guards + "ToolResultCode", "ToolResultEvent", + "ToolResultOutcome", "ToolUseEvent", "UserMessageEvent", # Async KYC verification (codespar_kyc) @@ -168,4 +208,10 @@ "WizardAction", # Version "__version__", + "assert_exhaustive_tool_result", + "is_approval_required", + "is_mocks_engine_error", + "is_mocks_exhausted", + "is_policy_denied", + "is_tool_not_mocked", ] diff --git a/packages/python/src/codespar/_async_client.py b/packages/python/src/codespar/_async_client.py index a46f489..048a1f1 100644 --- a/packages/python/src/codespar/_async_client.py +++ b/packages/python/src/codespar/_async_client.py @@ -10,6 +10,7 @@ from __future__ import annotations +import os from types import TracebackType import httpx @@ -25,6 +26,20 @@ from .types import SessionConfig +def _resolve_base_url(explicit: str | None) -> str: + """Resolve the effective base URL. + + Mirrors the TypeScript constructor cascade — explicit option wins, + then the ``CODESPAR_BASE_URL`` env var, then the production + default. The env var lets a caller point the same client wiring + at a local OSS runtime or at ``api.codespar.dev`` without + rebuilding the call sites. + """ + if explicit is not None: + return explicit + return os.environ.get("CODESPAR_BASE_URL") or DEFAULT_BASE_URL + + class AsyncCodeSpar: """ Async CodeSpar client. Pass an API key, create sessions, run them, @@ -42,7 +57,7 @@ def __init__( self, *, api_key: str, - base_url: str = DEFAULT_BASE_URL, + base_url: str | None = None, project_id: str | None = None, timeout: float = 60.0, client: httpx.AsyncClient | None = None, @@ -53,7 +68,7 @@ def __init__( "Get one from https://dashboard.codespar.dev." ) self._api_key = api_key - self._base_url = base_url.rstrip("/") + self._base_url = _resolve_base_url(base_url).rstrip("/") self._project_id = project_id # Share one transport across every session spawned by this # client. Closing the client closes every in-flight request. @@ -94,6 +109,12 @@ async def create( body: dict[str, object] = {"servers": servers, "user_id": user_id} if resolved.metadata: body["metadata"] = resolved.metadata + # Forward mocks verbatim — including the empty dict, which the + # backend accepts (strict-mode R3a activates only on non-empty + # maps). Omitting the key entirely keeps the absent-case wire + # body byte-identical to the pre-PRD shape (R18 wire-neutral). + if resolved.mocks is not None: + body["mocks"] = resolved.mocks data = await request_json( self._client, @@ -169,6 +190,7 @@ def _resolve_config( "manage_connections", "metadata", "project_id", + "mocks", } unknown = set(kwargs) - allowed if unknown: diff --git a/packages/python/src/codespar/_http.py b/packages/python/src/codespar/_http.py index 6cfbe13..02866ba 100644 --- a/packages/python/src/codespar/_http.py +++ b/packages/python/src/codespar/_http.py @@ -76,7 +76,17 @@ async def request_json( code: str | None = None message = f"{method} {path} failed: {response.status_code}" if isinstance(parsed, dict): - code = parsed.get("error") if isinstance(parsed.get("error"), str) else None + # ``code`` is the discriminant on the hosted-test-mode + # envelopes. ``error`` is the legacy field kept as a + # fallback so older envelopes still surface a structured + # code value rather than None. + raw_code = parsed.get("code") + if isinstance(raw_code, str): + code = raw_code + else: + raw_error = parsed.get("error") + if isinstance(raw_error, str): + code = raw_error msg = parsed.get("message") if isinstance(msg, str): message = f"{message} — {msg}" diff --git a/packages/python/src/codespar/tool_result_codes.py b/packages/python/src/codespar/tool_result_codes.py new file mode 100644 index 0000000..4cc832e --- /dev/null +++ b/packages/python/src/codespar/tool_result_codes.py @@ -0,0 +1,164 @@ +""" +Tool-result code helpers — Python parallel of tool-result-codes.ts. + +Mirrors the TypeScript surface 1:1: five frozen dataclasses, five +discriminant constants, the ``ToolResultCode`` Literal union, the +``TOOL_RESULT_CODES`` frozenset, five PEP 647 ``TypeGuard`` +predicates, and ``assert_exhaustive_tool_result``. + +Each guard checks both the ``code`` discriminant against +``TOOL_RESULT_CODES`` AND its own required sibling fields. A +payload with a well-formed ``code`` but a missing sibling returns +False rather than narrowing positive on the discriminant alone — +the test suite enforces this invariant per guard. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Final, Literal, TypeGuard + +# ── discriminant constants ──────────────────────────────────────── + +POLICY_DENIED: Final[Literal["policy_denied"]] = "policy_denied" +APPROVAL_REQUIRED: Final[Literal["approval_required"]] = "approval_required" +MOCKS_EXHAUSTED: Final[Literal["mocks_exhausted"]] = "mocks_exhausted" +MOCKS_ENGINE_ERROR: Final[Literal["mocks_engine_error"]] = "mocks_engine_error" +TOOL_NOT_MOCKED: Final[Literal["tool_not_mocked"]] = "tool_not_mocked" + +ToolResultCode = Literal[ + "policy_denied", + "approval_required", + "mocks_exhausted", + "mocks_engine_error", + "tool_not_mocked", +] + +TOOL_RESULT_CODES: Final[frozenset[str]] = frozenset( + [ + POLICY_DENIED, + APPROVAL_REQUIRED, + MOCKS_EXHAUSTED, + MOCKS_ENGINE_ERROR, + TOOL_NOT_MOCKED, + ] +) + + +# ── output dataclasses (frozen, slotted) ────────────────────────── + + +@dataclass(slots=True, frozen=True) +class PolicyDeniedOutput: + rule_id: str + message: str + code: Literal["policy_denied"] = POLICY_DENIED + + +@dataclass(slots=True, frozen=True) +class ApprovalRequiredOutput: + approval_id: str + expires_at: str + message: str + code: Literal["approval_required"] = APPROVAL_REQUIRED + + +@dataclass(slots=True, frozen=True) +class MocksExhaustedOutput: + message: str + code: Literal["mocks_exhausted"] = MOCKS_EXHAUSTED + + +@dataclass(slots=True, frozen=True) +class MocksEngineErrorOutput: + message: str + code: Literal["mocks_engine_error"] = MOCKS_ENGINE_ERROR + + +@dataclass(slots=True, frozen=True) +class ToolNotMockedOutput: + tool_name: str + message: str + code: Literal["tool_not_mocked"] = TOOL_NOT_MOCKED + + +ToolResultOutcome = ( + PolicyDeniedOutput + | ApprovalRequiredOutput + | MocksExhaustedOutput + | MocksEngineErrorOutput + | ToolNotMockedOutput +) + + +# ── guards ──────────────────────────────────────────────────────── + + +def _is_object(value: Any) -> TypeGuard[dict[str, Any]]: + return isinstance(value, dict) + + +def _has_str(obj: dict[str, Any], key: str) -> bool: + return isinstance(obj.get(key), str) + + +def is_policy_denied(value: Any) -> TypeGuard[dict[str, Any]]: + if not _is_object(value): + return False + code = value.get("code") + if code != POLICY_DENIED or code not in TOOL_RESULT_CODES: + return False + return _has_str(value, "rule_id") and _has_str(value, "message") + + +def is_approval_required(value: Any) -> TypeGuard[dict[str, Any]]: + if not _is_object(value): + return False + code = value.get("code") + if code != APPROVAL_REQUIRED or code not in TOOL_RESULT_CODES: + return False + return ( + _has_str(value, "approval_id") + and _has_str(value, "expires_at") + and _has_str(value, "message") + ) + + +def is_mocks_exhausted(value: Any) -> TypeGuard[dict[str, Any]]: + if not _is_object(value): + return False + code = value.get("code") + if code != MOCKS_EXHAUSTED or code not in TOOL_RESULT_CODES: + return False + return _has_str(value, "message") + + +def is_mocks_engine_error(value: Any) -> TypeGuard[dict[str, Any]]: + if not _is_object(value): + return False + code = value.get("code") + if code != MOCKS_ENGINE_ERROR or code not in TOOL_RESULT_CODES: + return False + return _has_str(value, "message") + + +def is_tool_not_mocked(value: Any) -> TypeGuard[dict[str, Any]]: + if not _is_object(value): + return False + code = value.get("code") + if code != TOOL_NOT_MOCKED or code not in TOOL_RESULT_CODES: + return False + return _has_str(value, "tool_name") and _has_str(value, "message") + + +def assert_exhaustive_tool_result(value: Any) -> None: + """Raise on any tool-result payload not matched by the five guards. + + Equivalent to TypeScript's ``assertExhaustiveToolResult(value: never)`` + — call from a default branch after handling each variant so a sixth + code landing without a handler trips at the boundary instead of + being silently swallowed. + """ + raise AssertionError( + f"tool-result-codes: unexpected output variant {value!r}", + ) diff --git a/packages/python/src/codespar/types.py b/packages/python/src/codespar/types.py index 58c84be..7ca9494 100644 --- a/packages/python/src/codespar/types.py +++ b/packages/python/src/codespar/types.py @@ -15,13 +15,23 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import Any, Literal +from typing import Any, Literal, TypeAlias Preset = Literal["brazilian", "mexican", "argentinian", "colombian", "all"] HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"] SessionStatus = Literal["active", "closed", "error"] AuthType = Literal["oauth", "api_key", "cert", "none"] +# ── Test-mode mocks ─────────────────────────────────────────────── +# +# Mirrors the TS MockObject / MockValue aliases in @codespar/types. +# A single MockObject is a static mock (same response every call); +# a list of MockObject is a stateful mock consumed in order, one +# per matching call, then ``mocks_exhausted`` once the list is +# drained. +MockObject: TypeAlias = dict[str, Any] +MockValue: TypeAlias = MockObject | list[MockObject] + @dataclass(slots=True) class ManageConnections: @@ -33,13 +43,24 @@ class ManageConnections: @dataclass(slots=True) class SessionConfig: - """Per-session configuration passed to ``CodeSpar.create``.""" + """Per-session configuration passed to ``CodeSpar.create``. + + ``mocks`` is the test-mode field — a dict keyed on canonical tool + names (slash form, e.g. ``asaas/create_payment``) where each value + is either a single MockObject (static mock) or a list of MockObject + (stateful mock, consumed in order). Forwarded verbatim to + ``POST /v1/sessions`` so the OSS-runtime double-underscore form + (``asaas__create_payment``) reaches the backend unrewritten and + surfaces as ``mocks_invalid``. Requires a ``csk_test_*`` key + against a ``test``-environment project. + """ servers: list[str] | None = None preset: Preset | None = None manage_connections: ManageConnections | None = None metadata: dict[str, str] | None = None project_id: str | None = None + mocks: dict[str, MockValue] | None = None @dataclass(slots=True) diff --git a/packages/python/tests/_fixtures/mocks_canonical.json b/packages/python/tests/_fixtures/mocks_canonical.json new file mode 100644 index 0000000..174b906 --- /dev/null +++ b/packages/python/tests/_fixtures/mocks_canonical.json @@ -0,0 +1 @@ +{"servers":["asaas"],"user_id":"user_demo","mocks":{"asaas/create_payment":{"id":"pay_test_42","status":"PENDING"},"asaas/get_payment":[{"id":"pay_test_42","status":"PENDING"},{"id":"pay_test_42","status":"CONFIRMED"}]}} diff --git a/packages/python/tests/test_base_url_resolution.py b/packages/python/tests/test_base_url_resolution.py new file mode 100644 index 0000000..1d30e6c --- /dev/null +++ b/packages/python/tests/test_base_url_resolution.py @@ -0,0 +1,45 @@ +""" +``CODESPAR_BASE_URL`` env-var resolution for the Python client. + +The constructor cascade is: explicit ``base_url`` keyword, then the +``CODESPAR_BASE_URL`` env var, then the production default. The env +var lets a caller point the same client wiring at a local OSS +runtime or at ``api.codespar.dev`` without rebuilding the call sites. +""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +from codespar import AsyncCodeSpar, CodeSpar + + +def test_async_client_reads_codespar_base_url() -> None: + with patch.dict(os.environ, {"CODESPAR_BASE_URL": "https://oss.example/"}): + cs = AsyncCodeSpar(api_key="csk_live_x") + assert cs.base_url == "https://oss.example" + + +def test_async_client_explicit_base_url_wins_over_env() -> None: + with patch.dict(os.environ, {"CODESPAR_BASE_URL": "https://env.example"}): + cs = AsyncCodeSpar(api_key="csk_live_x", base_url="https://override.example") + assert cs.base_url == "https://override.example" + + +def test_sync_client_reads_codespar_base_url() -> None: + with patch.dict(os.environ, {"CODESPAR_BASE_URL": "https://oss.example/"}): + cs = CodeSpar(api_key="csk_live_x") + try: + assert cs.base_url == "https://oss.example" + finally: + cs.close() + + +def test_default_base_url_when_env_unset() -> None: + """Unset env preserves the production default — no behavior change for callers.""" + env = dict(os.environ) + env.pop("CODESPAR_BASE_URL", None) + with patch.dict(os.environ, env, clear=True): + cs = AsyncCodeSpar(api_key="csk_live_x") + assert cs.base_url == "https://api.codespar.dev" diff --git a/packages/python/tests/test_error_code_precedence.py b/packages/python/tests/test_error_code_precedence.py new file mode 100644 index 0000000..994b087 --- /dev/null +++ b/packages/python/tests/test_error_code_precedence.py @@ -0,0 +1,94 @@ +""" +Tests for the Python ApiError code-extraction precedence. + +The original HTTP layer read ``parsed.get("error")`` as the structured +code field. The hosted-test-mode envelopes standardise on ``code`` as +the discriminant — the managed backend now returns +``{"code": "mocks_not_permitted", "message": "..."}`` for the +create-time gate envelopes. ``code`` takes precedence over ``error`` +so the new envelopes surface correctly; ``error`` is still honored as +a fallback for legacy responses that haven't migrated. + +The ApiError class itself is unchanged — only the call-site +extraction logic in ``_http.request_json`` shifts. +""" + +from __future__ import annotations + +import pytest +from pytest_httpx import HTTPXMock + +from codespar import ApiError, AsyncCodeSpar + + +async def test_code_field_takes_precedence_over_error_field( + httpx_mock: HTTPXMock, +) -> None: + """When both `code` and `error` are present, `code` wins.""" + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + status_code=403, + json={ + "code": "mocks_not_permitted", + "error": "old_legacy_code", + "message": "csk_test_* key required", + }, + ) + + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + with pytest.raises(ApiError) as exc_info: + await cs.create("user_demo", preset="brazilian") + + assert exc_info.value.code == "mocks_not_permitted" + assert exc_info.value.status == 403 + + +async def test_code_field_alone_is_extracted(httpx_mock: HTTPXMock) -> None: + """The new envelope shape — only `code`, no legacy `error` key.""" + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + status_code=400, + json={"code": "mocks_invalid", "message": "tool name not canonical"}, + ) + + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + with pytest.raises(ApiError) as exc_info: + await cs.create("user_demo", preset="brazilian") + + assert exc_info.value.code == "mocks_invalid" + + +async def test_error_field_still_honored_when_code_missing( + httpx_mock: HTTPXMock, +) -> None: + """Pre-PRD envelopes that only carry `error` remain compatible.""" + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + status_code=400, + json={"error": "validation_failed", "message": "missing servers"}, + ) + + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + with pytest.raises(ApiError) as exc_info: + await cs.create("user_demo", preset="brazilian") + + assert exc_info.value.code == "validation_failed" + + +async def test_non_string_code_field_is_ignored(httpx_mock: HTTPXMock) -> None: + """A numeric `code` doesn't poison the extraction — falls back.""" + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + status_code=500, + json={"code": 42, "error": "server_error", "message": "boom"}, + ) + + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + with pytest.raises(ApiError) as exc_info: + await cs.create("user_demo", preset="brazilian") + + assert exc_info.value.code == "server_error" diff --git a/packages/python/tests/test_forward_mocks.py b/packages/python/tests/test_forward_mocks.py new file mode 100644 index 0000000..f9ebdb9 --- /dev/null +++ b/packages/python/tests/test_forward_mocks.py @@ -0,0 +1,177 @@ +""" +Tests for the Python create body builder's `mocks` forwarding. + +Mirrors packages/core/src/__tests__/forward-mocks.test.ts. Asserts: + + - Wire-neutrality on absence (R18) — a cs.create without `mocks` + serializes byte-identically to today's body shape. + - Forwarded shape on presence — the mocks dict is included verbatim + (no SDK-side rewrite of canonical names). + - The allowed-kwargs gate accepts the new ``mocks`` keyword. The + symmetric kwargs/positional test catches a missed gate update on + every CI run. + - The double-underscore key form reaches the backend unrewritten. + - The empty dict is forwarded as ``"mocks": {}`` for parity with TS. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pytest_httpx import HTTPXMock + +from codespar import AsyncCodeSpar, ConfigError, SessionConfig + +FIXTURE_PATH = Path(__file__).parent / "_fixtures" / "mocks_canonical.json" + + +def _session_json() -> dict[str, object]: + return { + "id": "ses_demo", + "org_id": "org_demo", + "user_id": "user_demo", + "servers": ["asaas"], + "status": "active", + "created_at": "2026-05-22T12:00:00Z", + "closed_at": None, + } + + +async def test_create_omits_mocks_when_absent(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + json=_session_json(), + ) + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + await cs.create("user_demo", servers=["asaas"]) + req = httpx_mock.get_request() + assert req is not None + body = json.loads(req.content) + assert "mocks" not in body + assert body == {"servers": ["asaas"], "user_id": "user_demo"} + + +async def test_create_forwards_mocks_via_kwargs(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + json=_session_json(), + ) + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + await cs.create( + "user_demo", + servers=["asaas"], + mocks={"asaas/create_payment": {"id": "pay_test_42", "status": "PENDING"}}, + ) + req = httpx_mock.get_request() + assert req is not None + body = json.loads(req.content) + assert body["mocks"] == { + "asaas/create_payment": {"id": "pay_test_42", "status": "PENDING"} + } + + +async def test_create_forwards_mocks_via_session_config( + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + json=_session_json(), + ) + cfg = SessionConfig( + servers=["asaas"], + mocks={"asaas/create_payment": {"id": "pay_test_42", "status": "PENDING"}}, + ) + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + await cs.create("user_demo", cfg) + req = httpx_mock.get_request() + assert req is not None + body = json.loads(req.content) + assert body["mocks"] == { + "asaas/create_payment": {"id": "pay_test_42", "status": "PENDING"} + } + + +async def test_kwargs_and_positional_produce_byte_identical_body( + httpx_mock: HTTPXMock, +) -> None: + """Symmetric kwargs vs positional — if either drifts, the canonical body diverges.""" + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + json=_session_json(), + ) + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + json=_session_json(), + ) + payload = {"asaas/create_payment": {"id": "pay_test_42", "status": "PENDING"}} + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + await cs.create("user_demo", servers=["asaas"], mocks=payload) + await cs.create("user_demo", SessionConfig(servers=["asaas"], mocks=payload)) + reqs = httpx_mock.get_requests() + assert len(reqs) == 2 + assert reqs[0].content == reqs[1].content + + +async def test_double_underscore_keys_pass_through_unrewritten( + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + json=_session_json(), + ) + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + await cs.create( + "user_demo", + servers=["asaas"], + mocks={"asaas__create_payment": {"id": "pay_test_42"}}, + ) + req = httpx_mock.get_request() + assert req is not None + body = json.loads(req.content) + assert list(body["mocks"].keys()) == ["asaas__create_payment"] + + +async def test_empty_mocks_dict_is_forwarded(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + json=_session_json(), + ) + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + await cs.create("user_demo", servers=["asaas"], mocks={}) + req = httpx_mock.get_request() + assert req is not None + body = json.loads(req.content) + assert body["mocks"] == {} + + +def test_canonical_body_matches_fixture() -> None: + """The cross-language fixture stays the source of truth.""" + body = { + "servers": ["asaas"], + "user_id": "user_demo", + "mocks": { + "asaas/create_payment": {"id": "pay_test_42", "status": "PENDING"}, + "asaas/get_payment": [ + {"id": "pay_test_42", "status": "PENDING"}, + {"id": "pay_test_42", "status": "CONFIRMED"}, + ], + }, + } + serialized = json.dumps(body, separators=(",", ":")) + assert serialized == FIXTURE_PATH.read_text().rstrip("\n") + + +async def test_unknown_kwarg_still_rejected(httpx_mock: HTTPXMock) -> None: + """Allowed-kwargs gate keeps rejecting truly unknown kwargs.""" + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + with pytest.raises(ConfigError, match="unknown keyword argument"): + await cs.create("user_demo", servers=["asaas"], not_a_real_field=True) diff --git a/packages/python/tests/test_mocks_types.py b/packages/python/tests/test_mocks_types.py new file mode 100644 index 0000000..f2050f9 --- /dev/null +++ b/packages/python/tests/test_mocks_types.py @@ -0,0 +1,86 @@ +""" +Wire-shape parity test for the test-mode mocks field. + +Mirror of packages/core/src/__tests__/mocks-wire-parity.test.ts. Both +SDKs must serialize the same canonical example to byte-identical JSON; +the shared fixture at tests/_fixtures/mocks_canonical.json is the +source of truth. + +The MockObject + MockValue type aliases land on the Python side as +``TypeAlias``-style declarations in ``codespar.types``. The +SessionConfig dataclass gains an optional ``mocks`` field of shape +``dict[str, MockValue] | None``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from codespar import SessionConfig +from codespar.types import MockObject, MockValue + +FIXTURE_PATH = Path(__file__).parent / "_fixtures" / "mocks_canonical.json" + + +def test_mock_object_accepts_plain_dict() -> None: + obj: MockObject = {"id": "pay_test_42", "status": "PENDING"} + assert obj["id"] == "pay_test_42" + + +def test_mock_value_accepts_single_object() -> None: + v: MockValue = {"id": "pay_test_42", "status": "PENDING"} + assert isinstance(v, dict) + + +def test_mock_value_accepts_object_list() -> None: + v: MockValue = [ + {"id": "pay_test_42", "status": "PENDING"}, + {"id": "pay_test_42", "status": "CONFIRMED"}, + ] + assert isinstance(v, list) + assert len(v) == 2 + + +def test_session_config_carries_optional_mocks() -> None: + cfg = SessionConfig(servers=["asaas"]) + assert cfg.mocks is None + + +def test_session_config_accepts_canonical_mocks() -> None: + cfg = SessionConfig( + servers=["asaas"], + mocks={ + "asaas/create_payment": {"id": "pay_test_42", "status": "PENDING"}, + "asaas/get_payment": [ + {"id": "pay_test_42", "status": "PENDING"}, + {"id": "pay_test_42", "status": "CONFIRMED"}, + ], + }, + ) + assert cfg.mocks is not None + assert cfg.mocks["asaas/create_payment"] == { + "id": "pay_test_42", + "status": "PENDING", + } + + +def test_canonical_fixture_matches_python_serialization() -> None: + """Round-trip the canonical body and confirm byte-identical output.""" + body = { + "servers": ["asaas"], + "user_id": "user_demo", + "mocks": { + "asaas/create_payment": {"id": "pay_test_42", "status": "PENDING"}, + "asaas/get_payment": [ + {"id": "pay_test_42", "status": "PENDING"}, + {"id": "pay_test_42", "status": "CONFIRMED"}, + ], + }, + } + # separators=(',', ':') matches JS JSON.stringify's compact output — + # the SDK uses this same delimiter set for the production body + # builder (see _async_client.py). + serialized = json.dumps(body, separators=(",", ":")) + expected = FIXTURE_PATH.read_text().rstrip("\n") + assert serialized == expected diff --git a/packages/python/tests/test_tool_result_codes.py b/packages/python/tests/test_tool_result_codes.py new file mode 100644 index 0000000..c678e84 --- /dev/null +++ b/packages/python/tests/test_tool_result_codes.py @@ -0,0 +1,184 @@ +""" +Tool-result code guard tests — Python parallel of tool-result-codes.test.ts. + +Asserts the same surface: five output dataclasses, five +discriminant constants, the ToolResultCode literal union, the +TOOL_RESULT_CODES frozenset, five PEP 647 TypeGuard predicates, +and assert_exhaustive_tool_result. + +Round-trip parity: the same fixtures used in the TS test exercise +the Python guards and agree on every outcome. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from codespar.tool_result_codes import ( + APPROVAL_REQUIRED, + MOCKS_ENGINE_ERROR, + MOCKS_EXHAUSTED, + POLICY_DENIED, + TOOL_NOT_MOCKED, + TOOL_RESULT_CODES, + assert_exhaustive_tool_result, + is_approval_required, + is_mocks_engine_error, + is_mocks_exhausted, + is_policy_denied, + is_tool_not_mocked, +) + + +def test_tool_result_codes_frozenset_includes_five_core_variants() -> None: + """The five hosted-runtime variants are always present. + + The frozenset may grow over time as new governance variants land; + this test enforces the floor — every canonical hosted-runtime code + must remain reachable. + """ + assert POLICY_DENIED in TOOL_RESULT_CODES + assert APPROVAL_REQUIRED in TOOL_RESULT_CODES + assert MOCKS_EXHAUSTED in TOOL_RESULT_CODES + assert MOCKS_ENGINE_ERROR in TOOL_RESULT_CODES + assert TOOL_NOT_MOCKED in TOOL_RESULT_CODES + + +class TestPolicyDenied: + def test_positive(self) -> None: + out: Any = {"code": POLICY_DENIED, "rule_id": "spend_cap", "message": "boom"} + assert is_policy_denied(out) is True + + def test_missing_rule_id(self) -> None: + out: Any = {"code": POLICY_DENIED, "message": "boom"} + assert is_policy_denied(out) is False + + def test_missing_message(self) -> None: + out: Any = {"code": POLICY_DENIED, "rule_id": "spend_cap"} + assert is_policy_denied(out) is False + + def test_foreign_discriminant(self) -> None: + out: Any = {"code": APPROVAL_REQUIRED, "rule_id": "x", "message": "y"} + assert is_policy_denied(out) is False + + def test_unknown_code(self) -> None: + out: Any = {"code": "totally_made_up", "rule_id": "x", "message": "y"} + assert is_policy_denied(out) is False + + def test_non_dict(self) -> None: + assert is_policy_denied(None) is False + assert is_policy_denied("policy_denied") is False + assert is_policy_denied(42) is False + + +class TestApprovalRequired: + def test_positive(self) -> None: + out: Any = { + "code": APPROVAL_REQUIRED, + "approval_id": "apr_abc", + "expires_at": "2026-12-01T00:00:00Z", + "message": "approve", + } + assert is_approval_required(out) is True + + def test_missing_approval_id(self) -> None: + out: Any = { + "code": APPROVAL_REQUIRED, + "expires_at": "2026-12-01T00:00:00Z", + "message": "x", + } + assert is_approval_required(out) is False + + def test_missing_expires_at(self) -> None: + out: Any = {"code": APPROVAL_REQUIRED, "approval_id": "apr_abc", "message": "x"} + assert is_approval_required(out) is False + + def test_missing_message(self) -> None: + out: Any = { + "code": APPROVAL_REQUIRED, + "approval_id": "apr_abc", + "expires_at": "x", + } + assert is_approval_required(out) is False + + +class TestMocksExhaustedAndEngineError: + def test_mocks_exhausted_positive(self) -> None: + assert ( + is_mocks_exhausted({"code": MOCKS_EXHAUSTED, "message": "drained"}) is True + ) + + def test_mocks_exhausted_missing_message(self) -> None: + assert is_mocks_exhausted({"code": MOCKS_EXHAUSTED}) is False + + def test_mocks_engine_error_positive(self) -> None: + assert ( + is_mocks_engine_error( + {"code": MOCKS_ENGINE_ERROR, "message": "consume failed"} + ) + is True + ) + + def test_mocks_engine_error_missing_message(self) -> None: + assert is_mocks_engine_error({"code": MOCKS_ENGINE_ERROR}) is False + + +class TestToolNotMocked: + def test_positive(self) -> None: + out: Any = { + "code": TOOL_NOT_MOCKED, + "tool_name": "asaas/create_payment", + "message": "not in mocks", + } + assert is_tool_not_mocked(out) is True + + def test_missing_tool_name(self) -> None: + out: Any = {"code": TOOL_NOT_MOCKED, "message": "x"} + assert is_tool_not_mocked(out) is False + + +def test_assert_exhaustive_tool_result_raises_on_unknown() -> None: + with pytest.raises(AssertionError, match="tool-result-codes"): + assert_exhaustive_tool_result({"code": "rogue"}) # type: ignore[arg-type] + + +def test_round_trip_corpus_agrees_with_ts_guards() -> None: + """The shared fixture below mirrors the TS describe blocks.""" + corpus = [ + ({"code": POLICY_DENIED, "rule_id": "x", "message": "y"}, "policy"), + ({"code": POLICY_DENIED, "message": "missing rule"}, None), + ( + { + "code": APPROVAL_REQUIRED, + "approval_id": "a", + "expires_at": "t", + "message": "m", + }, + "approval", + ), + ({"code": MOCKS_EXHAUSTED, "message": "drained"}, "exhausted"), + ({"code": MOCKS_ENGINE_ERROR, "message": "boom"}, "engine"), + ( + {"code": TOOL_NOT_MOCKED, "tool_name": "asaas/x", "message": "m"}, + "not_mocked", + ), + ({"code": "unknown_variant"}, None), + ] + + def label(value: dict[str, Any]) -> str | None: + if is_policy_denied(value): + return "policy" + if is_approval_required(value): + return "approval" + if is_mocks_exhausted(value): + return "exhausted" + if is_mocks_engine_error(value): + return "engine" + if is_tool_not_mocked(value): + return "not_mocked" + return None + + for value, expected in corpus: + assert label(value) == expected diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index e783cce..619fe51 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -410,12 +410,46 @@ export interface ServerConnection { connected: boolean; } +/* ── Test-mode mocks ───────────────────────────────────────────── */ + +/** + * A single mock response payload. The backend forwards this payload + * verbatim to whatever consumer would have received the upstream + * provider's JSON, so any shape the catalog tool accepts as a real + * response is a valid MockObject. + */ +export type MockObject = Record; + +/** + * The value paired with a canonical tool name in a session's mocks + * map. Either a single MockObject (static mock — the same response + * every call) or an array of MockObject (stateful mock — consumed + * in order, one per matching call, then `mocks_exhausted` once the + * list is drained). + */ +export type MockValue = MockObject | MockObject[]; + /* ── Session creation ─────────────────────────────────────────── */ export interface CreateSessionRequest { servers: string[]; metadata?: Record; projectId?: string; + /** + * Optional map of canonical tool names to mock responses. Keys are + * canonical names in the slash form: `^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9_-]*$` + * (e.g. `asaas/create_payment`). The OSS-runtime double-underscore + * form (`asaas__create_payment`) is a known migration trap — the + * SDK forwards keys verbatim, so the backend surfaces the + * canonical-form rejection at validate time rather than the SDK + * silently rewriting. + * + * Values follow the MockValue shape: a single MockObject for a + * static mock, or a MockObject[] for a stateful mock consumed in + * order. An empty map (`{}`) is accepted on the wire; strict-mode + * R3a activates only on non-empty maps. + */ + mocks?: Record; } /* ── Tool execution ─────────────────────────────────────────────── */