Studio Backend

Test Suites

The Test section of a project lets its team test agents automatically: a simulated user — an agent that plays a human — talks to the agent under test, and agent evaluations score the conversation. The conversations themselves are run by the bridge, the test-runner service of the promethist repository; the platform defines what to run, starts it and reads the outcome back.

Building Blocks

Concept What it is

Simulated user

An agent of kind TEST in the same project (Agents): a persona and a scenario written as its system prompt, text-only by default, on a fixed realtime configuration. Any project Editor can create one; the AI Assistant can draft them from an existing agent.

Agent evaluation

An EvaluationDefinition with testMode set, kept apart from the project’s regular evaluations and attached to the simulated user. Its results are the verdict of a test conversation.

Test suite

A named, project-scoped set of scenarios. Stored as a TestSuite document that wraps a bridge test case and records the project, the agents it has used and when it last ran.

Scenario

One agent under test, one simulated user — each optionally pinned to a revision — and the settings they run with: conversations (default 1), sessions (1), sessionDelaySeconds (20), sessionTimeOffset, timeoutSeconds (600), maxTurnsPerSession, locale and textOnly (default true). A scenario is a configuration on the bridge; the platform stores nothing about it separately and reads it back from there.

Both agents of a scenario must belong to the suite’s project. An agent that is not pinned must have a Published revision when the suite is saved, because the engine resolves a plain agent:{ref} against Published only; a pinned revision may be in any active state then, since it can still be promoted. Revisions count from 0.

maxTurnsPerSession is at least 1, or empty for no limit — the conversation then runs until a side ends it or the timeout passes. The Studio form starts empty, and only the form can set no limit: scenarios the AI Assistant or Claude create get 30 turns unless they ask for another limit, at most 500.

conversations is the bridge’s deviceCount and sessions its sessionCount: the bridge runs one device per conversation and holds sessions sessions on each, one after another. A device keeps its ref (bridge:{runId}:{deviceIndex}) across them, so those sessions are one engine user coming back — which is what makes an agent’s cross-session memory testable without anyone in the loop. Each session is reported as a bridge run of its own, carrying the run id of the whole conversation as multisessionId, and each is a conversation the account pays for: conversations x sessions is capped at 50 per scenario. sessionDelaySeconds is the bridge’s sessionDelay, the pause between two sessions, and it is what gives the engine time to aggregate the one that just ended.

sessionTimeOffset is an ISO-8601 duration between consecutive sessions, which the bridge turns into the engine’s timeOffset per session: the last session runs at the real time and each earlier one is backdated by another multiple of it, so the gaps are even. The platform parses it on save with the same Duration.parse the bridge uses, and refuses a negative one — toProperties() throws on the bridge only once the run has started, which would otherwise surface as a failed run long after the scenario was accepted.

Running

POST /api/v1/project/{projectId}/test-suites/{id}/run (Editor) starts a suite (TestSuiteService):

  1. The suite’s project must admit sessions with no signed-in user, which is what every bridge session is. One that requires sign-in, or enforces external roles with an identity provider listed, is refused outright: the engine would turn away every conversation, the simulated user’s as well as the agent under test’s, and no scenario change helps. The project is read on a best-effort basis — if it cannot be read, the run goes ahead and the engine decides. The one exception is a suite whose every scenario carries testerAuthorization and testedAgentAuthorization in its bridge configuration, which the bridge sends as the credentials of the two conversations: the platform never writes them (a save rebuilds the configuration without them), but whoever has access to the bridge can, and such a suite is left to the engine.

  2. Every scenario is checked against what the engine would run. An agent that is not pinned needs a Published revision; a pinned revision must exist and be Preview or Published — the engine starts no Draft for a bridge session, which carries no signed-in user. All problems are reported at once and nothing starts. The check runs on every start, because an agent can be archived or reverted after the suite was saved, and the engine’s refusal would otherwise surface only as a run that waits out its timeout.

  3. The suite is claimed in MongoDB, so a second start — a double click — is refused while a run is starting. A claim older than promethist.testing.claim-stale-seconds no longer blocks.

  4. BridgeClient triggers the test case on the bridge, POST {bridge}/run/test-case/{id}?device-delay={ms}&parallelism={n}, with the token from promethist.testing.bridge-secret-token. The bridge records the runs and works through them asynchronously, so the call returns as soon as the work is queued.

  5. Each conversation connects to the engine with the agent under test’s pipeline key — agent:{ref}, or agent:{ref}.{revision} when pinned — and runs in test mode.

Nothing about a run’s state is copied into the platform. Status and verdict are derived from the bridge’s run documents on every read:

Value Meaning

Run status

Running, Passed, Failed or TimedOut — how the conversation ended. Passed means it ended cleanly, which includes a stop at its turn limit; a bridge before 2026.38.1 reported that stop as Failed, and the platform still reads it as Passed. Failed covers a conversation that was refused, broke off or ran into its time limit, and one the simulated user ended as failed, with its reason. A run still running past its timeout plus promethist.testing.grace-seconds reads as TimedOut.

Suite status

New, Running, Completed, TimedOut or Error. A suite that was triggered but produced no run within the grace period reads as Error.

Suite result

Passed, Failed, Mixed, or Unknown when no evaluation has produced a verdict. A run that did not end cleanly counts as failed.

The verdict of a run is filled in by the bridge once the agent evaluations have run; until then the run reports its verdict as pending. Only a run that ended cleanly carries one: the bridge fetches a verdict for every run with a session, and stores the engine’s "no evaluations" as passed, including for a conversation that never got a turn. A single run (GET .../test-suites/{id}/runs/{runId}) adds every evaluation result with its reasoning and evidence, and the transcript — both sides of the conversation, read from the simulated user’s session. A Number result carries its definition’s minValue and maxValue.

The single run also carries score, one figure for the conversation from 0 to 100, which the Studio shows in the Metrics tab. The platform works it out on every read and stores it nowhere: each Number result is scaled to its definition’s range — 4 on 0–5 counts as 80 — and the scaled results are averaged with equal weight. Every number counts as higher-is-better, and a 0 is a score like any other. Bool, Text and TextList results are left out, and so is a number whose definition no longer exists or whose range is empty; with nothing left, there is no score. Results are joined to their definitions by id, the latest result per definition, as in the metrics table. The run list (/runs) carries no score. The bridge’s own weightedAverageScore is a different figure: raw values weighted by aggregateWeights on the bridge configuration, which the platform never sets, so it stays empty for suites saved here.

GET .../test-suites/{id}/export?format=xlsx|csv|json returns the results as an Excel workbook, a zip of CSV files or JSON. Every run carries its score there too: Score (%), the last column of the runs table, or score in JSON.

Test Mode and Usage

The agent under test’s session is a test-mode session. Test-mode sessions are excluded from the analytics, and their minutes are reported separately (testMinutes) in the account’s usage report (Usage and Billing). The simulated user’s own session is an ordinary session of a TEST agent, which the analytics leave out by agent kind instead (Who Is Counted).

Configuration

Property Role

promethist.testing.bridge-url

The bridge’s REST API — -preview.{domain} in deployments. Blank disables test suites.

promethist.testing.bridge-secret-token

The bridge token, from the {release}-testing Kubernetes secret.

promethist.testing.engine-url

The engine the scenarios connect to.

promethist.testing.max-parallelism, .device-delay-millis, .grace-seconds, .trigger-timeout-seconds, .claim-stale-seconds

Run tuning, see Configuration.

Live Agent Tests

Besides test suites, the MCP tool test_agent lets Claude hold a live text conversation with an agent, one turn per call (McpTestAgentTools). It is the only MCP tool with no REST endpoint behind it:

  • It drives the engine’s public pipeline socket, the WebSocket {promethist.mcp.engine-url}/socket/pipeline/agent:{ref}[.{revision}] (wss:// for an https:// engine URL), with text input and JSON output: each turn is one text frame, and the tool reads the elements until the turn’s exit — the only reliable end of a turn, because the engine still plans and may speak again after the reply. A turn has a 90-second budget.

  • The engine endpoint itself asks for no credentials, so the tool performs its own authorization: the caller needs Viewer on the agent’s project, and a plain ref runs only the Published revision.

  • Sessions are anonymous, because the engine does not accept the MCP token. For projects the engine closes to anonymous sessions — sign-in required, or external roles enforced with an identity provider listed, the same rule the test-suite check uses — the tool signs in with the engine test identity (promethist.testing.engine-basic-identity). The engine runs a Draft for no anonymous session, so drafts are tested after promotion to Preview.

  • When the identity does not get a turn through, the error says why. An engine from 2026.42.1 on answers credentials it does not know with 401, reported as an unrecognised identity: the configured value is not one this engine has. An identity the engine knows but does not admit to the project gets a refusal, reported as not enabled for the project. A configured value that is not HTTP Basic credentials at all is never sent, and the refusal says so.

  • A conversation keeps one connection (TestAgentConversations). The engine holds the agent’s planning in its live realtime session; a new connection per turn would lose it after every line and make the engine treat every line as a disconnect and a new session start. The socket a first turn opens stays open, and the engine session id travels back to the caller as session_id (sessionId=<uuid>); the next call with it sends its line over the same socket. A conversation belongs to its caller and its agent: another caller’s or another agent’s call with its id is refused, and so is a line sent while a turn is still running.

  • The connection closes after four minutes without a turn (below the ingress’s 300-second read timeout on /socket), after a turn that did not end with its own exit (cut off, failed, or the call cancelled), when the agent ends the conversation, and on shutdown; a caller keeps at most five conversations open, and the pod 200, closing the least recently used idle one when a new one needs room. Closing is a plain socket close, never #exit: the engine sees the client hang up and about 30 seconds later runs the session’s end-of-conversation work (post-session integrations, evaluations at the end of the conversation) — once per conversation, up to about five minutes after the last turn unless the agent ended it.

  • A session_id with no live connection — closed, or held by another pod or a restarted one, since the registry lives in the pod’s memory — reconnects to the same engine session, which keeps its messages but not the earlier turns' planning, and the result says so. A conversation that closed on this pod is remembered: its reconnect is still refused to another caller or agent, and keeps the locale, simulated user, time zone and run label of its first turn.

  • On the socket a line starting with # is an engine action (#ready:key= would start a session with any agent past the tool’s guard, #exit would end the session), so #intro, the agent’s opening, is the only such line the tool sends.

  • The engine decodes the socket’s query twice (the decoded query is split on & and decoded again), so a value carrying an encoded & would arrive as parameters of its own, testMode=false among them. The locale must be one language tag (de_DE is sent as de-DE), the simulated user and the run label may use only letters, digits and . _ : @ -, and a time zone with ` in it is refused, since ` arrives as a space.

  • device_ref is who the simulated user is, and conversations sharing one are the same engine user coming back; time_offset, an ISO-8601 duration, backdates a whole conversation through the engine’s timeOffset, which the engine reads only because the tool sends testMode=true. Only a negative offset is accepted: the engine adds it to its own clock, so the positive one a caller writes for "ten days ago" would date the session in the future instead and say nothing about it. Like the locale and the simulated user, the offset is fixed when the conversation connects.

  • The result reports when the agent has ended the conversation.

  • Live tests run real engine sessions and count towards usage like any other conversation.

Memory Snapshots

The MCP tools save_memory_snapshot and restore_memory_snapshot (McpMemorySnapshotTools) copy what a simulated user remembers and put that copy back, so the last session of a multi-session test can be re-run without talking through the ones that built it up. They drive the engine’s memory endpoints through EngineMemoryClient: GET /api/user?deviceRef= resolves the device to its user, creating it if the ref is new, then POST /api/memory/state?userId= stores a state and POST /api/memory/state/{stateId}?userId= restores one.

  • Restoring is the engine’s own delete-then-write: it clears the user’s memory before loading the state, so whatever the sessions since the snapshot left behind is gone. The tool is confirmation-gated.

  • The engine’s /api/memory endpoints are permitAll and take a bare userId, so what a caller can reach is bounded by the platform instead. agent_ref authorizes the call — the caller needs Viewer on that agent’s project, as test_agent requires before it drives one — and every device_ref is forced under the mcp: namespace before it is resolved, the same prefix test_agent stamps on its own. A ref written as another client’s (client:, bridge:) therefore names a device of this connector’s, not the one it looks like.

  • A session is aggregated about 30 seconds after its connection closes, so a snapshot taken straight after the last turn can hold nothing. The result reports how many memory units it holds and says so when there are none.

Staff Test Harness

The staff-only MCP tools create_bridge_configuration, list_bridge_configurations and create_bridge_test_case (BridgeHarnessService) write the bridge’s documents directly into the bridge’s collections in the shared database, reproducing the bridge’s schema, with the values of promethist.mcp.bridge.*. They never start a run: the tool hands back the exact command, which the person runs with their own bridge key, so no shared credential appears in a conversation.