diff --git a/docs/superpowers/plans/2026-09-04-opencode-go-session-header.md b/docs/superpowers/plans/2026-09-04-opencode-go-session-header.md new file mode 100644 index 00000000..546de027 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-opencode-go-session-header.md @@ -0,0 +1,261 @@ +# OpenCode Go Session Header Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Send a stable, conversation-scoped `x-opencode-session` header on every OpenCode Go request and install the patched CLI locally. + +**Architecture:** Add a dedicated `OpenCodeGoExecutor` extending `DefaultExecutor`. `chatCore` passes the provider-scoped session resolved from the original request plus the detected client tool; the executor derives a request-local upstream session and delegates all existing transport, authentication, retry, and proxy behavior to `DefaultExecutor`. + +**Tech Stack:** Node.js ESM, Vitest, Next.js, npm CLI packaging, GitHub CLI. + +## Global Constraints + +- Apply the header to OpenCode Go chat completions, Claude Messages, and OpenAI Responses transports. +- Preserve a valid native `x-opencode-session`; hash all translated non-OpenCode identities to `ses_<32 lowercase hex>`. +- Namespace translated identities by detected client tool, using `generic` when unknown. +- Do not keep mutable per-request session state on the executor singleton or mutate the caller's credentials object. +- Do not change OpenCode Go models, routing, reasoning, tool behavior, dependencies, or unrelated providers. +- Reuse upstream issue #3759 instead of creating a duplicate issue. + +--- + +### Task 1: Add Failing OpenCode Go Session Tests + +**Files:** +- Create: `tests/unit/opencode-go-session.test.js` + +**Interfaces:** +- Consumes: `getExecutor(provider)` and `DefaultExecutor.buildHeaders(credentials, stream, url, model)`. +- Produces: the required public behavior for `OpenCodeGoExecutor.prepareRequestCredentials({ body, credentials, providerSessionId, clientTool })` and `OpenCodeGoExecutor.execute(args)`. + +- [ ] **Step 1: Write the failing tests** + +Create a Vitest suite that mocks `proxyAwareFetch`, obtains `getExecutor("opencode-go")`, and asserts: + +```js +const prepared = executor.prepareRequestCredentials({ + body: { messages: [{ role: "user", content: "hello" }] }, + credentials: { apiKey: "test-key", connectionId: "conn-a", rawHeaders: {} }, + providerSessionId: "conversation-a", + clientTool: "claude", +}); + +expect(prepared).not.toBe(credentials); +expect(prepared._opencodeGoSession).toMatch(/^ses_[0-9a-f]{32}$/); +expect(credentials).not.toHaveProperty("_opencodeGoSession"); +``` + +Cover native header preservation, stable values across all three runtime transports, different conversation IDs, different client tools using the same ID, connection fallback, no singleton state, no header on `DefaultExecutor("openai")`, and the final fetch headers returned by `execute()`. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +npx vitest run --config tests/vitest.config.js tests/unit/opencode-go-session.test.js +``` + +Expected: FAIL because `getExecutor("opencode-go")` still returns `DefaultExecutor` and `prepareRequestCredentials` does not exist. + +- [ ] **Step 3: Commit the failing test** + +```bash +git add tests/unit/opencode-go-session.test.js +git commit -m "test: cover OpenCode Go session headers" +``` + +### Task 2: Implement the Dedicated Executor + +**Files:** +- Create: `open-sse/executors/opencode-go.js` +- Modify: `open-sse/executors/index.js` + +**Interfaces:** +- Consumes: `DefaultExecutor`, `resolveSessionId()`, request `credentials.rawHeaders`, `providerSessionId`, and `clientTool`. +- Produces: `OpenCodeGoExecutor`, `prepareRequestCredentials()`, and an `execute()` override that delegates with cloned credentials. + +- [ ] **Step 1: Add the minimal executor implementation** + +Implement these rules: + +```js +function translatedSessionId(sessionId, clientTool) { + const digest = crypto + .createHash("sha256") + .update(`opencode-go\0${clientTool || "generic"}\0${sessionId}`) + .digest("hex") + .slice(0, 32); + return `ses_${digest}`; +} +``` + +`prepareRequestCredentials()` must read a case-insensitive native +`x-opencode-session` with the same non-empty, 256-character cap used by the +session manager. Otherwise it uses `providerSessionId` or calls +`resolveSessionId({ headers, body, connectionId, scope: "opencode-go" })`, then +returns `{ ...credentials, _opencodeGoSession: value }`. + +`execute(args)` must call `prepareRequestCredentials(args)` and delegate using +`super.execute({ ...args, credentials: prepared })`. `buildHeaders()` must call +`super.buildHeaders()` and add the prepared session, with a connection-scoped +fallback for direct callers. + +Register `new OpenCodeGoExecutor()` under `"opencode-go"` and export the class. + +- [ ] **Step 2: Run the focused test and verify partial GREEN** + +Run: + +```bash +npx vitest run --config tests/vitest.config.js tests/unit/opencode-go-session.test.js +``` + +Expected: executor-level tests pass; any chatCore-context assertion remains failing until Task 3. + +- [ ] **Step 3: Commit the executor** + +```bash +git add open-sse/executors/opencode-go.js open-sse/executors/index.js tests/unit/opencode-go-session.test.js +git commit -m "fix(opencode-go): add stable session header executor" +``` + +### Task 3: Pass Original Request Session Context + +**Files:** +- Modify: `open-sse/handlers/chatCore.js` +- Modify: `tests/unit/opencode-go-session.test.js` + +**Interfaces:** +- Consumes: existing `sessionSeed` and `clientTool` variables in `handleChatCore()`. +- Produces: `providerSessionId` and `clientTool` fields on both initial and refreshed-credential calls to `executor.execute()`. + +- [ ] **Step 1: Add or enable the failing integration assertion** + +Use a mocked executor or source request containing a body-only `session_id` and +assert the executor receives the provider-scoped session resolved before +translation. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +npx vitest run --config tests/vitest.config.js tests/unit/opencode-go-session.test.js +``` + +Expected: FAIL because `handleChatCore()` does not pass `providerSessionId` or +`clientTool` to `executor.execute()`. + +- [ ] **Step 3: Pass the request context** + +Add the same fields to both executor calls: + +```js +executor.execute({ + model, + body: translatedBody, + stream, + credentials, + providerSessionId: sessionSeed, + clientTool, + signal: streamController.signal, + log, + proxyOptions, +}); +``` + +- [ ] **Step 4: Run focused and neighboring tests** + +Run: + +```bash +npx vitest run --config tests/vitest.config.js \ + tests/unit/opencode-go-session.test.js \ + tests/unit/opencode-go-models.test.js \ + tests/unit/session-manager.test.js \ + tests/unit/executor-const-guard.test.js +``` + +Expected: PASS with zero failed tests. + +- [ ] **Step 5: Commit the context wiring** + +```bash +git add open-sse/handlers/chatCore.js tests/unit/opencode-go-session.test.js +git commit -m "fix(chat): forward provider session context" +``` + +### Task 4: Verify and Install the Local CLI Package + +**Files:** +- Generated: `9router-0.5.65.tgz` +- Packaged output: `cli/app/server.js` + +**Interfaces:** +- Consumes: completed source changes and existing CLI build scripts. +- Produces: a globally installed patched `9router@0.5.65`. + +- [ ] **Step 1: Run source verification** + +```bash +git diff --check origin/master...HEAD +npx vitest run --config tests/vitest.config.js tests/unit/ +npm run build +``` + +Expected: every command exits zero. Record any pre-existing full-suite failures +separately rather than hiding them. + +- [ ] **Step 2: Build and package the CLI** + +```bash +npm --prefix cli run build +npm --prefix cli pack -- --pack-destination .. +``` + +Expected: `9router-0.5.65.tgz` exists and contains the patched bundled server. + +- [ ] **Step 3: Replace the global npm installation** + +```bash +npm install -g ./9router-0.5.65.tgz +``` + +Expected: `/opt/homebrew/lib/node_modules/9router/package.json` reports `0.5.65` +and the installed bundle contains `x-opencode-session` plus the new executor. + +- [ ] **Step 4: Commit any required package-source adjustment** + +Do not commit generated tarballs or CLI build artifacts unless the repository +already tracks and requires them. + +### Task 5: Publish the Upstream Pull Request + +**Files:** +- No additional source files unless verification finds a required correction. + +**Interfaces:** +- Consumes: verified branch commits and GitHub issue #3759. +- Produces: a fork branch and a PR against `decolua/9router:master`. + +- [ ] **Step 1: Create or repair the GitHub fork remote** + +Use `gh repo fork decolua/9router --remote` if the current `fork` remote remains +missing, then push `fix/opencode-go-session-header`. + +- [ ] **Step 2: Create the PR** + +Use title: + +```text +fix(opencode-go): send stable session header +``` + +The body must include the root cause, downstream-session translation policy, +three covered transports, concurrency behavior, verification evidence, +`Fixes #3759`, and a note that this PR is intentionally narrower than #3780. + +- [ ] **Step 3: Verify the published PR** + +Run `gh pr view --json number,title,state,url,headRefName,baseRefName` and report +the issue and PR URLs. diff --git a/docs/superpowers/specs/2026-09-04-opencode-go-session-header-design.md b/docs/superpowers/specs/2026-09-04-opencode-go-session-header-design.md new file mode 100644 index 00000000..94daf644 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-opencode-go-session-header-design.md @@ -0,0 +1,114 @@ +# OpenCode Go Session Header Design + +## Problem + +OpenCode Go will begin rejecting some requests without an +`x-opencode-session` header on September 6, 2026. In 9Router v0.5.65, +`opencode-go` uses `DefaultExecutor`, whose generic header builder does not add +that header. The specialized OpenCode Free executor already sends it, but that +logic does not apply to the paid OpenCode Go provider or its three transports. + +## Goals + +- Add `x-opencode-session` to every OpenCode Go chat, Claude Messages, and + OpenAI Responses request. +- Translate a downstream conversation identity into a stable upstream identity. +- Keep identities isolated across different downstream agents and conversations. +- Avoid exposing non-OpenCode downstream session identifiers to OpenCode Go. +- Avoid mutable session state on the shared executor singleton. +- Leave OpenCode Free and all unrelated providers unchanged. + +## Non-Goals + +- Inferring an exact conversation boundary when a downstream client provides no + session or conversation identifier. +- Adding or changing OpenCode Go models, routing, reasoning, or tool behavior. +- Changing the general session-resolution policy for other providers. + +## Architecture + +Add a dedicated `OpenCodeGoExecutor` extending `DefaultExecutor`. The executor +keeps the existing generic URL, authentication, translation, retry, and proxy +behavior, and overrides only the OpenCode Go session-header concern. + +`handleChatCore` already resolves a provider-scoped session from the original +request before translation. It will pass that value and the detected client +tool to `executor.execute()` as request context. `OpenCodeGoExecutor.execute()` +will create a shallow request-local credentials object containing the resolved +OpenCode Go session. It will then delegate to `DefaultExecutor.execute()`. +This avoids storing request state on the executor singleton or mutating shared +provider credentials. + +## Session Resolution + +The original downstream request remains the source of truth. Existing +`resolveSessionId()` behavior recognizes Claude Code, Antigravity, generic +session headers, and common body fields before request translation can discard +them. + +Resolution rules: + +1. If the downstream request supplies `x-opencode-session`, treat it as an + authoritative OpenCode identity after trimming and length validation. +2. Otherwise use the provider-scoped session resolved from the original request. +3. Namespace the resolved value with the detected downstream agent, falling back + to `generic` when the agent is unknown. +4. Convert the namespaced value to an opaque deterministic identifier: + `ses_` plus the first 32 hexadecimal characters of SHA-256. +5. If no explicit downstream identity exists, the existing provider connection + fallback guarantees that a header is still sent. It is stable but cannot + distinguish multiple conversations sharing that connection. + +The same input conversation produces the same upstream identifier for all three +OpenCode Go transports. Different agents using the same raw session value +produce different identifiers. + +## Header Injection + +`OpenCodeGoExecutor.buildHeaders()` delegates to +`DefaultExecutor.buildHeaders()` and adds only: + +```text +x-opencode-session: +``` + +The implementation applies to: + +- `https://opencode.ai/zen/go/v1/chat/completions` +- `https://opencode.ai/zen/go/v1/messages` +- `https://opencode.ai/zen/go/v1/responses` + +## Error Handling + +Session derivation must not make requests fail. Invalid or oversized native +header values are ignored and the normal resolved-session fallback is used. +Hashing uses Node's built-in `crypto` module and requires no new dependency. + +## Testing + +Add a focused unit suite that proves: + +- all three OpenCode Go transports receive the header; +- the same conversation remains stable across requests and transports; +- different conversations produce different values; +- different agents using the same raw ID remain isolated; +- non-OpenCode session IDs are represented as opaque `ses_<32 hex>` values; +- a valid native `x-opencode-session` remains stable; +- headerless requests still receive a stable fallback; +- OpenCode Free behavior is unchanged; +- unrelated `DefaultExecutor` providers do not receive the header; +- no request state is retained on the shared executor instance. + +Run the focused unit tests first, then the neighboring executor/session tests, +the full offline test suite, the application build, and the CLI package build. + +## Delivery + +Build the CLI with `npm --prefix cli run build`, create a package with +`npm --prefix cli pack`, and install the generated tarball globally to replace +the current npm-installed `9router@0.5.65`. Verify the installed package version +and packaged source contains the new executor. + +Upstream issue #3759 already tracks the problem, so no duplicate issue will be +created. The pull request will be narrowly scoped to this fix, reference +`Fixes #3759`, and explain how it differs from the broader open PR #3780. diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index bd96ab49..8dd03421 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -10,6 +10,7 @@ import { CodexExecutor } from "./codex.js"; import { CursorExecutor } from "./cursor.js"; import { VertexExecutor } from "./vertex.js"; import { OpenCodeExecutor } from "./opencode.js"; +import { OpenCodeGoExecutor } from "./opencode-go.js"; import { GrokWebExecutor } from "./grok-web.js"; import { GrokCliExecutor } from "./grok-cli.js"; import { PerplexityWebExecutor } from "./perplexity-web.js"; @@ -40,6 +41,7 @@ const executors = { vertex: new VertexExecutor("vertex"), "vertex-partner": new VertexExecutor("vertex-partner"), opencode: new OpenCodeExecutor(), + "opencode-go": new OpenCodeGoExecutor(), "grok-web": new GrokWebExecutor(), "grok-cli": new GrokCliExecutor(), gcli: new GrokCliExecutor(), // Alias @@ -84,6 +86,7 @@ export { CursorExecutor } from "./cursor.js"; export { VertexExecutor } from "./vertex.js"; export { DefaultExecutor } from "./default.js"; export { OpenCodeExecutor } from "./opencode.js"; +export { OpenCodeGoExecutor } from "./opencode-go.js"; export { GrokWebExecutor } from "./grok-web.js"; export { GrokCliExecutor } from "./grok-cli.js"; export { PerplexityWebExecutor } from "./perplexity-web.js"; diff --git a/open-sse/executors/opencode-go.js b/open-sse/executors/opencode-go.js new file mode 100644 index 00000000..a4dc4bfa --- /dev/null +++ b/open-sse/executors/opencode-go.js @@ -0,0 +1,71 @@ +import crypto from "node:crypto"; +import { DefaultExecutor } from "./default.js"; +import { resolveSessionId } from "../utils/sessionManager.js"; + +const SESSION_HEADER = "x-opencode-session"; +const SESSION_FIELD = "_opencodeGoSession"; +const MAX_SESSION_LENGTH = 256; + +function normalizeSession(value) { + if (typeof value !== "string") return null; + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_SESSION_LENGTH) return null; + return normalized; +} + +function nativeSession(headers) { + if (!headers || typeof headers !== "object") return null; + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === SESSION_HEADER) return normalizeSession(value); + } + return null; +} + +function translatedSession(sessionId, clientTool) { + const digest = crypto + .createHash("sha256") + .update(`opencode-go\0${clientTool || "generic"}\0${sessionId}`) + .digest("hex") + .slice(0, 32); + return `ses_${digest}`; +} + +export class OpenCodeGoExecutor extends DefaultExecutor { + constructor() { + super("opencode-go"); + } + + prepareRequestCredentials({ body, credentials, providerSessionId, clientTool } = {}) { + const sourceCredentials = credentials || {}; + const native = nativeSession(sourceCredentials.rawHeaders); + const resolved = normalizeSession(providerSessionId) || resolveSessionId({ + headers: sourceCredentials.rawHeaders, + body, + connectionId: sourceCredentials.connectionId, + scope: "opencode-go", + }); + + return { + ...sourceCredentials, + [SESSION_FIELD]: native || translatedSession(resolved, clientTool), + }; + } + + async execute(args) { + const credentials = this.prepareRequestCredentials(args); + return super.execute({ ...args, credentials }); + } + + buildHeaders(credentials, stream = true, url, model) { + const headers = super.buildHeaders(credentials || {}, stream, url, model); + const prepared = credentials?.[SESSION_FIELD]; + if (prepared) { + headers[SESSION_HEADER] = prepared; + return headers; + } + + const fallback = this.prepareRequestCredentials({ credentials }); + headers[SESSION_HEADER] = fallback[SESSION_FIELD]; + return headers; + } +} diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 3c083978..d5dcf2d8 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -356,7 +356,17 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // exception: it is decoded by the executor into OpenAI-compatible output. let providerResponseFormat = targetFormat; try { - const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); + const result = await executor.execute({ + model, + body: translatedBody, + stream, + credentials, + providerSessionId: sessionSeed, + clientTool, + signal: streamController.signal, + log, + proxyOptions, + }); providerResponse = result.response; providerUrl = result.url; providerHeaders = result.headers; @@ -410,7 +420,17 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); } } try { - const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); + const retryResult = await executor.execute({ + model, + body: translatedBody, + stream, + credentials, + providerSessionId: sessionSeed, + clientTool, + signal: streamController.signal, + log, + proxyOptions, + }); if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; diff --git a/tests/unit/opencode-go-session.test.js b/tests/unit/opencode-go-session.test.js new file mode 100644 index 00000000..2d0ec560 --- /dev/null +++ b/tests/unit/opencode-go-session.test.js @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const { fetchMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), +})); + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: fetchMock, +})); + +import { DefaultExecutor } from "../../open-sse/executors/default.js"; +import { getExecutor } from "../../open-sse/executors/index.js"; + +const TRANSPORTS = [ + { format: "openai", baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" } }, + { format: "claude", baseUrl: "https://opencode.ai/zen/go/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } }, + { format: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } }, +]; + +function makeCredentials(overrides = {}) { + return { + apiKey: "test-key", + connectionId: "connection-a", + rawHeaders: {}, + runtimeTransport: TRANSPORTS[0], + ...overrides, + }; +} + +function prepare(executor, overrides = {}) { + const credentials = overrides.credentials || makeCredentials(); + const prepared = executor.prepareRequestCredentials({ + body: overrides.body || { messages: [{ role: "user", content: "hello" }] }, + credentials, + providerSessionId: overrides.providerSessionId ?? "conversation-a", + clientTool: overrides.clientTool ?? "claude", + }); + return { credentials, prepared }; +} + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + })); +}); + +describe("OpenCode Go x-opencode-session", () => { + it("uses a dedicated executor with request-local session credentials", () => { + const executor = getExecutor("opencode-go"); + const { credentials, prepared } = prepare(executor); + + expect(executor.constructor.name).toBe("OpenCodeGoExecutor"); + expect(prepared).not.toBe(credentials); + expect(prepared._opencodeGoSession).toMatch(/^ses_[0-9a-f]{32}$/); + expect(credentials).not.toHaveProperty("_opencodeGoSession"); + expect(executor).not.toHaveProperty("_currentSessionId"); + expect(executor).not.toHaveProperty("_opencodeGoSession"); + }); + + it("preserves a valid native session header case-insensitively", () => { + const executor = getExecutor("opencode-go"); + const { prepared } = prepare(executor, { + credentials: makeCredentials({ rawHeaders: { "X-OpenCode-Session": " native-session-a " } }), + }); + + expect(prepared._opencodeGoSession).toBe("native-session-a"); + }); + + it("ignores an oversized native session and uses the translated identity", () => { + const executor = getExecutor("opencode-go"); + const { prepared } = prepare(executor, { + credentials: makeCredentials({ rawHeaders: { "x-opencode-session": "x".repeat(257) } }), + }); + + expect(prepared._opencodeGoSession).toMatch(/^ses_[0-9a-f]{32}$/); + }); + + it("keeps the same translated conversation stable across all transports", () => { + const executor = getExecutor("opencode-go"); + const values = TRANSPORTS.map((runtimeTransport) => { + const { prepared } = prepare(executor, { + credentials: makeCredentials({ runtimeTransport }), + }); + return executor.buildHeaders(prepared, true)["x-opencode-session"]; + }); + + expect(new Set(values).size).toBe(1); + expect(values[0]).toMatch(/^ses_[0-9a-f]{32}$/); + expect(values[0]).not.toContain("conversation-a"); + }); + + it("isolates different conversations", () => { + const executor = getExecutor("opencode-go"); + const a = prepare(executor, { providerSessionId: "conversation-a" }).prepared._opencodeGoSession; + const b = prepare(executor, { providerSessionId: "conversation-b" }).prepared._opencodeGoSession; + + expect(a).not.toBe(b); + }); + + it("isolates different downstream agents that reuse the same raw id", () => { + const executor = getExecutor("opencode-go"); + const claude = prepare(executor, { clientTool: "claude" }).prepared._opencodeGoSession; + const codex = prepare(executor, { clientTool: "codex" }).prepared._opencodeGoSession; + + expect(claude).not.toBe(codex); + }); + + it("uses a stable opaque connection fallback when no session is supplied", () => { + const executor = getExecutor("opencode-go"); + const options = { + credentials: makeCredentials({ connectionId: "fallback-connection" }), + providerSessionId: null, + clientTool: null, + body: { messages: [{ role: "user", content: "headerless" }] }, + }; + const first = prepare(executor, options).prepared._opencodeGoSession; + const second = prepare(executor, options).prepared._opencodeGoSession; + + expect(first).toBe(second); + expect(first).toMatch(/^ses_[0-9a-f]{32}$/); + expect(first).not.toContain("fallback-connection"); + }); + + it("adds the prepared session to the actual fetch headers", async () => { + const executor = getExecutor("opencode-go"); + const credentials = makeCredentials(); + const result = await executor.execute({ + model: "glm-5.2", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: false, + credentials, + providerSessionId: "conversation-fetch", + clientTool: "codex", + }); + + expect(result.headers["x-opencode-session"]).toMatch(/^ses_[0-9a-f]{32}$/); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][1].headers["x-opencode-session"]).toBe(result.headers["x-opencode-session"]); + expect(credentials).not.toHaveProperty("_opencodeGoSession"); + }); + + it("does not add the header to unrelated default executors", () => { + const headers = new DefaultExecutor("openai").buildHeaders({ apiKey: "test-key" }, false); + expect(headers["x-opencode-session"]).toBeUndefined(); + }); +}); + +describe("chatCore provider session forwarding", () => { + it("passes the original provider session and client tool on initial and retry execution", () => { + const source = readFileSync( + fileURLToPath(new URL("../../open-sse/handlers/chatCore.js", import.meta.url)), + "utf8", + ); + const calls = [...source.matchAll(/executor\.execute\(\{([\s\S]*?)\}\)/g)].map((match) => match[1]); + + expect(calls).toHaveLength(2); + for (const call of calls) { + expect(call).toMatch(/providerSessionId:\s*sessionSeed/); + expect(call).toMatch(/\bclientTool\b/); + } + }); +});