Live tests against the real proxy + Claude Max SDK. These verify the full request cycle that unit tests (mocked SDK) cannot cover.
Prerequisites: Claude Max subscription, claude auth status shows loggedIn: true, opencode installed.
Droid tests (D1–D10) additionally require
droidinstalled (droid --version≥ 0.89.0) and a Factory AI account for BYOK configuration. Tests D1–D10 cover internal mode (the default). Passthrough mode for Droid is opt-in viaMERIDIAN_PASSTHROUGH=1and requiresdroid≥ 0.109 — see "Droid passthrough mode" below.
# 1. Build and start the proxy
npm run build
CLAUDE_PROXY_PORT=3456 bun run ./bin/cli.ts &
# 2. Wait for ready
curl -s http://127.0.0.1:3456/health | jq .status # → "healthy"
# 3. Run tests (pick a section below)
# 4. Kill proxy when done
kill $(lsof -ti :3456)| ID | Section | What It Proves | Verified |
|---|---|---|---|
| E1 | Basic Request/Response | Proxy starts, routes to SDK, returns valid Anthropic response | 2026-03-24 |
| E2 | Streaming SSE | SSE event format correct, events arrive in order | 2026-03-24 |
| E3 | Tool Use Loop | MCP tools (read/write/bash) execute through SDK | 2026-03-24 |
| E4 | Session Continuation | Same session header → lineage=continuation, SDK session reused |
2026-03-24 |
| E5 | Undo with Rollback | Shorter/diverged suffix → lineage=undo, rollback UUID emitted |
2026-03-24 |
| E6 | Compaction | Summarized prefix + preserved suffix → lineage=compaction |
2026-03-24 |
| E7 | Diverged Detection | Completely unrelated messages → lineage=new, fresh session |
2026-03-24 |
| E8 | Cross-Proxy Resume | Kill proxy → restart → session resumes from file store | 2026-03-24 |
| E9 | Fingerprint Fallback | No session header → fingerprint-based session lookup works | 2026-03-24 |
| E10 | Coding Task (opencode) | Full round-trip: opencode → proxy → SDK → tool use → file modified | 2026-03-24 |
| E11 | Telemetry | Dashboard HTML, /requests, /summary, /logs return data |
2026-03-24 |
| E12 | Health Check | /health returns auth status and mode |
2026-03-24 |
| E13 | Concurrent Requests | Parallel requests don't deadlock; active count increments | 2026-03-24 |
| E14 | Model Routing | haiku/sonnet/opus model strings map correctly in proxy logs | 2026-03-24 |
| E15 | Non-Streaming | stream:false → JSON response with Content-Type, session header |
2026-03-24 |
| E16 | Error Handling | Malformed JSON, missing fields, bad endpoints → structured errors | 2026-03-24 |
| E17 | Passthrough Mode | CLAUDE_PROXY_PASSTHROUGH=1 → tool_use forwarded, not executed |
2026-03-24 |
| E18 | Multimodal Content | Image blocks preserved, structured message path used | 2026-03-24 |
| E19 | Subagent / Task Tool | Task tool agent definitions extracted, request processes correctly | 2026-03-24 |
| E20 | Env Stripping | ANTHROPIC_* vars don't leak to SDK subprocess | 2026-03-24 |
| E21 | Session Store Pruning | File store respects count cap, oldest entries evicted | 2026-03-24 |
| D1 | Droid: Basic Response | Proxy accepts Droid User-Agent, routes via droid adapter, returns valid response | 2026-03-29 |
| D2 | Droid: MCP Server Name | Internal mode: tools use mcp__droid__ prefix, not mcp__opencode__ |
2026-03-29 |
| D3 | Droid: OpenCode Backward Compat | Requests without Droid UA still use opencode adapter | 2026-03-29 |
| D4 | Droid: CWD from system-reminder | Working directory extracted from <system-reminder> block |
2026-03-29 |
| D5 | Droid: Fingerprint Session Resume | Session continues via fingerprint (no session header needed) | 2026-03-29 |
| D6 | Droid: Real Binary Basic | Live droid exec → proxy → Claude Max returns correct response |
2026-03-29 |
| D7 | Droid: Real Binary Tool Use | Internal mode: live droid exec reads file via mcp__droid__read |
2026-03-29 |
| D8 | Droid: exec Session Isolation | Each droid exec call is a fresh session (expected — no history passed) |
2026-03-29 |
| D9 | Droid: Streaming SSE | SSE stream correct format with Droid User-Agent | 2026-03-29 |
| D10 | Droid: OpenCode Session Unaffected | OpenCode header-based session tracking still works alongside Droid | 2026-03-29 |
| C1 | Crush: Basic Response | Proxy accepts Charm-Crush/ User-Agent, routes via crush adapter, returns valid response | 2026-03-29 |
| C2 | Crush: Session Continuation | crush run --continue resumes via fingerprint; lineage=continuation in proxy log |
2026-03-29 |
| C3 | Crush: Tool Use (Read) | ls/view/grep tool round-trip: Crush executes, sends tool_result, proxy resumes |
2026-03-29 |
| C4 | Crush: Model Routing | sonnet-4-6→sonnet[1m], opus-4-6→opus[1m], haiku→haiku for Max users | 2026-03-29 |
| C5 | Crush: Backward Compat | OpenCode and Droid sessions unaffected when Crush requests coexist | 2026-03-29 |
| CL1 | Cline: Basic Response | Proxy accepts Cline requests via anthropicBaseUrl, returns valid response | 2026-03-29 |
| CL2 | Cline: File Read | Cline reads a file via tool_use/tool_result passthrough loop | 2026-03-29 |
| CL3 | Cline: File Write | Cline writes a file to disk in --yolo mode | 2026-03-29 |
| CL4 | Cline: Bash Execution | Cline runs bash commands through passthrough | 2026-03-29 |
| CL5 | Cline: File Edit | Cline edits an existing file (bug fix) | 2026-03-29 |
| CL6 | Cline: Session Continuation | -T taskId resumes session; lineage=continuation in proxy log |
2026-03-29 |
| CL7 | Cline: Model Routing | sonnet-4-6→sonnet[1m], opus-4-6→opus[1m], haiku→haiku | 2026-03-29 |
| CL8 | Cline: Multi-Agent Coexistence | Cline + Crush + OpenCode on same port simultaneously | 2026-03-29 |
| FC1 | File Changes: Write (non-stream) | PostToolUse hook tracks write, appends "Files changed" to non-stream response | 2026-03-30 |
| FC2 | File Changes: Write (stream) | PostToolUse hook tracks write, emits file change text block in SSE stream | 2026-03-30 |
| FC3 | File Changes: Edit | Edit operations tracked as "edited" in summary | 2026-03-30 |
| FC4 | File Changes: Read-only (no summary) | Read-only operations produce no "Files changed" section | 2026-03-30 |
| FC5 | File Changes: Multiple ops | Multiple writes + edits listed in a single summary | 2026-03-30 |
| FC6 | File Changes: Multiple ops (stream) | Multiple file changes emitted as a text block in SSE stream | 2026-03-30 |
| E22 | OAuth Token Refresh | Expired access token auto-refreshed inline; request succeeds without manual claude login |
2026-04-02 |
| E23 | Subagent Model Selection | x-opencode-agent-mode: subagent header selects base model; primary gets 1M; proxy log shows agent=subagent |
2026-04-02 |
| E24 | Default Non-Streaming | Omitting stream field returns JSON (not SSE), matching Anthropic API spec |
- |
| E25 | OpenAI Compat: Non-Streaming | /v1/chat/completions returns valid OpenAI completion shape |
- |
| E26 | OpenAI Compat: Streaming | /v1/chat/completions with stream: true returns OpenAI SSE chunks |
- |
| E27 | OpenAI Compat: Models | GET /v1/models returns Claude model list in OpenAI format |
- |
| E28 | SDK Param Passthrough | Live proxy accepts effort/thinking/task_budget/beta fields without breaking responses | 2026-04-03 |
| E29 | Context Usage Endpoint | /v1/sessions/:claudeSessionId/context-usage returns live token usage for a completed request |
2026-04-03 |
| E30 | Context Usage via Fingerprint + Restart | Context usage lookup works for headerless sessions and survives proxy restart via shared store | 2026-04-03 |
| E32 | Tool-use leak (#416) — opencode + opus-4-7 | Multi-turn opencode rehydration with prior tool_use blocks does not cause opus-4-7 to emit [Tool Use: / H: / Human: text in its response |
2026-04-26 |
| E33 | OpenAI Compat: system prompt, no preset | /v1/chat/completions honours the client's system prompt without injecting the claude_code preset (openai adapter default) |
2026-06-15 |
| E34 | Streaming parallel tool calls (#552) | Automated: bun scripts/e2e-stream-parallel.mjs — real CLI, SSE mode: parallel tool calls stream intact (no dangling {} blocks), denies held past generation, fast follow-up resumes. Run before any release touching the passthrough tool loop — mocked suites cannot catch CLI dispatch-ordering bugs (two shipped regressions proved it) |
2026-07-15 |
| E35 | SDK boundary assumptions (#694/#708/#710) | Automated: bun scripts/e2e-sdk-boundary.mjs — real SDK: rate-limit reset units land in a sane window, every live content-block type is classified for hashing, resume survives a client dropping thinking blocks, and reports whether the gitStatus block still misstates its provenance. Run after any @anthropic-ai/claude-agent-sdk bump and before releases touching lineage, rate limits, or the system prompt |
2026-07-29 |
| E36 | Client detection after an upgrade (#733) | Automated: bun scripts/e2e-client-detection.mjs — drives each installed client against a local stub, captures its real headers, and asserts the adapter Meridian resolves. Run after upgrading any client; costs no tokens |
2026-07-31 |
| E37 | WebFetch preflight scope (#748) | Automated: bun scripts/e2e-webfetch-preflight.mjs — stubbed claude + isolated HOME: the toggle reaches the right adapter's --settings, and only cherry can actually run the built-in WebFetch, so the documented scope is asserted rather than assumed. Run before releases touching sdkFeatures, query settings, or tool config; costs no tokens |
2026-08-03 |
| E38 | Silent turns (#768) | Automated: bun scripts/e2e-silent-turn.mjs — real CLI, SSE mode. Asserts four things per attempt: the client got text or a tool call; recovered content sits BEFORE the terminal message_delta (content behind it is dropped by a correct client); exactly one message_delta per message; and a third turn after a recovery still resumes. Attribution is read from /telemetry/logs, not stdout. Pair MERIDIAN_DEBUG_FORCE_SILENT_TURN=1 against MERIDIAN_SILENT_TURN_RECOVERY=0 for the before/after. Run before any release touching the passthrough tool loop, prompt assembly, or session resume |
2026-08-11 |
| E39 | OpenCode internal-agent session key (#845) | Manual, real OpenCode: its title agent runs under the USER'S session id, so the user's first turn used to queue behind it and then get HTTP 400 session_turn_conflict. Asserts the first turn succeeds, waits ~0ms on the session lease, and every later request is lineage=continuation. Run after any OpenCode upgrade and before releases touching session keys or the turn coordinator |
2026-08-19 |
| E40 | Passthrough digest-turn cap | Automated: bun scripts/e2e-digest-turn-cap.mjs — real SDK. Asserts the capped tool turn generates no digest text, costs materially less than uncapped on an identical prompt, still RESUMES at its captured checkpoint, leaves text-only turns returning success, and does not truncate parallel tool calls. Run before any release touching the passthrough tool loop, maxTurns, or the early-stop checkpoint |
2026-08-20 |
| E41 | Passthrough multi-turn: one call, one answer | Automated: bun scripts/e2e-passthrough-turns.mjs [--stream] — real proxy + SDK + Claude Max. Chain and PROBE_PARALLEL=1 modes assert exact tool-call batching, a distinct durable fork per result round, one real answer per delivered call in the active transcript, and full prompt-cache continuity. Run all four chain/parallel × stream/non-stream combinations before releases touching passthrough resume or the deny hook |
2026-08-26 |
| P1 | Profile: List & Auth Status | /profiles/list returns profiles with emails, login status, auth timestamps | - |
| P2 | Profile: Switch via API | POST /profiles/active switches profile; health endpoint reflects new email | - |
| P3 | Profile: Persistence Across Restart | Active profile survives proxy restart via settings.json | - |
| P4 | Profile: Request Routing | Request on profile A uses different SDK auth than profile B | - |
| P5 | Profile: Per-Request Header Override | x-meridian-profile header routes single request to non-active profile | - |
| P6 | Profile: Session Isolation | Same messages on different profiles get separate SDK sessions (no cross-contamination) | - |
| P7 | Profile: Invalid Profile Rejection | Switching to nonexistent profile returns 400; invalid persisted profile falls back safely | - |
| P8 | Profile: Settings Persistence | settings.json updated on switch; CLI meridian profile list reflects state | - |
| P9 | Profile: Health Reflects Active | /health email changes when active profile changes | - |
| P10 | Profile: Telemetry Records After Switch | Requests on both profiles appear in /telemetry/requests | - |
Model selection. Tests use claude-haiku-4-5-20251001 by default — it's the cheapest Claude Max tier and sufficient for verifying proxy behavior. Only use sonnet or opus when the test genuinely requires stronger reasoning (E3, E10: real coding tasks via opencode) or is explicitly testing model routing (E14, C4).
Proxy log verification. Most tests check proxy stderr for structured log lines:
[PROXY] <uuid> model=<m> stream=<bool> tools=<n> lineage=<type> session=<id|new> active=<n>/<max> msgCount=<n>
Extract these with:
cat /tmp/proxy-e2e.log | strings | grep "\[PROXY\]" | tail -5Session header. All curl tests use x-opencode-session to control session identity. This is the header the OpenCode adapter reads.
Diagnostics vs gates. scripts/e2e-*.mjs are gates: they assert and exit non-zero. Real-session gates must inspect history only through supported Agent SDK APIs such as getSessionMessages(). Never locate, parse, rewrite, or mutate Claude's private transcript files.
Cleanup. Each test section is independent. Kill the proxy and clear the session store between sections if you need isolation:
kill $(lsof -ti :3456) 2>/dev/null
rm -f ~/.cache/meridian/sessions.jsonVerifies: Proxy accepts Anthropic API format, routes to SDK, returns valid JSON response.
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-basic-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": false,
"messages": [{"role": "user", "content": "Respond with exactly: E2E_OK"}]
}'Pass criteria:
- Response has
"type": "message","role": "assistant" - Content includes a text block
stop_reasonis"end_turn"- Proxy log shows
lineage=new session=new
Verifies: SSE event stream has correct format, events arrive in proper order.
curl -sN http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-stream-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": true,
"messages": [{"role": "user", "content": "Say hello in one word"}]
}' | head -30Pass criteria:
- First event is
event: message_startwith amessageobject - At least one
event: content_block_startwithtype: "text" - At least one
event: content_block_deltawithtype: "text_delta" - Final events include
event: message_stop - No
mcp__opencode__*tool blocks leak through
Verifies: SDK MCP tools execute and produce correct results.
# Setup
echo "CANARY_12345" > /tmp/e2e-canary.txt
# Test via opencode (tools are registered by opencode, not by curl)
cd /tmp && opencode run --model anthropic/claude-sonnet-4-5 --format json \
"What are the contents of /tmp/e2e-canary.txt?" 2>/dev/null
# Cleanup
rm /tmp/e2e-canary.txtPass criteria:
- Response text includes
CANARY_12345 - Proxy log shows
tools=76(or similar — opencode registers its full tool set)
rm -f /tmp/e2e-write-test.txt
cd /tmp && opencode run --model anthropic/claude-sonnet-4-5 --format json \
"Write 'WRITE_OK' to /tmp/e2e-write-test.txt then read it back and confirm." 2>/dev/null
# Verify on disk
cat /tmp/e2e-write-test.txt # → WRITE_OK
rm /tmp/e2e-write-test.txtVerifies: Appending messages with the same session header resumes the SDK session.
# Turn 1: Create session
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-cont-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 100,
"stream": false,
"messages": [{"role": "user", "content": "Remember: DELTA_99"}]
}' > /dev/null
# Turn 2: Continue (prefix preserved, new message appended)
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-cont-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 100,
"stream": false,
"messages": [
{"role": "user", "content": "Remember: DELTA_99"},
{"role": "assistant", "content": [{"type":"text","text":"Noted: DELTA_99."}]},
{"role": "user", "content": "What was the code?"}
]
}'Pass criteria:
- Turn 2 proxy log:
lineage=continuation session=<8-char-id>(notnew) - Response mentions
DELTA_99
Verifies: When the message suffix changes (user edited/undid), proxy detects undo and emits rollback UUID.
Prerequisite: Run E4 first (builds a 3+ message session with e2e-cont-001).
# Send same prefix but DIFFERENT last message (undo turn 2, ask something else)
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-cont-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 100,
"stream": false,
"messages": [
{"role": "user", "content": "Remember: DELTA_99"},
{"role": "assistant", "content": [{"type":"text","text":"Noted: DELTA_99."}]},
{"role": "user", "content": "Actually, forget that. Tell me a joke."}
]
}'Pass criteria:
- Proxy log:
lineage=undo session=<same-id> rollback=<uuid> Undo detectedmessage in proxy stderr- Response is valid (not an error)
Verifies: When the agent summarizes early messages but preserves recent ones, proxy detects compaction and resumes.
# Step 1: Seed a 7-message conversation (≥6 required for compaction detection)
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-compact-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": false,
"messages": [
{"role": "user", "content": "Message one"},
{"role": "assistant", "content": "Reply one"},
{"role": "user", "content": "Message two"},
{"role": "assistant", "content": "Reply two"},
{"role": "user", "content": "Message three"},
{"role": "assistant", "content": "Reply three"},
{"role": "user", "content": "Message four"}
]
}' > /dev/null
# Step 2: Simulate compaction — early messages replaced, recent suffix preserved
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-compact-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": false,
"messages": [
{"role": "user", "content": "[Summary of earlier conversation]"},
{"role": "assistant", "content": "[Summary of replies]"},
{"role": "user", "content": "Message three"},
{"role": "assistant", "content": "Reply three"},
{"role": "user", "content": "Message four"},
{"role": "assistant", "content": "Reply four"},
{"role": "user", "content": "Continuing after compaction"}
]
}'Pass criteria:
- Step 2 proxy log:
lineage=compaction session=<same-id>(notnew) Compaction detectedmessage in proxy stderr- Response is valid (session was resumed, not restarted)
Key constants: MIN_SUFFIX_FOR_COMPACTION = 2, MIN_STORED_FOR_COMPACTION = 6 (in session/lineage.ts)
Verifies: Completely unrelated messages with the same session header start a fresh session.
Prerequisite: Run E6 first (session e2e-compact-001 exists).
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-compact-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": false,
"messages": [
{"role": "user", "content": "Completely unrelated topic about quantum physics"},
{"role": "assistant", "content": "Quantum physics is fascinating"},
{"role": "user", "content": "Tell me about entanglement"}
]
}'Pass criteria:
- Proxy log:
lineage=new session=new(old session discarded)
Verifies: Sessions survive proxy restart via the shared file store (~/.cache/meridian/sessions.json).
# Step 1: Create a session
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-persist-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": false,
"messages": [{"role": "user", "content": "Remember: PHOENIX_42"}]
}' > /dev/null
# Verify stored in file
cat ~/.cache/meridian/sessions.json | python3 -m json.tool | grep -A3 "e2e-persist"
# Step 2: Kill and restart proxy (in-memory caches wiped)
kill $(lsof -ti :3456); sleep 2
CLAUDE_PROXY_PORT=3456 bun run ./bin/cli.ts > /tmp/proxy-e2e.log 2>&1 &
sleep 5 # Wait for startup
# Step 3: Resume the session
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-persist-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 100,
"stream": false,
"messages": [
{"role": "user", "content": "Remember: PHOENIX_42"},
{"role": "assistant", "content": [{"type":"text","text":"Got it — PHOENIX_42."}]},
{"role": "user", "content": "What was the code?"}
]
}'Pass criteria:
- Step 3 proxy log:
lineage=continuation session=<same-8-char-id>(notnew) - Response mentions
PHOENIX_42 - SDK session was genuinely resumed (not a fresh start with flat text replay)
Verifies: When no x-opencode-session header is sent, sessions are matched by fingerprint (hash of first user message + working directory).
# Turn 1: No session header
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": false,
"messages": [{"role": "user", "content": "Unique fingerprint test message 98765"}]
}' > /dev/null
# Turn 2: Same first message, no header — should match by fingerprint
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": false,
"messages": [
{"role": "user", "content": "Unique fingerprint test message 98765"},
{"role": "assistant", "content": "Acknowledged."},
{"role": "user", "content": "Continue the conversation"}
]
}'Pass criteria:
- Turn 1 proxy log:
lineage=new - Turn 2 proxy log:
lineage=continuation(fingerprint matched, notnew)
Verifies: Full opencode → proxy → SDK → tool execution → file modification loop.
# Setup
mkdir -p /tmp/e2e-coding-test
cat > /tmp/e2e-coding-test/buggy.js << 'EOF'
function add(a, b) {
return a - b // BUG: should be +
}
module.exports = { add }
EOF
# Run
cd /tmp/e2e-coding-test && opencode run --model anthropic/claude-sonnet-4-5 \
"There's a bug in buggy.js. Find and fix it." 2>/dev/null
# Verify
cat /tmp/e2e-coding-test/buggy.js # Should show "a + b"
# Cleanup
rm -rf /tmp/e2e-coding-testPass criteria:
buggy.jsnow containsa + b(nota - b)- Proxy log shows tool execution (multiple
[PROXY]lines for the session)
SESSION_OUT=$(opencode run --model anthropic/claude-sonnet-4-5 --format json \
"Remember the code ALPHA_42. Just confirm." 2>/dev/null)
SESSION_ID=$(echo "$SESSION_OUT" | grep -o '"sessionID":"[^"]*"' | head -1 | cut -d'"' -f4)
opencode run --model anthropic/claude-sonnet-4-5 --session "$SESSION_ID" --format json \
"What was the code?" 2>/dev/nullPass criteria:
- Second response includes
ALPHA_42
Verifies: Telemetry dashboard and API endpoints return data after requests.
# Dashboard HTML
curl -s http://127.0.0.1:3456/telemetry | head -3
# → <!DOCTYPE html> ...
# Recent requests
curl -s http://127.0.0.1:3456/telemetry/requests?limit=5 | python3 -m json.tool | head -20
# Aggregate summary
curl -s http://127.0.0.1:3456/telemetry/summary | python3 -m json.tool
# Diagnostic logs
curl -s http://127.0.0.1:3456/telemetry/logs?limit=5 | python3 -m json.tool | head -20Pass criteria:
/telemetryreturns HTML with<title>Meridian/telemetry/requestsreturns an array of request metrics withrequestId,model,lineageType/telemetry/summaryreturnstotalRequests > 0,errorCount, percentile latencies/telemetry/logsreturns an array withlevel,category,messagefields
Verifies: /health endpoint returns auth and mode status.
curl -s http://127.0.0.1:3456/health | python3 -m json.toolPass criteria:
status: "healthy"auth.loggedIn: trueauth.subscriptionType: "max"mode: "internal"(or"passthrough"ifCLAUDE_PROXY_PASSTHROUGHis set)
Verifies: Multiple simultaneous requests are queued, not dropped or deadlocked.
# Fire 3 requests in parallel
for i in 1 2 3; do
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-concurrent-$i" \
-d "{
\"model\": \"claude-haiku-4-5-20251001\",
\"max_tokens\": 30,
\"stream\": false,
\"messages\": [{\"role\": \"user\", \"content\": \"Say $i\"}]
}" &
done
waitPass criteria:
- All 3 responses return valid JSON with
"type": "message" - Proxy log shows
active=counts incrementing (e.g.active=1/10,active=2/10,active=3/10) - No errors or deadlocks
Verifies: Different model strings map to the correct SDK model.
# Haiku
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-d '{"model":"claude-haiku-4-5-20250929","max_tokens":10,"stream":false,"messages":[{"role":"user","content":"Hi"}]}' > /dev/null
# Opus
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-d '{"model":"claude-opus-4-20250514","max_tokens":10,"stream":false,"messages":[{"role":"user","content":"Hi"}]}' > /dev/null
# Sonnet (default)
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-d '{"model":"claude-sonnet-4-5-20250514","max_tokens":10,"stream":false,"messages":[{"role":"user","content":"Hi"}]}' > /dev/nullPass criteria:
- Proxy log shows
model=haikufor the first request - Proxy log shows
model=opus(ormodel=opus[1m]) for the second - Proxy log shows
model=sonnet[1m]for the third
Verifies: stream: false returns a complete JSON response with correct headers.
curl -s -D /tmp/e2e-headers.txt http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-nonstream-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": false,
"messages": [{"role": "user", "content": "Say exactly: NONSTREAM_OK"}]
}'
cat /tmp/e2e-headers.txt
rm /tmp/e2e-headers.txtPass criteria:
- Response body:
"type": "message","stop_reason": "end_turn" - Response header:
Content-Type: application/json - Response header:
x-claude-session-id: <uuid>present - Content includes text block
Verifies: Invalid requests return structured error responses, not crashes.
# Malformed JSON
curl -s -w "\n%{http_code}" http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" -H "x-api-key: dummy" \
-d 'not json'
# Missing messages
curl -s -w "\n%{http_code}" http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" -H "x-api-key: dummy" \
-d '{"model":"claude-haiku-4-5-20251001","stream":false}'
# Unknown endpoint
curl -s -w "\n%{http_code}" http://127.0.0.1:3456/v1/nonexistent
# Wrong HTTP method
curl -s -w "\n%{http_code}" http://127.0.0.1:3456/v1/messagesPass criteria:
- Malformed JSON → HTTP 500,
{"type":"error","error":{"type":"api_error",...}} - Missing messages → HTTP 400,
{"type":"error","error":{"type":"invalid_request_error","message":"messages: Field required"}} - Unknown endpoint → HTTP 404,
{"error":{"type":"not_found",...}} - GET on POST endpoint → HTTP 404,
{"error":{"type":"not_found",...}} - Proxy does NOT crash on any of these
Verifies: With CLAUDE_PROXY_PASSTHROUGH=1, the SDK returns tool_use blocks to the client instead of executing them internally.
Requires proxy restart with env var:
kill $(lsof -ti :3456) 2>/dev/null; sleep 1
CLAUDE_PROXY_PORT=3456 CLAUDE_PROXY_PASSTHROUGH=1 bun run ./bin/cli.ts > /tmp/proxy-e2e.log 2>&1 &
# Wait for ready...curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-passthrough-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 200,
"stream": false,
"messages": [{"role": "user", "content": "Read the file /tmp/test.txt"}],
"tools": [
{
"name": "Read",
"description": "Read a file from disk",
"input_schema": {
"type": "object",
"properties": {"file_path": {"type": "string"}},
"required": ["file_path"]
}
}
]
}'Pass criteria:
"stop_reason": "tool_use"— SDK didn't execute the tool- Content includes a
tool_useblock with"name": "Read"and correctinput - Tool name is clean (no
mcp__passthrough__prefix) /healthshows"mode": "passthrough"
curl -sN http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-passthrough-stream-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 200,
"stream": true,
"messages": [{"role": "user", "content": "Read the file /tmp/test.txt"}],
"tools": [{"name":"Read","description":"Read a file","input_schema":{"type":"object","properties":{"file_path":{"type":"string"}},"required":["file_path"]}}]
}' | grep -E "tool_use|stop_reason"Pass criteria:
- Stream contains
content_block_startwithtype: "tool_use",name: "Read" message_deltahasstop_reason: "tool_use"
After testing, restart proxy in normal mode:
kill $(lsof -ti :3456) 2>/dev/null; sleep 1
CLAUDE_PROXY_PORT=3456 bun run ./bin/cli.ts > /tmp/proxy-e2e.log 2>&1 &Verifies: Image content blocks are preserved and passed through the structured message path.
# 1x1 red PNG pixel
IMG_B64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-multimodal-001" \
-d "{
\"model\": \"claude-haiku-4-5-20251001\",
\"max_tokens\": 100,
\"stream\": false,
\"messages\": [{
\"role\": \"user\",
\"content\": [
{\"type\": \"image\", \"source\": {\"type\": \"base64\", \"media_type\": \"image/png\", \"data\": \"$IMG_B64\"}},
{\"type\": \"text\", \"text\": \"What color is this image? Reply with just the color name.\"}
]
}]
}"Pass criteria:
- Response contains a text block with a color name
- Proxy log shows
msgs=user[image,text]— image content type was detected - No errors about unsupported content types
Verifies: When the request includes a Task tool with agent descriptions, the proxy extracts agent definitions and processes the request through the agent routing path.
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-task-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 100,
"stream": false,
"messages": [{"role": "user", "content": "Just say hello"}],
"tools": [
{
"name": "Task",
"description": "Launch a sub-agent. Available agents:\n- coder: Writes code\n- reviewer: Reviews code\n- explorer: Explores codebase",
"input_schema": {
"type": "object",
"properties": {
"description": {"type": "string"},
"subagent_type": {"type": "string"}
},
"required": ["description"]
}
},
{
"name": "Read",
"description": "Read a file",
"input_schema": {"type": "object", "properties": {"file_path": {"type": "string"}}}
}
]
}'Pass criteria:
- Response is
"type": "message"(no error) - Proxy log shows
tools=2— both tools were seen - No crash from agent definition parsing
Verifies: The proxy strips ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, and ANTHROPIC_AUTH_TOKEN from the environment before spawning SDK subprocesses, preventing the SDK from looping back through the proxy.
ANTHROPIC_API_KEY=should-be-stripped ANTHROPIC_BASE_URL=http://should-be-stripped:9999 \
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-envstrip-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 20,
"stream": false,
"messages": [{"role": "user", "content": "Say OK"}]
}'Pass criteria:
- Response is a valid message with text content (request succeeded)
- If env vars leaked, the SDK would try to call
http://should-be-stripped:9999and fail
Note: This test verifies the client-side env doesn't matter (the proxy runs in its own process). The actual env stripping happens inside server.ts before spawning the SDK. All prior tests implicitly prove this works (they'd fail if the SDK looped back), but this makes the verification explicit.
Verifies: The file-based session store (~/.cache/meridian/sessions.json) evicts the oldest entries when the count exceeds CLAUDE_PROXY_MAX_STORED_SESSIONS.
Requires proxy restart with env var:
kill $(lsof -ti :3456) 2>/dev/null; sleep 1
rm -f ~/.cache/meridian/sessions.json
CLAUDE_PROXY_PORT=3456 CLAUDE_PROXY_MAX_STORED_SESSIONS=3 bun run ./bin/cli.ts > /tmp/proxy-e2e.log 2>&1 &
# Wait for ready...# Create 5 sessions
for i in 1 2 3 4 5; do
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-prune-$i" \
-d "{\"model\":\"claude-haiku-4-5-20251001\",\"max_tokens\":10,\"stream\":false,\"messages\":[{\"role\":\"user\",\"content\":\"Session $i\"}]}" > /dev/null
sleep 1 # ensure distinct timestamps for deterministic eviction
done
# Verify the store is bounded
cat ~/.cache/meridian/sessions.json | python3 -c "
import sys,json
d=json.load(sys.stdin)
print(f'Entries: {len(d)} (should be <= 3)')
"Pass criteria:
- File store contains at most 3 entries
- Oldest sessions (lowest
lastUsedAt) were evicted
After testing, restart proxy in normal mode (no cap).
Verifies: When the Claude Code OAuth access token has expired, the proxy detects the 401, refreshes the token automatically, and retries the request — the caller sees a normal successful response.
Platform note: The credential store is platform-specific. Run on the platform you want to verify:
- macOS — credentials in Keychain (
/usr/bin/security) - Linux — credentials in
~/.claude/.credentials.json
# 1. Snapshot current expiry
python3 -c "
import subprocess, json
creds = json.loads(subprocess.check_output(
['/usr/bin/security', 'find-generic-password', '-s', 'Claude Code-credentials',
'-a', __import__('os').getlogin(), '-w']).decode())
print('Current expiresAt:', creds['claudeAiOauth']['expiresAt'])
"
# 2. Artificially expire the token
CREDS=$(security find-generic-password -s "Claude Code-credentials" -a "$(whoami)" -w)
EXPIRED=$(echo "$CREDS" | python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
d['claudeAiOauth']['expiresAt'] = 0 # epoch — definitely expired
print(json.dumps(d, indent=2))
")
security add-generic-password -U -s "Claude Code-credentials" -a "$(whoami)" -w "$EXPIRED"
echo "Token expired (expiresAt set to 0)"
# 3. Make a request — proxy should refresh inline and succeed
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-token-refresh-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 20,
"stream": false,
"messages": [{"role": "user", "content": "Say: REFRESH_OK"}]
}'
# 4. Verify token was refreshed
python3 -c "
import subprocess, json
creds = json.loads(subprocess.check_output(
['/usr/bin/security', 'find-generic-password', '-s', 'Claude Code-credentials',
'-a', __import__('os').getlogin(), '-w']).decode())
exp = creds['claudeAiOauth']['expiresAt']
import time
print(f'New expiresAt: {exp} ({"VALID" if exp > time.time()*1000 else "STILL EXPIRED"})')
"# 1. Snapshot current expiry
python3 -c "
import json, os
creds = json.loads(open(os.path.expanduser('~/.claude/.credentials.json')).read())
print('Current expiresAt:', creds['claudeAiOauth']['expiresAt'])
"
# 2. Artificially expire the token
python3 -c "
import json, os
path = os.path.expanduser('~/.claude/.credentials.json')
d = json.loads(open(path).read())
d['claudeAiOauth']['expiresAt'] = 0
open(path, 'w').write(json.dumps(d, indent=2))
print('Token expired')
"
# 3. Make a request
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-token-refresh-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 20,
"stream": false,
"messages": [{"role": "user", "content": "Say: REFRESH_OK"}]
}'
# 4. Verify token was refreshed
python3 -c "
import json, os, time
path = os.path.expanduser('~/.claude/.credentials.json')
d = json.loads(open(path).read())
exp = d['claudeAiOauth']['expiresAt']
print(f'New expiresAt: {exp} ({\"VALID\" if exp > time.time()*1000 else \"STILL EXPIRED\"})')
"Pass criteria:
- Response:
"type": "message"with text containingREFRESH_OK— request succeeded despite starting with an expired token - Proxy log:
[PROXY] <id> OAuth token expired — refreshed, retryingappears before the successful response log line - Step 4 expiresAt:
VALID(in the future — token was refreshed and written back) - No
authentication_errorin the response
What's being tested: The isExpiredTokenError() detection in errors.ts, the refreshOAuthToken() cross-platform credential read/write in tokenRefresh.ts, and the inline retry loop in server.ts.
While the proxy is running with a valid token, you can also verify the /auth/refresh endpoint directly:
curl -s -X POST http://127.0.0.1:3456/auth/refresh
# → {"success":true,"message":"OAuth token refreshed successfully"}Pass criteria: success: true and the expiresAt in the credential store is updated to a new future timestamp.
Verifies: When the x-opencode-agent-mode: subagent header or a generic x-meridian-source: subagent-* declaration is present, the proxy selects the base model (200k) instead of the 1M variant, conserving rate limit budget for the primary agent. The meridian-agent-mode.ts plugin sets the OpenCode header automatically based on the agent's runtime mode field. SDK-native Task agent definitions also receive the matching base tier explicitly, so they do not inherit an opus[1m] parent.
# Primary agent → opus[1m]
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-agent-mode: primary" \
-d '{"model":"claude-opus-4-6","max_tokens":10,"stream":false,"messages":[{"role":"user","content":"hi"}]}' > /dev/null
# Proxy log: model=opus[1m] ... agent=primary
# Subagent → opus (base)
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-agent-mode: subagent" \
-d '{"model":"claude-opus-4-6","max_tokens":10,"stream":false,"messages":[{"role":"user","content":"hi"}]}' > /dev/null
# Proxy log: model=opus ... agent=subagent
# Generic source declaration (works across adapters) → base opus
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-meridian-source: subagent-reviewer" \
-d '{"model":"claude-opus-4-6","max_tokens":10,"stream":false,"messages":[{"role":"user","content":"hi"}]}' > /dev/null
# Proxy log: model=opus ... source=subagent-reviewer agent=subagentPass criteria (Part A):
- Primary request proxy log:
model=opus[1m] ... agent=primary - Subagent request proxy log:
model=opus ... agent=subagent— base model, no[1m] - Generic
subagent-*source: base tier andagent=subagent, even outside OpenCode - No header →
model=opus[1m](default primary behaviour)
Setup:
# 1. Copy the plugin into your project
cp /path/to/meridian/examples/opencode-plugin/meridian-agent-mode.ts ./meridian-agent-mode.ts
# 2. Add to opencode.json
# { "plugin": ["./claude-max-headers.ts", "./meridian-agent-mode.ts"] }
# 3. Create a named agent (e.g. ~/.config/opencode/agents/researcher.md)
# The agent's frontmatter mode determines primary vs subagentTest:
# Run a task that uses the Task tool to spawn the researcher agent
opencode run --model anthropic/claude-opus-4-6 \
"Use the researcher agent to find out what day it is, then summarise."Pass criteria (Part B):
- Primary session log line:
model=opus[1m] agent=primary - Subagent session log line:
model=opus agent=subagent - Both requests succeed — no errors
- Two distinct proxy log entries visible (parent + subagent turn)
What's being tested: mapModelToClaudeModel() subagent tier selection in models.ts, OpenCode agent-mode extraction through its adapter, generic x-meridian-source fallback in server.ts, base-tier SDK agent definitions in agentDefs.ts, and the meridian-agent-mode.ts plugin's use of the runtime agent mode without any API calls.
Verifies: When the stream field is omitted from the request body, the proxy returns a single JSON response (application/json), not an SSE stream — matching the Anthropic API spec default.
curl -s -D /tmp/e2e-headers.txt http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 10,
"messages": [{"role": "user", "content": "Say OK"}]
}'
grep -i content-type /tmp/e2e-headers.txt
rm /tmp/e2e-headers.txtPass criteria:
- Response header:
Content-Type: application/json(nottext/event-stream) - Response body:
"type": "message","role": "assistant", validcontentarray - Response is a single JSON object, not SSE events
- Proxy log:
stream=false
What's being tested: The body.stream ?? false default in server.ts. Prior to this fix, omitting stream defaulted to true (SSE), which broke SDK clients calling messages.create() without an explicit stream parameter.
Verifies: POST /v1/chat/completions accepts an OpenAI-format request and returns a valid OpenAI completion JSON object.
curl -s http://127.0.0.1:3456/v1/chat/completions \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 20,
"stream": false,
"messages": [{"role": "user", "content": "Say: OK"}]
}' | python3 -m json.toolPass criteria:
"object": "chat.completion"idstarts withchatcmpl-choices[0].message.roleis"assistant"choices[0].message.contentcontains a responsechoices[0].finish_reasonis"stop"usage.prompt_tokens,usage.completion_tokens,usage.total_tokensare numbers- Proxy log:
stream=false(non-streaming path used internally)
What's being tested: translateOpenAiToAnthropic() and translateAnthropicToOpenAi() in openai.ts, internal routing via app.fetch() to /v1/messages.
Verifies: POST /v1/chat/completions with stream: true returns OpenAI SSE chunks in the correct format.
curl -sN http://127.0.0.1:3456/v1/chat/completions \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 20,
"stream": true,
"messages": [{"role": "user", "content": "Say: hello"}]
}'Pass criteria:
- Response
Content-Type: text/event-stream - First data chunk has
"object": "chat.completion.chunk"andchoices[0].delta.role == "assistant" - At least one chunk has non-empty
choices[0].delta.content - A chunk has
choices[0].finish_reason == "stop" - Stream ends with
data: [DONE] - All chunks share the same
idstarting withchatcmpl- - Proxy log:
stream=true
What's being tested: translateAnthropicSseEvent() in openai.ts, SSE stream translation in server.ts.
Verifies: GET /v1/models returns available Claude models in OpenAI format with correct context windows for the subscription tier.
curl -s http://127.0.0.1:3456/v1/models | python3 -m json.toolPass criteria:
"object": "list"dataarray containsclaude-sonnet-4-6,claude-opus-4-6,claude-haiku-4-5-20251001- Each model has
object: "model",owned_by: "anthropic",context_window > 0 - For Max subscription: sonnet and opus have
context_window: 1000000 - Haiku always has
context_window: 200000
What's being tested: buildModelList() in openai.ts, GET /v1/models route in server.ts.
Verifies: The live proxy accepts the new SDK passthrough fields (effort, thinking, task_budget, anthropic-beta) and still completes a normal Claude request. Exact option mapping is asserted by the integration tests in src/__tests__/proxy-sdk-params.test.ts and src/__tests__/query-passthrough.test.ts; this live test proves the real HTTP → proxy → SDK path does not reject or break on these fields.
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-sdk-params-001" \
-H "x-opencode-effort: high" \
-H "x-opencode-task-budget: 2000" \
-H "anthropic-beta: interleaved-thinking-2025-05-14" \
-d '{
"model": "claude-sonnet-4-5-20250514",
"max_tokens": 120,
"stream": false,
"thinking": {"type": "enabled", "budgetTokens": 1024},
"task_budget": {"total": 1000},
"messages": [{"role": "user", "content": "Reply with exactly: SDK_PARAMS_OK"}]
}' | python3 -m json.toolPass criteria:
- Response is a valid Anthropic-format assistant message
- Response is not a structured error
- Proxy stderr shows a normal request log line (
model=... stream=false ...) - Proxy stderr shows a
usage:line after the request
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-sdk-params-002" \
-H "x-opencode-thinking: not-valid-json{{{" \
-d '{
"model": "claude-sonnet-4-5-20250514",
"max_tokens": 120,
"stream": false,
"thinking": {"type": "enabled", "budgetTokens": 1024},
"messages": [{"role": "user", "content": "Reply with exactly: THINKING_FALLBACK_OK"}]
}' | python3 -m json.toolPass criteria:
- Response succeeds with a normal assistant message (HTTP 200)
- Proxy stderr contains
ignoring malformed x-opencode-thinking header - Request still completes normally instead of failing with a 4xx/5xx
Verifies: A completed request stores token usage under the Claude SDK session ID returned by the proxy, and /v1/sessions/:claudeSessionId/context-usage returns it.
# 1. Make a request and capture response headers + body
curl -sD /tmp/e2e-context-usage.headers \
-o /tmp/e2e-context-usage.body \
http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-context-usage-001" \
-d '{
"model": "claude-sonnet-4-5-20250514",
"max_tokens": 80,
"stream": false,
"messages": [{"role": "user", "content": "Reply with exactly: CONTEXT_USAGE_OK"}]
}'
# 2. Extract the Claude session ID the proxy returned
CLAUDE_SESSION_ID=$(awk 'BEGIN{IGNORECASE=1} /^X-Claude-Session-ID:/ {print $2}' /tmp/e2e-context-usage.headers | tr -d '\r')
echo "$CLAUDE_SESSION_ID"
# 3. Query the usage endpoint
curl -s http://127.0.0.1:3456/v1/sessions/$CLAUDE_SESSION_ID/context-usage | python3 -m json.toolPass criteria:
CLAUDE_SESSION_IDis non-empty- Endpoint returns HTTP 200
- JSON contains
session_idequal to the extracted Claude session ID - JSON contains
context_usage.input_tokensandcontext_usage.output_tokens - Proxy stderr for the original request contains a
usage:line
Verifies: The context-usage endpoint also works for sessions created without x-opencode-session (fingerprint fallback) and still works after restarting the proxy (shared session store persistence).
# 1. Make a headerless request and capture the returned Claude session ID
curl -sD /tmp/e2e-context-fp.headers \
-o /tmp/e2e-context-fp.body \
http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-d '{
"model": "claude-sonnet-4-5-20250514",
"max_tokens": 80,
"stream": false,
"messages": [{"role": "user", "content": "Reply with exactly: FP_CONTEXT_USAGE_OK"}]
}'
CLAUDE_SESSION_ID=$(awk 'BEGIN{IGNORECASE=1} /^X-Claude-Session-ID:/ {print $2}' /tmp/e2e-context-fp.headers | tr -d '\r')
echo "$CLAUDE_SESSION_ID"
# 2. Query usage immediately (proves fingerprint-backed sessions are discoverable)
curl -s http://127.0.0.1:3456/v1/sessions/$CLAUDE_SESSION_ID/context-usage | python3 -m json.tool
# 3. Restart the proxy WITHOUT deleting ~/.cache/meridian/sessions.json
kill $(lsof -ti :3456) 2>/dev/null
sleep 2
CLAUDE_PROXY_PORT=3456 bun run ./bin/cli.ts > /tmp/proxy-e2e.log 2>&1 &
sleep 5
# 4. Query usage again after restart (proves shared-store persistence)
curl -s http://127.0.0.1:3456/v1/sessions/$CLAUDE_SESSION_ID/context-usage | python3 -m json.toolPass criteria:
- Step 2 returns HTTP 200 for a request that had no
x-opencode-sessionheader - Step 4 also returns HTTP 200 after restart
- Both responses contain
session_idequal to the extracted Claude session ID - Both responses contain
context_usage.input_tokensandcontext_usage.output_tokens - No need to replay the original request after restart — the lookup should work from persisted session data alone
When extending this document:
- Assign an ID — use the next sequential
E##number in the index. - Add to the index table at the top with the date verified.
- Include the exact curl/opencode command — tests must be copy-pasteable.
- Define pass criteria — what to check in the response AND in the proxy log.
- Note prerequisites — if the test depends on a prior test's session state, say so.
- Note env vars — if the test requires a proxy restart with special env vars (E17, E21), say so explicitly.
- Keep tests independent where possible — use unique session IDs (
e2e-<test>-<nnn>).
Use e2e-<feature>-<nnn> format: e2e-cont-001, e2e-compact-001, e2e-persist-001.
The proxy writes structured log lines to stderr. When running as a background process:
CLAUDE_PROXY_PORT=3456 bun run ./bin/cli.ts > /tmp/proxy-e2e.log 2>&1 &
# Read logs (binary-safe — the log may contain emoji)
cat /tmp/proxy-e2e.log | strings | grep "\[PROXY\]"
cat /tmp/proxy-e2e.log | strings | grep -E "Compaction|Undo|diverged"Some tests need specific env vars. Group these at the end of a run to minimize restarts:
| Test | Env Var | Value |
|---|---|---|
| E17 | CLAUDE_PROXY_PASSTHROUGH |
1 |
| E21 | CLAUDE_PROXY_MAX_STORED_SESSIONS |
3 |
Unit tests (bun test) → Pure functions, no SDK, no network
Integration tests (bun test) → HTTP layer with mocked SDK (fast, deterministic)
E2E tests (this document) → Real proxy + real SDK + real Claude Max (slow, non-deterministic)
Unit and integration tests run in CI. E2E tests run manually before releases or after major refactors. They require an active Claude Max subscription.
Which proxy modules each E2E test exercises:
| Module | Tests |
|---|---|
server.ts (orchestration) |
All |
session/lineage.ts |
E4, E5, E6, E7, E8, E9 |
session/cache.ts |
E4, E5, E6, E7, E8, E9, E29, E30 |
session/fingerprint.ts |
E9, E30 |
sessionStore.ts |
E8, E21, E30 |
query.ts |
All (builds SDK options), especially E28 |
adapter.ts + adapters/opencode.ts |
All E-tests, D3, D10 |
adapters/droid.ts |
D1, D2, D4, D5, D6, D7, D8, D9 |
adapters/crush.ts |
C1, C2, C3, C4, C5 |
adapters/detect.ts |
D1, D2, D3, D6, D7, D9, D10, C1, C5 |
| (default adapter — no Cline adapter needed) | CL1–CL8 |
errors.ts |
E16, E22 |
tokenRefresh.ts |
E22 |
models.ts |
E14, E23 |
messages.ts |
E4, E5, E6 (content normalization for hashing) |
tools.ts |
E3, E17, E19 |
agentDefs.ts |
E19 |
agentMatch.ts |
E19 (fuzzy matching in PreToolUse hook) |
passthroughTools.ts |
E17 |
mcpTools.ts |
E3, E10 |
fileChanges.ts |
FC1, FC2, FC3, FC4, FC5, FC6 |
telemetry/ |
E11 |
These tests verify the Droid adapter added in the Droid support release. They require droid CLI installed and a Factory AI account.
Droid's passthrough behavior is env-controlled, defaulting to OFF:
- Without
MERIDIAN_PASSTHROUGH(default): Droid runs in internal mode. The proxy executes tools via themcp__droid__*MCP server and Claude sees results via the SDK's internal tool loop. This is what tests D1–D10 cover. - With
MERIDIAN_PASSTHROUGH=1(orCLAUDE_PROXY_PASSTHROUGH=1): the proxy forwardstool_useblocks to Droid, Droid executes the tools locally, and sendstool_resultback. Requires Droid ≥ 0.109 (earlier versions had a BYOK loop bug wheretool_resultwasn't delivered).
Historical note: this used to be hardcoded to internal mode for Droid because of the BYOK loop bug. Verified working on Droid 0.114.1 — tool_use → tool_result roundtrip completes correctly. See src/__tests__/droid-adapter.test.ts and src/__tests__/proxy-droid-integration.test.ts for the unit-level coverage of the env-controlled behavior.
Droid connects to the proxy via its BYOK (Bring Your Own Key) feature. Configure once before running D6–D8:
# 1. Back up Droid settings
cp ~/.factory/settings.json ~/.factory/settings.json.backup
# 2. Register all model tiers pointing at the proxy
# Model names drive mapModelToClaudeModel():
# "4-6" in name → 1M context for Max users
# "haiku" in name → haiku tier (no 1M)
# "4-5" in name → base tier (no 1M)
python3 -c "
import json
with open('$HOME/.factory/settings.json') as f:
s = json.load(f)
s['customModels'] = [
{'model':'claude-sonnet-4-6', 'name':'Sonnet 4.6 (1M — Meridian)', 'provider':'anthropic','baseUrl':'http://127.0.0.1:3457','apiKey':'sk-proxy'},
{'model':'claude-opus-4-6', 'name':'Opus 4.6 (1M — Meridian)', 'provider':'anthropic','baseUrl':'http://127.0.0.1:3457','apiKey':'sk-proxy'},
{'model':'claude-haiku-4-5-20251001', 'name':'Haiku 4.5 (Meridian)', 'provider':'anthropic','baseUrl':'http://127.0.0.1:3457','apiKey':'sk-proxy'},
{'model':'claude-sonnet-4-5-20250929', 'name':'Sonnet 4.5 (Meridian)', 'provider':'anthropic','baseUrl':'http://127.0.0.1:3457','apiKey':'sk-proxy'},
]
with open('$HOME/.factory/settings.json', 'w') as f:
json.dump(s, f, indent=2)
"
# 3. Verify Droid sees the model
droid exec --model "custom:claude-haiku-4-5-20251001" --list-tools 2>&1 | head -3
# → Available tools for claude-sonnet-4-5-20250514
# After all Droid tests, restore:
# cp ~/.factory/settings.json.backup ~/.factory/settings.jsonUse port 3457 to avoid conflicts with any existing proxy service on 3456:
# Note: if you have an existing proxy service with CLAUDE_PROXY_PASSTHROUGH=1
# (e.g., a launchd service), use a different port
CLAUDE_PROXY_DEBUG=1 CLAUDE_PROXY_PORT=3457 bun run ./bin/cli.ts > /tmp/proxy-droid-e2e.log 2>&1 &
sleep 5
curl -s http://127.0.0.1:3457/health | python3 -m json.tool
# → {"status":"healthy","mode":"internal",...}
# Check logs
cat /tmp/proxy-droid-e2e.log | grep "\[PROXY\]"Verifies: Proxy detects factory-cli/ User-Agent, selects droid adapter, returns valid Anthropic-format response.
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "User-Agent: factory-cli/0.89.0" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": false,
"messages": [{"role": "user", "content": "Respond with exactly: DROID_E2E_OK"}]
}' | python3 -m json.toolPass criteria:
"type": "message","role": "assistant"- Content includes text block with
DROID_E2E_OK "stop_reason": "end_turn"- Proxy log:
lineage=new session=new(no prior session)
Verifies: When Droid requests a tool execution, the proxy uses mcp__droid__* tool names (not mcp__opencode__*). Confirmed by observing the tool name in the response content block.
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "User-Agent: factory-cli/0.89.0" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 200,
"stream": false,
"messages": [{"role": "user", "content": "List the current directory. Use the Bash tool."}],
"tools": [
{"name": "Bash", "description": "Run a shell command", "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}
]
}' | python3 -c "
import json, sys
d = json.load(sys.stdin)
for block in d['content']:
if block['type'] == 'tool_use':
print('Tool name in response:', block['name'])
"Pass criteria:
- Tool block name is
mcp__droid__bash(internal SDK MCP name — confirms droid adapter selected) - NOT
mcp__opencode__bash
What's happening: The Droid adapter sets getMcpServerName() = "droid", so the SDK registers MCP tools as mcp__droid__*. The proxy strips these prefixes before returning to Droid, but the pre-strip name confirms adapter selection.
Verifies: Requests without Droid User-Agent still use the OpenCode adapter. All existing OpenCode behavior preserved.
# No User-Agent → OpenCode adapter
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: d3-compat-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 30,
"stream": false,
"messages": [{"role": "user", "content": "Say: OC_COMPAT_OK"}]
}' | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['content'][0]['text'])"
# With opencode User-Agent → still OpenCode adapter
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "User-Agent: opencode/1.0" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 30,
"stream": false,
"messages": [{"role": "user", "content": "Say: OC_UA_OK"}]
}' | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['content'][0]['text'])"Pass criteria:
- Both responses return valid messages
- No errors
- Proxy log:
lineage=new session=newfor both (both are first requests with those sessions)
Verifies: Proxy extracts the working directory from Droid's <system-reminder> block in the first user message content, not from a system field (which OpenCode uses).
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "User-Agent: factory-cli/0.89.0" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 30,
"stream": false,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "<system-reminder>\nUser system info\n% pwd\n/Users/dev/my-project\n% ls\nsrc\n</system-reminder>"},
{"type": "text", "text": "Say: CWD_EXTRACTED_OK"}
]
}]
}' | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['content'][-1]['text'])"Pass criteria:
- Response contains
CWD_EXTRACTED_OK - Proxy log:
msgs=user[text,text]— multiple content blocks received
What's happening internally: droidAdapter.extractWorkingDirectory() matches % pwd\n<path> inside <system-reminder> and returns /Users/dev/my-project as the cwd passed to the SDK. Different first messages will fingerprint to different sessions.
Verifies: Without a session header, Droid sessions are resumed via fingerprint (hash of first user message + CWD). Same first message = same fingerprint = resumed session.
# Turn 1: Establish session
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "User-Agent: factory-cli/0.89.0" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": false,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "<system-reminder>\n% pwd\n/Users/dev/my-project\n</system-reminder>"},
{"type": "text", "text": "Remember the code: DROID_FINGERPRINT_88"}
]
}]
}' > /dev/null
# Turn 2: Same first message → fingerprint resume
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "User-Agent: factory-cli/0.89.0" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 80,
"stream": false,
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "<system-reminder>\n% pwd\n/Users/dev/my-project\n</system-reminder>"},
{"type": "text", "text": "Remember the code: DROID_FINGERPRINT_88"}
]},
{"role": "assistant", "content": [{"type": "text", "text": "Got it — DROID_FINGERPRINT_88."}]},
{"role": "user", "content": [{"type": "text", "text": "What was the code?"}]}
]
}' | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['content'][-1]['text'][:80])"Pass criteria:
- Turn 2 proxy log:
lineage=continuation session=<8-char-id>— fingerprint matched, session resumed - Response includes
DROID_FINGERPRINT_88
Prerequisites: Droid BYOK configured (see Droid BYOK Setup). Proxy running on port 3457.
Verifies: Live droid exec binary successfully routes through the proxy and receives a valid Claude Max response.
droid exec \
--model "custom:claude-haiku-4-5-20251001" \
--skip-permissions-unsafe \
--cwd /tmp \
"Reply with exactly: REAL_DROID_OK. Nothing else."Pass criteria:
- Output:
REAL_DROID_OK(printed to stdout by droid) - Proxy log:
model=sonnet stream=true tools=<n> lineage=new session=new— request received and processed - No
"isByok": falseerrors — authentication via BYOK succeeded - No 402 Payment Required errors
Prerequisites: Droid BYOK configured, proxy on port 3457.
Verifies: Live droid exec can read a file using the mcp__droid__read MCP tool registered by the droid adapter.
# Setup canary file
echo "DROID_CANARY_E2E_42" > /tmp/droid-canary.txt
# Droid reads it via proxy
droid exec \
--model "custom:claude-haiku-4-5-20251001" \
--auto medium \
--cwd /tmp \
"Read the file /tmp/droid-canary.txt and tell me what it contains. Just the content, nothing else."
# Verify
rm /tmp/droid-canary.txtPass criteria:
- Output:
DROID_CANARY_E2E_42(droid read the file successfully) - Proxy log shows
tools=<n>for the request — Droid sent its tool definitions - Multi-turn exchange visible in proxy logs (tool call + result + final response)
Verifies: Each droid exec invocation is a fresh independent session. This is expected behavior — droid exec does not pass previous conversation history (unlike interactive TUI mode). Session continuity in interactive mode works via fingerprint resume (D5).
# Turn 1 — set a secret
droid exec \
--model "custom:claude-haiku-4-5-20251001" \
--skip-permissions-unsafe \
--cwd /tmp \
"Remember the code: DROID_SECRET_99. Just say 'noted'."
# Turn 2 — separate exec, no shared history
droid exec \
--model "custom:claude-haiku-4-5-20251001" \
--skip-permissions-unsafe \
--cwd /tmp \
"What was the secret code?"Pass criteria:
- Turn 1 output:
noted(or similar) - Turn 2 output: model says it has no record of any secret code — this is correct behavior
- Proxy log: both show
lineage=new session=new— each exec is a fresh session - No errors or crashes
Why this is correct: droid exec is a one-shot command that sends only the current prompt as the message. It does not replay prior conversation history. For multi-turn continuity in interactive mode, fingerprint-based resume (D5) kicks in because Droid sends the full message history including the same first-message content.
Verifies: When Droid requests streaming, the proxy returns correct SSE format with proper event ordering.
curl -sN http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "User-Agent: factory-cli/0.89.0" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": true,
"messages": [{"role": "user", "content": "Say: STREAM_DROID_OK"}]
}' | head -25Pass criteria:
- First event:
event: message_startwith a validmessageobject - At least one
event: content_block_deltawithtype: "text_delta"containing the response text - Final event:
event: message_stop - No
mcp__droid__*tool blocks leak to the client - Proxy log:
stream=true
Verifies: Adding Droid support does not break OpenCode session tracking. The x-opencode-session header is still used by the OpenCode adapter for session continuity.
# OpenCode Turn 1
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: d10-oc-backcompat-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 50,
"stream": false,
"messages": [{"role": "user", "content": "Remember: OPENCODE_BACKCOMPAT_55"}]
}' > /dev/null
# OpenCode Turn 2 — same session header → continuation
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: d10-oc-backcompat-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 80,
"stream": false,
"messages": [
{"role": "user", "content": "Remember: OPENCODE_BACKCOMPAT_55"},
{"role": "assistant", "content": [{"type": "text", "text": "Got it."}]},
{"role": "user", "content": "What was the code?"}
]
}' | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['content'][-1]['text'][:80])"Pass criteria:
- Response includes
OPENCODE_BACKCOMPAT_55 - Proxy log Turn 2:
lineage=continuation session=<id>— OpenCode header session resumed correctly - Droid requests in D1–D9 did not corrupt the OpenCode session cache
# Restore Droid settings (if BYOK was configured)
cp ~/.factory/settings.json.backup ~/.factory/settings.json 2>/dev/null
# Kill the test proxy
kill $(lsof -ti :3457) 2>/dev/nullThese tests verify the Crush adapter. Crush connects via a provider entry in ~/.config/crush/crush.json — no BYOK or special auth needed, just a base_url pointing at the proxy.
Add the meridian provider to ~/.config/crush/crush.json:
{
"providers": {
"meridian": {
"id": "meridian",
"name": "Meridian",
"type": "anthropic",
"base_url": "http://127.0.0.1:3456",
"api_key": "dummy",
"models": [
{
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6 (1M)",
"context_window": 1000000,
"default_max_tokens": 64000,
"can_reason": true,
"supports_attachments": true
},
{
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6 (1M)",
"context_window": 1000000,
"default_max_tokens": 32768,
"can_reason": true,
"supports_attachments": true
},
{
"id": "claude-haiku-4-5-20251001",
"name": "Claude Haiku 4.5",
"context_window": 200000,
"default_max_tokens": 16384,
"can_reason": true,
"supports_attachments": true
}
]
}
}
}Verify Crush sees the models:
crush models | grep meridian
# → meridian/claude-haiku-4-5-20251001
# → meridian/claude-opus-4-6
# → meridian/claude-haiku-4-5-20251001Verifies: Proxy detects Charm-Crush/ User-Agent, selects crush adapter, returns valid response.
crush run \
--model meridian/claude-haiku-4-5-20251001 \
--cwd /path/to/your/project \
--quiet \
"Respond with exactly: CRUSH_E2E_OK"Pass criteria:
- Output:
CRUSH_E2E_OK - Proxy log:
model=sonnet[1m] stream=true tools=19 lineage=new session=new - Note: first request may show
rate-limited on [1m], retrying with sonnet— this is expected, the proxy auto-falls back
Verifies: crush run --continue resumes the most recent Crush session via fingerprint-based cache lookup.
# Turn 1: establish session
crush run \
--model meridian/claude-haiku-4-5-20251001 \
--cwd /path/to/your/project \
--quiet \
"Remember the code: CRUSH_CONT_99. Reply with 'stored'."
# Turn 2: continue that session
crush run \
--model meridian/claude-haiku-4-5-20251001 \
--cwd /path/to/your/project \
--continue \
--quiet \
"What was the code I asked you to remember?"Pass criteria:
- Turn 1 output:
stored(or equivalent) - Turn 2 output: includes
CRUSH_CONT_99 - Proxy log Turn 2:
lineage=continuation session=<id>— fingerprint matched, not a new session
Verifies: Crush's tool execution loop works through the proxy. Crush sends a tool call, the proxy returns it (passthrough mode), Crush executes it, sends the result back, and Claude responds with the content.
crush run \
--model meridian/claude-haiku-4-5-20251001 \
--cwd /path/to/your/project \
--quiet \
"Use the ls tool to list the files in the current directory and show me the output"Pass criteria:
- Output shows directory listing (actual files, not hallucinated)
- Proxy log: two entries for the same session — first
lineage=new(initial turn), thenlineage=continuation(after tool result returned) — confirms the multi-turn tool loop worked msgs=on the second log entry showstool_useandtool_resultin the message chain
Note: In crush run (headless) mode, all tool operations execute automatically without prompting — there is no interactive terminal to ask for approval. This includes writes, edits, and bash commands.
Verifies: Write tool executes automatically in crush run headless mode — no approval prompt needed.
crush run \
--model meridian/claude-haiku-4-5-20251001 \
--cwd /path/to/project \
--quiet \
"Write the text 'CRUSH_WRITE_OK' to /tmp/crush-write-test.txt"
cat /tmp/crush-write-test.txt # → CRUSH_WRITE_OK
rm /tmp/crush-write-test.txtPass criteria:
- File exists on disk with correct content
- Proxy log shows multi-turn:
tool_usethentool_resultthen final text
Verifies: Model names in crush.json map to the correct Claude Max tiers.
# Sonnet 4.6 → sonnet[1m]
crush run --model meridian/claude-sonnet-4-6 --quiet "Say: SONNET_OK" 2>/dev/null
# Proxy log: model=sonnet[1m]
# Opus 4.6 → opus[1m]
crush run --model meridian/claude-opus-4-6 --quiet "Say: OPUS_OK" 2>/dev/null
# Proxy log: model=opus[1m]
# Haiku 4.5 → haiku
crush run --model meridian/claude-haiku-4-5-20251001 --quiet "Say: HAIKU_OK" 2>/dev/null
# Proxy log: model=haikuPass criteria:
- Each model routes to the expected tier in proxy logs
- Sonnet 4.6 and Opus 4.6 both show
[1m](extended context) for Max subscribers - Haiku shows
model=haiku(no extended context)
Verifies: Crush requests coexist with OpenCode and Droid sessions on the same proxy port. No cross-contamination between adapters.
# Fire all three agents in sequence
crush run --model meridian/claude-haiku-4-5-20251001 --quiet "Say: CRUSH_COEXIST" 2>/dev/null
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: c5-oc-001" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":20,"stream":false,"messages":[{"role":"user","content":"Say: OC_COEXIST"}]}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['content'][0]['text'])"
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "User-Agent: factory-cli/0.89.0" \
-d '{"model":"claude-sonnet-4-6","max_tokens":20,"stream":false,"messages":[{"role":"user","content":"Say: DROID_COEXIST"}]}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['content'][0]['text'])"Pass criteria:
- All three respond correctly without interfering with each other
- Proxy logs show
model=haikufor Crush, normal models for others - OpenCode session
c5-oc-001is tracked independently (header-based) - Droid and Crush both use fingerprint-based tracking independently
Cline connects via its anthropicBaseUrl config key. No adapter needed — it uses the standard Anthropic SDK and falls through to the default (OpenCode) adapter. Passthrough mode handles tool execution correctly.
1. Authenticate with the Anthropic provider:
cline auth --provider anthropic --apikey "dummy" --modelid "claude-sonnet-4-6"2. Set the proxy base URL in ~/.cline/data/globalState.json:
{
"anthropicBaseUrl": "http://127.0.0.1:3456"
}Verify Cline can reach the proxy:
cline --yolo --model claude-haiku-4-5-20251001 --timeout 20 --json "Say: OK" 2>/dev/null | grep completion_resultVerifies: Proxy accepts Cline requests routed via anthropicBaseUrl, returns valid response.
cline --yolo \
--model claude-haiku-4-5-20251001 \
--cwd /path/to/project \
--timeout 20 \
--json \
"Reply with exactly: CLINE_E2E_OK" 2>/dev/null | grep completion_resultPass criteria:
- Output includes
CLINE_E2E_OK - Proxy log:
model=haiku stream=true tools=11 lineage=new - No authentication errors
Verifies: Cline's tool_use/tool_result passthrough loop works for reading files.
echo "CLINE_CANARY_123" > /tmp/cline-canary.txt
cline --yolo \
--model claude-haiku-4-5-20251001 \
--cwd /path/to/project \
--timeout 45 \
--json \
"Read /tmp/cline-canary.txt and tell me its exact contents" 2>/dev/null | grep completion_result
rm /tmp/cline-canary.txtPass criteria:
- Output includes
CLINE_CANARY_123 - Proxy log shows multi-turn:
lineage=continuationwithtool_use→tool_resultin message chain
Verifies: Cline writes files to disk through the passthrough tool loop.
rm -f /tmp/cline-write-test.txt
cline --yolo \
--model claude-haiku-4-5-20251001 \
--cwd /path/to/project \
--timeout 45 \
--json \
"Write 'CLINE_WRITE_OK' to /tmp/cline-write-test.txt" 2>/dev/null | grep completion_result
cat /tmp/cline-write-test.txt # → CLINE_WRITE_OK
rm /tmp/cline-write-test.txtPass criteria:
- File exists on disk with correct content
- Proxy log shows tool_use → tool_result continuation
Verifies: Bash commands execute through the passthrough loop.
cline --yolo \
--model claude-haiku-4-5-20251001 \
--cwd /path/to/project \
--timeout 45 \
--json \
"Run 'echo CLINE_BASH_OK' using bash and show the output" 2>/dev/null | grep completion_resultPass criteria:
- Output includes
CLINE_BASH_OK
Verifies: Cline edits existing files correctly.
echo 'function add(a, b) { return a - b }' > /tmp/cline-edit-test.js
cline --yolo \
--model claude-haiku-4-5-20251001 \
--cwd /path/to/project \
--timeout 45 \
--json \
"Fix the bug in /tmp/cline-edit-test.js — it subtracts instead of adding" 2>/dev/null | grep completion_result
cat /tmp/cline-edit-test.js # → should contain a + b
rm /tmp/cline-edit-test.jsPass criteria:
- File on disk shows
a + b(nota - b) - Proxy log shows read → edit tool chain
Verifies: Resuming a session with -T taskId maintains conversation context through the proxy.
# Turn 1: create session
OUTPUT=$(cline --yolo \
--model claude-haiku-4-5-20251001 \
--cwd /path/to/project \
--timeout 30 \
--json \
"Remember the code: CLINE_RECALL_55. Say 'noted'." 2>/dev/null)
TASK_ID=$(echo "$OUTPUT" | head -1 | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('taskId',''))" 2>/dev/null)
echo "Task ID: $TASK_ID"
# Turn 2: resume with task ID
cline --yolo \
--model claude-haiku-4-5-20251001 \
--cwd /path/to/project \
--timeout 30 \
-T "$TASK_ID" \
--json \
"What was the code?" 2>/dev/null | grep completion_resultPass criteria:
- Turn 2 output includes
CLINE_RECALL_55 - Proxy log Turn 2:
lineage=continuation session=<id>
Verifies: Model names map to correct Claude Max tiers.
cline --yolo --model claude-sonnet-4-6 --timeout 20 --json "Say: OK" 2>/dev/null > /dev/null
# Proxy log: model=sonnet[1m]
cline --yolo --model claude-opus-4-6 --timeout 20 --json "Say: OK" 2>/dev/null > /dev/null
# Proxy log: model=opus[1m]
cline --yolo --model claude-haiku-4-5-20251001 --timeout 20 --json "Say: OK" 2>/dev/null > /dev/null
# Proxy log: model=haikuPass criteria:
claude-sonnet-4-6→model=sonnet[1m]claude-opus-4-6→model=opus[1m]claude-haiku-4-5-20251001→model=haiku
Verifies: Cline, Crush, and OpenCode all work on the same proxy port simultaneously.
# Cline
cline --yolo --model claude-haiku-4-5-20251001 --timeout 20 --json "Say: CLINE_COEXIST" 2>/dev/null | grep completion_result
# Crush
crush run --model meridian/claude-haiku-4-5-20251001 --quiet "Say: CRUSH_COEXIST" 2>/dev/null
# OpenCode (curl)
curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: cl8-oc-001" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":20,"stream":false,"messages":[{"role":"user","content":"Say: OC_COEXIST"}]}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['content'][0]['text'])"Pass criteria:
- All three respond correctly
- No cross-contamination between sessions
- Proxy handles all three without errors
These tests verify the PostToolUse hook that tracks file write/edit operations and appends a "Files changed" summary to responses. This feature is internal mode only — passthrough mode forwards tools to the client, so the proxy never sees tool execution results.
Requires: Proxy running in internal mode (no MERIDIAN_PASSTHROUGH env var). Use a separate port if your default service runs in passthrough mode.
kill $(lsof -ti :3457) 2>/dev/null; sleep 1
CLAUDE_PROXY_PORT=3457 bun run ./bin/cli.ts > /tmp/proxy-fc-e2e.log 2>&1 &
sleep 5
curl -s http://127.0.0.1:3457/health | python3 -m json.tool
# → mode: "internal"Verifies: PostToolUse hook captures a write operation and appends "Files changed" summary to non-streaming response.
rm -f /tmp/e2e-fc-write.txt
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-fc-write-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 300,
"stream": false,
"messages": [{"role": "user", "content": "Write the text FILECHANGE_OK to /tmp/e2e-fc-write.txt. Just write it, nothing else."}]
}' | python3 -c "
import json, sys
d = json.load(sys.stdin)
texts = [b['text'] for b in d['content'] if b['type'] == 'text']
full = '\n'.join(texts)
print(full)
"
cat /tmp/e2e-fc-write.txt # → FILECHANGE_OK
rm /tmp/e2e-fc-write.txtPass criteria:
- File
/tmp/e2e-fc-write.txtexists on disk with contentFILECHANGE_OK - Response text includes
Files changed:followed by- wrote /tmp/e2e-fc-write.txt "type": "message"in response (valid Anthropic format)
Verifies: PostToolUse hook captures a write operation and emits a file change text block in the SSE stream, before message_stop.
rm -f /tmp/e2e-fc-stream.txt
curl -sN http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-fc-stream-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 300,
"stream": true,
"messages": [{"role": "user", "content": "Write the text STREAMFC_OK to /tmp/e2e-fc-stream.txt. Just write it."}]
}' | tee /tmp/fc-stream-raw.txt | grep -E "text_delta.*Files changed"
cat /tmp/e2e-fc-stream.txt # → STREAMFC_OK
rm -f /tmp/e2e-fc-stream.txt /tmp/fc-stream-raw.txtPass criteria:
- File exists on disk with
STREAMFC_OK - SSE stream contains a
text_deltaevent withFiles changed:\n- wrote /tmp/e2e-fc-stream.txt - The file change block comes BEFORE
message_stopin the event stream - Block index is monotonically increasing (no index collision)
Verifies: Edit operations are tracked as "edited" (not "wrote") in the file change summary.
echo "function greet() { return 'hello' }" > /tmp/e2e-fc-edit.js
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-fc-edit-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 300,
"stream": false,
"messages": [{"role": "user", "content": "Edit /tmp/e2e-fc-edit.js to change hello to world. Do not rewrite the whole file, just edit it."}]
}' | python3 -c "
import json, sys
d = json.load(sys.stdin)
texts = [b['text'] for b in d['content'] if b['type'] == 'text']
print('\n'.join(texts))
"
cat /tmp/e2e-fc-edit.js # → function greet() { return 'world' }
rm /tmp/e2e-fc-edit.jsPass criteria:
- File on disk contains
'world'instead of'hello' - Response text includes
Files changed:followed by- edited /tmp/e2e-fc-edit.js - Not
- wrote— the operation must beedited
Verifies: Read-only tool operations (read, glob, grep) do NOT produce a "Files changed" section in the response.
echo "READ_ONLY_CONTENT" > /tmp/e2e-fc-readonly.txt
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-fc-readonly-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 200,
"stream": false,
"messages": [{"role": "user", "content": "Read the file /tmp/e2e-fc-readonly.txt and tell me what it contains. Do not modify it."}]
}' | python3 -c "
import json, sys
d = json.load(sys.stdin)
texts = [b['text'] for b in d['content'] if b['type'] == 'text']
full = '\n'.join(texts)
has_fc = 'Files changed' in full
print(f'Contains Files changed: {has_fc} (should be False)')
print(f'Contains READ_ONLY_CONTENT: {\"READ_ONLY_CONTENT\" in full}')
"
rm /tmp/e2e-fc-readonly.txtPass criteria:
- Response text includes
READ_ONLY_CONTENT(file was read) - Response text does NOT contain
Files changed:— no write/edit occurred - No extra text block appended
Verifies: Multiple file operations (write + edit) within one turn are all tracked and listed in the summary.
rm -f /tmp/e2e-fc-multi-a.txt
echo "original content" > /tmp/e2e-fc-multi-b.txt
curl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-fc-multi-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 400,
"stream": false,
"messages": [{"role": "user", "content": "Do two things: 1) Write MULTI_A to /tmp/e2e-fc-multi-a.txt. 2) Edit /tmp/e2e-fc-multi-b.txt to change \"original\" to \"modified\". Do both."}]
}' | python3 -c "
import json, sys
d = json.load(sys.stdin)
texts = [b['text'] for b in d['content'] if b['type'] == 'text']
full = '\n'.join(texts)
idx = full.find('Files changed:')
if idx >= 0:
print(full[idx:])
else:
print('NO FILES CHANGED SECTION FOUND')
"
cat /tmp/e2e-fc-multi-a.txt # → MULTI_A
cat /tmp/e2e-fc-multi-b.txt # → modified content
rm -f /tmp/e2e-fc-multi-a.txt /tmp/e2e-fc-multi-b.txtPass criteria:
- Both files modified on disk
- Summary includes both:
- wrote /tmp/e2e-fc-multi-a.txtand- edited /tmp/e2e-fc-multi-b.txt - Deduplication works — each path+operation listed once even if the model called the tool multiple times
Verifies: Multiple file changes in streaming mode are emitted as a single text block before message_stop.
rm -f /tmp/e2e-fc-stream-multi-a.txt /tmp/e2e-fc-stream-multi-b.txt
curl -sN http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "x-opencode-session: e2e-fc-stream-multi-001" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 400,
"stream": true,
"messages": [{"role": "user", "content": "Write FOO to /tmp/e2e-fc-stream-multi-a.txt and BAR to /tmp/e2e-fc-stream-multi-b.txt"}]
}' | grep "text_delta" | grep "Files changed"
cat /tmp/e2e-fc-stream-multi-a.txt # → FOO
cat /tmp/e2e-fc-stream-multi-b.txt # → BAR
rm -f /tmp/e2e-fc-stream-multi-a.txt /tmp/e2e-fc-stream-multi-b.txtPass criteria:
- Both files exist on disk with correct content
- A
text_deltaevent containsFiles changed:\n- wrote /tmp/e2e-fc-stream-multi-a.txt\n- wrote /tmp/e2e-fc-stream-multi-b.txt - Only one file change text block (not one per file)
kill $(lsof -ti :3457) 2>/dev/null
rm -f /tmp/proxy-fc-e2e.logVerifies that Claude's thinking content blocks and the SDK's internal Turn 2 prose summary are NOT forwarded to the client in passthrough mode. This fixes the missing diff-UI bug in OpenCode when using Claude Opus (issue #237).
# Proxy must be running in passthrough mode
MERIDIAN_PASSTHROUGH=1 MERIDIAN_PORT=3457 npm start &
sleep 3
curl -s http://127.0.0.1:3457/health | jq .mode # → "passthrough"
# Create a test file to edit
echo 'function greet(name) { return "Hello " + name }' > /tmp/e2e-passthrough-edit.jscurl -s http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-opus-4-6",
"max_tokens": 1024,
"stream": false,
"messages": [{"role":"user","content":"Edit /tmp/e2e-passthrough-edit.js — replace string concat with a template literal. Use the edit tool."}],
"tools": [{
"name": "edit",
"description": "Edit a file by replacing oldString with newString",
"input_schema": {
"type": "object",
"properties": {
"filePath": {"type":"string"},
"oldString": {"type":"string"},
"newString": {"type":"string"}
},
"required": ["filePath","oldString","newString"]
}
}]
}' | jq '{
stop_reason,
block_types: [.content[].type],
has_thinking: ([.content[].type] | contains(["thinking"])),
has_prose_about_forwarding: ([.content[] | select(.type=="text") | .text // ""] | any(contains("forwarded"))),
tool_use_name: (.content[] | select(.type=="tool_use") | .name),
tool_input_keys: (.content[] | select(.type=="tool_use") | .input | keys)
}'Pass criteria:
stop_reason="tool_use"has_thinking=falsehas_prose_about_forwarding=falsetool_use_name="edit"tool_input_keyscontains["filePath","oldString","newString"]
curl -sN http://127.0.0.1:3457/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-opus-4-6",
"max_tokens": 1024,
"stream": true,
"messages": [{"role":"user","content":"Edit /tmp/e2e-passthrough-edit.js — replace string concat with a template literal. Use the edit tool."}],
"tools": [{
"name": "edit",
"description": "Edit a file by replacing oldString with newString",
"input_schema": {
"type": "object",
"properties": {
"filePath": {"type":"string"},
"oldString": {"type":"string"},
"newString": {"type":"string"}
},
"required": ["filePath","oldString","newString"]
}
}]
}' | tee /tmp/e31-stream.txt | grep "thinking"
# → (no output)
# Verify the edit tool_use IS in the stream
grep '"tool_use"' /tmp/e31-stream.txt | head -1
# → data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"...","name":"edit","input":{}}}
grep '"thinking"' /tmp/e31-stream.txt
# → (no output — thinking blocks stripped)
rm -f /tmp/e31-stream.txt /tmp/e2e-passthrough-edit.jsPass criteria:
grep '"thinking"'returns no output- A
content_block_startwith"type":"tool_use"and"name":"edit"is present - The stream ends with
event: message_stop
kill $(lsof -ti :3457) 2>/dev/nullPrerequisites: Two profiles configured in ~/.config/meridian/profiles.json with valid auth. Example:
[
{"id": "personal", "claudeConfigDir": "/Users/you/.claude"},
{"id": "work", "claudeConfigDir": "/Users/you/.claude-work"}
]Both must pass claude auth status with loggedIn: true under their respective CLAUDE_CONFIG_DIR.
Proxy must be running with disk profile discovery (no MERIDIAN_PROFILES env var — let it auto-discover from the JSON file).
# Verify both profiles are authenticated
PROFILE1_DIR=$(python3 -c "import json; print(json.load(open('$HOME/.config/meridian/profiles.json'))[0]['claudeConfigDir'])")
PROFILE2_DIR=$(python3 -c "import json; print(json.load(open('$HOME/.config/meridian/profiles.json'))[1]['claudeConfigDir'])")
PROFILE1_ID=$(python3 -c "import json; print(json.load(open('$HOME/.config/meridian/profiles.json'))[0]['id'])")
PROFILE2_ID=$(python3 -c "import json; print(json.load(open('$HOME/.config/meridian/profiles.json'))[1]['id'])")
CLAUDE_CONFIG_DIR=$PROFILE1_DIR claude auth status | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['loggedIn'], f'{d}'; print(f'Profile 1 ({d[\"email\"]}): OK')"
CLAUDE_CONFIG_DIR=$PROFILE2_DIR claude auth status | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['loggedIn'], f'{d}'; print(f'Profile 2 ({d[\"email\"]}): OK')"
# Verify proxy is healthy
curl -sf http://127.0.0.1:3456/health | python3 -c "import json,sys; assert json.load(sys.stdin)['status']=='healthy'; print('Proxy: healthy')"Verifies: /profiles/list returns all configured profiles with live auth status, emails, and timestamps.
RESULT=$(curl -s http://127.0.0.1:3456/profiles/list)
# Should have at least 2 profiles
COUNT=$(echo "$RESULT" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['profiles']))")
test "$COUNT" -ge 2 && echo "PASS: $COUNT profiles found" || echo "FAIL: expected >=2, got $COUNT"
# Each profile should have id, email, loggedIn, isActive, lastSuccessAt
echo "$RESULT" | python3 -c "
import json, sys
d = json.load(sys.stdin)
for p in d['profiles']:
assert 'id' in p, f'missing id: {p}'
assert 'email' in p, f'missing email: {p}'
assert 'loggedIn' in p, f'missing loggedIn: {p}'
assert 'isActive' in p, f'missing isActive: {p}'
assert 'lastSuccessAt' in p or 'lastCheckedAt' in p, f'missing auth timestamps: {p}'
print(f' {p[\"id\"]:12} email={p[\"email\"]} loggedIn={p[\"loggedIn\"]} active={p[\"isActive\"]}')
assert d.get('activeProfile'), 'missing activeProfile'
print(f'Active: {d[\"activeProfile\"]} PASS')
"Pass criteria:
- At least 2 profiles returned
- Each has
id,email,loggedIn,isActive, auth timestamps - Exactly one profile has
isActive: true activeProfilefield present
Verifies: POST /profiles/active switches the active profile; /profiles/list and /health reflect the change.
# Get profile IDs
PROFILE1_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][0]['id'])")
PROFILE2_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][1]['id'])")
PROFILE1_EMAIL=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][0]['email'])")
PROFILE2_EMAIL=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][1]['email'])")
# Switch to profile 1
RES=$(curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE1_ID\"}")
echo "$RES" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['success']; assert d['activeProfile']=='$PROFILE1_ID'; print(f'Switch to $PROFILE1_ID: PASS')"
# Health should show profile 1 email
HEALTH_EMAIL=$(curl -s http://127.0.0.1:3456/health | python3 -c "import json,sys; print(json.load(sys.stdin)['auth']['email'])")
test "$HEALTH_EMAIL" = "$PROFILE1_EMAIL" && echo "PASS: health=$HEALTH_EMAIL" || echo "FAIL: expected $PROFILE1_EMAIL, got $HEALTH_EMAIL"
# Switch to profile 2
curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE2_ID\"}" > /dev/null
# Health should show profile 2 email
HEALTH_EMAIL=$(curl -s http://127.0.0.1:3456/health | python3 -c "import json,sys; print(json.load(sys.stdin)['auth']['email'])")
test "$HEALTH_EMAIL" = "$PROFILE2_EMAIL" && echo "PASS: health=$HEALTH_EMAIL" || echo "FAIL: expected $PROFILE2_EMAIL, got $HEALTH_EMAIL"Pass criteria:
- Switch returns
{"success": true, "activeProfile": "<id>"} /healthemail matches the switched profile
Verifies: Active profile survives a proxy restart.
# Get profile IDs
PROFILE2_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][1]['id'])")
PROFILE2_EMAIL=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][1]['email'])")
# Switch to profile 2
curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE2_ID\"}" > /dev/null
# Verify settings.json
SAVED=$(python3 -c "import json; print(json.load(open('$HOME/.config/meridian/settings.json'))['activeProfile'])")
test "$SAVED" = "$PROFILE2_ID" && echo "PASS: settings.json=$SAVED" || echo "FAIL: expected $PROFILE2_ID, got $SAVED"
# Restart proxy (adjust for your setup — launchd, systemd, or manual)
kill $(lsof -ti :3456) 2>/dev/null; sleep 1
MERIDIAN_PORT=3456 bun run ./bin/cli.ts &
sleep 3
# Verify profile restored
ACTIVE=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['activeProfile'])")
test "$ACTIVE" = "$PROFILE2_ID" && echo "PASS: restored=$ACTIVE" || echo "FAIL: expected $PROFILE2_ID, got $ACTIVE"
HEALTH_EMAIL=$(curl -s http://127.0.0.1:3456/health | python3 -c "import json,sys; print(json.load(sys.stdin)['auth']['email'])")
test "$HEALTH_EMAIL" = "$PROFILE2_EMAIL" && echo "PASS: health=$HEALTH_EMAIL" || echo "FAIL: expected $PROFILE2_EMAIL, got $HEALTH_EMAIL"Pass criteria:
settings.jsonhas the switched profile ID- After restart,
/profiles/listshows same active profile /healthshows the correct email
Verifies: Requests use the active profile's SDK auth context.
PROFILE1_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][0]['id'])")
PROFILE2_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][1]['id'])")
# Switch to profile 1, send request
curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE1_ID\"}" > /dev/null
curl -s -X POST http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" -H "x-api-key: dummy" \
-H "x-opencode-session: e2e-profile-p4a" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":10,"stream":false,
"messages":[{"role":"user","content":"say ok"}]}' > /dev/null
LOG_P1=$(cat /tmp/proxy-e2e.log 2>/dev/null | strings | grep 'e2e-profile-p4a' | grep '\[PROXY\]' | head -1)
echo "Profile 1 request: $LOG_P1"
# Switch to profile 2, send request
curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE2_ID\"}" > /dev/null
curl -s -X POST http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" -H "x-api-key: dummy" \
-H "x-opencode-session: e2e-profile-p4b" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":10,"stream":false,
"messages":[{"role":"user","content":"say ok"}]}' > /dev/null
LOG_P2=$(cat /tmp/proxy-e2e.log 2>/dev/null | strings | grep 'e2e-profile-p4b' | grep '\[PROXY\]' | head -1)
echo "Profile 2 request: $LOG_P2"
# Both should have returned 200 (no errors)
test -n "$LOG_P1" && test -n "$LOG_P2" && echo "PASS: both profiles handled requests" || echo "FAIL: missing log lines"Pass criteria:
- Both requests return 200
- Proxy log shows both requests processed
Verifies: x-meridian-profile header routes a single request to a different profile without changing the active profile.
PROFILE1_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][0]['id'])")
PROFILE2_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][1]['id'])")
# Set active to profile 1
curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE1_ID\"}" > /dev/null
# Send request with header override to profile 2
curl -sf -X POST http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" -H "x-api-key: dummy" \
-H "x-meridian-profile: $PROFILE2_ID" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":10,"stream":false,
"messages":[{"role":"user","content":"say ok"}]}' > /dev/null \
&& echo "PASS: header override request succeeded" || echo "FAIL: request failed"
# Active profile should still be profile 1
ACTIVE=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['activeProfile'])")
test "$ACTIVE" = "$PROFILE1_ID" && echo "PASS: active unchanged=$ACTIVE" || echo "FAIL: active changed to $ACTIVE"Pass criteria:
- Override request returns 200
- Active profile remains unchanged
Verifies: The same conversation messages on different profiles create separate SDK sessions (no cross-profile resume).
PROFILE1_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][0]['id'])")
PROFILE2_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][1]['id'])")
MSGS='[{"role":"user","content":"session isolation test e2e-p6"}]'
# Request on profile 1
curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE1_ID\"}" > /dev/null
curl -s -X POST http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" -H "x-api-key: dummy" \
-d "{\"model\":\"claude-haiku-4-5-20251001\",\"max_tokens\":10,\"stream\":false,\"messages\":$MSGS}" > /dev/null
# Same messages on profile 2 — should be lineage=new, NOT continuation
curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE2_ID\"}" > /dev/null
curl -s -X POST http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" -H "x-api-key: dummy" \
-d "{\"model\":\"claude-haiku-4-5-20251001\",\"max_tokens\":10,\"stream\":false,\"messages\":$MSGS}" > /dev/null
# Both requests should show session=new in the proxy log (not continuation)
# The last 2 request log lines should both be fresh sessions
COUNT=$(tail -10 /tmp/proxy-e2e.log 2>/dev/null | strings | grep '\[PROXY\].*adapter=.*session=new' | tail -2 | wc -l | tr -d ' ')
test "$COUNT" -ge 2 && echo "PASS: both requests got fresh sessions" || echo "FAIL: expected 2 session=new lines, got $COUNT"Pass criteria:
- Second request (profile 2) shows
session=newin proxy log, NOTlineage=continuation
Verifies: Switching to a nonexistent profile returns 400. Invalid persisted profile is handled gracefully on restart.
# Try to switch to nonexistent profile
RES=$(curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d '{"profile":"nonexistent_profile_xyz"}')
STATUS=$(echo "$RES" | python3 -c "import json,sys; print('error' if 'error' in json.load(sys.stdin) else 'success')")
test "$STATUS" = "error" && echo "PASS: nonexistent profile rejected" || echo "FAIL: expected error, got $RES"
# Write invalid profile to settings.json, restart, verify fallback
ORIG=$(cat ~/.config/meridian/settings.json)
echo '{"activeProfile":"does_not_exist_abc"}' > ~/.config/meridian/settings.json
kill $(lsof -ti :3456) 2>/dev/null; sleep 1
MERIDIAN_PORT=3456 bun run ./bin/cli.ts &
sleep 3
# Should fall back to first profile, not crash
ACTIVE=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['activeProfile'])")
HEALTH=$(curl -s http://127.0.0.1:3456/health | python3 -c "import json,sys; print(json.load(sys.stdin)['status'])")
test "$HEALTH" = "healthy" && echo "PASS: proxy healthy after invalid profile (active=$ACTIVE)" || echo "FAIL: proxy unhealthy"
# Restore
echo "$ORIG" > ~/.config/meridian/settings.jsonPass criteria:
- Switch to nonexistent profile returns error response (not 200)
- Proxy starts healthy with invalid
settings.json; falls back to first profile
Verifies: settings.json is updated when profile is switched; CLI meridian profile list shows correct state.
PROFILE2_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][1]['id'])")
# Switch via API
curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE2_ID\"}" > /dev/null
# Verify settings.json
SAVED=$(python3 -c "import json; print(json.load(open('$HOME/.config/meridian/settings.json'))['activeProfile'])")
test "$SAVED" = "$PROFILE2_ID" && echo "PASS: settings.json=$SAVED" || echo "FAIL: expected $PROFILE2_ID, got $SAVED"
# Verify CLI shows profiles (non-interactive, just list)
meridian profile list 2>&1 | grep -q "$PROFILE2_ID" && echo "PASS: CLI shows profile" || echo "FAIL: CLI missing profile"Pass criteria:
settings.jsoncontains the switched profile IDmeridian profile listoutput includes the profile
Verifies: /health endpoint email changes when active profile changes.
PROFILE1_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][0]['id'])")
PROFILE2_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][1]['id'])")
PROFILE1_EMAIL=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][0]['email'])")
PROFILE2_EMAIL=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][1]['email'])")
curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE1_ID\"}" > /dev/null
E1=$(curl -s http://127.0.0.1:3456/health | python3 -c "import json,sys; print(json.load(sys.stdin)['auth']['email'])")
curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE2_ID\"}" > /dev/null
E2=$(curl -s http://127.0.0.1:3456/health | python3 -c "import json,sys; print(json.load(sys.stdin)['auth']['email'])")
test "$E1" = "$PROFILE1_EMAIL" && test "$E2" = "$PROFILE2_EMAIL" && test "$E1" != "$E2" \
&& echo "PASS: health switches ($E1 → $E2)" \
|| echo "FAIL: expected $PROFILE1_EMAIL/$PROFILE2_EMAIL, got $E1/$E2"Pass criteria:
- Health email matches profile 1 email after switching to profile 1
- Health email matches profile 2 email after switching to profile 2
- The two emails are different
Verifies: Requests on both profiles appear in telemetry.
PROFILE1_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][0]['id'])")
PROFILE2_ID=$(curl -s http://127.0.0.1:3456/profiles/list | python3 -c "import json,sys; print(json.load(sys.stdin)['profiles'][1]['id'])")
# Note starting request count
BEFORE=$(curl -s 'http://127.0.0.1:3456/telemetry/requests?limit=100' | python3 -c "import json,sys; print(len(json.load(sys.stdin)))")
# Request on profile 1
curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE1_ID\"}" > /dev/null
curl -s -X POST http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" -H "x-api-key: dummy" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":10,"stream":false,
"messages":[{"role":"user","content":"telemetry test p10a"}]}' > /dev/null
# Request on profile 2 (streaming)
curl -s -X POST http://127.0.0.1:3456/profiles/active \
-H "Content-Type: application/json" -d "{\"profile\":\"$PROFILE2_ID\"}" > /dev/null
curl -s -N -X POST http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" -H "x-api-key: dummy" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":10,"stream":true,
"messages":[{"role":"user","content":"telemetry test p10b"}]}' > /dev/null
sleep 1
# Should have 2 more requests
AFTER=$(curl -s 'http://127.0.0.1:3456/telemetry/requests?limit=100' | python3 -c "import json,sys; print(len(json.load(sys.stdin)))")
NEW=$((AFTER - BEFORE))
test "$NEW" -ge 2 && echo "PASS: $NEW new telemetry records (non-stream + stream)" || echo "FAIL: expected >=2 new records, got $NEW"
# Verify both modes present
curl -s 'http://127.0.0.1:3456/telemetry/requests?limit=5' | python3 -c "
import json, sys
reqs = json.load(sys.stdin)
modes = {r['mode'] for r in reqs[:5]}
assert 'stream' in modes or 'non-stream' in modes, f'unexpected modes: {modes}'
print(f'Modes seen: {modes} PASS')
"Pass criteria:
- At least 2 new telemetry request records after the two requests
- Both streaming and non-streaming modes recorded
Verifies: When the opencode adapter (User-Agent opencode/<version>) sends a multi-turn request whose history contains real tool_use and tool_result content blocks, opus-4-7's response does not contain leaked text patterns like [Tool Use: name(args)], [Tool Result for toolu_...:], H:, Human: or Assistant: line prefixes.
The original report (#416) saw opus-4-7 emitting these strings as visible chat content while opus-4-6 and sonnet-4-6 did not — opus-4-7 is more sensitive to context patterns, so any leak in the rehydration prompt got mimicked back. The fix landed across SDK upgrade (#431) + cli.js refresh + the existing tool-flatten guard from #386.
Why opencode-specific: the opencode-with-claude wrapper hits this path more often because it forwards full message history on every turn — Meridian's buildFreshPrompt then runs whenever the SDK session is lost. Other adapters (pi, droid, crush) trigger the same code path but the user only reported it on opencode + opus-4-7.
# Proxy must be running on port 3456 with personal/working profile auth
curl -s http://127.0.0.1:3456/health | jq .auth.loggedIn # → trueSend a multi-turn request that mirrors the user's stack: opencode UA, opus-4-7, history containing real tool_use blocks (the model has no SDK session for this conversation yet, so buildFreshPrompt runs).
cat > /tmp/e2e-416-body.json <<'EOF'
{
"model": "claude-opus-4-7",
"max_tokens": 800,
"stream": false,
"messages": [
{"role": "user", "content": "create a todo list with 3 items: A, B, C"},
{"role": "assistant", "content": [
{"type": "text", "text": "I will create the todo list now."},
{"type": "tool_use", "id": "toolu_001", "name": "todowrite",
"input": {"todos": [
{"content": "A", "status": "pending"},
{"content": "B", "status": "pending"},
{"content": "C", "status": "pending"}
]}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_001", "content": "Wrote 3 todos."}
]},
{"role": "assistant", "content": "Done. 3 items added."},
{"role": "user", "content": "Reply with the todo names as a JSON array. Just the array, no tool calls."}
]
}
EOF
RESP=$(curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "User-Agent: opencode/1.14.20" \
-d @/tmp/e2e-416-body.json)
# Extract assistant text only (ignore tool_use blocks)
TEXT=$(echo "$RESP" | python3 -c "
import json, sys
d = json.load(sys.stdin)
parts = [b.get('text','') for b in d.get('content', []) if b.get('type') == 'text']
print(''.join(parts))
")
echo "=== model output ==="
echo "$TEXT"
echo "=== leak checks ==="
echo " [Tool Use: $(echo "$TEXT" | grep -c '\[Tool Use:')"
echo " [Tool Result: $(echo "$TEXT" | grep -c '\[Tool Result')"
echo " H: prefix: $(echo "$TEXT" | grep -cE '(^|\n)H: ')"
echo " Human: prefix:$(echo "$TEXT" | grep -cE '(^|\n)Human:')"
echo " Assistant: prefix:$(echo "$TEXT" | grep -cE '(^|\n)Assistant:')"Pass criteria (all five counts must be 0):
[Tool Use:count = 0[Tool Resultcount = 0H:line prefix count = 0Human:line prefix count = 0Assistant:line prefix count = 0- Response text is the actual answer (e.g.
["A", "B", "C"]), not a flattened transcript
Triggers buildFreshPrompt over a longer history that more closely resembles the user's reported scenario (todowrite progression across many turns). Run this if the basic case passes but you suspect leaks under longer rehydration.
# Construct an 11-message history with 3 tool_use rounds
cat > /tmp/e2e-416-aggressive.json <<'EOF'
{
"model": "claude-opus-4-7",
"max_tokens": 1500,
"stream": false,
"messages": [
{"role": "user", "content": "Track these 4 tasks via todowrite: locate code, analyze logic, modify file, verify build."},
{"role": "assistant", "content": [
{"type": "text", "text": "Creating todo list."},
{"type": "tool_use", "id": "toolu_a", "name": "todowrite",
"input": {"todos": [
{"content":"locate code","status":"pending"},
{"content":"analyze logic","status":"pending"},
{"content":"modify file","status":"pending"},
{"content":"verify build","status":"pending"}
]}}
]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_a", "content": "Created."}]},
{"role": "assistant", "content": [
{"type": "text", "text": "Working on the first item."},
{"type": "tool_use", "id": "toolu_b", "name": "todowrite",
"input": {"todos": [
{"content":"locate code","status":"in_progress"},
{"content":"analyze logic","status":"pending"},
{"content":"modify file","status":"pending"},
{"content":"verify build","status":"pending"}
]}}
]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_b", "content": "Updated."}]},
{"role": "assistant", "content": [
{"type": "text", "text": "First item complete, moving on."},
{"type": "tool_use", "id": "toolu_c", "name": "todowrite",
"input": {"todos": [
{"content":"locate code","status":"completed"},
{"content":"analyze logic","status":"in_progress"},
{"content":"modify file","status":"pending"},
{"content":"verify build","status":"pending"}
]}}
]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_c", "content": "Updated."}]},
{"role": "user", "content": "What status are the four tasks in right now? Reply as a numbered list, no tool calls."}
]
}
EOF
RESP=$(curl -s http://127.0.0.1:3456/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-H "User-Agent: opencode/1.14.20" \
-d @/tmp/e2e-416-aggressive.json)
TEXT=$(echo "$RESP" | python3 -c "
import json, sys
d = json.load(sys.stdin)
parts = [b.get('text','') for b in d.get('content', []) if b.get('type') == 'text']
print(''.join(parts))
")
echo "=== model output (first 1500 chars) ==="
echo "${TEXT:0:1500}"
echo "=== leak checks (all should be 0) ==="
for pat in '\[Tool Use:' '\[Tool Result' '(^|\n)H: ' '(^|\n)Human:' '(^|\n)Assistant:'; do
count=$(echo "$TEXT" | grep -cE "$pat")
echo " $pat: $count"
donePass criteria: all five leak counts = 0; the answer is a numbered list of the 4 tasks with their actual statuses (completed / in_progress / pending).
rm -f /tmp/e2e-416-body.json /tmp/e2e-416-aggressive.jsonThe existing regression test in src/__tests__/proxy-tool-flattening-regression.test.ts (issue #386) verifies the SDK prompt contains no [Tool Use: strings. That's necessary but not sufficient for #416 — the symptom there was the model's response containing those strings, picked up from context patterns the model imitates. Only a live model can verify that opus-4-7 doesn't mimic the rehydration format. The unit test guards Meridian's prompt construction; this E2E guards the model's actual behavior on the user's stack.
Verifies: A generic OpenAI client (Open WebUI, curl) hitting POST /v1/chat/completions with a system message has that prompt honoured directly, without the ~28KB claude_code preset being injected on top. The internal hop is tagged x-meridian-agent: openai, selecting the openai adapter whose codeSystemPrompt defaults OFF (mirrors the passthrough precedent, #190). Regression guard for the #526 investigation.
curl -s http://127.0.0.1:3456/v1/chat/completions \
-H "Content-Type: application/json" \
-H "x-api-key: dummy" \
-d '{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 40,
"messages": [
{"role": "system", "content": "You are Aristotle, a philosophy tutor. You are NOT a coding assistant. In one short sentence, state who you are."},
{"role": "user", "content": "Who are you?"}
]
}' | python3 -m json.toolPass criteria:
- Response reflects the client system prompt (e.g. "I am Aristotle, a philosophy tutor.")
- Proxy log shows
adapter=openaifor the inner hop (notadapter=opencode) - No Claude Code persona / tool-instruction leakage in the reply
To confirm the preset is actually gone (the deterministic check), set codeSystemPrompt for the openai adapter and observe the difference, or rely on the unit test src/__tests__/proxy-openai-compat.test.ts → "sends the client system prompt verbatim, without the claude_code preset", which asserts the SDK receives a plain-string systemPrompt rather than a {type: "preset", preset: "claude_code"} object.
What's being tested: openAiAdapter (adapters/openai.ts), the x-meridian-agent: openai tag on the internal hop (server.ts), and ADAPTER_DEFAULTS.openai = { codeSystemPrompt: false } (sdkFeatures.ts).
Automated — the one E2E that is a single command:
bun scripts/e2e-stream-parallel.mjs # 3 attempts (default)
E2E_ATTEMPTS=5 bun scripts/e2e-stream-parallel.mjs
E2E_WIDE=1 bun scripts/e2e-stream-parallel.mjs # #742 window (see below)E2E_WIDE=1 — the #742 ordering. The default prompt produces three
short-argument calls that close in one delta each, so the deny-before-block-close
window never opens: the run reports INCONCLUSIVE for #742 rather than a
pass, because clean assertions over an ordering that never occurred prove
nothing. E2E_WIDE=1 adds a fourth call carrying a multi-KB free-text argument
— the shape from the original report (a ~2.9 KB subagent prompt) — which keeps
one block streaming while an earlier call's deny settles. That hit the race on
6 of 6 attempts.
The run also watches Meridian's own diagnostics, not just the wire:
| signal | meaning |
|---|---|
dangling_blocks_closed / early_stop |
the race FIRING — the bug's signature |
passthrough.early_stop_deferred |
the fix ENGAGING — race occurred, handled |
Requiring at least one deferral is what makes a green run evidence instead of absence. Verified by reverting the fix and re-running the same scenario:
✗ tool task has EMPTY input (the '{} Tool execution aborted' render)
widest tool input: 29 bytes across 4 calls # vs 5259-6418 with the fix
Note the envelope marker did not fire in that failing run. The framing stayed valid throughout — only the payload assertion caught it. That is exactly why #675 mis-triaged this same race as "client impact: none".
Why this exists: the CLI dispatches PreToolUse hooks per-block while later
parallel blocks are still generating, and a deny landing mid-generation makes
the CLI cancel the in-flight request — beheading trailing parallel calls. The
client renders the cut block as an argument-less tool {} "Tool execution
aborted" (the #552 "red read"), the session store is skipped, and the model
loops. No mocked suite reproduced this dispatch ordering: v1.49.0 and v1.49.1
both shipped with "verified" fixes that failed in the field within hours.
This script runs the REAL CLI through the REAL proxy in SSE mode and asserts
the actual client contract.
Pass criteria (all attempts):
- ≥2 parallel tool_use blocks reach the client, every block terminated
- every tool input is complete, parseable JSON (no
{}) - exactly one
message_stop - the instant follow-up does not re-issue identical calls (session resumed)
What's being tested: deny-hold (holdDenyUntilTurnEnd), early stop +
drain, flushOpenClientBlocks, pendingSessionStores (server.ts);
passthroughEarlyStop.ts.
Automated — one command:
bun scripts/e2e-sdk-boundary.mjs
SDK_BOUNDARY_MODEL=claude-sonnet-5 bun scripts/e2e-sdk-boundary.mjsWhy this exists: three bugs shipped through this seam in one week, and the unit suite was green for all three — because a mocked suite can only assert what we already thought to look for.
- #708 — the SDK reports
resetsAtin epoch seconds. Every fixture used milliseconds, so the mismatch was unobservable and tier 1 of the priority cooldown was dead code for its entire life. - #710 —
thinkingblocks fell into the lineage hash's serialize-everything fallback, folding an encrypted per-generation signature into the hash. There was no thinking-block test at all. - #694 — the
claude_codepreset injects a gitStatus block claiming to be "the git status at the start of the conversation" and recomputes it every turn. A user was told the model had destroyed their work-in-progress files.
Each was found by watching real traffic. This script makes that watching repeatable instead of a fresh throwaway probe each time.
Pass criteria:
- every
resetsAt/overageResetsAtlands between now and 8 days out — bounded both ways, so a missed*1000(1970) and a double one (year 58000) both fail, and the check stays valid if the SDK ever switches units - every content-block type observed in live traffic is in one of the three
hashing buckets in
messages.ts - a session whose client stops echoing thinking blocks still logs
lineage=continuation, not a fresh replay - a check that cannot gather its evidence fails rather than passing quietly (no rate-limit bucket, no content blocks, missing lineage verdicts)
Informational, not asserted: check 4 asks the model whether the gitStatus
block still claims to describe the conversation's start. It is reported rather
than asserted because a model declining to answer must not fail a release. When
it reports the block is gone or honestly labelled, GIT_STATUS_PROVENANCE_NOTE
in query.ts can be removed.
Calibrated, not assumed. Both hard checks were verified to fail against the real bugs by reverting each fix and re-running:
✗ units: five_hour.resetsAt=1785404400 is in the past (1970-01-21…) — seconds treated as ms?
✗ lineage: third turn was lineage=new, expected continuation — dropped thinking blocks churned the hash (#710)
What's being tested: toEpochMs / RateLimitStore.record
(rateLimitStore.ts); normalizeContent block classification (messages.ts);
hashMessage / verifyLineage (session/lineage.ts);
GIT_STATUS_PROVENANCE_NOTE (query.ts).
Static counterpart: sdk-block-type-coverage.test.ts reads the
ContentBlockParam union out of the installed SDK and fails when a new block
type appears in none of the three buckets — so the next thinking is caught by
CI on the dependency bump rather than by a user.
Automated, and costs no tokens — every request is answered by a local stub and never forwarded upstream:
bun scripts/e2e-client-detection.mjs # check for drift
bun scripts/e2e-client-detection.mjs --update # re-record the fixtureWhy this exists: Meridian picks an adapter from request headers, so a client changing what it sends silently reroutes it — and nothing fails.
Crush 0.87 added x-session-affinity, which detection checked ahead of the
User-Agent chain, so every Crush request resolved to the OpenCode adapter:
OpenCode's transforms, tool config, MCP server name and CWD extraction applied
to a client with its own. Then fixing the detection made it worse, because
openCodeAdapter.getSessionId falls back to that same header — Crush had been
getting keyed sessions by accident, and correct detection downgraded it to
fingerprint-only continuity, looping until timeout.
That was found by upgrading a client and running one turn. No user would connect "sessions feel wrong" to header precedence, and no unit test can watch a client change its headers.
Pass criteria:
- every installed client resolves to the adapter recorded in
src/__tests__/fixtures/client-headers.json - a client that never reaches the capture server fails rather than being silently skipped — silence is not success
- an uninstalled client is skipped with a note, so the script is runnable on a machine that has only some clients
Also reported, not failed: headers added or removed since the recorded capture, and User-Agent changes. A new header is exactly how #733 started, one release before it did damage — so it is surfaced even while detection is still correct.
Adding a client: one entry in the CLIENTS table (how to write its config
and run one turn), then --update.
Static counterpart: client-detection-fixtures.test.ts pins detection
against the same captured header sets, so a change to detection ORDERING fails
in CI without needing any client installed. Verified: reintroducing #733 fails
that test and the live script.
Note on the fixtures: they are real captures, not hand-written. Both
opencode 1.18.9 and crush 0.87 send x-session-affinity and x-session-id
— which is why one of the tests asserts, as a property, that a shared session
header can never be what distinguishes two clients.
Automated, and costs no tokens — claude is replaced by a stub that
records its argv, and HOME is redirected to a temp dir so the run cannot
touch your real ~/.config/meridian/sdk-features.json:
bun scripts/e2e-webfetch-preflight.mjsWhy this exists: the WebFetch Preflight toggle has two independent failure modes, and only the first is obvious.
The first is routing: webFetchPreflight: false on one adapter must produce
skipWebFetchPreflight: true in that adapter's spawn and no other. The value
is threaded through six separate buildQueryOptions call sites in server.ts,
which is exactly the shape where one gets missed and the toggle appears to work
because you only ever tested the streaming path.
The second is scope, and it is the one that misleads users. The preflight lives
inside the SDK's built-in WebFetch, so the setting only changes behaviour
where the subprocess can invoke that tool. Every adapter but cherry prevents
it — passthrough modes send --tools empty (the SDK's "disable all built-ins")
and internal modes list WebFetch in --disallowed-tools. Cherry unblocks the
built-in web tools so Claude can browse for itself (#481), making it the only
adapter where the toggle does anything. A toggle that silently does nothing on
the adapter you flipped it on is worse than no toggle: you believe the hostname
stopped leaving your network when it never was.
Pass criteria:
cherry+webFetchPreflight:false→skipWebFetchPreflight:truein argvcherryunset →skipWebFetchPreflight:false(default matches the subprocess default — an omitted key would silently re-enable the check, the #634 failure mode)opencode+webFetchPreflight:false→ the setting still routes, but the spawn cannot reach the built-in WebFetch, so the case is asserted INERT- a case where no subprocess spawned fails rather than passing quietly — silence is not success
- the real
sdk-features.jsonis byte-identical before and after
The scope assertion is deliberate. builtinWebFetch is checked per case
against what docs/configuration.md promises. If a future tool-config change
lets another adapter run the built-in WebFetch, this fails with a pointer to
the docs — otherwise the scope note rots and users keep turning off a check
that is still running.
Verified: 2026-08-03. Mutation-tested both ways — flipping
DEFAULT_FEATURES.webFetchPreflight to false fails the default case, and
removing cherry from ADAPTER_LABELS fails the static counterpart in
sdk-features-unit.test.ts.
Static counterpart: the WebFetch preflight scope block in query.test.ts
pins the same three adapter shapes at the buildQueryOptions level, so tool
config drift fails in CI without starting a proxy.
Automated, costs real tokens (two turns per attempt, plus one per silence the recovery repairs):
bun scripts/e2e-silent-turn.mjs
E2E_ATTEMPTS=10 bun scripts/e2e-silent-turn.mjs
# The pair that actually proves the guard, on demand:
MERIDIAN_DEBUG_FORCE_SILENT_TURN=1 MERIDIAN_SILENT_TURN_RECOVERY=0 bun scripts/e2e-silent-turn.mjs # FAILS
MERIDIAN_DEBUG_FORCE_SILENT_TURN=1 bun scripts/e2e-silent-turn.mjs # passesWhy this exists: three separate defects have now ended in the same shape —
stop_reason: "end_turn", HTTP 200, error: null, and nothing the client can
act on. An interrupted tail, an unsettled client abort, a spent deny at the
boundary seam. Each was found by reading a transcript after the fact; each
mocked suite stayed green while the field kept breaking.
They have nothing in common except their outcome, so the outcome is what this measures. Every turn is asked one question — did the client receive text or a tool call? — which a cause nobody has found yet fails exactly like the three known ones.
It drives the shape all three took: a tool call, then the turn that must answer its result, in a fresh session each attempt. Every observed silence landed on a session's second turn, where the deny is the largest thing in a still-short context.
Fault injection, and why it is not optional. The live rate is about three in
five hundred requests. A ten-attempt run expects 0.06 occurrences, so a green
run without injection is ambiguous — the guard works, or the defect simply did
not happen. That ambiguity is what let two earlier "verified" fixes ship broken.
MERIDIAN_DEBUG_FORCE_SILENT_TURN=1 drops the upstream turn's text deltas while
leaving its block start and stop, which is the production signature exactly;
detection, recovery, envelope and telemetry then run for real against a real
model. Only the trigger is synthetic.
Pass criteria:
- every turn under test carries text or ≥1 tool call
- exactly one
message_stopper turn - any
errorevent precedesmessage_stop— behind it, clients have already stopped reading and the failure is invisible - a failed turn with no text does not claim
stop_reason: "end_turn" - an attempt where the model never called a tool is reported as skipped, not counted as a pass: it never reached the shape under test
Reading the output: silent: 0 says the client always got an answer, not
that nothing broke upstream — upstream: N silent turns detected, M recovered
is where the mechanism shows itself. Compare an injected run with recovery ON
against the same run with MERIDIAN_SILENT_TURN_RECOVERY=0; comparing a single
recovery-ON run against nothing tells you almost nothing.
Verified: 2026-08-11. Injected, recovery OFF: 2/2 attempts FAIL with
text=0 tools=0. Injected, recovery ON: 2/2 pass, the answer arriving as real
text deltas. Uninjected, both settings: 3/3 pass, no silences — the live rate is
far below what a run this size can see, which is the whole reason injection
exists.
Verifies: OpenCode's internal title agent cannot break or de-cache the
user's conversation.
OpenCode runs title (and summary, compaction) under the user's session
id, and fires it concurrently with the user's first real turn. Both requests
carried the same x-opencode-session, so they shared one lineage and one
per-session turn lease. Whichever arrived first committed its own conversation
under the shared key; the other was then measured against a history that was not
its own.
Mocked tests cover the key derivation and the HTTP outcome. This exists because
neither can prove OpenCode still sends what the fix keys on — a client upgrade
that renames or drops x-opencode-agent-mode / x-opencode-agent-name puts the
collision straight back with every suite green.
# Isolated OpenCode config. OPENCODE_CONFIG_DIR alone is NOT enough — OpenCode
# merges ~/.config/opencode/opencode.json on top of it, which drags in the real
# config's MCP servers and can hang `init` for minutes. XDG_CONFIG_HOME is what
# actually isolates it.
BASE=/tmp/e39; rm -rf $BASE; mkdir -p $BASE/{proj,cfg}
printf 'alpha\nbeta\ngamma\n' > $BASE/proj/notes.txt
cat > $BASE/cfg/opencode.json <<'JSON'
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["/absolute/path/to/meridian/plugin/meridian.ts"],
"provider": { "anthropic": { "options": { "apiKey": "dummy", "baseURL": "http://127.0.0.1:3499" } } },
"model": "anthropic/claude-haiku-4-5",
"small_model": "anthropic/claude-haiku-4-5"
}
JSON
MERIDIAN_TELEMETRY_PERSIST=1 MERIDIAN_TELEMETRY_DB=$BASE/t.db MERIDIAN_PORT=3499 \
node dist/cli.js > $BASE/proxy.log 2>&1 &
sleep 6
cd $BASE/proj
export OPENCODE_CONFIG_DIR=$BASE/cfg XDG_CONFIG_HOME=$BASE/xdg
OUT=$(opencode run --model anthropic/claude-haiku-4-5 --format json \
"Read notes.txt and report how many lines it has." 2>&1)
SID=$(echo "$OUT" | grep -o '"sessionID":"[^"]*"' | head -1 | cut -d'"' -f4)
opencode run --model anthropic/claude-haiku-4-5 --session "$SID" --format json \
"Append a line 'delta' to notes.txt using the edit tool." >/dev/null 2>&1
opencode run --model anthropic/claude-haiku-4-5 --session "$SID" --format json \
"Read notes.txt and list every line." >/dev/null 2>&1
grep -c session_turn_conflict $BASE/proxy.log # → 0
grep 'agent=primary' $BASE/proxy.log | head -1 # → sessionWait=0ms, lineage=new
grep -c 'lineage=continuation' $BASE/proxy.log # → ≥1 per later requestPass criteria:
- No
session_turn_conflictanywhere in the log, and no"session advanced while the request was waiting"in any turn's JSON output - The first
agent=primaryrequest showssessionWait=0ms— it does not queue behind theagent=subagenttitle request - The title request appears with
agent=subagentand a session key of its own - Every request after the first carries
lineage=continuationwith a non-zerocache_read
Verified: 2026-08-19, OpenCode 1.18.11. Before the fix, 3/3 runs: the title
request took the lease, the user's turn waited 9,836 ms and returned HTTP 400
session_turn_conflict, and OpenCode reported it as a non-retryable APIError —
the first turn was simply lost. After: 0 conflicts, sessionWait=0ms on the
user's turn, and 6/6 later requests lineage=continuation at 83-99% cache hit.
What it proves: that capping maxTurns at 1 for passthrough turns removes
the billed digest turn without costing the session.
Why it needs a live SDK: the thing under test is the SDK's own turn accounting — when it decides a turn is finished, when it declines to start another, and whether it still flushes its transcript on the way out. A mocked SDK can only replay assumptions about that; this asserts them.
bun scripts/e2e-digest-turn-cap.mjsPass criteria (the script asserts all of these and exits non-zero on any):
- Capped, the tool call still reaches the client, the SDK stops with
subtype: error_max_turns, and no digest text is generated - Uncapped (
maxTurns: 3) does generate digest text, costs more, and emits more output tokens on the identical prompt - The capped session resumes at the captured assistant UUID and answers from
the client's real
tool_result - A text-only turn returns
success, noterror_max_turns - Parallel tool calls are all still forwarded
Do not assert an assistant-message count. The SDK splits one turn across several assistant messages — a thinking message, then one per parallel tool call — so the count tracks the model's phrasing, not turns. The digest turn's signature is text produced after the tool call.
If the resume check ever fails, take the cap off. It is the claim #837 was defending: a lost transcript costs a full cold replay on every tool call, which is far worse than the digest turn the cap removes.
Verified: 2026-08-20, sonnet. Capped vs uncapped on one tool call: 121 vs 244 output tokens and $0.0046 vs $0.0469 (10.3x) in one run, $0.0046 vs $0.7687 (168x) in another where the uncapped digest turn wrote a large cache entry. Through the live proxy (E17 shape): output 306 → 144 and cache_read 12k → 4k on the tool turn. The discarded digest text was captured verbatim — "I attempted to read that file, but the tool call w…" — content the client never sees and the account is billed for.
What it proves: across dependent and parallel forwarded tool calls, the active SDK session contains exactly one answer per delivered call — the client's real result — and never replays the forwarding hook's denial.
Why it needs the real SDK and several turns: resumeSessionAt only trims a
suffix of the source transcript. A plain resume leaves the denial in that source,
and the CLI loader can splice it back on a later turn. Meridian therefore resumes
the assistant checkpoint with the supported forkSession option. The child fork
makes the replacement tail durable while the superseded source becomes dead
history. Mocked tests cannot prove the CLI loader or prompt-cache behavior.
Run the full matrix:
bun scripts/e2e-passthrough-turns.mjs
bun scripts/e2e-passthrough-turns.mjs --stream
PROBE_PARALLEL=1 bun scripts/e2e-passthrough-turns.mjs
PROBE_PARALLEL=1 bun scripts/e2e-passthrough-turns.mjs --streamPass criteria (asserted, non-zero exit on any):
- Chain mode returns three batches of one call; parallel mode returns one batch of all three calls. Eventually returning three serial calls does not pass the parallel gate.
- The final answer quotes all three delivered results and never claims a call went unanswered.
- Every result round advances to a distinct continuation session, proving the replacement tail was committed to a fork rather than only rewound for one query.
- A follow-up resumes the active fork. Supported
getSessionMessages()output contains exactly one realtool_resultfor every delivered id and no forwarding denial for those ids. - Every continuation, including the follow-up, reads at least 95% of the prior
turn's
cache_read_input_tokens + cache_creation_input_tokens.
The gate resolves Meridian's published session from its own durable store and
uses the supported Agent SDK getSessionMessages() API for the history check.
It does not inspect Claude's private persistence format.
Verified: 2026-08-27 at implementation SHA 73a966f2, sonnet, chain and
parallel, stream and non-stream. The active fork held one real answer per
delivered call and every continuation read the prior cached prefix in full.
The same SHA also passed an actual headless OpenCode 1.18.11 gate. It performed
a real read, three parallel reads, two ordinary continuations, a supported
OpenCode revert, a post-undo continuation, and two full Meridian restarts. The
ordinary and cross-process continuations used supported SDK forks with 99–100%
cache reuse. The undo was detected as a prefix rollback and replayed into a
fresh prepared transcript with 98% cache reuse. With
MERIDIAN_MAX_STORED_SESSIONS=1, Meridian retained one mapping and exactly its
current and direct-predecessor transcripts. Supported SDK GC then deleted ten
retired transcripts, retained both pinned transcripts, and verified every
history only through getSessionMessages().