M2-31: Conformance suite with the Fake-Agent¶
Status: ready | Depends on: M2-13, M2-15, M2-17, M2-18, M2-19, M2-20, M2-27, M2-29 | Milestone: M2
Goal¶
After this ticket the mandatory agent scenarios of 09 "Required scenarios" run data driven against the real server in every make check. Each test ID AG-01 to AG-32 has one scenario file under services/server/test/agents/scenarios/; a Go conductor in services/server/test/agents starts the real server in the process (shared harness of M1-71, own PostgreSQL 18 through pgtest, fake clock), acts for the people through the public API, checks the stored state, and drives one or more Fake-Agent processes. The Fake-Agent is the scriptable adapter of 09 "Test doubles": built on the Python SDK of M2-29 (module bothy_sdk.testing.fake_agent, the plan's sdk/python/testing), it connects as a named consumer of its agent, verifies invocation tokens with the SDK, keeps an inbox, acknowledges, reads the context, starts, patches, waits, resumes, renews and finishes runs, sends heartbeats, posts answers and cards, registers commands and reports sessions, and on command plays the faults the scenarios need: a wrong base_version, an expired or forged token, a wrong actual mode or tool class, a crash after accepting, a slow start. Only the public API lies between the two sides (ADR 0022 "Confirmation"). Why: M2-04 to M2-20 proved the adapter contract module by module with Go test clients; this suite proves it end to end with a real SDK on the other side, it is M2 exit criterion 1, and it is the conformance check that RK-09 promises for every adapter. Parts of a scenario that need a ticket of a later milestone (a message of origin import in AG-01, attachments and transcripts in AG-20, voice messages in AG-25) are held in a golden list of deferred parts with the ticket that brings them; nothing is skipped.
Read first¶
docs/PLAN.md: section 1 "Principles"; section 3 "Way of working per ticket"; section 5 "Test strategy" (rows "Integration server" and "Test doubles"); section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope, Out of scope, Exit criteria 1, 5 and 10, Test strategy, Red team, Dependencies, Risks); the row M2-31 (this ticket), the rows of what exists (M2-04 to M2-20, M2-23, M2-29) and of who builds on it (M2-36, M2-37); the rows M2-24, M2-27, M3-07, M3-09, M7-27 and M8-03 (deferred parts and later users of the Fake-Agent); section 19 P-09, P-10, P-11, P-26.- Dependencies: the header names M2-13, M2-15, M2-17, M2-18, M2-19, M2-20, M2-27 and M2-29. Implied through them and therefore not in the header: M2-24, M2-23, M2-16, M2-14, M2-12, M2-11, M2-10, M2-09, M2-08, M2-07, M2-06, M2-05, M2-04, M2-02 and the M1 tickets M1-12, M1-13, M1-14, M1-16, M1-17, M1-28, M1-29, M1-31, M1-32, M1-44, M1-45, M1-51, M1-54, M1-55, M1-56, M1-60, M1-63, M1-64, M1-65, M1-66 and M1-71. Dependencies outside the M2 agent block: M1-71 (
services/server/test/harness,BuildMatrixWorld) with M1-37 (the owner bootstrap thatharness.Startuses), M2-27 and through it M2-24 (the two paths of originappfor AG-01 and Tracker in AG-20). docs/spec/09-agents.md: 1 "Principles"; 2.1, 2.2 "Features", 2.4 "Tool class"; 3.1 to 3.7; 4.1 to 4.10 including 4.5.1 "Fresh token for a waiting run" and "Keys and rotation"; 5.1 "State machine", 5.2 "Reasons (runs.state_reason)", 5.3, 5.5; 6 "Trigger state (trigger_state)"; 7 "Follow-ups during a run"; 8 "Stop"; 9.4, 9.5; 10.1 to 10.5; 11.2 to 11.6; 12 "Auto-title"; 13; 14; 15 "Reliability"; 16.1 "Test doubles"; 16.2 "Required scenarios" (every row and the closing paragraph); 19; 21 "Configuration values of this chapter".docs/spec/04-sync-protocol.md(core contract, read only): 2.2 "Envelope" (delivery fieldsredeliveredandinvocation_token, rule 3); 7.4 "REST variant"; 11.1 "Run document", 11.2 "From the adapter to the server"; 15.1 to 15.5 and 15.7 "Autocomplete"; 16.5 "Agent run with follow-up".docs/spec/03-data-model.md(core contract, read only):runs,run_followups,message_components,interactions,messages(origin,trigger_state,trigger_state_reason,system_code),agent_sessions,agent_consumer_cursors,event_log,audit_events.docs/spec/05-actions.md(core contract, read only): 4.10, 5 "Agent invocationagent.invoke", 6.docs/spec/06-api.md: 2 "Basic rules", 5 "Errors", 7 "Idempotency", 15 "Contract tests and gate".docs/spec/07-permissions.md: 4.1 "Decisions and reasons"; SR4; 10 "Examples" and 11.1 "Test matrix" (the world).docs/spec/10-backend-policy.md: 7 "Adapter check (I3)", 15 "Tests" (BR-06).docs/spec/11-extensions.md: 11.2 "Hand-written layer" (rowtesting), 11.3, 13 step 10.docs/spec/02-architecture.md: 6 "Code layout" (test/and the componenttestsupport), 7.1, 12.3 "Job catalogue".docs/spec/23-quality.md: 1 "Principles", 3 "Component contract", "Documentation in code".docs/spec/19-security.md: SEC-LOG-2.docs/api/openapi.yaml: the operations the scenarios call (getDiscovery,getJwks,rotateSigningKey,createGrant,revokeGrant,createChannel,putChannelBehavior,setAgentToolClass,installApp,createToken,createIncomingWebhook,postIncomingWebhook,createThread,getThread,setThreadTitle,closeThread,createMessage,createInteraction,updateComponent,respondToInteraction,listAgentSessions,bindAgentSession,reportAgentSession,sendHeartbeat,getConsumerState,pollAgentEvents,ackAgentEvents,getRun,patchRun,getRunContext,startRun,resumeRun,renewInvocationToken,awaitRunInput,finishRun,cancelRun,registerCommand,autocompleteCommand,answerAutocomplete); schemasRunStart,InvocationTokenRenew,InvocationTokenGrant,RunAwaitInput,RunFinish,RunCancel,Run,RunPatch,JsonPatchOperation,RunContext,RunContextMessage,Heartbeat,BillingEvidence,ToolClass,AgentSessionBind,AgentSessionReport,ChannelBehavior,IncomingWebhookCreate,MessageCreate,Message,EventEnvelope,EventPage,AgentInvocationPayload,AgentFollowupPayload,InteractionCreatedPayload,RunCancelRequestedPayload,CommandAutocompleteRequestedPayload,FieldError,Problem(x-problem-codes).docs/api/asyncapi.yaml: messagesevent.agent.invocation,event.agent.followup,event.interaction.created,event.command.invoked,event.run.cancel_requested,event.run.state_changed,event.message.updated,event.command.autocomplete_requested; operationsreceive.ack,receive.heartbeat,receive.run.update,receive.command.autocomplete_result.- ADRs 0022 ("Confirmation": contract tests only through the public API of the running server), 0023 ("Confirmation": AG-08, AG-28, AG-31, AG-32), 0026 (generated core plus hand layer, FakeServer, shared scenarios), 0053 (no JSON
null), 0054 ("Confirmation": AG-20, AG-21). docs/DECISIONS.md: D-W-15, D-W-16, D-W-44, D-W-58, D-W-100; section 10 (rowsD-M2-04-<n>toD-M2-20-<n>,D-M2-23-<n>and those of M2-29; format of new rows).docs/security/threat-model.md: T-TK-05 (reuse of an invocation token), T-AI-07 (loops), T-AI-08 (personal subscription used by another person).docs/RISKS.md: RK-09, RK-13, RK-18.docs/OPEN-QUESTIONS.md: OQ-01 (strict I1, so no second person ever hastriggerwhere a personal agent operates), OQ-17 (package names; nothing is published).docs/operations/configuration.md:agent.ack_timeout,agent.ack_persist_interval,agent.cancel_grace,agent.presence_timeout,agent.heartbeat_persist_interval,agent.invocation_token_ttl,agent.jwks_max_age,agent.context_max_bytes,agent.context_message_max_bytes,agent.run_update_min_interval,auth.clock_leeway,run.orphan_timeout,run.queued_max_age,command.autocomplete_timeout,signing_key.invocation_rotation_interval,signing_key.cache_ttl,signing_key.rotation_check_interval,adapter.invocation_token_leeway,adapter.jwks_refresh_min_interval,sdk.http_timeout,sdk.retry_backoff_base,sdk.retry_backoff_cap,sdk.retry_max_attempts,http.request_timeout(all read, none new).docs/quality/gates.md(rowstestandcoverage),docs/quality/red-team-gate.md,docs/quality/push-discipline.md.- M1 briefs whose interfaces you use: M1-12 (
pgtest.Main,pgtest.New, one container per test binary), M1-14 (internal/platform/jobs: registry,Handler,PeriodicJob, roles), M1-31 (response validator inservices/server/test/apitest), M1-64 (wstest), M1-71 (the shared harness:harness.Start,OptionswithConfigandClock,ServerwithPublicURL,APIBase,GatewayURL,Owner,AdminDB,NewUser,NewAgent,Client.Do,Client.StepUp,BuildMatrixWorld; "extend it only additively"). - M2 briefs (all in
docs/briefs/), for names and for the tests that recorded the red runs (Design note 14):M2-04-trigger-decision.md(AG-01 to AG-06 as decision tables),M2-05-runs-state-machine.md,M2-06-invocation-token.md(AG-22 at token level, the reference verifier, its note 7 on the run level),M2-07-agent-consumers.md(pollAgentEvents,ackAgentEvents, the ack save ticker and shutdown flush),M2-08-invocation-delivery.md(AG-07, AG-09, AG-27, jobruns.queued_expiry),M2-09-run-lifecycle.md(AG-08, AG-10, AG-11, AG-14, AG-28 to AG-32, jobsruns.cancel_timeoutandruns.stall_check),M2-10-run-document-live-box.md(AG-12,409 conflictwithreasonrun_not_active),M2-11-run-followups.md(AG-13),M2-12-agent-sessions.md(AG-17, AG-18,agentstest.InsertImportedSession),M2-13-run-context.md(AG-20, AG-21, themessagingtest helper for messages of originimport),M2-15-components-interactions.md(AG-15, AG-16, AG-31 end to end),M2-16-slash-commands.md(AG-24),M2-17-command-autocomplete.md(AG-23),M2-18-auto-title.md(AG-19),M2-19-agent-messages.md(AG-01 fororigin = agent, AG-26),M2-20-heartbeat-presence.md(sendHeartbeat),M2-23-generated-clients.md(layout ofsdk/python,bothy_sdk,bothy_sdk.generated, tolerance),M2-24-apps-install.mdandM2-27-webhooks-incoming.md(the two paths of originapp). - M2-29 (
docs/briefs/M2-29-python-sdk.md): read its brief, its merged code and README undersdk/python(modulesauth,gateway,invocation,runs,components,commands,outbox,retry,heartbeat,testingwith the FakeServer, after 11 11.2) and its rows indocs/DECISIONS.mdsection 10. - Code on the base branch:
services/server/test/harness,services/server/test/apitest,services/server/internal/platform/pg/pgtest,internal/platform/jobs,internal/platform/config,internal/wire,internal/runs(the test helper with M2-06's reference verifier),internal/agents/adapters/agentstest, themessagingtest helper of M2-13,api/operations.yaml,architecture/modules.yaml;sdk/python(pyproject.toml,src/bothy_sdk/,tests/);tools/ci/components.pyandtools/ci/tests/test_components.py.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- AG-01 to AG-32 of 09 16.2 run data driven against the real server in
make check(M2 exit criterion 1; the plan row says AG-01 to AG-30, 09 16.2 also lists AG-31 and AG-32, and the chapter wins, PLAN 1 point 3); each scenario is one data file, and its expectations come literally from 09 16.2 and the chapters it names, never from server code. - The Fake-Agent is a scriptable adapter on the Python SDK with scenarios as data, and it can play faults: wrong
base_version, expired token, wrong actual mode, crash after accepting (09 16.1, 11 11.2 rowtesting, ADR 0026). - The Fake-Agent uses only the public API, REST and gateway, with its own agent API token (ADR 0022 "Confirmation", 09 1 points 1 and 2).
- The scenarios run against the real server with its own PostgreSQL instance (09 16.2 last paragraph); a missing Docker-compatible runtime or a missing
uvfails the suite, never skips it (P-09); every listener binds127.0.0.1and port 0 (P-10). - No real agent runtime, no model call and no third-party service runs (09 16.1 "Real runtimes ... only behind an explicit switch"; PLAN 3 point 5).
- Tokens and credentials never appear in scenario files, logs, test output, failure messages, event payloads or audit details (04 2.2 rule 3, SEC-LOG-2, T-TK-05).
- Every scenario was red before its implementation; the proof is in the report of the implementing ticket, and this ticket's report collects it per AG ID (plan row M2-31).
- A part of a scenario that needs a later ticket is recorded with that ticket in a golden list of deferred parts and is never silently left out (09 16.2 closing paragraph, P-09).
Scope¶
Paths are relative to the repository root. Names of M1-71, M2-04 to M2-20, M2-23 and M2-29 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).
Create:
services/server/test/agents/(componenttestsupport, Go packageagents, only_test.gofiles, so the suite stays out of the product binary and the Semgrep egress rule's*_test.goexclusion covers the proxy):main_test.go(TestMainwithpgtest.Main),conformance_test.go(TestConformancewith one subtest per scenario file),scenario_test.go(types, strict loader,Validate),conductor_test.go(step execution, waits, keeps, parallel groups),checks_test.go(state checks throughAdminDB),fakeagent_test.go(process manager and control client of the Fake-Agent),proxy_test.go(the fault proxy of Design note 9),world_test.go(world extensions of Design note 10),registry_test.go(coverage of 09 16.2 and the deferred golden list), and the unit tests of Tests below.services/server/test/agents/scenarios/ag-01.yamltoag-32.yaml: one file per test ID (Design note 3).services/server/test/agents/testdata/deferred.golden(Design note 13).services/server/test/agents/README.md(English, like the other READMEs underservices/server): purpose, how to run one scenario, the scenario format, the Fake-Agent control protocol, the deferred parts, how a later ticket adds a scenario part.sdk/python/src/bothy_sdk/testing/fake_agent.py(the Fake-Agent: classFakeAgent, entry pointmain()forpython -m bothy_sdk.testing.fake_agent),sdk/python/src/bothy_sdk/testing/control.py(typed requests and replies of the control protocol),sdk/python/src/bothy_sdk/testing/token_faults.py(the token variants of AG-08). If M2-29 madetestinga single module instead of a package, turn it into a package that re-exports its public names unchanged.sdk/python/tests/testing/test_fake_agent.py,test_control.py,test_token_faults.py: tests of the Fake-Agent against M2-29's FakeServer (no real server, no listener other than the FakeServer's on127.0.0.1port 0).
Change:
services/server/test/harness(M1-71, additive only; Design note 8):Options.ManualJobs,Options.PublicOrigin,(*Server).ListenURL,(*Server).RunDueJobs,(*Server).Restart,(*Client).AgentCredentials, with unit or integration tests next to them and the harness README.services/server/internal/messaging/adapters/messagingtest: only the fixture helper of Design note 10, and only if the base branch has none.sdk/python/src/bothy_sdk/: only the hook of Design note 5 f (an injectable clock for token expiry and JWKS cache age), with a test, if M2-29 did not provide it.tools/ci/components.py: the entryserverofGATE_COMPONENTSalso owns the prefixsdk/python/(Design note 15);tools/ci/tests/test_components.py: a case thatsdk/python/src/bothy_sdk/testing/fake_agent.pyselectsserverandsdk-python.docs/spec/09-agents.md(English, like the chapter, not core contract): 16.1 row "Fake agent" (control by the suite over JSON lines, modulebothy_sdk.testing.fake_agent, only public API); 16.2 closing paragraph (the suite inservices/server/test/agents, one file per ID, the deferred parts with their tickets).docs/spec/11-extensions.md11.2 rowtesting: "in-process FakeServer with fault injection, fake agent for the conformance suite, scenario helpers, recorded fixtures".docs/spec/02-architecture.md6 "Code layout":agents/in the list oftest/("conformance suite of the agents with fake agent, M2-31").docs/adr/0022-agents-and-plugins-outside-the-server-process.md"Confirmation": the contract-test bullet namesservices/server/test/agents(TestConformance).docs/quality/gates.mdrowtest: the conformance suite runs insidego testofservices/serverand starts the Fake-Agent fromsdk/pythonthroughuv.sdk/python/README.md(section "Test doubles": the Fake-Agent and its control protocol),services/server/test/harness/README.md(the additions);services/server/CHANGELOG.md,sdk/python/CHANGELOG.mdand the rootCHANGELOG.md(CI path filter) under "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M2-31-<n>(Design note 16).
This ticket creates no migration, no operation, event, action, job, metric or configuration key, adds no Go or Python dependency, and creates no new top-level make target.
Out of scope¶
- Every server behaviour the scenarios check: M2-04 to M2-20 built it. A scenario that fails because the server deviates from the specification is not fixed here (Boundaries).
- The SDK hand layer itself, the FakeServer and the SDK's own scenarios against it (AG-08 adapter side, AG-09, AG-12, AG-28 per M2-29's row), the adapter-side conformance of other adapters (M2-29, M2-30, M2-33, M2-34, M2-35).
- AG-01 with origin
import(M8-03, the first path that writes such messages), the attachment and transcript parts of AG-20 (M3-07, M3-09) and AG-25 (M3-09): deferred parts (Design note 13; plan gap reported). - Load on the agent path (
perf.invocation_delivery_p99,perf.run_patch_fanout_p99): M2-36. The guide check against a fresh server: M2-37. The demo instance that lets the Fake-Agent produce runs (P-26): M7-27. - Red-team scenarios beyond the AG IDs (personal subscription over every path, confused deputy): covered by the tests of M2-08, M2-11, M2-14 to M2-17 and M2-19.
- The TypeScript Fake-Agent and the TypeScript run of the same scenarios (M2-30).
docs/PLAN.md(the gaps go into the report), changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-31-agent-conformance-suite; one writer per worktree. - Conventional Commits with the component as scope (for example
test(agents): ...,feat(sdk-python): ...,test(harness): ...,ci(components): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. Never publish a package (OQ-17). - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime or withoutuvonPATHthe suite fails with a message that names what is missing; it never skips. - Stop only processes you started, by PID: every Fake-Agent process is killed in
t.Cleanupand its exit is awaited. Temporary directories only viamktemp -dort.TempDir(), removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools, Go modules and the locked Python packages of
sdk/python. - Never lower a threshold, disable a gate or delete a test. The
go testline of the rootMakefileis not changed (no-timeout, no tags, no-short). - If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same holds for the merged code of M2-04 to M2-20, M2-23, M2-24, M2-27 and M2-29: follow its names and list each difference. A scenario that is red because the server or the SDK behaves differently from 09 16.2 or the chapter it names is not weakened and not worked around: stop with
Verdict: BLOCKED <ticket> deviates: <AG ID> <expected> <observed>. - The Fake-Agent uses only the public API. Only the conductor reads the database (
AdminDB, assertions and the snapshot of Design note 8), runs jobs and writes the fixtures of Design note 10 that have no API path; it never writes state that an operation can write. - No agent runtime, no model and no third-party service: the Fake-Agent decides everything from its script. Time only through the harness clock (
kernel/clock); the conductor moves it between steps and never sleeps for a window of the specification. - Tokens are passed only in memory (control pipe, conductor variables); the conductor's failure messages, the captured stderr of the Fake-Agent and the scenario files never contain a token, a signature, an API token or a DPoP key (print the first hex characters of the SHA-256 of the
jtiwhen a token must be named).
Design notes¶
-
Start check. Run
make checkon the fresh worktree; if it is red, stop withVerdict: BLOCKED base branch redand the failing gate. Then confirm on the base branch:services/server/test/harnesswithStartandBuildMatrixWorld(M1-71); the operationsstartRun,resumeRun,renewInvocationToken,awaitRunInput,finishRun,cancelRun,patchRun,getRunContext,createInteraction,bindAgentSession,reportAgentSession,autocompleteCommand,answerAutocomplete,registerCommand,setThreadTitle,sendHeartbeat,installAppandpostIncomingWebhookunderenabledinservices/server/api/operations.yaml;sdk/python/src/bothy_sdkwith the hand-layer modules of 11 11.2 and the FakeServer inbothy_sdk.testing(M2-29). A missing one ends withVerdict: BLOCKED dependency <ticket> missing. -
Architecture. Ruling for docs/DECISIONS.md: the conductor is Go and the Fake-Agent is Python. The Go side owns what only it can own: the real server in the process with its fake clock,
pgtest, the matrix world of M1-71, the jobs, the database checks and the response validator of M1-31. The Python side is what 09 16.1 demands: an adapter on the SDK. One Fake-Agent process per agent and consumer of a scenario, started withuv run --project <repository root>/sdk/python --locked --quiet python -m bothy_sdk.testing.fake_agent; the repository root is found by walking up from the test directory to the directory that holdssdk/python/pyproject.toml. If M2-29 already built a Go-side runner that starts the real server for its own real-server tests, extend it instead of building a second one and list it in the report. If a linter flags the start of the subprocess (the program isuvfromPATH, the arguments are constants and repository paths), add a suppression at that line with this reason, never a global exclusion. -
Scenario files (
scenarios/ag-NN.yaml; parsed with the YAML library already inservices/server/go.mod, strict decoding with known fields only; if the module has no YAML library, the files are JSON with the same structure and the extension.json, and no dependency is added). The format is defined by the Go types inscenario_test.goand theirValidatemethod and documented in the README; there is no separate schema file. Top-level keys:id(exactly the file's ID),title(the English row text of 09 16.2),sources(chapter references such as09 16.2,04 15.5),world(matrix),config(dotted keys ofdocs/operations/configuration.mdwith test values; every key the scenario advances the clock by must be set here),setup(steps run before the first case),agents(name to{agent, consumer, transport, runtime, launch};transportisrestorgateway;launchisstart(default) orlater;runtimegives the actual billing mode, billing evidence, loaded tool class, reported capabilities and the adapter optionsadapter.invocation_token_leewayandadapter.jwks_refresh_min_interval),cases(named lists of steps; each case runs on its own server and world unless the scenario setsshared_world: true),matrix(only AG-02 and AG-12: rows of values substituted into the steps, each row with its literal expected outcome),deferred(parts with ticket and reason, Design note 13).- Step kinds, exactly one per step:
as: <person>(or the subjectstrackerandwebhookof Design note 10) withcall: <operationId>andwith(a call through that subject's client);agent: <name>withdo: <op>andwith(a Fake-Agent op, Design note 5);server:one ofadvance_clock(by: <key>, moves by that key's configured value plus one smallest tick of the fake clock),run_jobs(kind: <job kind>),snapshot_cursors(keep: <name>, the stored rows ofagent_consumer_cursors),restart(optionalrestore_cursors: <name>),proxy(drop_next: <operationId>);check:one ofrun,runs,message,followup,component,interaction,session,thread,events,audit,consumer(Design note 4);parallel:a list of steps released together behind a barrier;repeat: {times, steps};await: <name>with anexpect, which waits for a human call that an earlier step started withasync: <name>(a call that blocks inside the server, such asautocompleteCommand). expecton every call and op:status,code,reason,errors(list of{code, pointer}),none(no event of a type arrived),one_of(alternatives), and op-specific fields (event,verdict,redelivered,trigger_kind,session_mode);keepbinds names to IDs from answers (run: r1,message: m1,token: t2) for later steps ($r1). Names of people, agents, spaces, channels and threads are the world's names.- Expectations are literal data. AG-02 lists all its rows with the expected outcome written out from 09 3.4; nothing in the suite calls
Decideof M2-04 or any other server function to compute an expectation.
Example (illustrative, AG-14):
id: AG-14 title: Stop without an adapter answer sources: ["09 16.2", "09 8", "04 15.5"] world: matrix config: {agent.cancel_grace: <test value>} agents: lotse: {agent: Lotse, consumer: default, transport: rest} cases: main: - as: alex call: createMessage with: {thread: support-allgemein-1, content: "@lotse please check the export"} expect: {status: 201} keep: {message: m1} - agent: lotse do: receive expect: {event: agent.invocation, verdict: valid} keep: {run: r1} - agent: lotse do: start with: {run: $r1} expect: {status: 200} - as: alex call: cancelRun with: {run: $r1} expect: {status: 202} - server: advance_clock by: agent.cancel_grace - server: run_jobs kind: runs.cancel_timeout - check: run run: $r1 expect: {state: cancelled, state_reason: cancel_timeout} - agent: lotse do: patch with: {run: $r1, ops: [{op: replace, path: /status_line, value: "still here"}]} expect: {status: 409, code: conflict, reason: run_not_active} -
Conductor. Executes the steps of a case in order on a fresh
harness.StartwithOptions{Config: scenario config, Clock: a fake clock, ManualJobs: true, PublicOrigin: the origin of the case's fault proxy}(Design note 9) and the world of Design note 10.- At the start of each case it starts the Fake-Agent process of every entry in
agentsand runshelloandconnect, so every consumer's cursor exists before the first trigger (04 15.1: the cursor starts athead_seqon the first connection). An entry withlaunch: later(a restarted adapter in AG-10, a second process) is started by a stepagent: <name>withdo: launch, which runshelloandconnect. - Human calls go through the harness client of that person (DPoP,
Idempotency-Key, step-up for the Owner where the operation needs it); every response is validated with the response validator ofservices/server/test/apitest(M1-31) and must contain no JSONnull. - Checks read the stored state through
AdminDB()with read-only SQL:runs(state,state_reason,version,trigger_kind,triggered_by,agent_session_id,billing_mode_used, andstarted_jti,invocation_jti,renewed_from_jticompared only by their hash prefix),messages(origin,kind,system_code,trigger_state,trigger_state_reason),run_followups,message_components,interactions,agent_sessions,threads(title,title_source,parent_message_id,operator_agent_id),event_log(type,audience_principal_id,resource_kind,resource_id, order byseq, count),audit_events(action,decision,deny_reason, keys ofdetails),agent_consumer_cursors. Reads go throughAdminDB, also for history and audit rows, although the history and audit reads (M1-57, M1-50) are in this ticket's closure; every read names its table and column as 03 does. - Asynchronous effects (a delivery over the gateway, a reconnect after a restart) are awaited by polling the condition until it holds or the test's deadline (
t.Deadline()) is reached; no other wait exists, and no step waits for a window of the specification in real time. - A failed expectation names the scenario, case, step index, the expected and the observed value (codes, states, counts), never a token or a body that could contain one.
- Cases run with
t.Parallel();pgteststarts one container per test binary and clones a database per case. The package must finish within the default timeout ofgo test, because theMakefileline stays as it is; if it does not, first let the cases of one file share their world (shared_world: true) where they touch different threads, and report the wall time either way.
- At the start of each case it starts the Fake-Agent process of every entry in
-
Fake-Agent (
bothy_sdk.testing.fake_agent; public API of the module documented with google-style docstrings, rendered by M2-29's MkDocs reference). a. Control protocol: JSON lines on stdin (requests{id, op, args, now}) and stdout (replies{id, ok, result}or{id, ok: false, error: {kind, detail}}); the first request ishellowithbase_url,gateway_url,agent_id,consumer,transport,runtime, the adapter options and the credentials (Design note 6). Every request carriesnow, the harness clock as RFC 3339 UTC with milliseconds; the Fake-Agent sets its SDK clock to it before it acts. Logs go to stderr at levelWARNINGand never contain a token, a credential or message content; stdout carries only replies. b. Ops (each through the SDK hand layer; a request the hand layer would never send, such as a forged, empty or absent token, a stalebase_versionor a start past the adapter's own checks, goes through the generated corebothy_sdk.generateddirectly):Op Effect Result fields connectregister the consumer before the first trigger of a case (REST: a first pollAgentEvents, which creates the cursor and returns an empty page; gateway:identify), so its cursor starts athead_seq(04 15.1)head_seqreceiveconsume until an event of the requested type arrives or the consumer reached head_seq(REST:pollAgentEventspages; gateway: dispatch frames); verify its token with the SDK (invocation), store event and token in the inbox (newest token wins for the same event, 09 4.3) and acknowledge, unlessaccept: falseor the verdict forbids it (09 4.5: unknownkidafter one reload is neither accepted nor acknowledged);hold: truekeeps the event unverified and unacknowledged until a laterverifyof itevent,seq,redelivered,run_id,thread_id,message_id,interaction_id,command_id,trigger_kind,session_mode,verdict,jti_hash, ornonestart,resumestartRunorresumeRunwith the newest inbox token of the run, or the token named intoken(inbox, a kept name,empty,absent,forged:<variant>), withbilling_mode_used,tool_class_used,billing_evidenceandmodelfrom the runtime or overriddenstatus,code,reason,run_state,replayrenewrenewInvocationTokenwith the named token; on success the new token goes into the inbox; optionretry: falsesends this one call through a client whoseSdkSettings.retry_max_attemptsis 1, so a dropped answer is reported aserror {kind: transport}status,code,reason,jti_hashcontextgetRunContext; per elementmessage_id,role,trust,truncated, byte length; the SDK helper composes the prompt with marked blocks (ADR 0054), and the Fake-Agent reports for every element withtrustother thantriggerandselfwhether its text occurs only inside a marked blocktrigger_complete,elements,truncated_before_seqpatchpatchRun(REST) or oprun.update(gateway) withops;base_version: stalesends the version before the last stored change; the SDK's rebase after409 base_version_conflictruns unlessrebase: falsestatus,code,errors,current_versionawait_input,finishawaitRunInput;finishRunwithstate,reason,summarystatus,code,reasonheartbeatsendHeartbeatwithactive_runs(default: the runs it holds),capabilities, reported mode, evidence and tool class from the runtimestatuspostcreateMessageorcreateThread, plain or as a card (the SDKcomponentsbuilders, the approval card of 09 11.5)status,code,reason,message_id,component_idsrespond,update_component,set_title,report_session,register_commandsrespondToInteraction,updateComponent,setThreadTitle(with theETagofgetThread),reportAgentSession(withIf-Match),registerCommandthrough the SDK's declarative command liststatus,code,reasonautocomplete_modeanswerwith given choices, orignore(receivecommand.autocomplete_requested, report it, never answer)requestedverifySDK verification of a named token without any request; for a held event, acceptance and acknowledgement then follow the verdict as in receiveverdictexport_token,import_tokenhand a token of this agent's inbox to the conductor, or take one into the inbox (AG-08 foreign aud, AG-32 lost answer)jti_hash(andtokenonly inexport_token)crashexit at once with a non-zero status, no finishRun, no flushnone c. Verdicts of
verifyandreceive, mirroring M2-06's reference verifier:valid,expired,invalid,unknown_key,billing_mode_mismatch,tool_class_mismatch,replay. d. Well-behaved default (09 4.9): afterreceivewith verdictinvalid,billing_mode_mismatchortool_class_mismatchthe Fake-Agent starts nothing and callsfinishRunwithfailedand the matching reason (invalid_invocation_token,billing_mode_mismatch,tool_class_mismatch); a step withskip_own_checks: truesends the start anyway through the generated core (AG-11, AG-30 server parts). e. Token variants offorged:<variant>(token_faults.py):bad_signature(one byte of the signature changed),alg_hs256,alg_none(header re-encoded),typ_access(headertypof an access token), built from the run's current token;emptyis an empty string;absentleaves the member out. f. SDK hook (Scope, Change): if the merged SDK has none, add a keyword argumentclock(a callable returning an awaredatetime, default the system clock) to the invocation verifier and its JWKS cache, used for expiry,iatandmax-age, documented as a test hook; the default keeps M2-29's behaviour. -
Credentials. The conductor reads the agent's API token, and its DPoP private key when the harness bound the token to one, through
(*Client).AgentCredentials()and sends them only inhelloon stdin; never inargvor the environment of the process. Ruling for docs/DECISIONS.md: credentials of a test double travel only over its stdin. -
Time and jobs. One fake clock per case (the harness clock). The conductor advances it only between steps, after the previous step's effects were observed; the Fake-Agent follows through
now. Jobs never run by themselves:Options.ManualJobskeeps the job runtime from working jobs, andrun_jobscalls(*Server).RunDueJobs(kind), which runs every due job of that kind (scheduled at or before the harness clock) through its registered handler inidorder, or a periodic kind (runs.stall_check,instance.key_rotation) once. Ruling for docs/DECISIONS.md: jobs in the suite run only on request, because River's own scheduling follows the wall clock while the server follows the fake clock. Kinds used:runs.cancel_timeout,runs.stall_check(M2-09),runs.queued_expiry(M2-08),instance.key_rotation(M1-32). -
Harness additions (M1-71, additive; each with a doc comment and a test):
Options.ManualJobs boolandfunc (s *Server) RunDueJobs(tb testing.TB, kind string) int(Design note 7).Options.PublicOrigin string(when set:server.public_url, and the origin ofPublicURL,APIBase,GatewayURLand of every harness client) andfunc (s *Server) ListenURL() string(the actual public listener).func (s *Server) SnapshotCursors(tb testing.TB) CursorSnapshot(the stored rows ofagent_consumer_cursors) andfunc (s *Server) Restart(tb testing.TB, opts RestartOptions)withRestartOptions{RestoreCursors *CursorSnapshot}: stops the wire graph gracefully and starts it again on the same database, secrets, configuration and clock, with new listeners on127.0.0.1port 0;ListenURL()changes;PublicURL,APIBaseandGatewayURLstayOptions.PublicOriginwhen it is set, otherwise they andserver.public_urlfollow the new listener. WithRestoreCursorsit writes the snapshot back between stop and start, which leaves the state of a process that died before the acknowledgements made after the snapshot were stored (a gateway acknowledgement is held in memory until the save ticker ofagent.ack_persist_intervalor the shutdown stores it, M2-07 note 9; a REST acknowledgement is lost only when the process dies before its transaction commits). Ruling for docs/DECISIONS.md: crash emulation by cursor snapshot, because an in-process server cannot be killed without killing the test.func (c *Client) AgentCredentials() AgentCredentialswithAgentCredentials{Token string; DPoPPrivateJWK []byte}for clients made byNewAgent, documented as test-only and never logged.
-
Fault proxy (
proxy_test.go). Every Fake-Agent talks to the server through one reverse proxy per case on127.0.0.1port 0 (REST and the WebSocket upgrade of the gateway), whose upstream transport is the loopback client of M1-22 (egress.NewLoopback) with the case's value ofhttp.request_timeout. It keeps the Fake-Agent's base URL stable acrossrestart(it switches its upstream and closes open WebSockets, so the SDK reconnects) and servesproxy: {drop_next: <operationId>}: the next matching request is forwarded, the server's response is read completely and kept in memory for the conductor (keep: {token: t2}takes the token of anInvocationTokenGrant), and the client connection is closed without an answer. This is the "fake proxy" of AG-32. Beforeharness.Startthe conductor opens the proxy listener on127.0.0.1:0and starts the server withOptions.PublicOriginset to the proxy origin, which the harness also sets asserver.public_url, because the server builds the DPoPhtufromserver.public_url(08 7.3, M1-18) and every client signs the URL it calls. Every client of the case (the people through the harness,tracker,webhookand every Fake-Agent, REST and gateway) goes through the proxy; its upstream is(*Server).ListenURL().restartkeepsserver.public_url; the proxy switches only its upstream. -
World.
harness.BuildMatrixWorld(07 10 and 11.1: Support with Allgemein and Eskalation, Marketing with Kampagnen, Engineering with Projekt-A and Werkzeuge, Research with Notizen; Alex, Sam, Robin and the Owner; Lotse, Texter, Runner, Assistent; group Leser; grants G-1 to G-5), extended inworld_test.gothrough public operations only: the tool classes of 07 10 withsetAgentToolClasswhere the merged world left them at their default (Lotsechat, Texterscoped, Runnerhost, Assistentscoped); per scenario the channel switches withputChannelBehavior, extra grants withcreateGrant, channels withcreateChannel; for AG-01 and AG-20 the app Tracker throughinstallAppwith scopemessages:write, its grant G-6 (writeon Support/Eskalation) and its token throughcreateToken; for AG-01 an incoming webhook of formatnativeon Support/Eskalation throughcreateIncomingWebhook. Tracker acts as the subjecttrackerthrough a plain HTTP client with its app token, and the incoming webhook as the subjectwebhookthroughpostIncomingWebhookwith the token the world kept in memory. Fixtures without an API path are written by the module test helpers on the harness database and only those: imported session rows throughagentstest.InsertImportedSession(AG-18, M2-12) and messages of originimportin a thread through themessagingtest helper that M2-13 used for them (AG-20); if the base branch has no such helper, addInsertImportedMessage(ctx, ImportedMessage{ThreadID, AuthorID ids.ID; Content string})to the test-helper package ofmessaging(internal/messaging/adapters/messagingtest, 02 6), which stores the message through the append path thatcreateMessageuses (thread sequence through thechatport) withorigin = importand publishes noMessagePosted, with a test. Ruling for docs/DECISIONS.md: fixtures are allowed only for state that no operation can write before M8-03. -
Transports. REST (
pollAgentEvents,ackAgentEvents) is the default, because an empty poll up tohead_seqproves that nothing was delivered without a timeout; every expectationnoneuses REST. The gateway is used where the scenario needs it: AG-12 (both transports), AG-23 (ephemeralcommand.autocomplete_requestedreaches only a live gateway connection, M2-17). Ruling for docs/DECISIONS.md. -
Rules for single scenarios. a. AG-02: three switches of 09 3.3 that decide a candidate (
require_mention,thread_require_mention,free_response) in all eight combinations, times main thread and thread, times no mention and a mention of the operator: 32 rows, each in its own channel of Support created by the Owner withauto_thread = false(so the run stays in the thread of the message;auto_threadis AG-05's). A thread row's thread is created by Lotse's Fake-Agent withcreateThread(an agent message never triggers, AG-01), then Alex writes. The expected outcome per row is written out from 09 3.4: with the operator mentioned always a run; without mention a run exactly whenfree_response, or main thread and notrequire_mention, or thread and notthread_require_mention; otherwise no run and notrigger_state. b. AG-08 "missing" is theemptytoken (403 invocation_token_invalidwithreasoninvalid, M2-09 note 3e); theabsentmember is a schema error (400 validation_failed) and is asserted as such. Ruling for docs/DECISIONS.md (spec gap reported). c. AG-22 emergency: the scenario follows 09 4.5 "Keys and rotation" point 3: the Fake-Agent takes the delivery withhold: true; the Owner callsrotateSigningKeywithmode = emergency; the clock passesagent.jwks_max_age, so any JWKS the Fake-Agent cached has expired; the followingverifyfetches the JWKS, the token of the withdrawn key fails withunknown_key(the key check comes before the expiry check, 09 4.5 points 3 and 5), the event is neither accepted nor acknowledged, and once the clock has also passedagent.ack_timeoutit comes again with a token of the new key. The case of an adapter whose still-valid JWKS cache accepted a token of the withdrawn key before the rotation fails its run atstartRun; it contradicts "costs ... no run" and is reported as a spec gap (first found by M2-06), not asserted. d. AG-28: the slow start is the conductor holding the Fake-Agent between acknowledgement andcontext(09 16.2 "fake agent delaysrun.context.read"); the conductor takessnapshot_cursorsafter Alex's message and before the Fake-Agent'sreceiveacknowledges T1's event, and the laterrestartrestores that snapshot, which is the state a restart leaves when the acknowledgement was not stored (on the gateway it waits for the save ticker, M2-07 note 9); after the restart the next poll delivers the same event (same eventidandseq) again, withoutredelivered, because the window of the new process is fresh (M2-07 note 5). -
Deferred parts.
testdata/deferred.goldenholds exactly these lines, andregistry_test.gocompares thedeferredentries of all scenario files with it:AG-01 origin=import M8-03(no path writes such a message before M8-03, and a fixture row would not pass the acceptance),AG-20 attachment M3-07,AG-20 transcript M3-09,AG-25 voice M3-09(the file pipeline with scan and the transcript path are M3 tickets with their own test infrastructure). The ticket named in a line adds its part to the scenario file and removes the line in the same change. Ruling for docs/DECISIONS.md; plan gap reported (M2 exit criterion 1 names AG-01 to AG-30 for M2, and M3-07 and M8-03 do not depend on M2-31; M3-09 does since D-W-108). -
Red runs. The plan row requires that every scenario was red before its implementation, proven in the report of the implementing ticket. This ticket: a. writes each scenario file before the conductor and the Fake-Agent support its steps, runs it, and keeps the red output; b. proves that every kind of expectation bites (status, code, reason,
errors, event, verdict,none, everycheckkind): the unit tests of Tests feed a wrong expected value and assert the step fails; c. reports a table with one row per AG ID: the implementing ticket and its test that recorded the red run (AG-01 M2-04TestDecide_AG01_NonHumanOriginNeverTriggersand M2-19TestAG01_AgentMessagesNeverTrigger; AG-02 to AG-06 M2-04TestDecide_AG02_SwitchMatrixtoTestDecide_AG06_ActiveRunOtherAgent; AG-07 M2-08TestDelivery_AG07_AZM51_RevokedBeforeDelivery; AG-08 M2-09TestStartRun_TokenInvalid_AG08; AG-09 M2-08TestDelivery_AG09_TwoAdapterProcesses; AG-10 M2-09TestStallCheck_Interrupted_AG10; AG-11 M2-09TestFinishRun_AdapterReportsMismatch_AG11; AG-12 M2-10TestAG12PatchStateRejected,TestAG12StaleBaseVersion; AG-13 M2-11TestAG13_Steerand siblings; AG-14 M2-09TestCancelRun_Running_AG14; AG-15, AG-16 M2-15TestAG_15_ConcurrentClicksOneWins,TestAG_16_AlwaysAllowNonOwner403; AG-17, AG-18 M2-12; AG-19 M2-18TestAG19_OperatorAgentTitleAfterRun; AG-20, AG-21 M2-13; AG-22 M2-06; AG-23 M2-17; AG-24 M2-16TestAG24_ReservedNamesRefused; AG-26 M2-19TestAG26_AgentWithoutWrite; AG-27 M2-08TestQueuedExpiry_AG27; AG-28 to AG-32 M2-09 and M2-15TestAG_31_InteractionResumesWaitingRun; AG-25 M3-09), with the merged test names, and cites the red output where the launcher made the earlier reports available; otherwise it names the gap under "What is not proven". -
CI path filter. The suite runs inside the gate component
serverbut executes the code ofsdk/python; a change only undersdk/python/must therefore run the server gates too. Ruling for docs/DECISIONS.md:GATE_COMPONENTS["server"]ownssdk/python/in addition toservices/. -
Rulings for
docs/DECISIONS.md(section 10, one rowD-M2-31-<n>each, with date, reason and place): architecture (note 2); scenario format with literal expectations (note 3); state checks throughAdminDBand response validation (note 4); the Fake-Agent ops, verdicts, well-behaved default and the SDK clock hook (note 5); credentials over stdin (note 6); jobs on request (note 7); crash emulation by cursor snapshot (note 8); the fault proxy (note 9); fixtures only without API path (note 10); REST by default (note 11); AG-02 rows, AG-08 "missing", AG-22 emergency (note 12); deferred parts (note 13); red-run proof (note 14); CI path filter (note 15); AG-31 and AG-32 in the suite (requirement 11).
Tests (write them first)¶
Unit, Go (services/server/test/agents, no server, no container):
TestScenarioFiles_Load: every file underscenarios/loads with strict decoding, itsidequals the file name, every step has exactly one kind, everycallnames anoperationIdofdocs/api/openapi.yaml, everydonames an op of Design note 5 orlaunch, everyadvance_clockkey is set inconfigand listed indocs/operations/configuration.md, everyrun_jobskind is one of Design note 7, every call and op has anexpect, every case ends with at least onecheck, and a file without cases is accepted only when every part of its ID is indeferred; a file with an unknown key, a duplicateidor a missingexpectis rejected (table of broken inline documents).TestScenarioRegistry_CoversMandatoryScenarios: the IDs of the table indocs/spec/09-agents.md16.2 equal the set of scenario IDs (AG-01 to AG-32), none missing, none extra.TestScenarioRegistry_DeferredGolden: thedeferredentries of all files equaltestdata/deferred.goldenline for line; each line names a ticket ID that exists indocs/PLAN.md.TestScenarioFiles_NoSecretsAndWhiteLabel: no compact JWS (three base64url parts), no API token prefix of ADR 0061, no DPoP key, onlyexample.comaddresses and the world's names.TestExpectations_Bite(Design note 14 b): for each expectation kind and eachcheckkind, a step with a wrong expected value against a recorded reply or a fakeAdminDBresult fails with a message that names step, expected and observed value and contains no token.TestFaultProxy_DropsAnswerAfterUpstream: the upstream handler runs and commits, the client gets a closed connection, the kept body equals the upstream answer;TestFaultProxy_SwitchesUpstreamAndClosesSockets;TestFaultProxy_PassesWebSocketUpgrade.TestFakeAgentProcess_CredentialsOnlyOnStdin(the process'sargvand environment contain no credential) andTestFakeAgentProcess_KilledOnCleanup(the PID is gone after cleanup).
Unit and integration, harness (services/server/test/harness): TestRunDueJobs_RunsOnlyDueJobsOfKind, TestRunDueJobs_PeriodicKindRunsOnce, TestRestart_SameDatabaseNewListeners, TestRestart_DPoPAgentRequestThroughProxy (an agent request with a DPoP proof through a proxy on PublicOrigin gives 200 before and after Restart), TestRestart_RestoreCursors (an acknowledgement made after the snapshot is lost, one stored before it stays), TestAgentCredentials_OnlyForAgents.
Unit, Python (sdk/python/tests/testing/, against M2-29's FakeServer, run by make test; these tests alone keep sdk/python above its coverage threshold of M2-23, because the Go suite's use of the module is not measured):
test_control.py: request and reply round trip; an unknown op, a missinghelloand a malformed line give an error reply and the process keeps running; replies carry no token exceptexport_token.test_fake_agent.py:receiveaccepts a valid invocation, stores it and acknowledges only afterwards; a redelivered event replaces the stored token and is not processed twice; an unknownkidafter one reload is neither accepted nor acknowledged; the well-behaved default finishesfailedwith the matching reason forinvalid,billing_mode_mismatchandtool_class_mismatch;skip_own_checkssends the start anyway through the generated core;patchwithbase_version: stalereports the first answer and rebases;heartbeatlists held runs; arenewwithretry: falsewhose answer the FakeServer drops (Fault.disconnect()) replieserror {kind: transport}after exactly one request;crashexits non-zero without a request; the clock followsnowfor expiry and JWKSmax-age.test_token_faults.py: every variant of Design note 5 e is rejected by the SDK verifier withinvalid; the original token is unchanged.test_no_secrets_in_logs: with a canary token and a canary API token, captured stderr and replies (other thanexport_token) never contain them.
Conformance (TestConformance/<ID>, PostgreSQL 18 via pgtest, real modules through internal/wire via the harness, HTTP and gateway through the real chain behind the fault proxy, Fake-Agent processes, fake clock). World of Design note 10; "Lotse runs" means: Alex mentions @lotse in a thread of Support/Allgemein, Lotse's Fake-Agent receives and starts. Each file asserts:
- AG-01 (
originagent,app,import,systemin a free channel: no run): Support/Eskalation withfree_response = true; Lotse's Fake-Agent posts without and with@lotse; Tracker posts with its app token mentioning@lotse;postIncomingWebhookposts anativebody naming@lotse; the Owner closes a thread and Lotse answers in it, which writes thethread.reopenedline; the stored messages haveoriginagent(Lotse),app(token),app(webhook),system(the reopen line) andagent(the answer), none has atrigger_state, and norunsrow and noagent.invocationappear; control at the end: Alex writes without mention, one run of Lotsequeued. Deferred:origin=import. - AG-02 (switch matrix): the 32 rows of Design note 12 a, each with its literal outcome: run of Lotse in that thread with
trigger_state = received, or no run andtrigger_stateabsent. - AG-03: Alex mentions
@lotseand@texter:trigger_state = failedwithmultiple_agents_mentioned, no run. - AG-04:
@texterin Support/Allgemein withallow_mentioned_agents = false, and withtruewhile Texter has nowritethere:failedwithagent_not_allowed_hereboth times; aftercreateGrantofwritefor Texter: a guest run of Texter withagent_session_idNULL. - AG-05: Alex mentions
@lotsein the main thread of Allgemein withauto_thread = true: a new thread withparent_message_id= M,operator_agent_idLotse andtitle_source = auto; the run is in the new thread; M carries thetrigger_state; the deliveredagent.invocationnames the new thread. - AG-06: Lotse runs; Alex mentions
@texter(guests allowed, Texter haswrite) in that thread:failedwithrun_active_other_agent; Lotse's run unchanged, no second run. - AG-07: Lotse's Fake-Agent does not poll; Alex triggers; the Owner revokes G-1; the poll then delivers no
agent.invocation; the run isfailedwithaccess_revoked, M hastrigger_state = failedwithaccess_revoked, and one audit rowagent.invokedenywithstage = deliveryexists. - AG-08: on one
queuedrun of Lotse, each ofempty,forged:bad_signature,forged:alg_hs256,forged:alg_none,forged:typ_accessand Texter's real token for Texter's run (foreignaud, throughexport_tokenandimport_token) gives403 invocation_token_invalidwithreasoninvalidatstartRunand atrenewInvocationToken, the SDK verdictinvalid, and the run staysqueuedand unchanged;absentgives400 validation_failed; then the Fake-Agent's default ends the runfailedwithinvalid_invocation_token; no start reachedrunning. - AG-09: two Fake-Agent processes of Lotse with consumers
aandbboth receive and accept the invocation and start in aparallelgroup: exactly one200; the other gets409 already_startedor403 invocation_token_invalidwithreasonsuperseded; onerun.state_changedtorunning. - AG-10: Lotse runs, then
crash; a new Fake-Agent process of Lotse sends heartbeats without the run; afteragent.heartbeat_persist_intervalandagent.presence_timeoutandrun_jobs runs.stall_checkthe run isfailedwithinterrupted; the thread has exactly one run and no secondagent.invocation; casestale: no heartbeat at all, afterrun.orphan_timeoutthe run isfailedwithstale. - AG-11: Lotse's runtime reports
personal: verdictbilling_mode_mismatch, no start request,finishRunfailedwithbilling_mode_mismatch; caseserver:skip_own_checksandbilling_mode_used = personalgive409 billing_mode_mismatch, the runfailedwithbilling_mode_mismatch, one deny audit row with target, actual mode and evidence codes; caseunknown: matching mode withbilling_evidence.key_type = unknowngives409 billing_mode_mismatch. - AG-12 (rows
restandgateway): a patch on/stategives400 validation_failedwitherrors[].codepatch_path_forbiddenat pointer/ops/0(gateway: anerrorframe withvalidation_failed); a patch withbase_version: stalegives409 base_version_conflictwithcurrentequal togetRunat that moment; the SDK rebases and the next patch succeeds;runs.versionrose only for stored patches. - AG-13: Sam gets
triggeron Allgemein in the setup. Casesteer(heartbeatfollowups: steer): Sam writes while Lotse runs: onerun_followupsrowqueued,trigger_state = received, oneagent.followupwith audience Lotse; the Fake-Agent receives it withtrigger_kind = followup, patchesdelivered(messagefollowup_delivered) andabsorbed;finishRuncompletedcreates no new run. Casequeue: two follow-ups, noagent.followup; aftercompletedthe earlier one bythread_seqis the triggering message of a newqueuedrun withtriggered_byits author, the later one aqueuedfollow-up of the new run. Casestop: Alex callscancelRunwith two open follow-ups, the Fake-Agent finishescancelledwithuser_cancelled: both rowsdiscarded, both messagestrigger_state = cancelledwithuser_cancelled, no new run. Casewaiting_input(steerandqueue): the Fake-Agent starts with S and callsawait_input; Alex's follow-up is delivered with token A;resumewith S gives403 invocation_token_invalidwithreasonsupersededand the run stayswaiting_input; thenresumewith A gives200andrunning; a furtherresumewith S gives409 already_started. - AG-14: as the example of Design note 3, plus: after
cancel_timeout,finishcompletedandawait_inputgive409 invalid_state_transitionandresumegives409 already_started. - AG-15: Lotse posts a card with two single-use buttons (
restricted_to = any) for itswaiting_inputrun; Alex and the Owner click different buttons in aparallelgroup: one201, one409 component_used; one componentused, the otherdisabled; oneinteractionsrow; oneinteraction.created; one deny audit row withcomponent_inactive. - AG-16: Lotse posts the approval card of 09 11.5 for Alex's run; Alex clicks "Always allow":
403 forbiddenwithreasoncomponent_restricted, nothing changes; the Owner's click on the same button of a second card gives201. - AG-17: the Fake-Agent reports sessions
ref-aandref-bfor two threads of Lotse; Alex binds with an outdatedexpected_current_session_id:409 conflictwithreasonsession_changed; Alex bindsref-bto the first thread withouttake_over:409 conflictwithreasonsession_bound_elsewhere; sessions and audit unchanged. - AG-18: an imported session row of Lotse in a thread (fixture); Alex triggers; the delivered invocation carries the session mode
bind(09 9.1, as M2-12 put it into the payload); the Fake-Agent starts and reportsbind_failed: session_missingwith a new reference: a new current session, the bound row ends withrebind, exactly one system line, the run staysrunningand finishescompleted. - AG-19: Alex mentions
@lotsein the main thread of Support/Eskalation; Lotse runs and finishes;set_titlegives200withtitle_source = auto; Alex sets a title (manual); Lotse'sset_titlegives409 title_manual, title and version unchanged. - AG-20: in the setup the Owner grants Robin
writeand Texterwriteon Support/Eskalation and setsallow_mentioned_agents = truethere; before Alex's trigger a thread of Eskalation holds messages of Robin (writeonly), of Texter as guest, of Tracker (G-6), animportmessage (fixture) and of the Owner; the Fake-Agent'scontextreportstrust = unverifiedfor Robin, Texter, Tracker and the import,authorizedfor the Owner,triggerfor M, and everyunverifiedtext only inside a marked block of the composed prompt. Deferred: attachment, transcript. - AG-21: with
agent.context_max_bytesandagent.context_message_max_bytesset low inconfig: long elementstruncated, the sum within the limit, the oldest missing,truncated_before_seqset, the trigger complete. - AG-22: case
scheduled(configuration as M2-06's AG-22 test:signing_key.cache_ttlandagent.invocation_token_ttlplusauth.clock_leewaybelowagent.jwks_max_age, belowsigning_key.invocation_rotation_interval): arepeatof trigger, receive, start, finish while the clock andrun_jobs instance.key_rotationcarry two complete rotations: every verdictvalid, every start200, no runfailed; caseemergency(Design note 12 c): one delayed delivery (redelivered: true, token of the new key), then200, no runfailed. - AG-23: Lotse's Fake-Agent on the gateway registers a command with an option
autocomplete = trueand modeignore; Alex'sautocompleteCommand(asynchronous step) is reported ascommand.autocomplete_requestedby the Fake-Agent; aftercommand.autocomplete_timeoutthe answer is200with an empty list; control caseanswer: the choices arrive. - AG-24:
register_commandswith each ofstop,status,new,resume,title,close:409 conflictwithreasoncommand_name_reserved; nocommandsrow. - AG-25: only the deferred line
voice M3-09and thetitleof 09 16.2; the file has no case until M3-09 adds it. - AG-26: Lotse's Fake-Agent posts into a thread of Marketing/Kampagnen:
404identical to an unknown thread; after the Owner grants Lotsereadon Marketing:403 forbiddenwithreasonlevel_insufficient; nothing stored. - AG-27: Lotse does not poll; Alex triggers; after
run.queued_max_ageandrun_jobs runs.queued_expiry:failedwithagent_unavailable, Mfailedwithagent_unavailable; a later poll delivers nothing for that run. - AG-28: Design note 12 d: accept and acknowledge T1; the clock passes
agent.ack_timeoutandagent.invocation_token_ttlplusauth.clock_leeway; a poll delivers no redelivery;startwith T1 gives403 invocation_token_invalidwithreasonexpired;renewgives T2;startwith T1 givessuperseded;restartrestoring the snapshot; the next poll delivers the same event again with T3, which replaces T2 in the inbox (09 4.3);startwith T2 givessuperseded;startwith T3 gives200and again200(replay); exactly onerun.state_changedtorunning. - AG-29:
renewwith a token replaced by a redelivery:403 invocation_token_invalidwithreasonsuperseded;renewof arunningrun:409 already_started; after the Owner revokes G-1:403 agent_invocation_denied, the runfailedwithaccess_revoked, one deny audit row. - AG-30: Texter's runtime loads
host, Sam triggers Texter (tokentool_class = scoped): verdicttool_class_mismatch, no start,finishRunfailedwithtool_class_mismatch; caseserver:skip_own_checkswithtool_class_used = hostgives409 tool_class_mismatchand the runfailedwithtool_class_mismatch;chatis accepted. - AG-31: Lotse starts with S, posts an approval card bound to the run and calls
await_input;renewandresumewith S give403 invocation_token_invalidwithreasonsupersededand the run stayswaiting_input; Alex clicks "Allow once"; the Fake-Agent receivesinteraction.createdwith token A (interactions.state = dispatched);resumewith A gives200andrunning. - AG-32: the clock passes T1's expiry;
proxy drop_next renewInvocationToken;renewwith T1 andretry: falsefails at the transport, the conductor keeps T2;renewwith T1 gives200with T3 andruns.renewed_from_jtiis T1'sjti;startwith T2 givessuperseded;startwith T3 gives200; one start;renewwith T1 gives409 already_started; afterawait_inputit gives403 invocation_token_invalidwithreasonsuperseded.
Plan row M2-31, first acceptance test ("the suite runs in make check"): TestConformance is a plain Go test of services/server, so make test (go test -race ./... per Go module) runs it inside make check; no build tag, no skip, no separate target. Second acceptance test ("every scenario was red before its implementation"): the red run of every scenario file before the conductor and the Fake-Agent supported it (Design note 14 a), TestExpectations_Bite (note 14 b) and the table of note 14 c in the report.
Contract: every human response in the suite validates against docs/api/openapi.yaml (M1-31 validator); every Fake-Agent reply that reports a problem carries code and, where named above, reason or errors[].code; no JSON null in any response or in any event_log payload the checks read.
Red team focus of the milestone (PLAN 8 "Red team": forging or reusing the invocation token; faking the reported mode; another person triggering a personal subscription) is covered end to end by AG-08, AG-11, AG-28 to AG-32 and AG-01; name them in the red team request.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./test/agents/... ./test/harness/...cd services/server && go test -race -count=1 -run 'TestConformance/AG-14' ./test/agents/(one scenario alone, as the README describes)cd sdk/python && uv run --locked pytestcd tools && uv run --locked --quiet pytest ci/tests/test_components.pymake arch,make lint,make typecheck,make coverage,make docsmake check
Acceptance criteria¶
-
services/server/test/agentsholds one scenario file per AG-01 to AG-32, andTestConformanceruns all of them against the real server insidemake check, green. - The Fake-Agent in
bothy_sdk.testing.fake_agentis a scriptable adapter on the Python SDK, uses only the public API, plays every fault of Design note 5, and its tests pass against the FakeServer with the coverage threshold ofsdk/python. - AG-01 (
agent,app,system), AG-02 to AG-07, AG-09, AG-10, AG-12 to AG-19, AG-21, AG-23, AG-24, AG-26 and AG-27 assert exactly the outcomes of 09 16.2 in their files. - AG-08, AG-11, AG-28, AG-29, AG-30, AG-31 and AG-32 assert the token, mode and tool-class outcomes on both sides: the SDK verdict and the server's answer and run state.
- AG-20 (authors without
trigger, other agents, apps, imports) and AG-22 (scheduled and emergency rotation at run level) are green. - The deferred parts (
AG-01 origin=import,AG-20 attachment,AG-20 transcript,AG-25 voice) are exactly the lines oftestdata/deferred.golden, each with its ticket. - Every scenario was red before its implementation: the report has the table of Design note 14 c, this ticket's red runs of the scenario files, and
TestExpectations_Biteis green. - No token or credential appears in scenario files, logs, test output or failure messages; credentials reach the Fake-Agent only over stdin.
- The harness additions, the CI path filter, 09 16.1 and 16.2, 11 11.2, 02 6, ADR 0022,
docs/quality/gates.md, the READMEs, the CHANGELOGs andD-M2-31-<n>are written. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
services/server/test/agents(new),services/server/test/harnessandsdk/pythonupdated (scenario format, step kinds, Fake-Agent ops and control protocol, deferred parts and how a later ticket adds a part, the harness additions, running one scenario). - Rulings of Design note 16 recorded in
docs/DECISIONS.md(one row each with reason); the changes to 09, 11, 02, ADR 0022 anddocs/quality/gates.mdof the Scope made in the same change. services/server/CHANGELOG.md,sdk/python/CHANGELOG.mdand the rootCHANGELOG.mdentries under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after, including the red run of every scenario file and the table of Design note 14 c), the wall time of
go test ./test/agents/..., the summary ofmake check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found; at least the deferred parts with their tickets, the AG-22 emergency case of Design note 12 c and the AG-08 "missing" interpretation of Design note 12 b.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.