Skip to content

feat(sdk): hosted test-mode SDK surface — mocks forwarding, structured errors, tool-result guards, OSS test-parity seam - #54

Merged
dangazineu merged 17 commits into
mainfrom
feat/hosted-test-mode-sdk
May 24, 2026
Merged

feat(sdk): hosted test-mode SDK surface — mocks forwarding, structured errors, tool-result guards, OSS test-parity seam#54
dangazineu merged 17 commits into
mainfrom
feat/hosted-test-mode-sdk

Conversation

@dangazineu

@dangazineu dangazineu commented May 23, 2026

Copy link
Copy Markdown
Contributor

Lands the client-side surface for hosted test-mode across @codespar/sdk, @codespar/types, and the codespar Python package. The PR ships five capability slices in paired TS + Python commits: optional mocks forwarding on cs.create, the MockObject / MockValue type aliases, a structured CodesparApiError carrying { status, code, body, cause }, five type-narrowed tool-result guards with an exhaustive-match helper, and CODESPAR_BASE_URL env-var resolution so the same client wiring targets the managed backend or a self-hosted OSS runtime.

Test-mode authors writing against @codespar/sdk (TypeScript) or codespar (Python on PyPI) are the consumers. The mocks API and structured errors give them deterministic fixtures and discriminable failures without parsing error strings; the env-var swap lets the same test suite run against api.codespar.dev or a local OSS runtime by flipping one variable. The wire shape is byte-identical between runtimes; storage shape differs (the managed backend persists mocks and counters, the OSS runtime holds them in process memory) and that split is documented in the test-mode concept doc.

What's in this PR

  • Test-mode type aliases. MockObject and MockValue exported from @codespar/types; matching TypeAlias declarations in the Python codespar.types module. SessionConfig widens in both languages to carry the optional mocks field. A wire-shape parity test keyed on a shared canonical fixture (packages/python/tests/_fixtures/mocks_canonical.json) asserts byte-identical serialization between the two builders.

  • Structured CodesparApiError. Replaces every throw new Error("send failed: ...") transport site in packages/core/src/session.ts with throws of a new CodesparApiError extends Error carrying { status, code, body, cause }. Network errors that never reach the backend surface as status: 0 with the underlying fetch rejection preserved on cause. session.execute(...) keeps its returns-vs-throws asymmetry — non-ok responses still come back as ToolResult.success === false. On the Python side, _http.py honors code over the legacy error field so the new envelopes (mocks_not_permitted, mocks_invalid, mocks_payload_too_large) surface as ApiError.code correctly.

  • Mocks forwarding on cs.create. The body builder includes the mocks field on POST /v1/sessions when present and omits the key entirely when absent (wire-neutral on the absent case). Forwarded verbatim — the SDK does not rewrite canonical tool names, so the OSS-runtime double-underscore form reaches the backend unrewritten and surfaces as the structured mocks_invalid envelope. Python: adds "mocks" to the _resolve_config allowed-kwargs gate so cs.create("u", mocks={...}) reaches the HTTP layer. Symmetric kwargs/positional parity test catches a missed gate update on every CI run.

  • Tool-result type-narrowed guards. packages/core/src/tool-result-codes.ts and packages/python/src/codespar/tool_result_codes.py export the five output types, five matching narrowed ToolCallRecord aliases, the discriminant constants, the union type, five predicate guards, and an exhaustive-match utility (assertExhaustiveToolResult / assert_exhaustive_tool_result). Each guard verifies the 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 on the discriminant alone.

  • CODESPAR_BASE_URL env-var resolution. Python AsyncCodeSpar / CodeSpar constructors read CODESPAR_BASE_URL as the default baseUrl (the TS constructor already read it). An explicit baseUrl option always wins. Both languages now honor the env-var swap pattern uniformly, so a customer can target a local OSS runtime or api.codespar.dev without rebuilding the client wiring.

Documentation

The README on each language's package documents the new surface — anyone reading @codespar/sdk on npm or codespar on PyPI gets the mocks API, typed guards, and env-var swap pattern in the top-level README.

  • packages/core/README.md — "Test-mode mocks" section (static + stateful fixtures, MockObject / MockValue types), "Typed errors" section covering CodesparApiError with a try / catch discriminating on e.code, a "Tool-result guards" subsection with all five is* guards plus the assertExhaustiveToolResult exhaustive-match pattern, a CODESPAR_BASE_URL note on the new CodeSpar(config) table row, and a per-runtime storage-shape paragraph noting that the managed backend persists mocks while the OSS runtime holds them in process memory.
  • packages/python/README.md — mirrors the TypeScript additions: cs.create(..., mocks={...}) with both fixture shapes, a "Base URL — managed or self-hosted OSS" section, an expanded "Errors" section showing the ApiError.code precedence pattern, the five is_* guards plus assert_exhaustive_tool_result, and the same per-runtime storage-shape paragraph.
  • packages/core/CHANGELOG.md — Unreleased entry covering the full surface (mocks forwarding, the five guards + exhaustive helper, env-var resolution, MockObject / MockValue exports).
  • examples/mocks-round-trip/ (TypeScript) and packages/python/examples/mocks_round_trip.py — paired runnable demos exercising a static mock, a stateful mock list, the mocks_exhausted branch through the isMocksExhausted / is_mocks_exhausted guard, and the mocks_not_permitted error path. Listed in the Python examples README table; the TS example carries its own README.
  • Cross-repo link discipline: docs cite the OSS runtime's mocks support via codespar/codespar#113 when explaining that the same fixtures work against managed and self-hosted runtimes.

Verification

  • TypeScript: npx turbo run build typecheck test — 50 tasks pass; new tests in packages/core/src/__tests__/.
  • Python: pytest (67 tests pass), mypy --strict src clean, ruff check src tests clean.

Migration note

CodesparApiError replacing the generic throw new Error(...) shape is a SemVer-minor break for callers parsing e.message strings. Migration recipe: e.message.includes("foo") becomes e.code === "foo", where code is the structured discriminant on the new error class. See packages/core/CHANGELOG.md for the full list.

…ld on SessionConfig

Adds two test-mode type aliases to the shared @codespar/types package:

  - MockObject = Record<string, unknown>
  - MockValue  = MockObject | MockObject[]

Widens both CreateSessionRequest (wire shape in @codespar/types) and
SessionConfig (the cs.create user-facing surface in @codespar/sdk) to
carry an optional mocks?: Record<string, MockValue> field. Mirrors the
same shape on the Python side via TypeAlias declarations in
codespar.types and an extra dataclass field on SessionConfig.

Forwarded verbatim to POST /v1/sessions — the OSS-runtime
double-underscore key form (`asaas__create_payment`) reaches the
backend unrewritten so the SDK doesn't paper over a canonical-name
migration trap. An empty mocks={} body is accepted on the wire; strict
mock-or-real-or-reject behavior activates only on non-empty maps.

Ships a wire-shape parity test in both languages keyed on a single
canonical fixture (packages/python/tests/_fixtures/mocks_canonical.json)
so future contributors update both sides together. The fixture asserts
TS JSON.stringify and Python json.dumps(separators=(',', ':'))
produce byte-identical output for the same example body.
…Error

Replaces every `throw new Error("... failed: 5xx")` transport site in
session.ts with throws of CodesparApiError carrying { status, code,
body, cause }. The class survives prototype-chain dropouts via
Object.setPrototypeOf and threads ES2022 cause through every wrap.

Network errors that never reach the backend (fetch rejected with
TypeError, DOMException, AbortError) surface as CodesparApiError with
status: 0 and the underlying error preserved on cause — callers no
longer need to differentiate between SDK-layer transport faults and
upstream HTTP responses by parsing error messages.

session.execute keeps its returns-vs-throws asymmetry: a non-ok
backend response still comes back as ToolResult.success === false
with the body in error. Only the genuine transport-failure paths
change shape.

Python side: _http.py reads the response body's `code` field with
precedence over the legacy `error` field. The hosted-test-mode
envelopes (mocks_not_authorized, mocks_invalid, mocks_payload_too_large)
all carry `code`; pre-existing envelopes that only set `error`
remain compatible. The ApiError class is unchanged — only the
extraction logic shifts.

CHANGELOG calls out the SemVer-minor break for callers parsing
e.message strings, with the e.code === "X" migration recipe.
Extends the body builder in both languages so cs.create({ mocks: {...} })
includes the field on POST /v1/sessions when present and omits the key
entirely when absent — the absent-case wire body stays byte-identical
to the pre-PRD shape (wire-neutrality).

The field is forwarded verbatim. The SDK does not rewrite canonical
tool names, so the double-underscore migration trap
(`asaas__create_payment` vs `asaas/create_payment`) reaches the
backend unrewritten and surfaces as the structured `mocks_invalid`
envelope at the right layer rather than as a silent SDK-side rename.

The empty mocks={} body is accepted on the wire — strict-mode
mock-or-real-or-reject activates only on non-empty maps.

Python: adds `mocks` to the _resolve_config allowed-kwargs gate in
_async_client.py. Without this, cs.create("u", mocks={...}) raises
ConfigError before the HTTP call. The symmetric kwargs/positional
parity test asserts that the kwargs path and the SessionConfig path
produce byte-identical request bodies, so a missed allowed-set
update on a future field fails CI immediately rather than silently
diverging the two surfaces.

Cross-language parity is asserted against the shared canonical fixture
at packages/python/tests/_fixtures/mocks_canonical.json.
Adds the consumer-side narrowing surface for the hosted-test-mode
tool_result discriminated union: five output types (PolicyDenied,
ApprovalRequired, MocksExhausted, MocksEngineError, ToolNotMocked),
five matching narrowed ToolCallRecord aliases, the AgentGateCode
constant + Literal union, the AGENT_GATE_CODES frozenset, five
predicate guards, and an exhaustive-match utility.

Each guard verifies BOTH the discriminant against AGENT_GATE_CODES
AND its own required sibling fields — a payload with a well-formed
code but a missing sibling (e.g. rule_id missing for policy_denied)
returns false rather than narrowing positive on the discriminant
alone. The test suite enforces the sibling-strict invariant per
guard.

assertExhaustiveAgentGate / assert_exhaustive_agent_gate is the
compile-time + runtime witness for switch coverage. The default
branch of a switch over AgentGateCode passes the discriminant
through this function — if a sixth code variant lands without
the consumer handler being updated, the TS compiler errors at
that call site (the argument is no longer narrowed to never).

Python mirrors the surface 1:1: five @DataClass(slots=True,
frozen=True) outputs, five Literal constants, the AgentGateCode
Literal union, the _AGENT_GATE_CODES frozenset, five PEP 647
TypeGuard predicates, and assert_exhaustive_agent_gate.

Round-trip parity fixture: the same corpus tests both languages'
guards and confirms they agree on every input.
…and isNotSupportedOnOss guard

Two pieces of the cross-runtime test-parity story:

1. CODESPAR_BASE_URL env-var fallback in the Python AsyncCodeSpar /
   CodeSpar constructors. The TS constructor already read this
   variable; Python now matches. The cascade is explicit option →
   env var → production default. The env var is the seam test
   suites use to point at an OSS runtime without rewriting test
   code: a suite authored against the hosted backend re-runs
   against the OSS runtime by flipping CODESPAR_BASE_URL.

2. isNotSupportedOnOss guard (TS) / is_not_supported_on_oss (Python)
   extending the AgentGate surface by one variant. The
   NotSupportedOnOss output has a capability sibling that names the
   missing surface (e.g. "session.send", "meta_tool.codespar_pay")
   so callers translate e.code === "not_supported_on_oss" into
   it.skip(...) / pytest.skip(...) rather than catching an opaque
   transport error. The reserved-code namespace on CodesparApiError
   extends by one to match.

The exhaustive-match witness keeps its contract: a switch over
AgentGateCode that omits NotSupportedOnOss in TS or skips the new
discriminant in Python falls into the assert_exhaustive default
and fails at the boundary.
@dangazineu
dangazineu marked this pull request as ready for review May 23, 2026 21:40
…ames (TS)

AgentGate is the managed-tier governance capability set (programmable
wallet + policy engine + audit + commercial memory + fiscal-compliance
certifications). Shipping AgentGate-named symbols in the OSS SDK
telegraphed "this is managed-tier surface" inside the MIT layer even
though the types themselves are inert wire shapes. Renamed to
backend-agnostic names — the guards stay; only the brand-carrying
identifiers change.

Predicate function names (isPolicyDenied, isApprovalRequired,
isMocksExhausted, isMocksEngineError, isToolNotMocked,
isNotSupportedOnOss) are unchanged — they already describe what they
check, not who emits it.

Rename map (TS):

  packages/core/src/agent-gate.ts             → tool-result-codes.ts
  packages/core/src/__tests__/agent-gate.test.ts
                                              → tool-result-codes.test.ts
  AgentGateCode                               → ToolResultCode
  AGENT_GATE_CODES                            → TOOL_RESULT_CODES
  AgentGateToolResultOutput                   → ToolResultOutcome
  assertExhaustiveAgentGate                   → assertExhaustiveToolResult

Re-exports in packages/core/src/index.ts updated. errors.ts header
comment updated. The Python parallel lands in the next commit so the
TS + Python surface stays lockstep.
…ames (Python)

Python parallel of the previous commit — keeps TS + Python lockstep.

Rename map (Python):

  packages/python/src/codespar/agent_gate.py  → tool_result_codes.py
  packages/python/tests/test_agent_gate.py    → test_tool_result_codes.py
  AgentGateCode                               → ToolResultCode
  AgentGateToolResultOutput                   → ToolResultOutcome
  AGENT_GATE_CODES                            → TOOL_RESULT_CODES
  assert_exhaustive_agent_gate                → assert_exhaustive_tool_result

Constants (POLICY_DENIED, APPROVAL_REQUIRED, MOCKS_EXHAUSTED,
MOCKS_ENGINE_ERROR, TOOL_NOT_MOCKED, NOT_SUPPORTED_ON_OSS) and
predicate functions (is_policy_denied, is_approval_required,
is_mocks_exhausted, is_mocks_engine_error, is_tool_not_mocked,
is_not_supported_on_oss) keep their names — they already describe
what they check.

Public re-exports in packages/python/src/codespar/__init__.py updated
(import block + __all__). pytest (72 tests), mypy --strict, ruff
check all clean.
@dangazineu dangazineu changed the title feat(sdk): hosted test-mode SDK surface — mocks forwarding, structured errors, AgentGate guards, OSS test-parity seam feat(sdk): hosted test-mode SDK surface — mocks forwarding, structured errors, tool-result guards, OSS test-parity seam May 24, 2026
dangazineu added 10 commits May 23, 2026 22:54
The not_supported_on_oss tool-result variant encoded a paywall
pattern that contradicts the superset model — enterprise is a
strict superset of OSS, not a parallel runtime with feature-gap
markers. Tests authored for enterprise-only behavior should not
run against OSS at all rather than rely on a skip envelope.

Removes ToolResultCode.NotSupportedOnOss, NotSupportedOnOssOutput,
NotSupportedOnOssToolCall, and isNotSupportedOnOss from
@codespar/sdk; drops the matching re-exports from index.ts.

The CODESPAR_BASE_URL env-var resolution is preserved — it remains
useful for swapping between a local OSS runtime and api.codespar.dev
without rebuilding the client wiring. The env-var tests move into a
dedicated base-url-resolution.test.ts file.
Mirrors the TS removal in the prior commit. Enterprise is a strict
superset of OSS, not a parallel runtime with feature-gap markers —
tests authored for enterprise-only behavior should not run against
OSS at all rather than rely on a skip envelope.

Removes NOT_SUPPORTED_ON_OSS, NotSupportedOnOssOutput, and
is_not_supported_on_oss from the codespar Python package; drops the
matching re-exports from __init__.py.

CODESPAR_BASE_URL env-var resolution stays — it remains useful for
swapping between a local OSS runtime and api.codespar.dev without
rebuilding client wiring. The env-var tests move into a dedicated
test_base_url_resolution.py file, and the docstring in
_async_client._resolve_base_url drops the parity-seam framing.
Expand packages/core/README.md with the test-mode mocks surface, the
typed CodesparApiError, the five tool-result guards plus exhaustive-
match helper, and the CODESPAR_BASE_URL env-var resolution. Add a
runnable examples/mocks-round-trip TS demo that exercises static +
stateful mocks and branches on isMocksExhausted. Expand the CHANGELOG
to cover the full surface (mocks forwarding, guards, env-var
resolution, MockObject / MockValue exports) rather than just
CodesparApiError.
Mirror the TypeScript README additions: a Test-mode mocks section with
static + stateful fixture examples, MockObject / MockValue type
aliases, the CODESPAR_BASE_URL env-var cascade, the ApiError.code
precedence over the legacy error field, and the five typed guards
(is_policy_denied, is_approval_required, is_mocks_exhausted,
is_mocks_engine_error, is_tool_not_mocked) plus the exhaustive-match
helper. Add a paired runnable examples/mocks_round_trip.py and list it
in the examples README table.
…SDK source and tests

Aligns the SDK source-of-truth with the renamed wire-contract envelope
shipped by the backend (codespar-enterprise PR #192) and the OSS runtime
(codespar PR #113). Pairs with the README + examples + CHANGELOG rename
in the previous commit so the documented try / catch sites and the
underlying types + test fixtures all branch on the same code string.
… runtimes

The CODESPAR_BASE_URL claim around shared fixtures held only when the
self-hosted OSS server has CODESPAR_TEST_MODE_ENABLED=true on its
process env. Without the flag, SDK callers receive mocks_not_permitted
HTTP 501 instead of fixture responses. Add a one-sentence caveat to
both TS and Python READMEs for lockstep accuracy with codespar/codespar
PR #113's OSS gate semantics.
Adds a one-paragraph note to both TS and Python README mocks sections
clarifying that test mode lives on the runtime (CODESPAR_TEST_MODE_ENABLED
on OSS, project.environment on the managed backend) rather than on the
session. When the runtime is in test mode, every dispatched tool call
must match a declared mock; a session without mocks declared can't
dispatch tools at all. Aligns the SDK docs with the runtime semantic
shift landing in codespar/codespar PR #113 and codespar-enterprise
PR #192.
…-list for test-mode dispatch

Extends the test-mode runtime-property paragraph in both READMEs with
the locked OSS+enterprise specifics: tool_not_mocked is HTTP 422 on
the catalog-routed /execute path; the envelope covers three failure
modes (missing entry, no mocks field, unknown server prefix); the
built-in metadata-tool allow-list (codespar_list_tools on OSS;
codespar_discover + codespar_manage_connections on enterprise) bypasses
the gate. Lockstep update with codespar-web docs reframe.

References: codespar/codespar PR #113 commit ac774eb,
codespar-enterprise PR #192 commit 66fa577.
Both READMEs add a paragraph clarifying that the wire contract is
identical across runtimes but the storage shape is not — the managed
backend persists mocks and counters, the OSS runtime holds them in
process memory and channel-bridge sessions cannot carry mocks under
the OSS shape. Links to the test-mode concept doc for the full split.

Drops private-roadmap codename leakage from the http error-parsing
comment and from the matching pytest module docstring — references to
decision numbers from the enterprise backend doc should not appear in
this MIT-published repo.
@dangazineu
dangazineu merged commit 24fc6df into main May 24, 2026
4 checks passed
dangazineu added a commit that referenced this pull request May 24, 2026
….0 (#56)

Release prep for the hosted-test-mode SDK surface that landed in #54.
Aligns the three publishable packages at `0.10.0` so versions match each
other (one number, not three).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant