Skip to content

Latest commit

 

History

History
3575 lines (2836 loc) · 144 KB

File metadata and controls

3575 lines (2836 loc) · 144 KB

End-to-End Testing

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 droid installed (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 via MERIDIAN_PASSTHROUGH=1 and requires droid ≥ 0.109 — see "Droid passthrough mode" below.

Quick Start

# 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)

Test Index

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 | - |


Conventions

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 -5

Session 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.json

E1: Basic Request/Response

Verifies: 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_reason is "end_turn"
  • Proxy log shows lineage=new session=new

E2: Streaming SSE

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 -30

Pass criteria:

  • First event is event: message_start with a message object
  • At least one event: content_block_start with type: "text"
  • At least one event: content_block_delta with type: "text_delta"
  • Final events include event: message_stop
  • No mcp__opencode__* tool blocks leak through

E3: Tool Use Loop

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.txt

Pass criteria:

  • Response text includes CANARY_12345
  • Proxy log shows tools=76 (or similar — opencode registers its full tool set)

Variant: Write + Read

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.txt

E4: Session Continuation

Verifies: 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> (not new)
  • Response mentions DELTA_99

E5: Undo with Rollback

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 detected message in proxy stderr
  • Response is valid (not an error)

E6: Compaction

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> (not new)
  • Compaction detected message 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)


E7: Diverged Detection

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)

E8: Cross-Proxy Resume

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> (not new)
  • Response mentions PHOENIX_42
  • SDK session was genuinely resumed (not a fresh start with flat text replay)

E9: Fingerprint Fallback

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, not new)

E10: Coding Task via opencode

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-test

Pass criteria:

  • buggy.js now contains a + b (not a - b)
  • Proxy log shows tool execution (multiple [PROXY] lines for the session)

Variant: Multi-turn via opencode

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/null

Pass criteria:

  • Second response includes ALPHA_42

E11: Telemetry

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 -20

Pass criteria:

  • /telemetry returns HTML with <title>Meridian
  • /telemetry/requests returns an array of request metrics with requestId, model, lineageType
  • /telemetry/summary returns totalRequests > 0, errorCount, percentile latencies
  • /telemetry/logs returns an array with level, category, message fields

E12: Health Check

Verifies: /health endpoint returns auth and mode status.

curl -s http://127.0.0.1:3456/health | python3 -m json.tool

Pass criteria:

  • status: "healthy"
  • auth.loggedIn: true
  • auth.subscriptionType: "max"
  • mode: "internal" (or "passthrough" if CLAUDE_PROXY_PASSTHROUGH is set)

E13: Concurrent Requests

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
wait

Pass 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

E14: Model Routing

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/null

Pass criteria:

  • Proxy log shows model=haiku for the first request
  • Proxy log shows model=opus (or model=opus[1m]) for the second
  • Proxy log shows model=sonnet[1m] for the third

E15: Non-Streaming

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.txt

Pass 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

E16: Error Handling

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/messages

Pass 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

E17: Passthrough Mode

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...

Non-streaming

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_use block with "name": "Read" and correct input
  • Tool name is clean (no mcp__passthrough__ prefix)
  • /health shows "mode": "passthrough"

Streaming

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_start with type: "tool_use", name: "Read"
  • message_delta has stop_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 &

E18: Multimodal Content

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

E19: Subagent / Task Tool

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

E20: Env Stripping

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:9999 and 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.


E21: Session Store Pruning

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).


E22: OAuth Token Refresh

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

macOS

# 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"})')
"

Linux

# 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 containing REFRESH_OK — request succeeded despite starting with an expired token
  • Proxy log: [PROXY] <id> OAuth token expired — refreshed, retrying appears before the successful response log line
  • Step 4 expiresAt: VALID (in the future — token was refreshed and written back)
  • No authentication_error in 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.

Bonus: manual refresh endpoint

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.


E23: Subagent Model Selection

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.

Part A — header routing (curl, no plugin needed)

# 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=subagent

Pass 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 and agent=subagent, even outside OpenCode
  • No header → model=opus[1m] (default primary behaviour)

Part B — plugin integration (requires OpenCode)

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 subagent

Test:

# 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.


E24: Default Non-Streaming

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.txt

Pass criteria:

  • Response header: Content-Type: application/json (not text/event-stream)
  • Response body: "type": "message", "role": "assistant", valid content array
  • 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.


E25: OpenAI Compat: Non-Streaming

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.tool

Pass criteria:

  • "object": "chat.completion"
  • id starts with chatcmpl-
  • choices[0].message.role is "assistant"
  • choices[0].message.content contains a response
  • choices[0].finish_reason is "stop"
  • usage.prompt_tokens, usage.completion_tokens, usage.total_tokens are 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.


E26: OpenAI Compat: Streaming

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" and choices[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 id starting with chatcmpl-
  • Proxy log: stream=true

What's being tested: translateAnthropicSseEvent() in openai.ts, SSE stream translation in server.ts.


E27: OpenAI Compat: Models

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.tool

Pass criteria:

  • "object": "list"
  • data array contains claude-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.


E28: SDK Param Passthrough

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.tool

Pass 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

Variant: malformed thinking override falls back cleanly

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.tool

Pass 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

E29: Context Usage Endpoint

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.tool

Pass criteria:

  • CLAUDE_SESSION_ID is non-empty
  • Endpoint returns HTTP 200
  • JSON contains session_id equal to the extracted Claude session ID
  • JSON contains context_usage.input_tokens and context_usage.output_tokens
  • Proxy stderr for the original request contains a usage: line

E30: Context Usage via Fingerprint + Restart

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.tool

Pass criteria:

  • Step 2 returns HTTP 200 for a request that had no x-opencode-session header
  • Step 4 also returns HTTP 200 after restart
  • Both responses contain session_id equal to the extracted Claude session ID
  • Both responses contain context_usage.input_tokens and context_usage.output_tokens
  • No need to replay the original request after restart — the lookup should work from persisted session data alone

Adding New E2E Tests

When extending this document:

  1. Assign an ID — use the next sequential E## number in the index.
  2. Add to the index table at the top with the date verified.
  3. Include the exact curl/opencode command — tests must be copy-pasteable.
  4. Define pass criteria — what to check in the response AND in the proxy log.
  5. Note prerequisites — if the test depends on a prior test's session state, say so.
  6. Note env vars — if the test requires a proxy restart with special env vars (E17, E21), say so explicitly.
  7. Keep tests independent where possible — use unique session IDs (e2e-<test>-<nnn>).

Session ID Convention

Use e2e-<feature>-<nnn> format: e2e-cont-001, e2e-compact-001, e2e-persist-001.

Checking Proxy Logs

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"

Tests That Require Proxy Restart

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

Relationship to Unit/Integration Tests

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.

Coverage Map

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

Droid (Factory AI) Tests

These tests verify the Droid adapter added in the Droid support release. They require droid CLI installed and a Factory AI account.

Droid passthrough mode

Droid's passthrough behavior is env-controlled, defaulting to OFF:

  • Without MERIDIAN_PASSTHROUGH (default): Droid runs in internal mode. The proxy executes tools via the mcp__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 (or CLAUDE_PROXY_PASSTHROUGH=1): the proxy forwards tool_use blocks to Droid, Droid executes the tools locally, and sends tool_result back. Requires Droid ≥ 0.109 (earlier versions had a BYOK loop bug where tool_result wasn'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_usetool_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 BYOK Setup

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.json

Droid Proxy Quick Start

Use 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\]"

D1: Droid Basic Response

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.tool

Pass 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)

D2: Droid MCP Server Name

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.


D3: Droid OpenCode Backward Compat

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=new for both (both are first requests with those sessions)

D4: Droid CWD from system-reminder

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.


D5: Droid Fingerprint Session Resume

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

D6: Droid Real Binary Basic

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": false errors — authentication via BYOK succeeded
  • No 402 Payment Required errors

D7: Droid Real Binary Tool Use

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.txt

Pass 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)

D8: Droid exec Session Isolation

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.


D9: Droid Streaming SSE

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 -25

Pass criteria:

  • First event: event: message_start with a valid message object
  • At least one event: content_block_delta with type: "text_delta" containing the response text
  • Final event: event: message_stop
  • No mcp__droid__* tool blocks leak to the client
  • Proxy log: stream=true

D10: Droid OpenCode Session Unaffected

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

Droid Cleanup

# 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/null

Crush (Charm) Tests

These 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.

Crush Provider Setup

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-20251001

C1: Crush Basic Response

Verifies: 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

C2: Crush Session Continuation

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

C3: Crush Tool Use (Read)

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), then lineage=continuation (after tool result returned) — confirms the multi-turn tool loop worked
  • msgs= on the second log entry shows tool_use and tool_result in 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.


C3b: Crush Tool Use (Write)

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.txt

Pass criteria:

  • File exists on disk with correct content
  • Proxy log shows multi-turn: tool_use then tool_result then final text

C4: Crush Model Routing

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=haiku

Pass 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)

C5: Crush Backward Compat

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=haiku for Crush, normal models for others
  • OpenCode session c5-oc-001 is tracked independently (header-based)
  • Droid and Crush both use fingerprint-based tracking independently

Cline Tests

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.

Cline Setup

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_result

CL1: Cline Basic Response

Verifies: 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_result

Pass criteria:

  • Output includes CLINE_E2E_OK
  • Proxy log: model=haiku stream=true tools=11 lineage=new
  • No authentication errors

CL2: Cline File Read

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.txt

Pass criteria:

  • Output includes CLINE_CANARY_123
  • Proxy log shows multi-turn: lineage=continuation with tool_usetool_result in message chain

CL3: Cline File Write

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.txt

Pass criteria:

  • File exists on disk with correct content
  • Proxy log shows tool_use → tool_result continuation

CL4: Cline Bash Execution

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_result

Pass criteria:

  • Output includes CLINE_BASH_OK

CL5: Cline File Edit

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.js

Pass criteria:

  • File on disk shows a + b (not a - b)
  • Proxy log shows read → edit tool chain

CL6: Cline Session Continuation

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_result

Pass criteria:

  • Turn 2 output includes CLINE_RECALL_55
  • Proxy log Turn 2: lineage=continuation session=<id>

CL7: Cline Model Routing

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=haiku

Pass criteria:

  • claude-sonnet-4-6model=sonnet[1m]
  • claude-opus-4-6model=opus[1m]
  • claude-haiku-4-5-20251001model=haiku

CL8: Cline Multi-Agent Coexistence

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

File Change Visibility Tests

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"

FC1: File Changes Write (non-stream)

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.txt

Pass criteria:

  • File /tmp/e2e-fc-write.txt exists on disk with content FILECHANGE_OK
  • Response text includes Files changed: followed by - wrote /tmp/e2e-fc-write.txt
  • "type": "message" in response (valid Anthropic format)

FC2: File Changes Write (stream)

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.txt

Pass criteria:

  • File exists on disk with STREAMFC_OK
  • SSE stream contains a text_delta event with Files changed:\n- wrote /tmp/e2e-fc-stream.txt
  • The file change block comes BEFORE message_stop in the event stream
  • Block index is monotonically increasing (no index collision)

FC3: File Changes Edit

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.js

Pass 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 be edited

FC4: File Changes Read-only (no summary)

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.txt

Pass 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

FC5: File Changes Multiple ops

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.txt

Pass criteria:

  • Both files modified on disk
  • Summary includes both: - wrote /tmp/e2e-fc-multi-a.txt and - edited /tmp/e2e-fc-multi-b.txt
  • Deduplication works — each path+operation listed once even if the model called the tool multiple times

FC6: File Changes Multiple ops (stream)

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.txt

Pass criteria:

  • Both files exist on disk with correct content
  • A text_delta event contains Files 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)

FC Cleanup

kill $(lsof -ti :3457) 2>/dev/null
rm -f /tmp/proxy-fc-e2e.log

E31: Passthrough — thinking blocks stripped, Turn 2 prose suppressed

Verifies 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).

Setup

# 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.js

Non-streaming: no thinking, no Turn 2 prose

curl -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 = false
  • has_prose_about_forwarding = false
  • tool_use_name = "edit"
  • tool_input_keys contains ["filePath","oldString","newString"]

Streaming: no thinking_delta events forwarded

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.js

Pass criteria:

  • grep '"thinking"' returns no output
  • A content_block_start with "type":"tool_use" and "name":"edit" is present
  • The stream ends with event: message_stop

Cleanup

kill $(lsof -ti :3457) 2>/dev/null

Profile Tests

Prerequisites: 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).

Setup

# 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')"

P1: Profile List & Auth Status

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
  • activeProfile field present

P2: Profile Switch via API

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>"}
  • /health email matches the switched profile

P3: Profile Persistence Across Restart

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.json has the switched profile ID
  • After restart, /profiles/list shows same active profile
  • /health shows the correct email

P4: Profile Request Routing

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

P5: Profile Per-Request Header Override

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

P6: Profile Session Isolation

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=new in proxy log, NOT lineage=continuation

P7: Profile Invalid Profile Rejection

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.json

Pass criteria:

  • Switch to nonexistent profile returns error response (not 200)
  • Proxy starts healthy with invalid settings.json; falls back to first profile

P8: Profile Settings Persistence

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.json contains the switched profile ID
  • meridian profile list output includes the profile

P9: Profile Health Reflects Active

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

P10: Profile Telemetry Records After Switch

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

E32: Tool-use leak (#416) — opencode + opus-4-7

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.

Setup

# Proxy must be running on port 3456 with personal/working profile auth
curl -s http://127.0.0.1:3456/health | jq .auth.loggedIn   # → true

Reproduce the original symptom shape

Send 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 Result count = 0
  • H: line prefix count = 0
  • Human: line prefix count = 0
  • Assistant: line prefix count = 0
  • Response text is the actual answer (e.g. ["A", "B", "C"]), not a flattened transcript

Aggressive variant — long history with multiple tool_use turns

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"
done

Pass criteria: all five leak counts = 0; the answer is a numbered list of the 4 tasks with their actual statuses (completed / in_progress / pending).

Cleanup

rm -f /tmp/e2e-416-body.json /tmp/e2e-416-aggressive.json

Why this isn't fully covered by unit tests

The 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.


E33: OpenAI Compat: system prompt, no preset

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.tool

Pass criteria:

  • Response reflects the client system prompt (e.g. "I am Aristotle, a philosophy tutor.")
  • Proxy log shows adapter=openai for the inner hop (not adapter=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).

E34: Streaming parallel tool calls (#552)

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.

E35: SDK boundary assumptions (#694/#708/#710)

Automated — one command:

bun scripts/e2e-sdk-boundary.mjs
SDK_BOUNDARY_MODEL=claude-sonnet-5 bun scripts/e2e-sdk-boundary.mjs

Why 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 resetsAt in 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.
  • #710thinking blocks 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_code preset 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 / overageResetsAt lands 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.

E36: Client detection after an upgrade (#733)

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 fixture

Why 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.

E37: WebFetch preflight scope (#748)

Automated, and costs no tokensclaude 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.mjs

Why 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:falseskipWebFetchPreflight:true in argv
  • cherry unset → 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.json is 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.

E38: Silent turns (#768)

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                                  # passes

Why 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_stop per turn
  • any error event precedes message_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.


E39: OpenCode internal-agent session key (#845)

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 request

Pass criteria:

  • No session_turn_conflict anywhere in the log, and no "session advanced while the request was waiting" in any turn's JSON output
  • The first agent=primary request shows sessionWait=0ms — it does not queue behind the agent=subagent title request
  • The title request appears with agent=subagent and a session key of its own
  • Every request after the first carries lineage=continuation with a non-zero cache_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.


E40: Passthrough digest-turn cap

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.mjs

Pass 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, not error_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.

E41: Passthrough multi-turn: one call, one answer

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 --stream

Pass 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 real tool_result for 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().