Skip to main content

Python Testing SDK (digitorn.testing)

The digitorn.testing package is the canonical client library for writing live integration tests against a running Digitorn daemon. It is shipped inside the digitorn PyPI package; no separate install.

The SDK is a library, not a test runner. You write your own scenarios under tools/live_tests/<feature>_scenarios (or any other location) and import the SDK as a consumer would.

Public surface

from digitorn.testing import (
DevClient,
AppHandle,
LiveEvent,
LiveEventStream,
SessionHandle,
TurnResult,
assertions,
)
SymbolPurpose
DevClientTop-level client. Carries auth, talks REST + Socket.IO to the daemon.
SessionHandleLightweight value object identifying (app_id, session_id, daemon_url, workspace). Constructed locally.
AppHandleSame shape for apps.
LiveEventOne Socket.IO event envelope as a dataclass with seq, type, payload.
LiveEventStreamA live tap on a session's Socket.IO stream. Buffer + helpers.
TurnResultFinal outcome of an agent turn.
assertionsSub-module with sort_by_seq, event_order, ... helpers.

Connecting

from digitorn.testing import DevClient

client = DevClient(
daemon_url=None, # default: $DIGITORN_DEV_DAEMON_URL → $DIGITORN_DAEMON_URL → http://127.0.0.1:8000
auto_approve=True, # auto-approves every pending capability prompt
timeout=3600.0, # default request timeout (1 h)
token=None, # explicit JWT - none = anonymous; pair with .login() / .register()
)

The constructor does not auto-load a token from disk. Pass token= explicitly, or call client.login(email, password) / DevClient.with_user(email, password) to obtain one. For scripted CI, set DIGITORN_DAEMON_URL and pass token=....

Two flow shapes

One-shot chat (REST polling)

result = client.chat(app_id="my-app", message="Hello!")
print(result.content)

chat is the simple synchronous loop. Internally it creates a session, POSTs the message, polls GET /sessions/{sid}, and auto-approves pending prompts. Returns a TurnResult after message_done.

Use this when you don't care about per-event hooks.

Live event stream (Socket.IO)

import uuid
from digitorn.testing import DevClient, SessionHandle

client = DevClient(daemon_url="http://127.0.0.1:8000", auto_approve=True)
session = SessionHandle(
session_id=f"test-{uuid.uuid4().hex[:8]}",
app_id="my-app",
daemon_url="http://127.0.0.1:8000",
workspace="",
)

stream = client.send_live(session, "ping", total_timeout=60.0)
try:
events = stream.events()
for ev in events:
print(ev["type"], ev.get("payload", {}))
finally:
stream.stop(timeout=2.0)

send_live POSTs the message AND opens a live Socket.IO stream in one call. It waits for message_done (or total_timeout) before returning the stream so stream.events is complete.

This is the path you want for per-event assertions (tool calls, hooks fired, intermediate states).

Manual control

When you need finer control (e.g. multiple POSTs on one stream):

# Step 1 - POST without waiting
client.post_message_raw(session, "ping")

# Step 2 - wait for the daemon to register the session
client.wait_for_session(session, timeout=20.0)

# Step 3 - open the stream without waiting (already waited)
from digitorn.testing.events import LiveEventStream
stream = LiveEventStream(
daemon_url=client.daemon_url,
token=client._get_auth_token(),
app_id=session.app_id,
session_id=session.session_id,
)
stream.start()

# Step 4 - wait for an arbitrary event type
done = stream.wait_for("message_done", timeout=45.0)

Assertions

from digitorn.testing import assertions

events = assertions.sort_by_seq(stream.events())

ok, detail = assertions.event_order(
events, ["user_message", "message_started", "tool_start", "tool_call", "message_done"]
)
assert ok, detail

assertions.sort_by_seq orders by the envelope's monotonic seq field (Socket.IO does NOT guarantee delivery order across namespaces, but seq does within a session).

assertions.event_order checks that the listed event types appear in the given order. Returns (ok, detail).

Other helpers worth knowing:

HelperPurpose
assertions.contains_textThe buffered out_token content concatenated includes a substring.
assertions.tool_calledA tool_call with the given name was emitted.
assertions.no_errorsNo error-typed event landed.

Approvals

auto_approve=True (the default in tests) makes the client poll GET (apps API) every second and resolve every pending request. For tests of approval flows themselves, pass auto_approve=False and call:

pending = client.list_pending_approvals(app_id)
client.resolve_approval(app_id, request_id, approved=True)

Auth

Pass a JWT explicitly via token= to the constructor, or use the helpers:

client = DevClient.with_token(token)
client = DevClient.with_user(email, password) # login
client = DevClient.with_user(email, password, register_if_missing=True)

You can also call client.login(email, password) or client.register(email, password) on an existing anonymous client; both return the token dict and store the access token on the instance. In CI, mint a short-lived token via the auth API and pass it explicitly.

Where scenarios live

By convention, live test scenarios go under tools/live_tests/<feature>_scenarios in the source repo. Each scenario function returns a tuple (ok: bool, detail: str, artifacts: dict). The runner in drives them.

The digitorn.testing package itself contains no scenarios. Adding a scenario inside this package is a violation of the SDK's design - it is meant to stay a thin, reusable library.