Repository navigation
Description
Problem
A person runs Claude Code in a terminal through relay's model broker, which serves a local model under a Claude alias. The session goes idle. Adopt from eve answers 409 model_unknown ("the model of the session is unknown; send model"). Eve's Adopt button sends no model by design (barelyworkingcode/eve#597 contract), so the person has no way to adopt the session.
Shown on a verification stack (relay epic/525-session-adoption at 2faa421, the default stack, where Claude Code runs on the broker's local model), by eve's outside-adopt journey through eve's Adopt button:
JOURNEY outside-adopt FAIL POST /api/outside-sessions/<id>/adopt answered 409 ({"error":"model_unknown","message":"the model of the session is unknown; send model"})
The outside transcript's assistant record carries the local model id, not a claude-* id. On the same stack with real Claude, the same journey passes.
Root cause
modelFamily (internal/sessions/adopt/recovery.go) maps only ids that start with claude-haiku-, claude-sonnet- or claude-opus-. A broker-served id answers "", so the door refuses with model_unknown unless the caller sends model.
Fix
To be decided by relay's planner. Relay owns the broker's alias map, so one direction is to map a broker-served model id back to the alias that routes to it.
Effect on eve: barelyworkingcode/eve#598's verification runs outside-adopt on a default stack, so it fails until this is fixed, or until the run uses real Claude.
Found while working barelyworkingcode/eve#597. Epic: #525.
🤖 Generated with Claude Code
Contract
ship-blocker: yes. G1 is must-have in docs/FEATURES.md, and eve's Adopt button (G1.50's simple door) fails on every broker-served Claude.
Rule
With no model in the body, the adopt door picks the model in this order. The first step that gives an answer wins:
- The body's
model(unchanged). Tail.ModelFamily, theclaude-haiku-/claude-sonnet-/claude-opus-family (unchanged).- New: the broker resolution of
Tail.Model, the newest main-chain assistant record'smessage.model:
a. Relay reads a fresh catalog:Cache.Resolve(ctx, Tail.Model), the same cache/v1/messagesclassification reads. WhenTail.Modelis itself a modelMap row (owned_by: anthropic-map), the keys are[Tail.Model]. Otherwise the keys are every modelMap row whosetargetequalsTail.Model. The first case covers relayLLM's translated dialect, which echoes the requested key. The second covers the native dialect, which echoes the target.
b. Each key maps to a Claude aliasFinhaiku,sonnet,opuswhen relay's process environment setsANTHROPIC_DEFAULT_<F>_MODEL(HAIKU,SONNET,OPUS) to that key. relay-sessions inherits relay's environment, soclaude --model Fin the adopted session sends that key to the broker, which routes it to the same target as the outside session did.
c. Exactly one distinct alias across all keys is the model. Zero aliases, two or more, no matching row, an emptyTail.Model, or an unreadable catalog all answer409 model_unknownwith the current message. An unreadable catalog also logs a warning withoutside_idand the error. Nothing is signalled, because this step stays where the model step is now: beforeAuthorizeLaunchand the intent rows.
- The adopted session's model is the alias (
haiku), never the key or the target.AuthorizeLaunchchecks that alias againstallowed_models, as today. Thesession_adoptauditmodelarg carries the alias. - Freshness: the catalog goes through
Cache.Resolveonce per adopt request, and only when steps 1 and 2 give nothing. The environment is read per request withos.Getenv. - The wire does not change: there is no new code, field or message.
fakerelaydoes not change, because its world spec states the family directly.
Interfaces
internal/modelbroker/normalise.go:func ModelMapKeysFor(rows []Row, id string) []string. This is pure logic: rule 3a in catalog order, nil when nothing matches. It reusesfindRowandclassify.cmd/relay/model_endpoint.go:func (m *ModelEndpointServer) ModelMapKeys(ctx context.Context, id string) ([]string, error)ism.catalog.ResolveplusModelMapKeysFor. It returns an error when the catalog cannot be read. It mirrorsIsSystemModel.cmd/relay/session_routes.go:sessionRouteDepsgainsmodelMapKeys func(ctx context.Context, id string) ([]string, error). A nil value means step 3 answersmodel_unknown.cmd/relay/server_core.go:modelMapKeys: app.modelEndpoint.ModelMapKeys, besidesystemModel.internal/sessions/adopt/adopt.go:TailgainsModel string, the newest assistant record's model id, skipping""and<synthetic>.internal/sessions/adopt/recovery.gofills it inReadTail's assistant case.ModelFamilykeeps its current meaning.cmd/relay/session_adopt.go: steps 3a to 3c sit behind one helper thatadoptOutsidecalls wheremodel == "". The helper's name and shape are the dev's choice.internal/does not importcmd/relay, and the adopt package stays evidence-only: it is handed no resolver.
File ownership
Dev task:
internal/modelbroker/normalise.gocmd/relay/model_endpoint.gocmd/relay/session_routes.go: only the new fieldcmd/relay/server_core.go: only the wiring linecmd/relay/session_adopt.gointernal/sessions/adopt/adopt.go: only theTail.Modelfieldinternal/sessions/adopt/recovery.go: onlyReadTail's assistant casedocs/outside-sessions.md: only "The resumed session" bullet, rewritten to the Ruledocs/model-endpoint.md: one sentence in "Choosing a project's models" or "Client model routing" naming the adopt door as a reader of the modelMap through the same cache, which never shows the target to a callerdocs/FEATURES.md: only the G1.50 row. The description gains one clause (a broker-served transcript model adopts with the Claude alias that relay's environment maps to its modelMap key). The proof cell addse2e:TestAdoptBrokerModelAdoptsWithAliasande2e:TestAdoptBrokerModelUnknownRefused.
Test task (test writer):
e2e/features/bug562_adopt_broker_model_test.go(new). It reuses the rig and helpers ine2e/features/g1_outside_adopt_test.go(adStart,fake,answer/transcript,adopt,adCreated,adStartLine,adHasFlag,otRefusal,adNoSignals) and does not edit that file. relay#561 owns it in this campaign.
Overlap: relay#561 also edits recovery.go (ReadTail), docs/outside-sessions.md (the "Idle check" bullet) and the G1.50 proof cell. Merge #561 first. This branch then merges epic/525-session-adoption and resolves conflicts by keeping both changes.
Paths
- Simple: eve > Chief of Staff > Agents rail > Adopt. The person makes no choice, and relay resolves the model.
- Power: the body's
model(theclaudeSessionIdselector) still wins. The operator configures through the existing relayLLMrouter.anthropic.modelMapand theANTHROPIC_DEFAULT_<F>_MODELvariables in relay's environment. There is no new config.
Acceptance mapping
- AC-a: when an idle outside Claude's transcript names a modelMap key that
ANTHROPIC_DEFAULT_HAIKU_MODELnames, and no model is sent, the adopt returns 201 and the resumed session starts with--model haiku. Owner: the dev task (rule 3;ModelMapKeys,Tail.Model). Test:TestAdoptBrokerModelAdoptsWithAlias/key, using theoutsideIdselector as eve's button does. It is red on the epic head (7ec4af5): 409model_unknown. - AC-a2: the same holds when the transcript names that key's target. Test:
TestAdoptBrokerModelAdoptsWithAlias/target, which is red on the epic head. - AC-b: a transcript model that is not a claude-* id and has no unique alias returns 409
model_unknown, and the process gets no signal. Owner: the dev task (rule 3c). Test:TestAdoptBrokerModelUnknownRefused, with the subtestsnot served(the id is not in the catalog),no alias(the key is served and no environment alias names it) andtwo aliases(HAIKUandSONNETboth name the key). These are guards and are green on the epic head.
Test setup: harness.Options.FakeModelHost with rows {"id":"acme-lab/fake-echo","owned_by":"acme-lab"} and {"id":"Acme-Local-27B","owned_by":"anthropic-map","target":"acme-lab/fake-echo"}, plus Options.Env ANTHROPIC_DEFAULT_HAIKU_MODEL=Acme-Local-27B (two aliases also sets ANTHROPIC_DEFAULT_SONNET_MODEL). No model_endpoint listener is needed. The outside fake is the existing adSpawn fake, and answer(at, model, text) writes the model id.
Test surface
- e2e feature tests: all the criteria above. There are no unit tests (repo rule).
- Real-app check (user-visible): eve's
outside-adoptjourney on a default stack. It is red with relay at 7ec4af5 and green with relay at this PR's head.
Waits
- Dev task: Waits: none. The resolution is a synchronous call inside the adopt request, and
Cache.Resolvereturns when its fetch completes. - Test task: Waits:
- relay boot:
harness.Start's ready.json; - first inventory probe:
osStart's/ws/outside-sessionssubscription; - model host registered:
i.WaitModelHost, themodel.host.registerok event. This wait is required before every adopt in both tests, or an unregistered host turns AC-b green for the wrong reason; - the fake outside process: its existing start line,
TERMline and exit channel (expect,signalAt,gone); - the resumed session's argv:
adStartLine, whose reply is the ready signal; - no signal sent:
adNoSignalsafter the 409, the existing pattern.
- relay boot:
Verification task
- On this PR's head:
repo-suite, which is(cd e2e && go test -race -timeout 30m -parallel 32 ./features/). -
real-app: runstack-up r562 --relay <PR worktree> --eve <eve main>(a fresh stack), then eve'snode devboxverify/main.js --checkout <eve wt> --stack <stack.json>. Expectoutside-adoptPASS, then runstack-down r562. The task takes no devlock. - Post
Verified: <head sha> · repo-suite: <result> · real-app: <result> · log: <path>. The epic's--stackruns belong to the close child.
Budget
- Dev: 10 files, about 120 changed lines (code about 70, docs about 50).
- Test: 1 new file, about 150 lines.
Open questions
- INSIDE. Recommendation: cover both a key and a target in the transcript (3a). The stack transcript was not inspected. relayLLM's translate path echoes the key, and its native path echoes the target.
- INSIDE. Recommendation: keep
ModelFamilybefore broker resolution. A transcript with an older claude-* record and a newer broker record then adopts on the claude family. This is theoretical and has not been shown. - INSIDE. Recommendation: leave relay's
session-adoptjourney sending"model":"haiku". A harness change is a gate item, and eve'soutside-adoptis this bug's real-app proof. - BEYOND. Recommendation: put it on the theoretical list and add no code. With a claude-shaped modelMap key, the family alias may make the adopted Claude send its current tier id instead of the key, so the request goes to Anthropic, not the broker. This behaviour predates this issue and has not been shown.
Activity
barelyworkingcode commentedon Oct 21, 2026
Done by #565, merged into epic/525-session-adoption. Verified on the epic head b6f530d (see #532).