Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 139 additions & 12 deletions src/__tests__/plugin-agent-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,74 @@
* agent silently mapped to "primary" sends subagent traffic out at the
* primary 1M tier, burning rate-limit budget and (field-observed) tripping
* Anthropic's extra-usage metering on fresh subagent sessions.
*
* `headersFor` invokes the hook with OpenCode's real empty output object, then
* composes the final wire headers from OpenCode's base headers, model headers,
* and plugin output. It used to return only the plugin contribution, which made
* class of change look verified when it was a no-op: a header the plugin stops
* setting is still on the wire if OpenCode set it, and Meridian reads the wire.
*
* Extracted from the OpenCode 1.18.11 binary (bun-compiled; visible via
* `strings`), for any provider whose id does not start with "opencode" — which
* is every request that reaches Meridian:
*
* headers: {
* ...(providerID.startsWith("opencode")
* ? { "x-opencode-session": sessionID, "x-opencode-request": user.id, ... }
* : { "x-session-affinity": sessionID, "X-Session-Id": sessionID,
* ...(parentSessionID ? {"x-parent-session-id": parentSessionID} : {}),
* "User-Agent": Ri }),
* ...model.headers,
* ...f // f = this plugin's chat.headers output — spread LAST
* }
*
* Two facts follow, and both are load-bearing for anything that touches
* session identity:
*
* 1. OpenCode ALWAYS contributes `x-session-affinity` (and `X-Session-Id`)
* on this path. A plugin cannot remove that key by declining to set one.
* 2. Plugin headers are spread LAST, so a plugin CAN override it.
*/
import { describe, it, expect, test } from "bun:test"
import { Hono } from "hono"
import MeridianPlugin from "../../plugin/meridian"
import { PRIORITY_ATTESTATION_HEADER } from "../../plugin/priority-attestation"
import { openCodeAdapter } from "../proxy/adapters/opencode"
import { verifyPriorityAttestation } from "../proxy/priorityAttestation"

type Hooks = Awaited<ReturnType<typeof MeridianPlugin>>
type ChatHeadersHook = NonNullable<Hooks["chat.headers"]>
type AgentInputForTest = Parameters<ChatHeadersHook>[0]["agent"]
type ChatHeadersOutput = Parameters<ChatHeadersHook>[1]

async function instance(cfgAgents?: Record<string, { mode?: string; hidden?: boolean }>): Promise<Hooks> {
const hooks = await MeridianPlugin({})
if (cfgAgents) await hooks.config?.({ agent: cfgAgents })
return hooks
}

async function headersFor(
/** The native headers OpenCode places on the final request before plugin output. */
function openCodeBaseHeaders(sessionID: string): Record<string, string> {
return {
"x-session-affinity": sessionID,
"X-Session-Id": sessionID,
"User-Agent": "opencode/1.18.11 ai-sdk/provider-utils/4.0.27 runtime/bun/1.3.14",
}
}

async function pluginHeadersFor(
hooks: Hooks,
agent: unknown,
agent: AgentInputForTest,
providerID = "anthropic",
sessionID = "ses_test",
): Promise<Record<string, string>> {
const output = { headers: {} as Record<string, string> }
await hooks["chat.headers"]!(
const output: ChatHeadersOutput = { headers: {} }
const hook = hooks["chat.headers"]
if (!hook) throw new Error("chat.headers hook was not registered")
await hook(
{
sessionID: "ses_test",
agent: agent as any,
sessionID,
agent,
model: { providerID },
message: { id: "msg_test", time: { created: Date.now() } },
},
Expand All @@ -39,6 +83,29 @@ async function headersFor(
return output.headers
}

async function headersFor(
hooks: Hooks,
agent: AgentInputForTest,
providerID = "anthropic",
sessionID = "ses_test",
modelHeaders: Record<string, string> = {},
): Promise<Record<string, string>> {
const pluginHeaders = await pluginHeadersFor(hooks, agent, providerID, sessionID)
return { ...openCodeBaseHeaders(sessionID), ...modelHeaders, ...pluginHeaders }
}

/** Resolve a header bag through a real Hono request, the way the proxy does. */
async function sessionKeyFor(headers: Record<string, string>): Promise<string | undefined> {
let sessionKey: string | undefined
const app = new Hono()
app.get("/", (context) => {
sessionKey = openCodeAdapter.getSessionId(context)
return context.body(null)
})
await app.request("http://localhost/", { headers: new Headers(headers) })
return sessionKey
}

describe("plugin/meridian.ts agent-mode header", () => {
it("legacy object agent: reads mode directly", async () => {
const hooks = await instance()
Expand Down Expand Up @@ -81,17 +148,31 @@ describe("plugin/meridian.ts agent-mode header", () => {
expect(legacy["x-opencode-agent-mode"]).toBe("primary")
})

it("session and request headers are always set for anthropic requests", async () => {
it("composes native, model, and plugin headers in final wire order", async () => {
const hooks = await instance()
const h = await headersFor(hooks, "explore")
const h = await headersFor(hooks, "explore", "anthropic", "ses_test", {
"x-opencode-session": "model-spoof",
"x-opencode-request": "model-spoof",
"x-model-header": "present",
})
expect(h["x-session-affinity"]).toBe("ses_test")
expect(h["X-Session-Id"]).toBe("ses_test")
expect(h["User-Agent"]).toStartWith("opencode/1.18.11 ")
expect(h["x-model-header"]).toBe("present")
expect(h["x-opencode-session"]).toBe("ses_test")
expect(h["x-opencode-request"]).toBe("msg_test")
expect(h["x-opencode-agent-name"]).toBe("explore")
expect(h["x-opencode-agent-mode"]).toBe("subagent")
})

it("non-anthropic providers get no headers", async () => {
it("keeps plugin output empty for other providers while retaining native headers", async () => {
const hooks = await instance()
expect(await pluginHeadersFor(hooks, "title", "openrouter")).toEqual({})

const h = await headersFor(hooks, "title", "openrouter")
expect(Object.keys(h)).toHaveLength(0)
expect(h["x-session-affinity"]).toBe("ses_test")
expect(h["X-Session-Id"]).toBe("ses_test")
expect(h["User-Agent"]).toStartWith("opencode/1.18.11 ")
})

it("agent names are sanitized to printable ASCII", async () => {
Expand Down Expand Up @@ -120,6 +201,54 @@ describe("plugin/meridian.ts agent-mode header", () => {
await hooks.config?.({ agent: {} })
expect((await headersFor(hooks, "general"))["x-opencode-agent-mode"]).toBe("subagent")
})

/**
* The proposition PR #845 believed it had proved, stated as a test.
*
* It stopped the plugin setting `x-opencode-session` for title/summary,
* expecting that to detach those one-shots from the user's conversation. It
* does not: OpenCode's own `x-session-affinity` survives, and
* `openCodeAdapter.getSessionId` reads `x-opencode-session ?? x-session-affinity`.
* With the old harness returning only `output.headers`, it could not see
* this, so the change shipped green CI on a no-op.
*
* These assert on the key Meridian DERIVES, not on any single header, so they
* stay true through any future reshuffle of which header carries the id.
*/
describe("session identity as the proxy resolves it", () => {
it("retains a session key when x-opencode-session is absent", async () => {
const hooks = await instance()
const headers = await headersFor(hooks, "build")
delete headers["x-opencode-session"]
expect(await sessionKeyFor(headers)).toBe("ses_test")
})

it("uses native affinity as the base for agent scoping", async () => {
const hooks = await instance()
const title = await headersFor(hooks, "title")
const build = await headersFor(hooks, "build")
delete title["x-opencode-session"]
delete build["x-opencode-session"]

expect(title["x-session-affinity"]).toBe(build["x-session-affinity"])
expect(await sessionKeyFor(title)).toBe("ses_test#title")
expect(await sessionKeyFor(build)).toBe("ses_test")
})

it("resolves the user's turn and each internal one-shot to exact distinct keys", async () => {
const hooks = await instance()
const keys: Record<string, string | undefined> = {}
for (const agent of ["build", "title", "summary", "compaction"]) {
keys[agent] = await sessionKeyFor(await headersFor(hooks, agent))
}
expect(keys).toEqual({
build: "ses_test",
title: "ses_test#title",
summary: "ses_test#summary",
compaction: "ses_test#compaction",
})
})
})
})


Expand Down Expand Up @@ -158,8 +287,6 @@ describe("plugin/meridian.ts trusted routing attestation", () => {
return output.headers
}

type AgentInputForTest = string | { name?: string; mode?: string; hidden?: boolean }

test("signs only an exact visible primary root human message", async () => {
const saved = process.env.MERIDIAN_OPENCODE_ATTESTATION_KEY
process.env.MERIDIAN_OPENCODE_ATTESTATION_KEY = key.toString("base64url")
Expand Down
29 changes: 20 additions & 9 deletions src/__tests__/plugin-v2-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
import MeridianV2Plugin, {
applyMeridianV2Headers,
fallbackAgentTraits,
Expand All @@ -27,6 +28,17 @@ function coreHeaders(sessionID: string, parentID = "ses_parent"): Record<string,
}
}

async function sessionKeyFor(headers: Record<string, string>): Promise<string | undefined> {
let sessionKey: string | undefined
const app = new Hono()
app.get("/", (context) => {
sessionKey = openCodeAdapter.getSessionId(context)
return context.body(null)
})
await app.request("http://localhost/", { headers: new Headers(headers) })
return sessionKey
}

function rewrite(
agent: string,
traits: AgentTraits = fallbackAgentTraits(agent),
Expand All @@ -45,7 +57,7 @@ describe("plugin/meridian-v2.ts export", () => {

describe("plugin/meridian-v2.ts hidden parent-session one-shots", () => {
for (const agent of ["title", "summary"]) {
test(`${agent} is detached from the parent session`, () => {
test(`${agent} is detached from the parent session`, async () => {
const headers = rewrite(agent)

expect(headers["x-opencode-session"]).toBeUndefined()
Expand All @@ -55,6 +67,7 @@ describe("plugin/meridian-v2.ts hidden parent-session one-shots", () => {
expect(headers["x-meridian-source"]).toBe(`subagent-${agent}`)
expect(headers["x-opencode-agent-mode"]).toBe("subagent")
expect(headers["x-opencode-project"]).toBe("prj_1")
expect(await sessionKeyFor(headers)).toBeUndefined()
})
}

Expand Down Expand Up @@ -87,17 +100,13 @@ describe("plugin/meridian-v2.ts hidden parent-session one-shots", () => {
expect(headers["x-meridian-source"]).toBeUndefined()
})

test("compaction remains on the primary key but gets the subagent tier", () => {
test("compaction remains on the primary key but gets the subagent tier", async () => {
const headers = rewrite("compaction", { mode: "primary", hidden: false })

expect(headers["x-opencode-session"]).toBe("ses_abc")
expect(headers["x-meridian-source"]).toBe("subagent-compaction")
expect(headers["x-opencode-agent-mode"]).toBe("primary")

const sessionKey = Reflect.apply(openCodeAdapter.getSessionId, openCodeAdapter, [{
req: { header: (name: string) => headers[name.toLowerCase()] },
}])
expect(sessionKey).toBe("ses_abc")
expect(await sessionKeyFor(headers)).toBe("ses_abc")
})

test("exact beta built-ins fall back to their native primary traits", () => {
Expand All @@ -109,17 +118,18 @@ describe("plugin/meridian-v2.ts hidden parent-session one-shots", () => {

describe("plugin/meridian-v2.ts primary and subagent identity", () => {
for (const agent of ["build", "plan"]) {
test(`${agent} keeps the primary session`, () => {
test(`${agent} keeps the primary session`, async () => {
const headers = rewrite(agent)
expect(headers["x-opencode-session"]).toBe("ses_abc")
expect(headers["x-session-affinity"]).toBe("ses_abc")
expect(headers["x-session-id"]).toBe("ses_abc")
expect(headers["x-opencode-agent-mode"]).toBe("primary")
expect(await sessionKeyFor(headers)).toBe("ses_abc")
})
}

for (const agent of ["general", "explore", "code-reviewer"]) {
test(`${agent} keeps its own session as a subagent`, () => {
test(`${agent} keeps its own session as a subagent`, async () => {
const traits = agent === "code-reviewer"
? { mode: "subagent" as const, hidden: false }
: fallbackAgentTraits(agent)
Expand All @@ -129,6 +139,7 @@ describe("plugin/meridian-v2.ts primary and subagent identity", () => {
expect(headers["x-opencode-agent-name"]).toBe(agent)
expect(headers["x-opencode-agent-mode"]).toBe("subagent")
expect(headers["x-meridian-source"]).toBeUndefined()
expect(await sessionKeyFor(headers)).toBe(`ses_abc#${agent}`)
})
}

Expand Down
Loading