Stop claiming a session cannot delegate when it can, and run Ox Alpha locally

2a2be871da7f · AtlantisPleb · · parent afc81b5e4c40

Stop claiming a session cannot delegate when it can, and run Ox Alpha locally

Two things, both from the same session: a notice that said "This session
cannot delegate" directly above a model correctly answering that it could.

The model was right. `buildDelegation` prefers a free harness model over the
account's grant — a grant lives an hour, has to be minted, and expires under a
console that outlives it, while the harness's own catalog costs nothing. So a
refused child thread is the normal path, not a loss of capability: the
`delegate` tool was declared, children ran, and only the notice disagreed. It
now fires when there is really no way to run a child.

Verified by delegating one: the child ran and came back.

The refusal that triggered it was `ox-alpha` having no configured provider on
the deployment. Ox Alpha is free and unlimited on OpenCode Zen, and this machine
already holds a credential for it, so `--model opencode:ox-alpha` runs the
session there.

Zen is an OpenAI-compatible endpoint that takes tool calls, so this is the same
wire shape the inference proxy speaks and the same turn loop, with the session's
own tools and the session's own anchor. What it is deliberately not is
`opencode` the agent: opencode's server runs its own loop with its own tools,
and a session driven through that would be running opencode's tools rather than
this session's. Only the endpoint and the key are borrowed, and the key stays
where opencode put it — read from `OPENCODE_API_KEY` or opencode's own store,
never copied.

`ox-alpha` is the name it is known by and `x-preview-f-free` is the slug it
answers to; both work. No credential and no model name are each refused up
front with the command that fixes them.

Nothing on this lane spends a grant or opens a thread, so it keeps no
server-side transcript — the same trade the Ollama lane makes.

Verified end to end against the live endpoint: the session answers as Ox Alpha
and runs `shell` through it. 652 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TRDRrfL1khQhQtNr3SRrA
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-thread.ts
  • added packages/openagents-cli/src/coder-zen.ts
  • added packages/openagents-cli/test/coder-zen.test.ts

Diff

6 files changed, +556 -22

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2453,
7
    "filesScanned": 2454,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:ac35b91f0360afc7210b7c55d483e80298724af060859b7f702fba112d503937",
4
  "sourceDigest": "sha256:3fdcfedaf7211556caf02d4b9566d017ffbe1b92e761595b065e85d47b5576e2",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (53 tracked test files)"
1879
          "ref": "packages/openagents-cli (54 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +46 -15

@@ -85,6 +85,7 @@ import { resolve as resolvePath } from "node:path";

85 85
import { rebuild, RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
86 86
import { loadSkillSelection, standingContext } from "./coder-skills.js";
87 87
import { startDevServer } from "./coder-dev-server.js";
88
import { ZenReplySource, zenCredential } from "./coder-zen.js";
88 89
import { describeWorkspace } from "./coder-workspace.js";
89 90
import { ComputerClient } from "./computer-client.js";
90 91
import { ComputerUp } from "./computer-up.js";

@@ -1918,7 +1919,31 @@ const coderCommand = Command.make(

1918 1919
        });
1919 1920
      }
1920 1921
1922
      // `--model opencode:<slug>` answers from OpenCode Zen on the credential
1923
      // opencode already holds here. It is how a session runs on Ox Alpha,
1924
      // which is free there and which no deployment serves.
1925
      const wantsZen = named !== undefined && /^opencode:/.test(named);
1926
      const zenAsked = wantsZen && named !== undefined ? named.slice("opencode:".length) : undefined;
1927
1921 1928
      const wantsOllama = named === undefined ? localModel !== undefined : isOllamaModelFlag(named);
1929
      const zenKey = wantsZen ? zenCredential() : undefined;
1930
1931
      if (wantsZen && (zenAsked === undefined || zenAsked.length === 0)) {
1932
        return yield* new InputError({
1933
          message:
1934
            "`--model opencode:` is missing a model name. Use `opencode:<model>`, " +
1935
            "for example `opencode:ox-alpha`.",
1936
        });
1937
      }
1938
1939
      if (wantsZen && zenKey === undefined) {
1940
        return yield* new InputError({
1941
          message:
1942
            "No OpenCode Zen credential is available on this machine. Sign in with " +
1943
            "`opencode providers`, or set OPENCODE_API_KEY.",
1944
        });
1945
      }
1946
1922 1947
      const askedFor =
1923 1948
        named === undefined
1924 1949
          ? localModel

@@ -2037,7 +2062,7 @@ const coderCommand = Command.make(

2037 2062
      // A dev server and production do not serve the same list, and a client
2038 2063
      // that assumes one of them is wrong against the other.
2039 2064
      const served =
2040
        Option.isSome(stored) && !wantsOllama && !offline && resumed === undefined
2065
        Option.isSome(stored) && !wantsOllama && !wantsZen && !offline && resumed === undefined
2041 2066
          ? yield* Effect.promise(() =>
2042 2067
              fetchServedCatalog({
2043 2068
                origin: endpoint.origin,

@@ -2056,7 +2081,7 @@ const coderCommand = Command.make(

2056 2081
      // never overrules a server that has spoken. The flag takes a string
2057 2082
      // rather than an enum so an `ollama:` prefix can reach the local server,
2058 2083
      // which is why this is a check and not `Flag.choice`.
2059
      if (named !== undefined && !wantsOllama) {
2084
      if (named !== undefined && !wantsOllama && !wantsZen) {
2060 2085
        const refusal =
2061 2086
          served !== undefined
2062 2087
            ? refuseBackend(served, named)

@@ -2080,7 +2105,7 @@ const coderCommand = Command.make(

2080 2105
      const thread =
2081 2106
        resumed !== undefined
2082 2107
          ? resumed.source
2083
          : Option.isSome(stored) && !wantsOllama && !resume
2108
          : Option.isSome(stored) && !wantsOllama && !wantsZen && !resume
2084 2109
            ? yield* Effect.tryPromise({
2085 2110
                try: () =>
2086 2111
                  openThread({

@@ -2107,12 +2132,14 @@ const coderCommand = Command.make(

2107 2132
      // A `--model ollama:<name>` session answers from the local Ollama server,
2108 2133
      // so it takes neither a thread nor the stand-in.
2109 2134
      const source: ReplySource =
2110
        wantsOllama && ollamaName !== undefined
2111
          ? new OllamaReplySource({
2112
              model: ollamaName,
2113
              ...(Option.isSome(reasoning) ? { reasoning: reasoning.value } : {}),
2114
            })
2115
          : (thread ?? new DummyReplySource());
2135
        wantsZen && zenAsked !== undefined && zenKey !== undefined
2136
          ? new ZenReplySource({ model: zenAsked, key: zenKey })
2137
          : wantsOllama && ollamaName !== undefined
2138
            ? new OllamaReplySource({
2139
                model: ollamaName,
2140
                ...(Option.isSome(reasoning) ? { reasoning: reasoning.value } : {}),
2141
              })
2142
            : (thread ?? new DummyReplySource());
2116 2143
2117 2144
      // Children get their own thread on their own model. The conversation
2118 2145
      // stays on the model it opened with, and a fan-out spends a budget the

@@ -2259,14 +2286,18 @@ const coderCommand = Command.make(

2259 2286
        return described;
2260 2287
      };
2261 2288
2262
      // Delegation is off rather than quietly running children on the
2263
      // conversation's model, so the refusal that turned it off is what the
2264
      // reader sees.
2265
      if (childThread?.kind === "refused") {
2289
      // Only when there is really no way to run a child. A refused child
2290
      // thread is not that: `buildDelegation` prefers a free harness model over
2291
      // the account's grant anyway, so the grant lane failing is the normal
2292
      // path rather than a loss of capability. Reporting it as "this session
2293
      // cannot delegate" was false — the `delegate` tool was declared, the
2294
      // model correctly said it could delegate, and the notice above it said
2295
      // the opposite.
2296
      if (setup === undefined && childThread?.kind === "refused") {
2266 2297
        session.notice(`This session cannot delegate: ${childThread.reason}`);
2267 2298
      }
2268 2299
2269
      if (Option.isNone(stored) && !offline && !wantsOllama) {
2300
      if (Option.isNone(stored) && !offline && !wantsOllama && !wantsZen) {
2270 2301
        session.notice(
2271 2302
          "No stored credential, so replies come from the built-in stand-in. " +
2272 2303
            `Run \`${loginCommandFor(endpoint)}\` to reach a real model.`,

@@ -2283,7 +2314,7 @@ const coderCommand = Command.make(

2283 2314
      // A grant pins the model the proxy will use, and the thread route takes
2284 2315
      // no model parameter, so a named backend cannot reach this turn. Saying
2285 2316
      // nothing would leave a reader with a flag that appeared to work.
2286
      if (thread !== undefined && Option.isSome(model) && !wantsOllama) {
2317
      if (thread !== undefined && Option.isSome(model) && !wantsOllama && !wantsZen) {
2287 2318
        session.notice(
2288 2319
          `This thread's grant pins ${thread.model}. \`--model\` names a chat API ` +
2289 2320
            "backend, which the inference proxy does not route to, so it had no effect.",
packages/openagents-cli/src/coder-thread.ts modified +4 -4

@@ -914,7 +914,7 @@ export class ThreadReplySource implements ReplySource {

914 914
}
915 915
916 916
/** Frames of an SSE body, yielded as the body arrives rather than after it. */
917
async function* frames(
917
export async function* frames(
918 918
  body: ReadableStream<Uint8Array>,
919 919
  signal: AbortSignal,
920 920
): AsyncIterable<string> {

@@ -957,7 +957,7 @@ function dataOf(frame: string): string | undefined {

957 957
  return parts.length === 0 ? undefined : parts.join("\n");
958 958
}
959 959
960
function parse(frame: string): Record<string, unknown> | undefined {
960
export function parse(frame: string): Record<string, unknown> | undefined {
961 961
  try {
962 962
    const value: unknown = JSON.parse(frame);
963 963
    return typeof value === "object" && value !== null

@@ -974,7 +974,7 @@ function parse(frame: string): Record<string, unknown> | undefined {

974 974
 * Chat-completions splits one call across frames and identifies the pieces by
975 975
 * `index`, so a name and its arguments can arrive separately.
976 976
 */
977
function accumulate(
977
export function accumulate(
978 978
  calls: Map<number, { id: string; name: string; args: string }>,
979 979
  fragments: ReadonlyArray<unknown>,
980 980
): void {

@@ -1080,6 +1080,6 @@ function number(value: unknown): number {

1080 1080
 * refusal ("prompt is required") is a sentence the model can act on, and a
1081 1081
 * parse error thrown here would end the turn instead.
1082 1082
 */
1083
function parseArguments(args: string): Record<string, unknown> {
1083
export function parseArguments(args: string): Record<string, unknown> {
1084 1084
  return parse(args) ?? {};
1085 1085
}
packages/openagents-cli/src/coder-zen.ts added +318

@@ -0,0 +1,318 @@

1
import { existsSync, readFileSync } from "node:fs";
2
import { homedir } from "node:os";
3
import { join } from "node:path";
4
5
import type { ReplyChunk, ReplySource } from "./coder-session.js";
6
import { systemPrompt } from "./coder-system.js";
7
import { accumulate, boundedResult, frames, parse, parseArguments } from "./coder-thread.js";
8
import type { CoderTool } from "./coder-tools.js";
9
10
/**
11
 * A coder session answered by OpenCode Zen, on the credential opencode already
12
 * holds on this machine.
13
 *
14
 * The reason this lane exists: Ox Alpha is free and unlimited there, and no
15
 * OpenAgents deployment serves it — the model is in the server's catalog but
16
 * its provider credential is not configured, so a thread opened on it is
17
 * refused. Waiting for that credential to reach a deployment is a wait; the
18
 * machine already has one.
19
 *
20
 * Zen is an OpenAI-compatible endpoint that takes tool calls, so this is the
21
 * same wire shape the inference proxy speaks and the same turn loop. What it is
22
 * not is `opencode` the agent: opencode's own server runs its own loop with its
23
 * own tools, and a session driven through that would be running opencode's
24
 * tools rather than this session's. Only the endpoint and the key are borrowed.
25
 *
26
 * Nothing here spends an OpenAgents grant and nothing reaches a thread, so a
27
 * session on this lane keeps no server-side transcript.
28
 */
29
30
/** Where opencode keeps the credential this lane borrows. */
31
const AUTH_FILE = join(homedir(), ".local", "share", "opencode", "auth.json");
32
33
const ZEN_BASE = "https://opencode.ai/zen/v1";
34
35
/**
36
 * The names a reader uses for a Zen model, mapped to what the API takes.
37
 *
38
 * `ox-alpha` is the name it is known by and `x-preview-f-free` is the slug it
39
 * answers to; Zen itself calls it "Ox Alpha Free (Unlimited)". A reader who
40
 * types the name it is called should not have to know the other one.
41
 */
42
const ALIASES: Record<string, string> = {
43
  "ox-alpha": "x-preview-f-free",
44
  "ox-alpha-free": "x-preview-f-free",
45
};
46
47
export const zenModelId = (asked: string): string => ALIASES[asked] ?? asked;
48
49
/**
50
 * The credential, from the environment or from opencode's own store.
51
 *
52
 * Read rather than copied: this is opencode's key, it stays where opencode put
53
 * it, and a session that finds none says so instead of calling without one.
54
 */
55
export const zenCredential = (
56
  env: NodeJS.ProcessEnv = process.env,
57
  authFile: string = AUTH_FILE,
58
): string | undefined => {
59
  const named = env["OPENCODE_API_KEY"];
60
  if (named !== undefined && named.length > 0) return named;
61
62
  if (!existsSync(authFile)) return undefined;
63
  try {
64
    const store = JSON.parse(readFileSync(authFile, "utf8")) as Record<string, unknown>;
65
    const entry = store["opencode"];
66
    if (typeof entry !== "object" || entry === null) return undefined;
67
    const key = (entry as Record<string, unknown>)["key"];
68
    return typeof key === "string" && key.length > 0 ? key : undefined;
69
  } catch {
70
    return undefined;
71
  }
72
};
73
74
const LANE = "You answer from Ox Alpha through OpenCode Zen, on this machine's own credential.";
75
76
/** How many rounds of tool calls one turn may take before it is stopped. */
77
const MAX_ROUNDS = 100;
78
79
type WireMessage =
80
  | { readonly role: "system"; readonly content: string }
81
  | { readonly role: "user"; readonly content: string }
82
  | {
83
      readonly role: "assistant";
84
      readonly content: string;
85
      readonly tool_calls?: ReadonlyArray<Record<string, unknown>>;
86
    }
87
  | { readonly role: "tool"; readonly tool_call_id: string; readonly content: string };
88
89
export class ZenReplySource implements ReplySource {
90
  private readonly key: string;
91
  private readonly slug: string;
92
  private readonly transcript: WireMessage[] = [];
93
  private tools: ReadonlyArray<CoderTool> = [];
94
  private standing: string | undefined;
95
  private steered: string[] = [];
96
  private spentIn = 0;
97
  private spentOut = 0;
98
  private callCount = 0;
99
100
  constructor(options: { readonly model: string; readonly key: string }) {
101
    this.slug = zenModelId(options.model);
102
    this.key = options.key;
103
  }
104
105
  get model(): string {
106
    return this.slug === "x-preview-f-free" ? "ox-alpha" : this.slug;
107
  }
108
109
  get modelId(): string {
110
    return this.slug;
111
  }
112
113
  /** What the status line shows in place of a thread budget: this lane has none. */
114
  get budget(): string {
115
    return `${String(this.callCount)} calls · ${String(this.spentIn + this.spentOut)} tok · free`;
116
  }
117
118
  useContext(standing: string): void {
119
    this.standing = standing;
120
  }
121
122
  useTools(tools: ReadonlyArray<CoderTool>): void {
123
    this.tools = tools;
124
  }
125
126
  toolDefinitions(): ReadonlyArray<Record<string, unknown>> {
127
    return this.tools.map((tool) => ({
128
      type: "function",
129
      function: { name: tool.name, description: tool.description, parameters: tool.parameters },
130
    }));
131
  }
132
133
  describeContext(): string {
134
    const declarations =
135
      this.tools.length === 0
136
        ? "No tools are declared to the model."
137
        : `${String(this.tools.length)} tool${this.tools.length === 1 ? "" : "s"} declared to the model:\n\n${this.tools
138
            .map(
139
              (tool) =>
140
                `- \`${tool.name}\`\n  ${tool.description}\n  parameters: ${JSON.stringify(tool.parameters)}`,
141
            )
142
            .join("\n\n")}`;
143
144
    return [
145
      `System message sent with every turn:\n\n${systemPrompt(this.tools, LANE, this.standing)}`,
146
      "",
147
      declarations,
148
    ].join("\n");
149
  }
150
151
  steer(text: string): boolean {
152
    this.steered.push(text);
153
    return true;
154
  }
155
156
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
157
    if (!this.transcript.some((message) => message.role === "system")) {
158
      this.transcript.unshift({
159
        role: "system",
160
        content: systemPrompt(this.tools, LANE, this.standing),
161
      });
162
    }
163
    this.transcript.push({ role: "user", content: prompt });
164
165
    for (let round = 0; round < MAX_ROUNDS; round += 1) {
166
      if (signal.aborted) return;
167
168
      // Read between two model calls rather than at the end of the turn, which
169
      // is the difference between steering a model and waiting one out.
170
      for (const said of this.steered.splice(0)) {
171
        this.transcript.push({ role: "user", content: said });
172
      }
173
174
      const calls: Map<number, { id: string; name: string; args: string }> = new Map();
175
      let assistant = "";
176
177
      const response = await this.call(signal);
178
      if (response === undefined || signal.aborted) return;
179
180
      for await (const frame of frames(response, signal)) {
181
        if (signal.aborted) return;
182
        if (frame === "[DONE]") break;
183
184
        const payload = parse(frame);
185
        if (payload === undefined) continue;
186
187
        const usage = payload["usage"];
188
        if (typeof usage === "object" && usage !== null) this.spend(usage as Record<string, unknown>);
189
190
        const choices = payload["choices"];
191
        if (!Array.isArray(choices)) continue;
192
193
        for (const choice of choices) {
194
          const delta = (choice as Record<string, unknown>)["delta"];
195
          if (typeof delta !== "object" || delta === null) continue;
196
          const parts = delta as Record<string, unknown>;
197
198
          const thought = parts["reasoning"] ?? parts["reasoning_content"];
199
          if (typeof thought === "string" && thought.length > 0) {
200
            yield { type: "reasoning", value: thought };
201
          }
202
203
          const content = parts["content"];
204
          if (typeof content === "string" && content.length > 0) {
205
            assistant += content;
206
            yield { type: "text", value: content };
207
          }
208
209
          const toolCalls = parts["tool_calls"];
210
          if (Array.isArray(toolCalls)) accumulate(calls, toolCalls);
211
        }
212
      }
213
214
      const asked = [...calls.values()];
215
      if (asked.length === 0) {
216
        if (assistant.length > 0) this.transcript.push({ role: "assistant", content: assistant });
217
        yield {
218
          type: "usage",
219
          promptTokens: this.spentIn,
220
          completionTokens: this.spentOut,
221
          calls: round + 1,
222
        };
223
        return;
224
      }
225
226
      // The assistant turn carries the calls it made, and every one of them is
227
      // answered before the next round: a call whose result never follows is a
228
      // transcript the provider refuses.
229
      this.transcript.push({
230
        role: "assistant",
231
        content: assistant,
232
        tool_calls: asked.map((call) => ({
233
          id: call.id,
234
          type: "function",
235
          function: { name: call.name, arguments: call.args },
236
        })),
237
      });
238
239
      for (const call of asked) {
240
        yield { type: "tool_call", callId: call.id, name: call.name, arguments: call.args };
241
242
        const tool = this.tools.find((candidate) => candidate.name === call.name);
243
        const output =
244
          tool === undefined
245
            ? `No tool called ${call.name} is declared in this session.`
246
            : await tool
247
                .run(parseArguments(call.args), signal)
248
                .catch((cause: unknown) => `The tool failed: ${String(cause)}`);
249
250
        this.transcript.push({
251
          role: "tool",
252
          tool_call_id: call.id,
253
          content: boundedResult(output),
254
        });
255
        yield { type: "tool_result", callId: call.id, output, error: undefined };
256
      }
257
    }
258
259
    yield {
260
      type: "text",
261
      value: `\n\nThe turn stopped after ${String(MAX_ROUNDS)} rounds of tool calls.`,
262
    };
263
  }
264
265
  private async call(signal: AbortSignal): Promise<ReadableStream<Uint8Array> | undefined> {
266
    this.callCount += 1;
267
268
    const response = await fetch(`${ZEN_BASE}/chat/completions`, {
269
      method: "POST",
270
      signal,
271
      headers: {
272
        authorization: `Bearer ${this.key}`,
273
        "content-type": "application/json",
274
        accept: "text/event-stream",
275
      },
276
      body: JSON.stringify({
277
        model: this.slug,
278
        stream: true,
279
        stream_options: { include_usage: true },
280
        messages: this.transcript,
281
        ...(this.tools.length === 0
282
          ? {}
283
          : {
284
              tools: this.tools.map((tool) => ({
285
                type: "function",
286
                function: {
287
                  name: tool.name,
288
                  description: tool.description,
289
                  parameters: tool.parameters,
290
                },
291
              })),
292
            }),
293
      }),
294
    }).catch((cause: unknown) => {
295
      if (signal.aborted) return undefined;
296
      throw new Error(`OpenCode Zen could not be reached: ${String(cause)}`);
297
    });
298
299
    if (response === undefined || signal.aborted) return undefined;
300
301
    if (!response.ok) {
302
      const detail = (await response.text().catch(() => "")).slice(0, 300);
303
      throw new Error(
304
        `OpenCode Zen refused the call (${String(response.status)})` +
305
          (detail.length === 0 ? "." : `: ${detail}`),
306
      );
307
    }
308
309
    return response.body ?? undefined;
310
  }
311
312
  private spend(usage: Record<string, unknown>): void {
313
    const input = usage["prompt_tokens"];
314
    const output = usage["completion_tokens"];
315
    if (typeof input === "number") this.spentIn = input;
316
    if (typeof output === "number") this.spentOut = output;
317
  }
318
}
packages/openagents-cli/test/coder-zen.test.ts added +185

@@ -0,0 +1,185 @@

1
import { mkdtempSync, writeFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
import { afterEach, describe, expect, it, vi } from "vitest";
5
6
import type { ReplyChunk } from "../src/coder-session.js";
7
import { ZenReplySource, zenCredential, zenModelId } from "../src/coder-zen.js";
8
9
const authFile = (contents: unknown) => {
10
  const path = join(mkdtempSync(join(tmpdir(), "oa-zen-")), "auth.json");
11
  writeFileSync(path, JSON.stringify(contents));
12
  return path;
13
};
14
15
const sse = (lines: ReadonlyArray<string>) =>
16
  new Response(
17
    new ReadableStream<Uint8Array>({
18
      start(controller) {
19
        controller.enqueue(new TextEncoder().encode(lines.join("\n\n") + "\n\n"));
20
        controller.close();
21
      },
22
    }),
23
    { status: 200, headers: { "content-type": "text/event-stream" } },
24
  );
25
26
interface Sent {
27
  readonly url: string;
28
  readonly authorization: string;
29
  readonly body: Record<string, unknown>;
30
}
31
32
const stub = (responses: ReadonlyArray<Response>) => {
33
  const sent: Sent[] = [];
34
  const queue = [...responses];
35
  vi.stubGlobal(
36
    "fetch",
37
    vi.fn(async (target: URL | string, init?: RequestInit) => {
38
      const headers = (init?.headers ?? {}) as Record<string, string>;
39
      sent.push({
40
        url: String(target),
41
        authorization: headers["authorization"] ?? "",
42
        body: JSON.parse(typeof init?.body === "string" ? init.body : "{}") as Record<
43
          string,
44
          unknown
45
        >,
46
      });
47
      return queue.shift() ?? sse([`data: [DONE]`]);
48
    }),
49
  );
50
  return sent;
51
};
52
53
const chunks = async (source: ZenReplySource, prompt: string) => {
54
  const out: ReplyChunk[] = [];
55
  for await (const chunk of source.reply(prompt, new AbortController().signal)) out.push(chunk);
56
  return out;
57
};
58
59
const textOf = (out: ReadonlyArray<ReplyChunk>) =>
60
  out.map((chunk) => (chunk.type === "text" ? chunk.value : "")).join("");
61
62
afterEach(() => {
63
  vi.unstubAllGlobals();
64
});
65
66
describe("naming the model", () => {
67
  it("takes the name Ox Alpha is known by and sends the slug it answers to", () => {
68
    expect(zenModelId("ox-alpha")).toBe("x-preview-f-free");
69
  });
70
71
  it("passes an unrecognised name through for the API to refuse by name", () => {
72
    expect(zenModelId("some-other-model")).toBe("some-other-model");
73
  });
74
75
  it("reports the readable name back, whichever was asked for", () => {
76
    expect(new ZenReplySource({ model: "x-preview-f-free", key: "k" }).model).toBe("ox-alpha");
77
    expect(new ZenReplySource({ model: "ox-alpha", key: "k" }).model).toBe("ox-alpha");
78
  });
79
});
80
81
describe("finding the credential", () => {
82
  it("prefers the environment", () => {
83
    expect(zenCredential({ OPENCODE_API_KEY: "from-env" }, authFile({}))).toBe("from-env");
84
  });
85
86
  it("reads opencode's own store when the environment names none", () => {
87
    const path = authFile({ opencode: { type: "api", key: "from-store" } });
88
    expect(zenCredential({}, path)).toBe("from-store");
89
  });
90
91
  it("takes only opencode's entry, not another provider's key", () => {
92
    const path = authFile({ google: { type: "api", key: "not-this-one" } });
93
    expect(zenCredential({}, path)).toBe(undefined);
94
  });
95
96
  it("reports none rather than calling without one", () => {
97
    expect(zenCredential({}, join(tmpdir(), "oa-zen-absent", "auth.json"))).toBe(undefined);
98
    expect(zenCredential({}, authFile("not an object"))).toBe(undefined);
99
  });
100
});
101
102
describe("answering a turn", () => {
103
  it("calls Zen with the slug and the borrowed key", async () => {
104
    const sent = stub([
105
      sse([`data: {"choices":[{"delta":{"content":"Hi"}}]}`, `data: [DONE]`]),
106
    ]);
107
    const source = new ZenReplySource({ model: "ox-alpha", key: "secret-key" });
108
109
    expect(textOf(await chunks(source, "hello"))).toBe("Hi");
110
    expect(sent[0]?.url).toBe("https://opencode.ai/zen/v1/chat/completions");
111
    expect(sent[0]?.authorization).toBe("Bearer secret-key");
112
    expect(sent[0]?.body["model"]).toBe("x-preview-f-free");
113
  });
114
115
  it("opens with the session's anchor, once", async () => {
116
    const sent = stub([
117
      sse([`data: {"choices":[{"delta":{"content":"a"}}]}`, `data: [DONE]`]),
118
      sse([`data: {"choices":[{"delta":{"content":"b"}}]}`, `data: [DONE]`]),
119
    ]);
120
    const source = new ZenReplySource({ model: "ox-alpha", key: "k" });
121
    source.useContext("Workspace facts.");
122
123
    await chunks(source, "first");
124
    await chunks(source, "second");
125
126
    const messages = sent[1]?.body["messages"] as Array<Record<string, unknown>>;
127
    const anchors = messages.filter((message) => message["role"] === "system");
128
    expect(anchors).toHaveLength(1);
129
    expect(String(anchors[0]?.["content"])).toContain("Ox Alpha through OpenCode Zen");
130
    expect(String(anchors[0]?.["content"])).toContain("Workspace facts.");
131
  });
132
133
  it("runs a declared tool and answers the model with its result", async () => {
134
    const ran: string[] = [];
135
    const sent = stub([
136
      sse([
137
        `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"shell","arguments":"{\\"command\\":\\"ls\\"}"}}]}}]}`,
138
        `data: [DONE]`,
139
      ]),
140
      sse([`data: {"choices":[{"delta":{"content":"two files"}}]}`, `data: [DONE]`]),
141
    ]);
142
    const source = new ZenReplySource({ model: "ox-alpha", key: "k" });
143
    source.useTools([
144
      {
145
        name: "shell",
146
        description: "run a command",
147
        parameters: { type: "object" },
148
        run: async (args) => {
149
          ran.push(String((args as Record<string, unknown>)["command"]));
150
          return "a.txt b.txt";
151
        },
152
      },
153
    ]);
154
155
    expect(textOf(await chunks(source, "list them"))).toBe("two files");
156
    expect(ran).toEqual(["ls"]);
157
158
    // The result is answered back on the transcript, keyed to the call. A call
159
    // whose result never follows is a transcript the provider refuses.
160
    const second = sent[1]?.body["messages"] as Array<Record<string, unknown>>;
161
    const result = second.find((message) => message["role"] === "tool");
162
    expect(result).toMatchObject({ tool_call_id: "c1", content: "a.txt b.txt" });
163
  });
164
165
  it("declares the session's tools to the model", async () => {
166
    const sent = stub([sse([`data: [DONE]`])]);
167
    const source = new ZenReplySource({ model: "ox-alpha", key: "k" });
168
    source.useTools([
169
      { name: "shell", description: "run", parameters: { type: "object" }, run: async () => "" },
170
    ]);
171
172
    await chunks(source, "hello");
173
174
    const tools = sent[0]?.body["tools"] as Array<Record<string, unknown>>;
175
    expect(tools).toHaveLength(1);
176
    expect((tools[0]?.["function"] as Record<string, unknown>)["name"]).toBe("shell");
177
  });
178
179
  it("says which call was refused rather than falling silent", async () => {
180
    stub([new Response("no such model", { status: 404 })]);
181
    const source = new ZenReplySource({ model: "nonsense", key: "k" });
182
183
    await expect(chunks(source, "hello")).rejects.toThrow(/404.*no such model/s);
184
  });
185
});

This page updates live while a promote is in flight · changelog