Provider Authoring¶
How to add a new AgentProvider adapter (Gemini, …) and prove it's done: implement the seam, then pass the two-tier conformance bar (issue #80 / ADR-0036). Two adapters ship today — claude-code and codex (internal/provider/claudecode/, internal/provider/codex/) — and both are working reference implementations of everything below.
Hard requirements — no escape hatch¶
- A durable, locatable transcript.
LocateTranscriptmust find a real file — verified available for Codex's rollout JSONLs and Gemini CLI's session recording. An adapter may not ship without one. - The lab-owned
.localcontext-file name.SeedMeta.ContextFileNameis a bare.local-suffixed name, never a repo-tracked one (issue #79 / ADR-0035). Making the CLI actually read it is the adapter's ownSeedWorkspacejob, live-verified — never solved by declaring a tracked name. - Live verification before merge. Tier 2 below is required, not optional, even though it never runs in CI.
- Core stays adapter-agnostic. No provider names or markers outside the adapter package — enforced by
internal/provider/neutrality_test.goandweb/src/providerNeutral.test.ts.
Author path¶
- Implement the seam in
internal/provider/<id>/— the fullAgentProviderinterface (internal/provider/provider.go). - Declare
SeedMeta(): context-file name (.local-suffixed), skills dir, exclude entries, andScrubPatterns/SeededPathPatternsin the BRE∩RE2 common dialect (issue #75 / ADR-0033). - Wire host config from the generic
-provider-bin/-provider-configmaps, with the adapter owning its own defaults (issue #78 / ADR-0034) — no provider-named field goes intointernal/config. - Add the one entry to the registration table in
internal/provider/conformance_test.go(TestConformance, packageprovider_test), constructing the adapter hermetically (temp dirs, a fake runner) with itsprovidertest.Fixture. Get Tier 1 green. - Run the Tier-2 live spikes below on a host with the real CLI (spikes 1–4 always; spike 5 where the adapter implements the override).
- Commit the compat record at
internal/compat/<providerID>/compat.md. - Verify core neutrality:
internal/provider/neutrality_test.goandweb/src/providerNeutral.test.tsmust both stay clean. - Register the provider in
cmd/lab/main.go.
Definition of done: Tier 1 green in CI, Tier 2 evidenced in the committed compat record (ADR-0036). Both shipped adapters passed this bar; internal/compat/codex/compat.md (issue #2 / ADR-0037) is the reference example of a compat record.
Beyond the required interface there are optional capability interfaces (internal/provider/provider.go): ConnectingReporter, DeepLinker, LiveSignals, LoginCodeReporter, RemoteCapable. Implement one only when the CLI genuinely supports the behavior — core detects each by type assertion and degrades gracefully when absent (ADR-0017).
Tier 1 — the conformance suite (CI, every adapter)¶
Entry point: providertest.Conformance(t *testing.T, p provider.AgentProvider, fx providertest.Fixture) (internal/provider/providertest/conformance.go). Fixture.AttributionSamples (required, ≥1) are real single-line marker strings the CLI writes into commits/PRs — your adapter's ground-truth test vectors; Fixture.CleanSamples (optional) must NOT match, guarding against an over-broad pattern.
Subtests assert:
- patterns-dialect —
ScrubPatterns/SeededPathPatternscompile viaprovider.CompileScrubPatterns(issue #75 / ADR-0033);ScrubPatternsnon-empty. - context-file-lab-owned —
ContextFileNameis a bare.local-suffixed name (issue #79 / ADR-0035). - seedmeta-clone —
SeedMeta/Models/Efforts/SpawnOptionsreturn defensively-cloned slices. - catalogs — model/effort catalogs are non-empty with unique non-empty values; spawn-option specs are typed with valid defaults.
- spawn-argv —
argv(prompt) == argv(no prompt) + [prompt]; every declared spawn option round-trips. - spawn-remote — a
RemoteCapableadapter's argv differs between remote on/off exactly as its interface declares; a non-RemoteCapableadapter produces an IDENTICAL argv for both values (no undeclared coupling). - auth-flow —
AuthFlow().Kindis a recognizedAuthFlow*constant. - login-session — the id charset and
tmuxx.IsLoginSession(tmuxx.LoginSessionName(ID()))hold (issue #77 / ADR-0034). - read-chat —
ReadChaton a fresh spawn (empty transcript path, runtime dir absent or empty) yields an idle read, never an error; a transcript path that no longer exists surfacesprovider.ErrTranscriptGone, not a raw error. - locate-homeless —
LocateTranscriptwith no per-run instance HOME is a miss ("", nil) — never an error, never a fall back to the machine's master store (issue #202). - inject-credentials — with no master credential present,
InjectCredentialsreturns nil and writes nothing to the master store; the injector only ever copies FROM the master, never into it. - credential-authority — the credential-authority obligations (
CredentialsSig,AdoptCredentials, …;internal/provider/provider.go) hold against a genuinely absent master store.RefreshCredentialsshells out to the live CLI, so it's pinned in adapter-side unit tests, not here. - master-store-spec —
MasterStoreSpecreturns a non-empty absoluteHostDir, resolved freshly per call and honoring its declaredEnvVaroverride (issue #211). - seeding-exclude-coverage — in a real temp linked worktree, the provider's
SeedWorkspaceplus the realinternal/seederrun, in production order, leavegit status --porcelainempty; the context file/skills dir exist; every seeded path matches aSeededPathPattern. - seeding-incogni — same, with
SeedOpts{Incogni: true}: no tracked-file writes. - seed-home-containment — seeding against a temp instance Home leaves the machine's master store untouched: HOME-global trust grants (claude's
~/.claude.json, codex'sconfig.toml+AGENTS.md) land under the run's private Home. - scrub-markers — every
AttributionSampleis stripped by the adapter's ownScrubPatternsthrough both real engines (RE2 viaprovider.CompileScrubPatterns, POSIX BRE via a realgrep -i, skipped when absent) and byprovider.NewRegistry(p).ScrubRegexps()— the union path the agent-API sanitizer actually runs (ADR-0033).
A deliberately-broken fake adapter lives in the suite's own tests; failures name the obligation they broke.
Tier 2 — live spikes (required, documented, not CI-run)¶
Held to the ADR-0008 live-verification bar: real CLI, real host, no credentials in CI. Evidenced by the committed compat record, not by a test that runs on every push.
- transcript — locate by cwd, read/map to the universal schema, confirm a NEW transcript identity on context clear (native rotation or a synthesized epoch — see
LocateTranscript's doc); and confirm when the transcript is born — at session init, or lazily at the first turn (an idle interactive session may then have no transcript file at all, so nothing may gate first-message delivery on its existence — codex 0.133.0 is the precedent, issue #96). - reply / dialog-answer / interrupt — hazard-check the send-keys recipes against the real TUI; double-Esc/Ctrl-C must never be able to kill the session.
- context-file discovery — prove the CLI actually READS the lab-owned context file (e.g. Codex ↔
AGENTS.local.mdvia project config). - incogni attribution ground truth — capture what the CLI actually writes into commits/PRs and reconcile it against the adapter's
ScrubPatterns; these live samples becomeFixture.AttributionSamples. - dialog auto-dismiss defeat — conditional: only for adapters that override their CLI's unattended dialog timeout (claude:
CLAUDE_AFK_TIMEOUT_MSvia the per-run settingsenvblock, compat §11; codex has no equivalent knob today). Live-verify the override actually reaches the dialog timer — a small value self-resolves an undriven dialog early — AND that the effectively-never max holds: the dialog survives well past the CLI's default, with no immediate-fire hazard at the cap.
Compat record convention¶
Follow claude-code's worked example, internal/compat/compat.md: numbered sections, each carrying a provenance tag (live / fixture / schema extraction); captured fixtures under testdata/ tagged with the CLI version (e.g. -2.1.198); opt-in live tests gated by LAB_COMPAT_LIVE=1 (internal/compat/live_recipes_test.go, no build tags). A new adapter's record lives at internal/compat/<providerID>/compat.md, same shape; claude-code's stays where it is.