fix(opencode-go): send stable session header (#3800)
- add a dedicated OpenCode Go executor that always sends x-opencode-session - preserve a valid caller-provided native OpenCode session header - translate downstream Agent session IDs into opaque, stable, Agent-scoped IDs - forward the original provider session seed and client tool on both initial and credential-refresh requests
This commit is contained in:
261
docs/superpowers/plans/2026-09-04-opencode-go-session-header.md
Normal file
261
docs/superpowers/plans/2026-09-04-opencode-go-session-header.md
Normal file
@@ -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.
|
||||
@@ -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: <stable-session-id>
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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";
|
||||
|
||||
71
open-sse/executors/opencode-go.js
Normal file
71
open-sse/executors/opencode-go.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
166
tests/unit/opencode-go-session.test.js
Normal file
166
tests/unit/opencode-go-session.test.js
Normal file
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user