Answer coder prompts from Ox Alpha through the account chat API

39e351a888c8 · AtlantisPleb · · parent 522feac7bc09

Answer coder prompts from Ox Alpha through the account chat API

The session loop shipped against a stand-in. This connects it to the model the
web console already drives: the CLI submits a turn to `POST /api/v3/chat/turns`
and reads the durable event log at `GET /api/v3/chat/events`, so OpenRouter is
reached by the server and the CLI never holds a provider key. A session costs
what the server metered and leaves the same receipts the browser leaves.

Two properties of that contract are visible in the client rather than hidden.
The server keeps one conversation per account and one active turn per
conversation, so a coder session shares the account's conversation and a
concurrent turn is refused with the reason rather than queued. The events route
returns the whole log rather than a stream, so the reply is polled and yielded
in pieces, and the pre-submit snapshot is what keeps an earlier turn's text out
of a new reply.

`auth login` grows `--scope`, because the device authorization defaulted to
`forge:write` alone and the CLI never asked for anything else. Without a way to
request `chat:account` there was no token that could reach the chat API at all.
The server already accepted the parameter and shows the requested scopes on the
approval page.

A turn that fails now ends the turn instead of the session: the reason lands on
the transcript beside the prompt that caused it, and partial text is kept. The
line-oriented mode reports notices too, tracked by content rather than by
position, because a failed turn removes the empty assistant entry and an index
into the transcript can move backwards past the very notice that explains it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
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 packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/coder-ox.ts
  • modified packages/openagents-cli/src/coder-plain.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/device-client.ts
  • added packages/openagents-cli/test/coder-ox.test.ts

Diff

6 files changed, +495 -18

packages/openagents-cli/src/cli.ts modified +62 -13

@@ -18,6 +18,7 @@ import { BrowserLauncher } from "./browser-launcher.js";

18 18
import { runCoderPlain } from "./coder-plain.js";
19 19
import { CoderSession, DummyReplySource } from "./coder-session.js";
20 20
import { runCoderUi } from "./coder-ui.js";
21
import { OxAlphaReplySource } from "./coder-ox.js";
21 22
import { describeWorkspace } from "./coder-workspace.js";
22 23
import { ComputerConfiguration, type ComputerConfigurationValues } from "./computer-config.js";
23 24
import { ComputerJournal, journalMaxBytes, journalReadTailBytes } from "./computer-journal.js";

@@ -373,6 +374,12 @@ const loginTokenStdinFlag = Flag.boolean("token-stdin").pipe(

373 374
const loginHeadlessFlag = Flag.boolean("headless").pipe(
374 375
  Flag.withDescription("Return an authorization URL and code without waiting for approval"),
375 376
);
377
const loginScopeFlag = Flag.string("scope").pipe(
378
  Flag.atLeast(0),
379
  Flag.withDescription(
380
    "Request a scope for the new token; repeatable. Omit to take the server's default. Use chat:account to reach the chat API from `openagents coder`.",
381
  ),
382
);
376 383
const loginResumeFlag = Flag.boolean("resume").pipe(
377 384
  Flag.withDescription("Complete the pending device authorization for the selected API"),
378 385
);

@@ -387,8 +394,13 @@ const resumeCommandFor = (endpoint: {

387 394
388 395
const authLoginCommand = Command.make(
389 396
  "login",
390
  { tokenStdin: loginTokenStdinFlag, headless: loginHeadlessFlag, resume: loginResumeFlag },
391
  ({ headless, resume, tokenStdin }) =>
397
  {
398
    tokenStdin: loginTokenStdinFlag,
399
    headless: loginHeadlessFlag,
400
    resume: loginResumeFlag,
401
    scope: loginScopeFlag,
402
  },
403
  ({ headless, resume, scope, tokenStdin }) =>
392 404
    Effect.gen(function* () {
393 405
      if ((tokenStdin && (headless || resume)) || (headless && resume)) {
394 406
        return yield* new InputError({

@@ -425,7 +437,7 @@ const authLoginCommand = Command.make(

425 437
      if (!resume) {
426 438
        const devices = yield* DeviceClient;
427 439
        const terminal = yield* TerminalSession;
428
        const authorization = yield* devices.start(endpoint.origin);
440
        const authorization = yield* devices.start(endpoint.origin, scope);
429 441
        const returnForApproval = headless || !terminal.interactive || flags.json;
430 442
        if (returnForApproval) {
431 443
          const now = yield* Clock.currentTimeMillis;

@@ -1107,23 +1119,60 @@ const coderPrompt = Argument.string("prompt").pipe(

1107 1119
const coderPlainFlag = Flag.boolean("plain").pipe(
1108 1120
  Flag.withDescription("Use line-oriented output with no cursor control, even on a terminal"),
1109 1121
);
1122
const coderOfflineFlag = Flag.boolean("offline").pipe(
1123
  Flag.withDescription("Answer from the built-in stand-in instead of the chat API"),
1124
);
1125
const coderReasoningFlag = Flag.choice("reasoning", [
1126
  "minimal",
1127
  "low",
1128
  "medium",
1129
  "high",
1130
  "max",
1131
]).pipe(Flag.optional, Flag.withDescription("Reasoning effort the server passes to the provider"));
1110 1132
1111 1133
const coderCommand = Command.make(
1112 1134
  "coder",
1113
  { prompt: coderPrompt, plain: coderPlainFlag },
1114
  ({ prompt, plain }) =>
1135
  {
1136
    prompt: coderPrompt,
1137
    plain: coderPlainFlag,
1138
    offline: coderOfflineFlag,
1139
    reasoning: coderReasoningFlag,
1140
  },
1141
  ({ prompt, plain, offline, reasoning }) =>
1115 1142
    Effect.gen(function* () {
1116 1143
      const flags = yield* rootCommand;
1117 1144
      const terminal = yield* TerminalSession;
1118 1145
      const workspace = describeWorkspace();
1146
      const endpoint = yield* resolveApiEndpoint(endpointOverrides(flags));
1119 1147
1120
      // The reply source is a stand-in until the ACP client lands. Nothing
1121
      // else in this command changes when it is replaced.
1122
      const session = new CoderSession(
1123
        new DummyReplySource(),
1124
        workspace.repository,
1125
        workspace.branch,
1126
      );
1148
      // Replies come from the account chat API, which runs Ox Alpha through
1149
      // OpenRouter on the server, so the CLI never holds a provider key and a
1150
      // session costs exactly what the server metered. Without a credential it
1151
      // falls back to the stand-in and says so rather than failing.
1152
      const stored = offline
1153
        ? Option.none()
1154
        : yield* findToken(endpoint.origin).pipe(
1155
            Effect.catchTag("OpenAgentsCli.CredentialPersistenceUnavailable", () =>
1156
              Effect.succeed(Option.none()),
1157
            ),
1158
          );
1159
1160
      const source = Option.isSome(stored)
1161
        ? new OxAlphaReplySource({
1162
            origin: endpoint.origin,
1163
            token: Redacted.value(stored.value.token),
1164
            reasoning: Option.getOrUndefined(reasoning),
1165
          })
1166
        : new DummyReplySource();
1167
1168
      const session = new CoderSession(source, workspace.repository, workspace.branch);
1169
1170
      if (Option.isNone(stored) && !offline) {
1171
        session.notice(
1172
          "No stored credential, so replies come from the built-in stand-in. " +
1173
            "Run `openagents auth login --scope chat:account` to reach Ox Alpha.",
1174
        );
1175
      }
1127 1176
1128 1177
      const oneShot = Option.getOrUndefined(prompt);
1129 1178
      const interactive = terminal.interactive && !plain && !flags.json && oneShot === undefined;

@@ -1144,7 +1193,7 @@ const coderCommand = Command.make(

1144 1193
    }),
1145 1194
).pipe(
1146 1195
  Command.withDescription(
1147
    "Open a terminal coding session. Development build: the interface, the session loop, and streaming are real; the replies are a stand-in until the agent runtime is attached",
1196
    "Open a terminal coding session. Replies come from Ox Alpha through the account chat API; --offline answers from a built-in stand-in instead",
1148 1197
  ),
1149 1198
);
1150 1199
packages/openagents-cli/src/coder-ox.ts added +226

@@ -0,0 +1,226 @@

1
/**
2
 * A reply source backed by the account chat API, which runs Ox Alpha through
3
 * OpenRouter on the server.
4
 *
5
 * The CLI never holds a provider key and never talks to OpenRouter. It submits
6
 * a turn and reads the durable event log the server writes, so a coder session
7
 * costs exactly what the server metered and leaves the same receipts the web
8
 * surface leaves.
9
 *
10
 * Two properties of the shipped contract shape this file:
11
 *
12
 * - The server records one conversation per account (`DATA-002`) and one active
13
 *   turn per conversation (`TURN-001`). A coder session therefore shares the
14
 *   account's conversation rather than opening its own, and a second turn while
15
 *   one is running is refused with `turn_in_progress` rather than queued.
16
 * - `GET /api/v3/chat/events` returns the conversation's whole event log, not a
17
 *   stream. This polls it and yields what is new, which is why the reply
18
 *   appears in pieces rather than at once.
19
 */
20
21
const SUBMIT_PATH = "/api/v3/chat/turns";
22
const EVENTS_PATH = "/api/v3/chat/events";
23
24
const POLL_INTERVAL_MS = 250;
25
/** Give up rather than poll forever when a turn never reaches a terminal event. */
26
const TURN_TIMEOUT_MS = 300_000;
27
28
export interface OxAlphaOptions {
29
  readonly origin: string;
30
  readonly token: string;
31
  /** Reasoning effort the server passes to the provider. */
32
  readonly reasoning?: string | undefined;
33
  readonly model?: string | undefined;
34
}
35
36
interface ChatEvent {
37
  readonly id?: string;
38
  readonly run_id?: string;
39
  readonly sequence?: number;
40
  readonly type?: string;
41
  readonly payload?: Record<string, unknown>;
42
}
43
44
export class OxAlphaUnavailable extends Error {
45
  constructor(
46
    readonly code: string,
47
    message: string,
48
  ) {
49
    super(message);
50
    this.name = "OxAlphaUnavailable";
51
  }
52
}
53
54
/**
55
 * Submit a turn and yield the assistant text as the server records it.
56
 *
57
 * Reasoning deltas are read but not yielded: the transcript shows what the
58
 * assistant said, and the runtime already treats a thought as something a
59
 * client may drop.
60
 */
61
export class OxAlphaReplySource {
62
  readonly model: string;
63
64
  constructor(private readonly options: OxAlphaOptions) {
65
    this.model = options.model ?? "stealth/ox-alpha";
66
  }
67
68
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<string> {
69
    const seen = await this.latestSequence();
70
    const runId = await this.submit(prompt, signal);
71
    const startedAt = Date.now();
72
    const delivered = new Map<string, number>();
73
    if (runId !== undefined) delivered.set(runId, seen.get(runId) ?? -1);
74
75
    while (!signal.aborted) {
76
      if (Date.now() - startedAt > TURN_TIMEOUT_MS) {
77
        throw new OxAlphaUnavailable(
78
          "turn_timed_out",
79
          "The turn produced no terminal event within five minutes.",
80
        );
81
      }
82
83
      const events = await this.events(signal);
84
      if (signal.aborted) return;
85
86
      // A submit that answered without a run id still identifies its run in the
87
      // log; take the newest run not already accounted for.
88
      const target = runId ?? newestRun(events, seen);
89
      if (target === undefined) {
90
        await sleep(POLL_INTERVAL_MS, signal);
91
        continue;
92
      }
93
94
      const floor = delivered.get(target) ?? seen.get(target) ?? -1;
95
      let highest = floor;
96
      let finished = false;
97
98
      for (const event of events) {
99
        if (event.run_id !== target) continue;
100
        const sequence = typeof event.sequence === "number" ? event.sequence : -1;
101
        if (sequence <= floor) continue;
102
        highest = Math.max(highest, sequence);
103
104
        if (event.type === "text_delta") {
105
          const value = event.payload?.["value"];
106
          if (typeof value === "string" && value.length > 0) yield value;
107
        } else if (event.type === "completed") {
108
          finished = true;
109
        } else if (event.type === "failed") {
110
          const reason = event.payload?.["message"] ?? event.payload?.["error"];
111
          throw new OxAlphaUnavailable(
112
            "turn_failed",
113
            typeof reason === "string" ? reason : "The turn failed on the server.",
114
          );
115
        }
116
      }
117
118
      delivered.set(target, highest);
119
      if (finished) return;
120
      await sleep(POLL_INTERVAL_MS, signal);
121
    }
122
  }
123
124
  /** Highest sequence per run before submitting, so old events are not replayed. */
125
  private async latestSequence(): Promise<Map<string, number>> {
126
    const seen = new Map<string, number>();
127
    for (const event of await this.events()) {
128
      if (typeof event.run_id !== "string") continue;
129
      const sequence = typeof event.sequence === "number" ? event.sequence : -1;
130
      seen.set(event.run_id, Math.max(seen.get(event.run_id) ?? -1, sequence));
131
    }
132
    return seen;
133
  }
134
135
  private async submit(prompt: string, signal: AbortSignal): Promise<string | undefined> {
136
    const response = await fetch(new URL(SUBMIT_PATH, this.options.origin), {
137
      method: "POST",
138
      signal,
139
      headers: {
140
        authorization: `Bearer ${this.options.token}`,
141
        "content-type": "application/json",
142
        accept: "application/json",
143
      },
144
      body: JSON.stringify({
145
        message: prompt,
146
        ...(this.options.reasoning === undefined ? {} : { reasoning: this.options.reasoning }),
147
      }),
148
    });
149
150
    const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
151
152
    if (response.status === 401 || response.status === 403) {
153
      throw new OxAlphaUnavailable(
154
        "scope_missing",
155
        "This token cannot reach the chat API. Sign in again with the chat:account scope.",
156
      );
157
    }
158
    if (response.status === 409) {
159
      throw new OxAlphaUnavailable(
160
        "turn_in_progress",
161
        "The account already has a turn running. One turn runs at a time.",
162
      );
163
    }
164
    if (response.status === 429) {
165
      throw new OxAlphaUnavailable("rate_limited", "The chat API is rate limiting this account.");
166
    }
167
    if (response.status < 200 || response.status >= 300) {
168
      const code = typeof body["error"] === "string" ? body["error"] : `http_${response.status}`;
169
      throw new OxAlphaUnavailable(code, `The chat API refused the turn (${code}).`);
170
    }
171
172
    const turn = body["turn"];
173
    if (turn !== null && typeof turn === "object") {
174
      const id = (turn as Record<string, unknown>)["id"];
175
      if (typeof id === "string") return id;
176
    }
177
    return undefined;
178
  }
179
180
  private async events(signal?: AbortSignal): Promise<ReadonlyArray<ChatEvent>> {
181
    const response = await fetch(new URL(EVENTS_PATH, this.options.origin), {
182
      ...(signal === undefined ? {} : { signal }),
183
      headers: {
184
        authorization: `Bearer ${this.options.token}`,
185
        accept: "application/json",
186
      },
187
    });
188
189
    if (response.status === 401 || response.status === 403) {
190
      throw new OxAlphaUnavailable(
191
        "scope_missing",
192
        "This token cannot read chat events. Sign in again with the chat:account scope.",
193
      );
194
    }
195
    if (response.status < 200 || response.status >= 300) return [];
196
197
    const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
198
    const events = body["events"];
199
    return Array.isArray(events) ? (events as ReadonlyArray<ChatEvent>) : [];
200
  }
201
}
202
203
/** The newest run in the log that the pre-submit snapshot did not know about. */
204
function newestRun(
205
  events: ReadonlyArray<ChatEvent>,
206
  seen: ReadonlyMap<string, number>,
207
): string | undefined {
208
  for (let index = events.length - 1; index >= 0; index -= 1) {
209
    const runId = events[index]?.run_id;
210
    if (typeof runId === "string" && !seen.has(runId)) return runId;
211
  }
212
  return undefined;
213
}
214
215
function sleep(ms: number, signal: AbortSignal): Promise<void> {
216
  return new Promise((resolve) => {
217
    if (signal.aborted) return resolve();
218
    const timer = setTimeout(finish, ms);
219
    signal.addEventListener("abort", finish, { once: true });
220
    function finish() {
221
      clearTimeout(timer);
222
      signal.removeEventListener("abort", finish);
223
      resolve();
224
    }
225
  });
226
}
packages/openagents-cli/src/coder-plain.ts modified +16 -2

@@ -28,9 +28,22 @@ export async function runCoderPlain(

28 28
  const { stdin, stdout, prompt } = options;
29 29
30 30
  let written = 0;
31
  // Notices are tracked by their text rather than by position: a failed turn
32
  // removes the empty assistant entry, so an index into the transcript can move
33
  // backwards and skip the very notice that explains the failure.
34
  const reported = new Set<string>();
31 35
  const flush = () => {
32
    const snapshot = session.snapshot();
33
    const last = snapshot.entries.at(-1);
36
    const entries = session.snapshot().entries;
37
38
    // Notices carry refusals and failures. Dropping them here is how a failed
39
    // turn becomes a silent empty reply, so they are written as they arrive.
40
    for (const entry of entries) {
41
      if (entry.role !== "notice" || reported.has(entry.text)) continue;
42
      reported.add(entry.text);
43
      stdout.write(`${entry.text}\n`);
44
    }
45
46
    const last = entries.at(-1);
34 47
    if (last === undefined || last.role !== "assistant") return;
35 48
    if (last.text.length > written) {
36 49
      stdout.write(last.text.slice(written));

@@ -39,6 +52,7 @@ export async function runCoderPlain(

39 52
  };
40 53
41 54
  const unsubscribe = session.onChange(flush);
55
  flush();
42 56
43 57
  const answer = async (line: string) => {
44 58
    written = 0;
packages/openagents-cli/src/coder-session.ts modified +9

@@ -176,6 +176,15 @@ export class CoderSession {

176 176
        // failure, so what the agent already said stays on the transcript.
177 177
        reply.text += "\n\n[interrupted]";
178 178
      }
179
    } catch (cause) {
180
      // A failed turn ends the turn, not the session. The reason belongs on the
181
      // transcript where the prompt that caused it is still visible, and any
182
      // text the source produced before failing is kept.
183
      const message = cause instanceof Error ? cause.message : String(cause);
184
      if (reply.text.length === 0) {
185
        this.entries.splice(this.entries.indexOf(reply), 1);
186
      }
187
      this.entries.push({ role: "notice", text: message, settled: true });
179 188
    } finally {
180 189
      reply.settled = true;
181 190
      this.controller = undefined;
packages/openagents-cli/src/device-client.ts modified +12 -3

@@ -29,7 +29,10 @@ class DevicePending extends Schema.TaggedErrorClass<DevicePending>()(

29 29
) {}
30 30
31 31
export interface DeviceClientInterface {
32
  readonly start: (origin: string) => Effect.Effect<DeviceAuthorization, CliError>;
32
  readonly start: (
33
    origin: string,
34
    scopes?: ReadonlyArray<string>,
35
  ) => Effect.Effect<DeviceAuthorization, CliError>;
33 36
  readonly wait: (
34 37
    origin: string,
35 38
    authorization: DeviceAuthorization,

@@ -45,12 +48,18 @@ export const deviceClientLayer = Layer.effect(

45 48
  Effect.gen(function* () {
46 49
    const transport = yield* ApiTransport;
47 50
48
    const start = Effect.fn("DeviceClient.start")(function* (origin: string) {
51
    const start = Effect.fn("DeviceClient.start")(function* (
52
      origin: string,
53
      scopes?: ReadonlyArray<string>,
54
    ) {
49 55
      const response = yield* transport.request({
50 56
        origin,
51 57
        method: "POST",
52 58
        path: "/api/v3/device/authorizations",
53
        body: {},
59
        // The server decides the default scope set. Asking for none keeps that
60
        // decision on the server; asking names exactly what the approval page
61
        // must show the person approving it.
62
        body: scopes === undefined || scopes.length === 0 ? {} : { scope: scopes.join(" ") },
54 63
      });
55 64
      if (response.status !== 201) {
56 65
        return yield* new ApiError({
packages/openagents-cli/test/coder-ox.test.ts added +170

@@ -0,0 +1,170 @@

1
import { afterEach, describe, expect, it, vi } from "vitest";
2
3
import { OxAlphaReplySource, OxAlphaUnavailable } from "../src/coder-ox.js";
4
5
const json = (status: number, body: unknown) =>
6
  new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
7
8
const collect = async (source: OxAlphaReplySource, prompt = "hello") => {
9
  const chunks: string[] = [];
10
  for await (const chunk of source.reply(prompt, new AbortController().signal)) chunks.push(chunk);
11
  return chunks.join("");
12
};
13
14
const source = () =>
15
  new OxAlphaReplySource({ origin: "https://openagents.test", token: "test-token" });
16
17
afterEach(() => {
18
  vi.unstubAllGlobals();
19
});
20
21
/** Answer `GET /chat/events` from a script and `POST /chat/turns` with a run id. */
22
const stubFetch = (pages: ReadonlyArray<ReadonlyArray<unknown>>, submit = json(202, {})) => {
23
  let page = 0;
24
  const calls: string[] = [];
25
  vi.stubGlobal(
26
    "fetch",
27
    vi.fn((url: URL, init?: RequestInit) => {
28
      const path = url.pathname;
29
      calls.push(`${init?.method ?? "GET"} ${path}`);
30
      if (path.endsWith("/chat/turns")) return Promise.resolve(submit.clone());
31
      const events = pages[Math.min(page, pages.length - 1)] ?? [];
32
      page += 1;
33
      return Promise.resolve(json(200, { events }));
34
    }),
35
  );
36
  return calls;
37
};
38
39
describe("OxAlphaReplySource", () => {
40
  it("yields text deltas for the submitted run and stops at completed", async () => {
41
    stubFetch(
42
      [
43
        [],
44
        [
45
          { run_id: "run-1", sequence: 1, type: "text_delta", payload: { value: "Hello" } },
46
          { run_id: "run-1", sequence: 2, type: "text_delta", payload: { value: " there" } },
47
          { run_id: "run-1", sequence: 3, type: "completed", payload: {} },
48
        ],
49
      ],
50
      json(202, { turn: { id: "run-1" } }),
51
    );
52
53
    expect(await collect(source())).toBe("Hello there");
54
  });
55
56
  it("does not replay events that existed before the turn was submitted", async () => {
57
    stubFetch(
58
      [
59
        [{ run_id: "run-0", sequence: 9, type: "text_delta", payload: { value: "OLD" } }],
60
        [
61
          { run_id: "run-0", sequence: 9, type: "text_delta", payload: { value: "OLD" } },
62
          { run_id: "run-1", sequence: 1, type: "text_delta", payload: { value: "new" } },
63
          { run_id: "run-1", sequence: 2, type: "completed", payload: {} },
64
        ],
65
      ],
66
      json(202, { turn: { id: "run-1" } }),
67
    );
68
69
    expect(await collect(source())).toBe("new");
70
  });
71
72
  it("does not repeat a delta already delivered on an earlier poll", async () => {
73
    stubFetch(
74
      [
75
        [],
76
        [{ run_id: "run-1", sequence: 1, type: "text_delta", payload: { value: "one" } }],
77
        [
78
          { run_id: "run-1", sequence: 1, type: "text_delta", payload: { value: "one" } },
79
          { run_id: "run-1", sequence: 2, type: "text_delta", payload: { value: " two" } },
80
          { run_id: "run-1", sequence: 3, type: "completed", payload: {} },
81
        ],
82
      ],
83
      json(202, { turn: { id: "run-1" } }),
84
    );
85
86
    expect(await collect(source())).toBe("one two");
87
  });
88
89
  it("ignores reasoning deltas, which the transcript does not show", async () => {
90
    stubFetch(
91
      [
92
        [],
93
        [
94
          { run_id: "run-1", sequence: 1, type: "reasoning_delta", payload: { value: "thinking" } },
95
          { run_id: "run-1", sequence: 2, type: "text_delta", payload: { value: "said" } },
96
          { run_id: "run-1", sequence: 3, type: "completed", payload: {} },
97
        ],
98
      ],
99
      json(202, { turn: { id: "run-1" } }),
100
    );
101
102
    expect(await collect(source())).toBe("said");
103
  });
104
105
  it("reports a missing scope rather than an empty reply", async () => {
106
    stubFetch([[]], json(401, { error: "invalid_api_token" }));
107
    await expect(collect(source())).rejects.toThrow(OxAlphaUnavailable);
108
    await expect(collect(source())).rejects.toThrow(/chat:account/);
109
  });
110
111
  it("names the one-turn-at-a-time rule when the server refuses a concurrent turn", async () => {
112
    stubFetch([[]], json(409, { error: "turn_in_progress" }));
113
    await expect(collect(source())).rejects.toThrow(/one turn runs at a time/i);
114
  });
115
116
  it("surfaces a failed turn with the server's reason", async () => {
117
    stubFetch(
118
      [
119
        [],
120
        [
121
          {
122
            run_id: "run-1",
123
            sequence: 1,
124
            type: "failed",
125
            payload: { message: "provider refused" },
126
          },
127
        ],
128
      ],
129
      json(202, { turn: { id: "run-1" } }),
130
    );
131
    await expect(collect(source())).rejects.toThrow(/provider refused/);
132
  });
133
134
  it("sends the prompt and the reasoning effort the caller chose", async () => {
135
    const seen: Array<Record<string, unknown>> = [];
136
    let submitted = false;
137
    vi.stubGlobal(
138
      "fetch",
139
      vi.fn((url: URL, init?: RequestInit) => {
140
        if (url.pathname.endsWith("/chat/turns")) {
141
          seen.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
142
          submitted = true;
143
          return Promise.resolve(json(202, { turn: { id: "run-1" } }));
144
        }
145
        // The run does not exist until it is submitted, which is what the
146
        // pre-submit snapshot relies on.
147
        return Promise.resolve(
148
          json(200, {
149
            events: submitted
150
              ? [{ run_id: "run-1", sequence: 1, type: "completed", payload: {} }]
151
              : [],
152
          }),
153
        );
154
      }),
155
    );
156
157
    const configured = new OxAlphaReplySource({
158
      origin: "https://openagents.test",
159
      token: "test-token",
160
      reasoning: "high",
161
    });
162
    await collect(configured, "do the thing");
163
164
    expect(seen[0]).toEqual({ message: "do the thing", reasoning: "high" });
165
  });
166
167
  it("reports the model it runs on", () => {
168
    expect(source().model).toBe("stealth/ox-alpha");
169
  });
170
});

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