feat(sdk): hosted test-mode SDK surface — mocks forwarding, structured errors, tool-result guards, OSS test-parity seam - #54
Merged
Conversation
…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
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.
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.
…ted in READMEs and examples
…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.
This was referenced Jun 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lands the client-side surface for hosted test-mode across
@codespar/sdk,@codespar/types, and thecodesparPython package. The PR ships five capability slices in paired TS + Python commits: optionalmocksforwarding oncs.create, theMockObject/MockValuetype aliases, a structuredCodesparApiErrorcarrying{ status, code, body, cause }, five type-narrowed tool-result guards with an exhaustive-match helper, andCODESPAR_BASE_URLenv-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) orcodespar(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 againstapi.codespar.devor 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.
MockObjectandMockValueexported from@codespar/types; matchingTypeAliasdeclarations in the Pythoncodespar.typesmodule.SessionConfigwidens in both languages to carry the optionalmocksfield. 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 everythrow new Error("send failed: ...")transport site inpackages/core/src/session.tswith throws of a newCodesparApiError extends Errorcarrying{ status, code, body, cause }. Network errors that never reach the backend surface asstatus: 0with the underlyingfetchrejection preserved oncause.session.execute(...)keeps its returns-vs-throws asymmetry — non-ok responses still come back asToolResult.success === false. On the Python side,_http.pyhonorscodeover the legacyerrorfield so the new envelopes (mocks_not_permitted,mocks_invalid,mocks_payload_too_large) surface asApiError.codecorrectly.Mocks forwarding on
cs.create. The body builder includes themocksfield onPOST /v1/sessionswhen 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 structuredmocks_invalidenvelope. Python: adds"mocks"to the_resolve_configallowed-kwargs gate socs.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.tsandpackages/python/src/codespar/tool_result_codes.pyexport the five output types, five matching narrowedToolCallRecordaliases, 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-formedcodebut a missingrule_id/approval_id/tool_namereturns false rather than narrowing positive on the discriminant alone.CODESPAR_BASE_URLenv-var resolution. PythonAsyncCodeSpar/CodeSparconstructors readCODESPAR_BASE_URLas the defaultbaseUrl(the TS constructor already read it). An explicitbaseUrloption always wins. Both languages now honor the env-var swap pattern uniformly, so a customer can target a local OSS runtime orapi.codespar.devwithout rebuilding the client wiring.Documentation
The README on each language's package documents the new surface — anyone reading
@codespar/sdkon npm orcodesparon 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/MockValuetypes), "Typed errors" section coveringCodesparApiErrorwith atry / catchdiscriminating one.code, a "Tool-result guards" subsection with all fiveis*guards plus theassertExhaustiveToolResultexhaustive-match pattern, aCODESPAR_BASE_URLnote on thenew 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 theApiError.codeprecedence pattern, the fiveis_*guards plusassert_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/MockValueexports).examples/mocks-round-trip/(TypeScript) andpackages/python/examples/mocks_round_trip.py— paired runnable demos exercising a static mock, a stateful mock list, themocks_exhaustedbranch through theisMocksExhausted/is_mocks_exhaustedguard, and themocks_not_permittederror path. Listed in the Python examples README table; the TS example carries its own README.Verification
npx turbo run build typecheck test— 50 tasks pass; new tests inpackages/core/src/__tests__/.pytest(67 tests pass),mypy --strict srcclean,ruff check src testsclean.Migration note
CodesparApiErrorreplacing the genericthrow new Error(...)shape is a SemVer-minor break for callers parsinge.messagestrings. Migration recipe:e.message.includes("foo")becomese.code === "foo", wherecodeis the structured discriminant on the new error class. Seepackages/core/CHANGELOG.mdfor the full list.