Switch the coder's model with tab, and name it with --model

0b42ec5004d8 · AtlantisPleb · · parent 368f36defc64

Switch the coder's model with tab, and name it with --model

`openagents coder` reached one model, and said so in three hardcoded
places: a default string in the reply source, a literal in the status
line, and a sentence in the no-credential notice. The server now offers a
second backend, so all three were about to be wrong.

`coder-backends.ts` is the client's copy of the list the server publishes
at `GET /api/v3`. The `--model` flag takes its accepted values from it,
the status line takes its label from it, and tab walks it. Cycling rather
than toggling is what keeps a third backend to one entry: no second key,
no menu.

`OxAlphaReplySource` became `ChatApiReplySource`, parameterized by backend
rather than branching on one. It gained no provider knowledge in the
process — the server answers every backend with the same events, so the
backend here is a value the source sends as `model` and a label it
reports. The file is renamed to match, since it never ran Ox Alpha in the
first place; it talks to our chat API, which decides.

Tab applies to the next turn and is refused while one is running. The
refusal is the interesting half: the turn on screen was submitted against
the model the status line names, and switching mid-stream would move the
label out from under text that model produced. The session says so in a
notice instead. Tab also had to be claimed before the composer's run
scanner, which treats it as printable and would otherwise have appended
whitespace.

The hint bar names tab only where pressing it would do something — with
another backend to reach, and with nothing running — which is the rule the
bottom bar already followed for esc.

The choice does not persist across runs. `persisted-configuration.ts`
reads two fields and has no writer, and inventing a settings-writing path
is more than this change should carry. `--model` covers a one-shot, and
tab covers a thread.

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-backends.ts
  • renamed packages/openagents-cli/src/coder-chat-api.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • added packages/openagents-cli/test/coder-backends.test.ts
  • renamed packages/openagents-cli/test/coder-chat-api.test.ts
  • modified packages/openagents-cli/test/coder-session.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

9 files changed, +413 -39

packages/openagents-cli/src/cli.ts modified +18 -8

@@ -18,7 +18,8 @@ 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
import { backendIds, findBackend } from "./coder-backends.js";
22
import { ChatApiReplySource } from "./coder-chat-api.js";
22 23
import { describeWorkspace } from "./coder-workspace.js";
23 24
import { ComputerClient } from "./computer-client.js";
24 25
import { ComputerUp } from "./computer-up.js";

@@ -1401,6 +1402,12 @@ const coderReasoningFlag = Flag.choice("reasoning", [

1401 1402
  "high",
1402 1403
  "max",
1403 1404
]).pipe(Flag.optional, Flag.withDescription("Reasoning effort the server passes to the provider"));
1405
// The accepted values come from the same list the status line and Tab read, so
1406
// a backend cannot be offered by one and refused by another.
1407
const coderModelFlag = Flag.choice("model", backendIds() as string[]).pipe(
1408
  Flag.optional,
1409
  Flag.withDescription("The backend that answers the turn"),
1410
);
1404 1411
1405 1412
const coderCommand = Command.make(
1406 1413
  "coder",

@@ -1409,18 +1416,19 @@ const coderCommand = Command.make(

1409 1416
    plain: coderPlainFlag,
1410 1417
    offline: coderOfflineFlag,
1411 1418
    reasoning: coderReasoningFlag,
1419
    model: coderModelFlag,
1412 1420
  },
1413
  ({ prompt, plain, offline, reasoning }) =>
1421
  ({ prompt, plain, offline, reasoning, model }) =>
1414 1422
    Effect.gen(function* () {
1415 1423
      const flags = yield* rootCommand;
1416 1424
      const terminal = yield* TerminalSession;
1417 1425
      const workspace = describeWorkspace();
1418 1426
      const endpoint = yield* resolveApiEndpoint(endpointOverrides(flags));
1419 1427
1420
      // Replies come from the account chat API, which runs Ox Alpha through
1421
      // OpenRouter on the server, so the CLI never holds a provider key and a
1422
      // session costs exactly what the server metered. Without a credential it
1423
      // falls back to the stand-in and says so rather than failing.
1428
      // Replies come from the account chat API, so the CLI never holds a
1429
      // provider key and a thread costs exactly what the server metered.
1430
      // Without a credential it falls back to the stand-in and says so rather
1431
      // than failing.
1424 1432
      const stored = offline
1425 1433
        ? Option.none()
1426 1434
        : yield* findToken(endpoint.origin).pipe(

@@ -1429,11 +1437,13 @@ const coderCommand = Command.make(

1429 1437
            ),
1430 1438
          );
1431 1439
1440
      const chosen = Option.getOrUndefined(model);
1432 1441
      const source = Option.isSome(stored)
1433
        ? new OxAlphaReplySource({
1442
        ? new ChatApiReplySource({
1434 1443
            origin: endpoint.origin,
1435 1444
            token: Redacted.value(stored.value.token),
1436 1445
            reasoning: Option.getOrUndefined(reasoning),
1446
            backend: chosen === undefined ? undefined : findBackend(chosen),
1437 1447
          })
1438 1448
        : new DummyReplySource();
1439 1449

@@ -1442,7 +1452,7 @@ const coderCommand = Command.make(

1442 1452
      if (Option.isNone(stored) && !offline) {
1443 1453
        session.notice(
1444 1454
          "No stored credential, so replies come from the built-in stand-in. " +
1445
            "Run `openagents auth login --scope chat:account` to reach Ox Alpha.",
1455
            "Run `openagents auth login --scope chat:account` to reach a real model.",
1446 1456
        );
1447 1457
      }
1448 1458
packages/openagents-cli/src/coder-backends.ts added +49

@@ -0,0 +1,49 @@

1
/**
2
 * The backends `openagents coder` can send a turn to.
3
 *
4
 * The server owns the real list and publishes it at `GET /api/v3` under
5
 * `extensions["chat.openagents"].parameters.model`. This is the client's copy,
6
 * kept as data for the same reason the server keeps one: a backend the status
7
 * line offers, the `--model` flag accepts, and Tab cycles through has to be one
8
 * list, or the three drift and the CLI offers something the server refuses.
9
 *
10
 * Adding a backend is one entry here and one entry on the server. Nothing else
11
 * in this package names a backend.
12
 *
13
 * The `id` is what `POST /api/v3/chat/turns` takes as `model`, so it must match
14
 * the server's published enum exactly. The `label` is what a person reads in
15
 * the status bar, where the whole line is competing for a narrow terminal.
16
 */
17
18
export interface CoderBackend {
19
  /** The value the chat API takes as `model`. Matches the server's enum. */
20
  readonly id: string;
21
  /** The short name the status line shows. */
22
  readonly label: string;
23
}
24
25
export const CODER_BACKENDS: readonly CoderBackend[] = [
26
  { id: "ox-alpha", label: "Ox Alpha" },
27
  { id: "gemini-3.7-flash", label: "Gemini 3.7 Flash" },
28
];
29
30
/** The backend a turn uses when nothing named one. */
31
export const defaultBackend = (): CoderBackend => CODER_BACKENDS[0] as CoderBackend;
32
33
/** The backend with this id, or `undefined` when nothing matches. */
34
export const findBackend = (id: string): CoderBackend | undefined =>
35
  CODER_BACKENDS.find((backend) => backend.id === id);
36
37
/**
38
 * The next backend after this one, wrapping at the end.
39
 *
40
 * Cycling rather than toggling is what makes a third backend data: Tab keeps
41
 * working without a second key or a menu.
42
 */
43
export const nextBackend = (current: CoderBackend): CoderBackend => {
44
  const index = CODER_BACKENDS.findIndex((backend) => backend.id === current.id);
45
  return CODER_BACKENDS[(index + 1) % CODER_BACKENDS.length] as CoderBackend;
46
};
47
48
/** Every id, for a flag's error message and its accepted values. */
49
export const backendIds = (): readonly string[] => CODER_BACKENDS.map((backend) => backend.id);
packages/openagents-cli/src/coder-ox.ts → packages/openagents-cli/src/coder-chat-api.ts renamed +49 -21

@@ -1,11 +1,14 @@

1 1
/**
2
 * A reply source backed by the account chat API, which runs Ox Alpha through
3
 * OpenRouter on the server.
2
 * A reply source backed by the account chat API.
4 3
 *
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.
4
 * The CLI never holds a provider key and never talks to a model vendor. It
5
 * submits a turn and reads the durable event log the server writes, so a coder
6
 * thread costs exactly what the server metered and leaves the same receipts the
7
 * web surface leaves.
8
 *
9
 * Which model answers is the server's `model` parameter, and every backend
10
 * answers with the same events, so this file has no branch per backend: the
11
 * backend is a value it sends and a label it reports.
9 12
 *
10 13
 * Two properties of the shipped contract shape this file:
11 14
 *

@@ -18,6 +21,7 @@

18 21
 *   appears in pieces rather than at once.
19 22
 */
20 23
24
import { type CoderBackend, defaultBackend, nextBackend } from "./coder-backends.js";
21 25
import type { ReplyChunk } from "./coder-session.js";
22 26
23 27
const SUBMIT_PATH = "/api/v3/chat/turns";

@@ -27,12 +31,13 @@ const POLL_INTERVAL_MS = 250;

27 31
/** Give up rather than poll forever when a turn never reaches a terminal event. */
28 32
const TURN_TIMEOUT_MS = 300_000;
29 33
30
export interface OxAlphaOptions {
34
export interface ChatApiOptions {
31 35
  readonly origin: string;
32 36
  readonly token: string;
33 37
  /** Reasoning effort the server passes to the provider. */
34 38
  readonly reasoning?: string | undefined;
35
  readonly model?: string | undefined;
39
  /** The backend that answers. Defaults to the first in the published list. */
40
  readonly backend?: CoderBackend | undefined;
36 41
}
37 42
38 43
interface ChatEvent {

@@ -60,13 +65,13 @@ interface ToolCallView {

60 65
  readonly status?: string;
61 66
}
62 67
63
export class OxAlphaUnavailable extends Error {
68
export class ChatApiUnavailable extends Error {
64 69
  constructor(
65 70
    readonly code: string,
66 71
    message: string,
67 72
  ) {
68 73
    super(message);
69
    this.name = "OxAlphaUnavailable";
74
    this.name = "ChatApiUnavailable";
70 75
  }
71 76
}
72 77

@@ -78,8 +83,8 @@ export class OxAlphaUnavailable extends Error {

78 83
 * which made a tool call invisible and joined the sentence before it to the
79 84
 * sentence after it.
80 85
 */
81
export class OxAlphaReplySource {
82
  readonly model: string;
86
export class ChatApiReplySource {
87
  private backend: CoderBackend;
83 88
84 89
  /**
85 90
   * The server records one conversation per account, so a turn submitted here

@@ -94,8 +99,30 @@ export class OxAlphaReplySource {

94 99
    "This conversation is the account's one conversation, shared with /chat " +
95 100
    "and with earlier coder runs, so the model remembers turns from all of them.";
96 101
97
  constructor(private readonly options: OxAlphaOptions) {
98
    this.model = options.model ?? "stealth/ox-alpha";
102
  constructor(private readonly options: ChatApiOptions) {
103
    this.backend = options.backend ?? defaultBackend();
104
  }
105
106
  /** The label the status line shows, which is the current backend's. */
107
  get model(): string {
108
    return this.backend.label;
109
  }
110
111
  /** The id the next turn sends as `model`. */
112
  get backendId(): string {
113
    return this.backend.id;
114
  }
115
116
  /**
117
   * Move to the next backend and return its label.
118
   *
119
   * This changes only what the next turn asks for. A turn already running was
120
   * submitted with the backend it named and keeps it, because the server has
121
   * already accepted that turn and cannot be told to change its mind.
122
   */
123
  cycleBackend(): string {
124
    this.backend = nextBackend(this.backend);
125
    return this.backend.label;
99 126
  }
100 127
101 128
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {

@@ -107,7 +134,7 @@ export class OxAlphaReplySource {

107 134
108 135
    while (!signal.aborted) {
109 136
      if (Date.now() - startedAt > TURN_TIMEOUT_MS) {
110
        throw new OxAlphaUnavailable(
137
        throw new ChatApiUnavailable(
111 138
          "turn_timed_out",
112 139
          "The turn produced no terminal event within five minutes.",
113 140
        );

@@ -154,7 +181,7 @@ export class OxAlphaReplySource {

154 181
          // `code` beside it.
155 182
          const reason = event.payload?.["reason"];
156 183
          const code = event.payload?.["code"];
157
          throw new OxAlphaUnavailable(
184
          throw new ChatApiUnavailable(
158 185
            typeof code === "string" ? code : "turn_failed",
159 186
            typeof reason === "string" ? reason : "The turn failed on the server.",
160 187
          );

@@ -189,6 +216,7 @@ export class OxAlphaReplySource {

189 216
      },
190 217
      body: JSON.stringify({
191 218
        message: prompt,
219
        model: this.backend.id,
192 220
        ...(this.options.reasoning === undefined ? {} : { reasoning: this.options.reasoning }),
193 221
      }),
194 222
    });

@@ -196,23 +224,23 @@ export class OxAlphaReplySource {

196 224
    const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
197 225
198 226
    if (response.status === 401 || response.status === 403) {
199
      throw new OxAlphaUnavailable(
227
      throw new ChatApiUnavailable(
200 228
        "scope_missing",
201 229
        "This token cannot reach the chat API. Sign in again with the chat:account scope.",
202 230
      );
203 231
    }
204 232
    if (response.status === 409) {
205
      throw new OxAlphaUnavailable(
233
      throw new ChatApiUnavailable(
206 234
        "turn_in_progress",
207 235
        "The account already has a turn running. One turn runs at a time.",
208 236
      );
209 237
    }
210 238
    if (response.status === 429) {
211
      throw new OxAlphaUnavailable("rate_limited", "The chat API is rate limiting this account.");
239
      throw new ChatApiUnavailable("rate_limited", "The chat API is rate limiting this account.");
212 240
    }
213 241
    if (response.status < 200 || response.status >= 300) {
214 242
      const code = typeof body["error"] === "string" ? body["error"] : `http_${response.status}`;
215
      throw new OxAlphaUnavailable(code, `The chat API refused the turn (${code}).`);
243
      throw new ChatApiUnavailable(code, `The chat API refused the turn (${code}).`);
216 244
    }
217 245
218 246
    const turn = body["turn"];

@@ -233,7 +261,7 @@ export class OxAlphaReplySource {

233 261
    });
234 262
235 263
    if (response.status === 401 || response.status === 403) {
236
      throw new OxAlphaUnavailable(
264
      throw new ChatApiUnavailable(
237 265
        "scope_missing",
238 266
        "This token cannot read chat events. Sign in again with the chat:account scope.",
239 267
      );
packages/openagents-cli/src/coder-session.ts modified +39

@@ -88,6 +88,14 @@ export interface ReplySource {

88 88
   * another surface has to say so, because nothing else on screen would.
89 89
   */
90 90
  readonly scopeNotice?: string;
91
  /**
92
   * Move to the next backend and return its new label.
93
   *
94
   * A source that has only one backend leaves this undefined, and the interface
95
   * then offers no key for it. That is deliberate: the bottom bar names a key
96
   * only where pressing it would do something.
97
   */
98
  cycleBackend?(): string;
91 99
  /**
92 100
   * Yield the reply to `prompt` in chunks. Rendering appends each chunk as it
93 101
   * arrives, so a slow source shows partial text rather than nothing.

@@ -221,6 +229,37 @@ export class CoderSession {

221 229
    };
222 230
  }
223 231
232
  /**
233
   * Whether this thread can change backend at all.
234
   *
235
   * False for a source with nothing to switch to, which is what the interface
236
   * reads before offering the key.
237
   */
238
  get canCycleBackend(): boolean {
239
    return typeof this.source.cycleBackend === "function";
240
  }
241
242
  /**
243
   * Move the next turn to the next backend.
244
   *
245
   * Refused while a turn is running. The running turn was submitted with the
246
   * backend it named and the server has already accepted it, so switching now
247
   * would change the label without changing the answer being streamed under it
248
   * — the status line would name a model that did not produce the text on
249
   * screen. The caller shows the refusal rather than switching silently.
250
   */
251
  cycleBackend(): { readonly switched: boolean; readonly label: string | undefined } {
252
    if (this.source.cycleBackend === undefined) return { switched: false, label: undefined };
253
    if (this.controller !== undefined) {
254
      this.notice("A turn is running. The model switches on the next turn.");
255
      return { switched: false, label: this.source.model };
256
    }
257
258
    const label = this.source.cycleBackend();
259
    this.notice(`Model switched to ${label}.`);
260
    return { switched: true, label };
261
  }
262
224 263
  onChange(listener: () => void): () => void {
225 264
    this.listeners.add(listener);
226 265
    return () => this.listeners.delete(listener);
packages/openagents-cli/src/coder-ui.ts modified +12

@@ -364,6 +364,9 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

364 364
        if (composer.length > 0) keys.push("esc to clear");
365 365
        else keys.push("ctrl+d to quit");
366 366
      }
367
      // Only when there is another model to switch to, and only while nothing
368
      // is running: a turn already accepted keeps the backend it named.
369
      if (session.canCycleBackend && !snapshot.running) keys.push("tab to switch model");
367 370
      if (lines.length > transcriptHeight) keys.push("pgup/pgdn to scroll");
368 371
      if (focusedTool(snapshot) !== undefined) keys.push("ctrl+o to expand");
369 372

@@ -534,6 +537,15 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

534 537
          continue;
535 538
        }
536 539
540
        // Tab is a printable character to the run scanner below, so it has to
541
        // be claimed here or it lands in the composer as literal whitespace.
542
        if (char === "\t" && session.canCycleBackend) {
543
          session.cycleBackend();
544
          dirty = false;
545
          index += 1;
546
          continue;
547
        }
548
537 549
        if (char === "\x7f" || char === "\b") {
538 550
          composer = composer.slice(0, -1);
539 551
          dirty = true;
packages/openagents-cli/test/coder-backends.test.ts added +54

@@ -0,0 +1,54 @@

1
import { describe, expect, it } from "vitest";
2
3
import {
4
  backendIds,
5
  CODER_BACKENDS,
6
  defaultBackend,
7
  findBackend,
8
  nextBackend,
9
} from "../src/coder-backends.js";
10
11
/**
12
 * The list three surfaces read.
13
 *
14
 * `--model` takes its accepted values from it, the status line takes its label
15
 * from it, and Tab walks it. These pin the properties those three depend on, so
16
 * adding a backend stays one entry rather than one entry plus three fixes.
17
 */
18
describe("coder backends", () => {
19
  it("names each backend once", () => {
20
    expect(backendIds()).toEqual([...new Set(backendIds())]);
21
    expect(CODER_BACKENDS.length).toBeGreaterThan(1);
22
  });
23
24
  it("gives every backend a label a status line can show", () => {
25
    for (const backend of CODER_BACKENDS) {
26
      expect(backend.label).not.toBe("");
27
      expect(backend.id).not.toBe("");
28
    }
29
  });
30
31
  it("defaults to the first entry, and finds a backend by the id the server takes", () => {
32
    expect(defaultBackend()).toBe(CODER_BACKENDS[0]);
33
    expect(findBackend("gemini-3.7-flash")?.label).toBe("Gemini 3.7 Flash");
34
    expect(findBackend("gpt-4")).toBeUndefined();
35
  });
36
37
  it("reaches every backend by cycling, and returns to the start", () => {
38
    const seen: string[] = [];
39
    let current = defaultBackend();
40
    for (let step = 0; step < CODER_BACKENDS.length; step += 1) {
41
      current = nextBackend(current);
42
      seen.push(current.id);
43
    }
44
45
    expect(new Set(seen)).toEqual(new Set(backendIds()));
46
    expect(current).toBe(defaultBackend());
47
  });
48
49
  it("publishes ids the chat API's own enum lists", () => {
50
    // These are the values `POST /api/v3/chat/turns` accepts as `model`, so a
51
    // change here without the matching server change is a refusal at runtime.
52
    expect(backendIds()).toEqual(["ox-alpha", "gemini-3.7-flash"]);
53
  });
54
});
packages/openagents-cli/test/coder-ox.test.ts → packages/openagents-cli/test/coder-chat-api.test.ts renamed +60 -10

@@ -1,25 +1,26 @@

1 1
import { afterEach, describe, expect, it, vi } from "vitest";
2 2
3
import { OxAlphaReplySource, OxAlphaUnavailable } from "../src/coder-ox.js";
3
import { CODER_BACKENDS, defaultBackend, findBackend } from "../src/coder-backends.js";
4
import { ChatApiReplySource, ChatApiUnavailable } from "../src/coder-chat-api.js";
4 5
import type { ReplyChunk } from "../src/coder-session.js";
5 6
6 7
const json = (status: number, body: unknown) =>
7 8
  new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
8 9
9
const chunks = async (source: OxAlphaReplySource, prompt = "hello") => {
10
const chunks = async (source: ChatApiReplySource, prompt = "hello") => {
10 11
  const out: ReplyChunk[] = [];
11 12
  for await (const chunk of source.reply(prompt, new AbortController().signal)) out.push(chunk);
12 13
  return out;
13 14
};
14 15
15 16
/** The assistant text a turn produced, which most of these tests assert on. */
16
const collect = async (source: OxAlphaReplySource, prompt = "hello") =>
17
const collect = async (source: ChatApiReplySource, prompt = "hello") =>
17 18
  (await chunks(source, prompt))
18 19
    .map((chunk) => (chunk.type === "text" ? chunk.value : ""))
19 20
    .join("");
20 21
21 22
const source = () =>
22
  new OxAlphaReplySource({ origin: "https://openagents.test", token: "test-token" });
23
  new ChatApiReplySource({ origin: "https://openagents.test", token: "test-token" });
23 24
24 25
afterEach(() => {
25 26
  vi.unstubAllGlobals();

@@ -43,7 +44,7 @@ const stubFetch = (pages: ReadonlyArray<ReadonlyArray<unknown>>, submit = json(2

43 44
  return calls;
44 45
};
45 46
46
describe("OxAlphaReplySource", () => {
47
describe("ChatApiReplySource", () => {
47 48
  it("yields text deltas for the submitted run and stops at completed", async () => {
48 49
    stubFetch(
49 50
      [

@@ -223,7 +224,7 @@ describe("OxAlphaReplySource", () => {

223 224
224 225
  it("reports a missing scope rather than an empty reply", async () => {
225 226
    stubFetch([[]], json(401, { error: "invalid_api_token" }));
226
    await expect(collect(source())).rejects.toThrow(OxAlphaUnavailable);
227
    await expect(collect(source())).rejects.toThrow(ChatApiUnavailable);
227 228
    await expect(collect(source())).rejects.toThrow(/chat:account/);
228 229
  });
229 230

@@ -273,18 +274,67 @@ describe("OxAlphaReplySource", () => {

273 274
      }),
274 275
    );
275 276
276
    const configured = new OxAlphaReplySource({
277
    const configured = new ChatApiReplySource({
277 278
      origin: "https://openagents.test",
278 279
      token: "test-token",
279 280
      reasoning: "high",
280 281
    });
281 282
    await collect(configured, "do the thing");
282 283
283
    expect(seen[0]).toEqual({ message: "do the thing", reasoning: "high" });
284
    expect(seen[0]).toEqual({
285
      message: "do the thing",
286
      model: defaultBackend().id,
287
      reasoning: "high",
288
    });
289
  });
290
291
  it("reports the label of the backend it is set to", () => {
292
    expect(source().model).toBe(defaultBackend().label);
293
    expect(source().backendId).toBe(defaultBackend().id);
294
  });
295
296
  it("sends the backend it was constructed with", async () => {
297
    const seen: Array<Record<string, unknown>> = [];
298
    let page = 0;
299
    vi.stubGlobal(
300
      "fetch",
301
      vi.fn((url: URL, init?: RequestInit) => {
302
        if (url.pathname.endsWith("/chat/turns")) {
303
          seen.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
304
          return Promise.resolve(json(202, { turn: { id: "run-1" } }));
305
        }
306
        // The source snapshots the log before it submits, so the first read
307
        // has to predate the turn or it counts the reply as already seen.
308
        const events =
309
          page++ === 0
310
            ? []
311
            : [
312
                { run_id: "run-1", sequence: 1, type: "text_delta", payload: { value: "hi" } },
313
                { run_id: "run-1", sequence: 2, type: "response_completed", payload: {} },
314
              ];
315
        return Promise.resolve(json(200, { events }));
316
      }),
317
    );
318
319
    const gemini = new ChatApiReplySource({
320
      origin: "https://openagents.test",
321
      token: "test-token",
322
      backend: findBackend("gemini-3.7-flash"),
323
    });
324
325
    expect(gemini.model).toBe("Gemini 3.7 Flash");
326
    expect(await collect(gemini, "hello")).toBe("hi");
327
    expect(seen[0]?.["model"]).toBe("gemini-3.7-flash");
284 328
  });
285 329
286
  it("reports the model it runs on", () => {
287
    expect(source().model).toBe("stealth/ox-alpha");
330
  it("cycles through every backend and wraps back to the first", () => {
331
    const cycling = source();
332
    const labels = CODER_BACKENDS.map(() => cycling.cycleBackend());
333
334
    // Every backend is reachable, and the last cycle returns to the start, so
335
    // a third entry needs no second key.
336
    expect(new Set(labels).size).toBe(CODER_BACKENDS.length);
337
    expect(cycling.model).toBe(defaultBackend().label);
288 338
  });
289 339
290 340
  it("says that its turns land in the account's one shared conversation", () => {
packages/openagents-cli/test/coder-session.test.ts modified +65

@@ -259,4 +259,69 @@ describe("CoderSession", () => {

259 259
    const roles = new Set(session.snapshot().entries.map((entry) => entry.role));
260 260
    expect(roles).toEqual(new Set(["you", "reasoning", "tool", "assistant"]));
261 261
  });
262
263
  describe("switching backend", () => {
264
    const noop = () => {};
265
266
    /** A source that can switch, and records how often it was asked to. */
267
    const switchable = (hold: Promise<void>): ReplySource & { switches: number } => {
268
      const state = {
269
        switches: 0,
270
        model: "First",
271
        cycleBackend() {
272
          state.switches += 1;
273
          state.model = `Model ${state.switches}`;
274
          return state.model;
275
        },
276
        async *reply() {
277
          await hold;
278
          yield { type: "text", value: "done" } as const;
279
        },
280
      };
281
      return state;
282
    };
283
284
    it("a source with one backend offers no switch", () => {
285
      const session = new CoderSession(new DummyReplySource(0), "repo", "main");
286
287
      expect(session.canCycleBackend).toBe(false);
288
      expect(session.cycleBackend()).toEqual({ switched: false, label: undefined });
289
    });
290
291
    it("switches while idle and names the new model", () => {
292
      const session = new CoderSession(switchable(Promise.resolve()), "repo", "main");
293
294
      expect(session.canCycleBackend).toBe(true);
295
      expect(session.cycleBackend()).toEqual({ switched: true, label: "Model 1" });
296
      expect(session.snapshot().model).toBe("Model 1");
297
      expect(session.snapshot().entries.at(-1)?.text).toBe("Model switched to Model 1.");
298
    });
299
300
    it("refuses while a turn is running, and says why", async () => {
301
      let release: () => void = noop;
302
      const hold = new Promise<void>((resolve) => {
303
        release = resolve;
304
      });
305
      const source = switchable(hold);
306
      const session = new CoderSession(source, "repo", "main");
307
308
      const running = session.submit("go");
309
      expect(session.running).toBe(true);
310
311
      const result = session.cycleBackend();
312
313
      // The turn on screen was submitted against the model the status line
314
      // names, so the label must not move out from under it.
315
      expect(result.switched).toBe(false);
316
      expect(source.switches).toBe(0);
317
      expect(session.snapshot().model).toBe("First");
318
      expect(session.snapshot().entries.at(-1)?.text).toContain("on the next turn");
319
320
      release();
321
      await running;
322
323
      // And it works again the moment the turn is over.
324
      expect(session.cycleBackend().switched).toBe(true);
325
    });
326
  });
262 327
});
packages/openagents-cli/test/coder-ui.test.ts modified +67

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

1 1
import { EventEmitter } from "node:events";
2 2
import { describe, expect, it } from "vitest";
3 3
4
import { CODER_BACKENDS, defaultBackend } from "../src/coder-backends.js";
4 5
import { CoderSession, type ReplyChunk, type ReplySource } from "../src/coder-session.js";
5 6
import { runCoderUi } from "../src/coder-ui.js";
6 7

@@ -39,6 +40,23 @@ const source = (chunks: ReadonlyArray<ReplyChunk>): ReplySource => ({

39 40
  },
40 41
});
41 42
43
/** A source with backends to cycle, which is what makes Tab mean anything. */
44
const switchable = (chunks: ReadonlyArray<ReplyChunk>): ReplySource => {
45
  let index = 0;
46
  return {
47
    get model() {
48
      return CODER_BACKENDS[index]?.label ?? "none";
49
    },
50
    cycleBackend() {
51
      index = (index + 1) % CODER_BACKENDS.length;
52
      return CODER_BACKENDS[index]?.label ?? "none";
53
    },
54
    async *reply() {
55
      for (const chunk of chunks) yield chunk;
56
    },
57
  };
58
};
59
42 60
/**
43 61
 * Replay the painted rows.
44 62
 *

@@ -220,5 +238,54 @@ describe("runCoderUi", () => {

220 238
    expect(bar).toContain("ctrl+d to quit");
221 239
    // There is nothing to interrupt while the session is idle.
222 240
    expect(bar).not.toContain("interrupt");
241
    // And nothing to switch to, because this source has one backend.
242
    expect(bar).not.toContain("tab to switch");
243
  });
244
245
  describe("switching backend with tab", () => {
246
    const driveSwitchable = async (keys: ReadonlyArray<string>) => {
247
      const stdin = new FakeIn();
248
      const stdout = new FakeOut();
249
      const session = new CoderSession(
250
        switchable([{ type: "text", value: "hello" }]),
251
        "repo",
252
        "main",
253
      );
254
      const running = runCoderUi(session, {
255
        stdin: stdin as unknown as NodeJS.ReadStream,
256
        stdout: stdout as unknown as NodeJS.WriteStream,
257
      });
258
259
      await session.submit("go");
260
      for (const key of keys) stdin.emit("data", key);
261
      const painted = stdout.written;
262
      stdin.emit("data", "\x04");
263
      await running;
264
      return { painted, rows: screen(painted), session };
265
    };
266
267
    it("names the key only where there is another backend to reach", async () => {
268
      const { rows } = await driveSwitchable([]);
269
      expect(rows.at(-1) ?? "").toContain("tab to switch model");
270
    });
271
272
    it("moves to the next backend and says so", async () => {
273
      const { session, rows } = await driveSwitchable(["\t"]);
274
275
      const expected = CODER_BACKENDS[1]?.label ?? "";
276
      expect(session.snapshot().model).toBe(expected);
277
      expect(rows.join("\n")).toContain(`Model switched to ${expected}.`);
278
    });
279
280
    it("wraps around, so every backend is reachable from one key", async () => {
281
      const { session } = await driveSwitchable(CODER_BACKENDS.map(() => "\t"));
282
      expect(session.snapshot().model).toBe(defaultBackend().label);
283
    });
284
285
    it("does not leave a tab in the composer", async () => {
286
      const { rows } = await driveSwitchable(["\t"]);
287
      const composer = rows.find((row) => row.includes(">")) ?? "";
288
      expect(composer).not.toContain("\t");
289
    });
223 290
  });
224 291
});

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