Name Coder tiers to the reader, never vendor models

fb705a506a2d · AtlantisPleb · · parent 8703101286a8

Name Coder tiers to the reader, never vendor models

The invariant (#40, INVARIANTS.md "Coder Model Naming"): the coder shows
"OpenAgents Coder" and its tiers — Coder Auto, Coder Flash, Coder Pro,
Coder Local — and never a vendor model name. The tier map lives in one
module; ReplySource.model is the tier label and modelId carries the
vendor id for records. A fresh session with no --model runs as Coder
Auto: its thread opens unpinned and the proxy request names no model,
so the server selects the lane per call. Shift+tab cycles the tier,
building the next tier's source lazily and carrying the conversation
over; tab now cycles the reasoning level. Tool-family emphasis follows
modelId, which the label had been quietly starving.

Closes #40.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1mRkPGYmTVvMKqAzmQvy5
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>
Closes
#40

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 INVARIANTS.md
  • 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-backends.ts
  • modified packages/openagents-cli/src/coder-ollama.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-thread.ts
  • added packages/openagents-cli/src/coder-tiers.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/test/coder-ollama.test.ts
  • modified packages/openagents-cli/test/coder-resume.test.ts
  • modified packages/openagents-cli/test/coder-thread.test.ts
  • added packages/openagents-cli/test/coder-tiers.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

15 files changed, +516 -68

INVARIANTS.md modified +23

@@ -2500,3 +2500,26 @@ codex session` execution per agent. Only agent/turn refs, monotonic thread

2500 2500
- Operations (Cloud SQL monitoring, migration runner, compaction, capture
2501 2501
  daemon, hub reset, connection-pool saturation, secrets locations) are in
2502 2502
  `docs/khala-sync/RUNBOOK.md`.
2503
2504
## Coder Model Naming
2505
2506
The coder never shows a vendor model name. What a reader sees — the status
2507
line, the fleet rows, the resume picker, switch notices, the `/help` text —
2508
is "OpenAgents Coder" and its tiers: `Coder Auto` (the server picks the
2509
lane), `Coder Flash` (the fast tier), `Coder Pro` (the strong tier),
2510
`Coder Local` (a local model server answers). A model outside the tier map
2511
renders as the bare product name `Coder`, never as its id.
2512
2513
- The tier-to-model map lives in exactly one module,
2514
  `packages/openagents-cli/src/coder-tiers.ts`. Display code holds no vendor
2515
  strings.
2516
- `ReplySource.model` is the tier label; `ReplySource.modelId` carries the
2517
  vendor id for records and exports, which are allowed to name what a reader
2518
  could run again. The split is the enforcement seam: rendering reads
2519
  `model`, records read `modelId`.
2520
- Shift+Tab cycles the tier (Auto → Flash → Pro → Local → Auto); Tab cycles
2521
  the reasoning level. A fresh `openagents coder` session with no `--model`
2522
  runs as `Coder Auto`: its thread is opened unpinned and the inference proxy
2523
  request names no model, so the server selects the lane per call.
2524
- Held by `packages/openagents-cli/test/coder-tiers.test.ts`. Issue
2525
  OpenAgentsInc/openagents#40.
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": 2482,
7
    "filesScanned": 2483,
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:393cbfb62fb12f46fc1d8141ce09db98fe2310378007b0e6b4672fa0b033e9e3",
4
  "sourceDigest": "sha256:f6441bc52df1b0a94c3842b9ca752847c53ab41dcd78869b2b2bb6a8abdf2d11",
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 (73 tracked test files)"
1879
          "ref": "packages/openagents-cli (74 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +94 -7

@@ -58,7 +58,6 @@ import { runCoderUi } from "./coder-ui.js";

58 58
import {
59 59
  backendIds,
60 60
  chooseBackend,
61
  defaultBackendId,
62 61
  fetchServedCatalog,
63 62
  refuseBackend,
64 63
} from "./coder-backends.js";

@@ -74,6 +73,7 @@ import {

74 73
  remintThread,
75 74
  ThreadUnavailable,
76 75
  type ThreadReplySource,
76
  type WireMessage,
77 77
} from "./coder-thread.js";
78 78
import {
79 79
  assertResumable,

@@ -115,6 +115,7 @@ import { fileURLToPath } from "node:url";

115 115
import { rebuild, RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
116 116
import { loadSkillSelection, standingContext } from "./coder-skills.js";
117 117
import { startDevServer } from "./coder-dev-server.js";
118
import { TIER_MODELS, tierForModel, type CoderTierId } from "./coder-tiers.js";
118 119
import { ZenReplySource, zenCredential } from "./coder-zen.js";
119 120
import { describeWorkspace } from "./coder-workspace.js";
120 121
import { ComputerClient } from "./computer-client.js";

@@ -2228,10 +2229,10 @@ const coderCommand = Command.make(

2228 2229
                    // records it on the thread, and `--resume` filters on it
2229 2230
                    // rather than parsing the objective back.
2230 2231
                    repository: workspace.repository,
2231
                    // What the server said it serves, having been asked. The
2232
                    // static fallback is for a server too old to publish a
2233
                    // catalog, which is the only case where `chosen` is absent.
2234
                    model: chosen?.id ?? named ?? defaultBackendId(),
2232
                    // Pinned only when the reader named a model. Named
2233
                    // nothing, the thread opens unpinned — Coder Auto — and
2234
                    // the server picks the lane for every call.
2235
                    ...(named === undefined ? {} : { model: chosen?.id ?? named }),
2235 2236
                    reasoning: Option.getOrUndefined(reasoning),
2236 2237
                  }),
2237 2238
                // The server's own code and sentence, which is what turns a ninth

@@ -2329,12 +2330,98 @@ const coderCommand = Command.make(

2329 2330
      );
2330 2331
2331 2332
      const skills = loadSkillSelection();
2333
2334
      // How this session moves between Coder tiers (shift+tab). Granted tiers
2335
      // open a fresh thread carrying the conversation; Coder Local answers
2336
      // from the local Ollama server and still records when it can. Absent a
2337
      // stored credential there is nothing to build with, and the key falls
2338
      // back to whatever the source itself can cycle.
2339
      const buildTier = Option.isNone(stored)
2340
        ? undefined
2341
        : async (tier: CoderTierId, history: ReadonlyArray<unknown>): Promise<ReplySource> => {
2342
            const accountToken = Redacted.value(stored.value.token);
2343
            const carried = history as ReadonlyArray<WireMessage>;
2344
            if (tier === "local") {
2345
              const name = await discoverOllamaModel(process.env["OLLAMA_HOST"] ?? undefined);
2346
              if (name === undefined) {
2347
                throw new Error(
2348
                  "no local model server answered. Start Ollama with a model pulled, or set OLLAMA_HOST.",
2349
                );
2350
              }
2351
              const local = new OllamaReplySource({
2352
                model: name,
2353
                ...(process.env["OLLAMA_HOST"] ? { host: process.env["OLLAMA_HOST"] } : {}),
2354
                ...(Option.isSome(reasoning) ? { reasoning: reasoning.value } : {}),
2355
              });
2356
              local.preload(carried);
2357
              if (threadSyncWanted(process.env)) {
2358
                const recorded = await openLocalThread({
2359
                  origin: endpoint.origin,
2360
                  token: accountToken,
2361
                  objective: `openagents coder in ${workspace.repository} on ${workspace.branch}`,
2362
                  repository: workspace.repository,
2363
                  model: `ollama:${name}`,
2364
                  reasoning: Option.getOrUndefined(reasoning),
2365
                });
2366
                if (recorded?.threadId !== undefined) {
2367
                  local.useTranscript(
2368
                    new ThreadTranscriptWriter({
2369
                      origin: endpoint.origin,
2370
                      threadId: recorded.threadId,
2371
                      token: accountToken,
2372
                      onTrouble: (message) => {
2373
                        session.notice(message);
2374
                      },
2375
                    }),
2376
                  );
2377
                }
2378
              }
2379
              return local;
2380
            }
2381
            const opened = await openThread({
2382
              origin: endpoint.origin,
2383
              token: accountToken,
2384
              objective: `openagents coder in ${workspace.repository} on ${workspace.branch}`,
2385
              repository: workspace.repository,
2386
              ...(tier === "auto" ? {} : { model: TIER_MODELS[tier] }),
2387
              reasoning: Option.getOrUndefined(reasoning),
2388
            });
2389
            opened.preload(carried);
2390
            opened.useTranscript(
2391
              new ThreadTranscriptWriter({
2392
                origin: endpoint.origin,
2393
                threadId: opened.threadId,
2394
                token: accountToken,
2395
                onTrouble: (message) => {
2396
                  session.notice(message);
2397
                },
2398
              }),
2399
            );
2400
            return opened;
2401
          };
2402
2403
      // The tier this session opens as: Local on the Ollama lane, Auto when
2404
      // nothing was named, the named model's tier when it has one. A model
2405
      // outside the tier map leaves tier switching off rather than mislabeled.
2406
      const initialTier: CoderTierId | undefined =
2407
        ollamaSource !== undefined
2408
          ? "local"
2409
          : thread !== undefined
2410
            ? named === undefined
2411
              ? "auto"
2412
              : tierForModel(thread.modelId)
2413
            : undefined;
2414
2332 2415
      const session = new CoderSession(
2333 2416
        source,
2334 2417
        workspace.repository,
2335 2418
        workspace.branch,
2336 2419
        setup?.delegation,
2337 2420
        standingContext(skills.active(), process.cwd()),
2421
        undefined,
2422
        buildTier !== undefined && initialTier !== undefined
2423
          ? { initial: initialTier, build: buildTier }
2424
          : undefined,
2338 2425
      );
2339 2426
2340 2427
      // The resumed thread's history goes on the session before anything new,

@@ -2432,7 +2519,7 @@ const coderCommand = Command.make(

2432 2519
          capability,
2433 2520
          ...plugins.map((plugin) => pluginTool(plugin)),
2434 2521
        ];
2435
        source.useTools?.(tools);
2522
        session.declareTools(tools);
2436 2523
      };
2437 2524
      declareTools();
2438 2525

@@ -2609,7 +2696,7 @@ const coderCommand = Command.make(

2609 2696
    }),
2610 2697
).pipe(
2611 2698
  Command.withDescription(
2612
    "Open a terminal coding session on a thread of its own, or continue one with --resume. Replies come from the thread's grant through the inference proxy, so nothing typed here reaches /chat; --offline answers from a built-in stand-in instead. The session can delegate: ask it to split work and it runs child coding agents on a thread of their own pinned to Ox Alpha, or launch a fan-out yourself with `/delegate [<n>x] <prompt>`, and the interface shows the fleet",
2699
    "Open a terminal coding session on a thread of its own, or continue one with --resume. Replies come from the thread's grant through the inference proxy, so nothing typed here reaches /chat; --offline answers from a built-in stand-in instead. The session can delegate: ask it to split work and it runs child coding agents on a thread of their own, or launch a fan-out yourself with `/delegate [<n>x] <prompt>`, and the interface shows the fleet",
2613 2700
  ),
2614 2701
);
2615 2702
packages/openagents-cli/src/coder-backends.ts modified +9 -16

@@ -33,31 +33,24 @@ export interface CoderBackend {

33 33
  readonly label: string;
34 34
}
35 35
36
// Labels are Coder tiers, never vendor names: the invariant is that a vendor
37
// model name does not render (INVARIANTS.md "Coder Model Naming"). The ids
38
// stay vendor ids — they are what the API takes, not what a reader sees.
36 39
export const CODER_BACKENDS: readonly CoderBackend[] = [
37
  { id: "gemini-3.7-flash", label: "Gemini 3.7 Flash" },
38
  { id: "ox-alpha", label: "Ox Alpha" },
39
  { id: "gpt-5.6-luna", label: "Luna" },
40
  { id: "gemini-3.7-flash", label: "Coder Flash" },
41
  { id: "ox-alpha", label: "Coder" },
42
  { id: "gpt-5.6-luna", label: "Coder Pro" },
40 43
];
41 44
42 45
/**
43 46
 * The backend a coder session leads with when nobody names one.
44 47
 *
45
 * Gemini 3.7 Flash: fast, a million tokens of context, and steady enough to
46
 * hold a conversation. Delegated children run on Ox Alpha instead — it is
47
 * built for sustained agentic coding, which is what a child is given, and
48
 * where it stalls the cost is one child rather than the conversation.
49
 *
50
 * A *preference*, not an answer. Where a deployment does not serve it,
51
 * `chooseBackend` falls to the server's own default rather than opening a
52
 * thread on a name the catalog will refuse.
48
 * A *preference*, not an answer: `chooseBackend` falls to the server's own
49
 * default where a deployment does not serve it. A session that names nothing
50
 * at all does not use this — it opens unpinned, as Coder Auto.
53 51
 */
54 52
export const DEFAULT_CODER_BACKEND = "gemini-3.7-flash";
55 53
56
export const defaultBackendId = (): string =>
57
  CODER_BACKENDS.some((backend) => backend.id === DEFAULT_CODER_BACKEND)
58
    ? DEFAULT_CODER_BACKEND
59
    : (CODER_BACKENDS[0]?.id ?? "");
60
61 54
/** Every id, for a flag's error message and its accepted values. */
62 55
export const backendIds = (): readonly string[] => CODER_BACKENDS.map((backend) => backend.id);
63 56
packages/openagents-cli/src/coder-ollama.ts modified +31 -1

@@ -24,10 +24,12 @@ import { merge } from "./coder-merge.js";

24 24
import type { ReplyChunk, ReplySource } from "./coder-session.js";
25 25
import { LOCAL_LANE, systemPrompt } from "./coder-system.js";
26 26
import type { CoderTool } from "./coder-tools.js";
27
import { tierLabel } from "./coder-tiers.js";
27 28
import type { TranscriptSink } from "./coder-transcript.js";
28 29
29 30
const DEFAULT_HOST = "http://127.0.0.1:11434";
30 31
32
31 33
/**
32 34
 * How many rounds of tool calls one turn may take before it has to answer.
33 35
 *

@@ -215,6 +217,8 @@ export class OllamaReplySource implements ReplySource {

215 217
  private readonly host: string;
216 218
  private readonly modelName: string;
217 219
  private readonly transcript: WireMessage[] = [];
220
  /** Carried-over turns from a tier switch, spliced in after the anchor. */
221
  private seeded: ReadonlyArray<WireMessage> = [];
218 222
  private tools: ReadonlyArray<CoderTool> = [];
219 223
  /**
220 224
   * Messages that arrived mid-turn, waiting for the next step of it.

@@ -239,7 +243,31 @@ export class OllamaReplySource implements ReplySource {

239 243
  private callCount = 0;
240 244
241 245
  get model(): string {
242
    return `Ollama ${this.modelName}`;
246
    return tierLabel("local");
247
  }
248
249
  /** The wire transcript so far, without the system anchor. */
250
  history(): ReadonlyArray<WireMessage> {
251
    return this.transcript.filter((message) => message.role !== "system");
252
  }
253
254
  /**
255
   * Seed the transcript with the conversation a tier switch carries over.
256
   *
257
   * Held aside until the first turn composes the system anchor, so the anchor
258
   * still leads and the carried turns follow it in order. Only plain user and
259
   * assistant text carries: the thread lane's tool-call structures are another
260
   * wire shape, and a local model rereads the words, not the plumbing.
261
   */
262
  preload(messages: ReadonlyArray<{ readonly role: string; readonly content?: unknown }>): void {
263
    this.seeded = messages
264
      .filter(
265
        (message): message is { role: "user" | "assistant"; content: string } =>
266
          (message.role === "user" || message.role === "assistant") &&
267
          typeof message.content === "string" &&
268
          message.content.length > 0,
269
      )
270
      .map((message) => ({ role: message.role, content: message.content }));
243 271
  }
244 272
245 273
  /**

@@ -396,6 +424,8 @@ export class OllamaReplySource implements ReplySource {

396 424
        role: "system",
397 425
        content: systemPrompt(this.tools, LOCAL_LANE, this.standing),
398 426
      });
427
      this.transcript.push(...this.seeded);
428
      this.seeded = [];
399 429
    }
400 430
401 431
    this.transcript.push({ role: "user", content: prompt });
packages/openagents-cli/src/coder-session.ts modified +114 -5

@@ -20,6 +20,7 @@

20 20
 */
21 21
22 22
import type { DelegationOutcome, DelegationRequest } from "./coder-delegate.js";
23
import { nextTier, tierLabel, type CoderTierId } from "./coder-tiers.js";
23 24
import { parseDelegateCommand } from "./coder-delegate.js";
24 25
import { exportTrajectory } from "./coder-export.js";
25 26
import { VERSION } from "./version.js";

@@ -305,6 +306,13 @@ export interface ReplySource {

305 306
   * only where pressing it would do something.
306 307
   */
307 308
  cycleBackend?(): string;
309
  /**
310
   * The wire transcript so far, for a tier switch to carry to the next source.
311
   *
312
   * Optional: a source that keeps no transcript hands the next tier a fresh
313
   * conversation, which is the honest floor.
314
   */
315
  history?(): ReadonlyArray<unknown>;
308 316
  /**
309 317
   * How hard the model is asked to think, and what else it could be asked.
310 318
   *

@@ -495,6 +503,12 @@ export class CoderSession {

495 503
   * replayed history, so the first new turn must not pay for it again.
496 504
   */
497 505
  private restored = false;
506
  /** The tier this session answers as, when tier switching is wired. */
507
  private tier: CoderTierId | undefined;
508
  /** A tier chosen but not yet built; applied before the next turn. */
509
  private pendingTier: CoderTierId | undefined;
510
  /** The last tool set declared, re-declared to a source a tier switch builds. */
511
  private lastTools: ReadonlyArray<CoderTool> | undefined;
498 512
  /** Plugin loads and refusals, in the order they happened. */
499 513
  private readonly pluginEvents: CoderPluginEvent[] = [];
500 514
  /**

@@ -504,7 +518,7 @@ export class CoderSession {

504 518
  private readonly pluginTools = new Map<string, CoderPluginProvenance>();
505 519
506 520
  constructor(
507
    private readonly source: ReplySource,
521
    private source: ReplySource,
508 522
    private readonly repository: string,
509 523
    private readonly branch: string,
510 524
    private readonly delegation?: CoderDelegation,

@@ -524,7 +538,22 @@ export class CoderSession {

524 538
     * took their clipboard is a suite that changed the machine it was checking.
525 539
     */
526 540
    private readonly exports?: { readonly directory: string },
541
    /**
542
     * How this session moves between Coder tiers, when it can.
543
     *
544
     * `build` returns the source that answers as `tier`, already holding the
545
     * carried conversation. Absent, shift+tab falls back to the source's own
546
     * `cycleBackend`, which is the local lane cycling its own models.
547
     */
548
    private readonly tiers?: {
549
      readonly initial: CoderTierId;
550
      build(
551
        tier: CoderTierId,
552
        history: ReadonlyArray<unknown>,
553
      ): Promise<ReplySource>;
554
    },
527 555
  ) {
556
    this.tier = tiers?.initial;
528 557
    // A child reporting progress has to reach the renderer, and the renderer
529 558
    // subscribes to the session rather than to the registry, so the session
530 559
    // forwards. Without this the fleet block only moved when a chat chunk

@@ -560,7 +589,12 @@ export class CoderSession {

560 589
      running: this.controller !== undefined,
561 590
      repository: this.repository,
562 591
      branch: this.branch,
563
      model: this.source.model,
592
      model:
593
        this.pendingTier !== undefined
594
          ? tierLabel(this.pendingTier)
595
          : this.tier !== undefined
596
            ? tierLabel(this.tier)
597
            : this.source.model,
564 598
      reasoning: this.source.reasoning?.level,
565 599
      turns: this.turnCount,
566 600
      budget: this.source.budget,

@@ -666,6 +700,79 @@ export class CoderSession {

666 700
    return { changed: true, level };
667 701
  }
668 702
703
  /**
704
   * Declare the session's tools, and remember them.
705
706
   * Remembered because a tier switch builds a fresh source mid-session, and a
707
   * source that was never told the tools would answer a fan-out request by
708
   * saying it cannot.
709
   */
710
  declareTools(tools: ReadonlyArray<CoderTool>): void {
711
    this.lastTools = tools;
712
    this.source.useTools?.(tools);
713
  }
714
715
  /**
716
   * Move to the next Coder tier: Auto, Flash, Pro, Local, and around again.
717
   *
718
   * The label flips at once and the switch itself is lazy: the next turn
719
   * builds the tier's source, carrying the conversation over. Chosen mid-turn,
720
   * the running turn finishes on the source that accepted it.
721
   */
722
  cycleTier(): { readonly switched: boolean; readonly label: string | undefined } {
723
    if (this.tiers === undefined || this.tier === undefined) return this.cycleBackend();
724
    const to = nextTier(this.pendingTier ?? this.tier);
725
    this.pendingTier = to;
726
    this.notice(
727
      this.controller !== undefined
728
        ? `Switching to ${tierLabel(to)} on the next turn.`
729
        : `Switching to ${tierLabel(to)}.`,
730
      "model",
731
    );
732
    this.emit();
733
    return { switched: true, label: tierLabel(to) };
734
  }
735
736
  /** Whether shift+tab has anywhere to go. */
737
  get canCycleTier(): boolean {
738
    return this.tiers !== undefined || this.canCycleBackend;
739
  }
740
741
  /**
742
   * Build and install the tier chosen since the last turn, if any.
743
   *
744
   * A build that fails keeps the session on the tier that was answering, and
745
   * says why rather than switching silently or failing the turn.
746
   */
747
  private async applyPendingTier(): Promise<void> {
748
    if (this.tiers === undefined || this.pendingTier === undefined) return;
749
    const wanted = this.pendingTier;
750
    this.pendingTier = undefined;
751
    if (wanted === this.tier) return;
752
    try {
753
      const history = this.source.history?.() ?? [];
754
      const next = await this.tiers.build(wanted, history);
755
      const previous = this.source as { revoke?: () => Promise<void> };
756
      this.source = next;
757
      this.tier = wanted;
758
      if (this.lastTools !== undefined) next.useTools?.(this.lastTools);
759
      if (this.standing !== undefined && this.standing.length > 0) {
760
        next.useContext?.(this.standing);
761
      }
762
      // The thread the old source held is closed in the background; a switch
763
      // must not spend the reader's next turn waiting on a revocation.
764
      void Promise.resolve(previous.revoke?.()).catch(() => undefined);
765
      this.notice(`Now answering as ${tierLabel(wanted)}.`, "model");
766
    } catch (cause) {
767
      this.notice(
768
        `${tierLabel(wanted)} is not available: ` +
769
          (cause instanceof Error ? cause.message : String(cause)),
770
        "model",
771
      );
772
    }
773
    this.emit();
774
  }
775
669 776
  cycleBackend(): { readonly switched: boolean; readonly label: string | undefined } {
670 777
    if (this.source.cycleBackend === undefined) return { switched: false, label: undefined };
671 778
    if (this.controller !== undefined) {

@@ -786,8 +893,8 @@ export class CoderSession {

786 893
          "  enter                       send · steer a running turn",
787 894
          "  shift+enter                 queue for when the turn ends",
788 895
          "  esc                         interrupt the reply · clear the composer",
789
          "  tab                         switch model",
790
          "  shift+tab                   change how hard it thinks",
896
          "  shift+tab                   switch Coder tier (Auto, Flash, Pro, Local)",
897
          "  tab                         change how hard it thinks",
791 898
          "  ctrl+o                      expand a tool call",
792 899
          "  ctrl+x                      stop the children",
793 900
          "  →                           move into the children column",

@@ -843,7 +950,7 @@ export class CoderSession {

843 950
          "  enter        send, or steer a running turn",
844 951
          "  shift+enter  queue for when the turn ends",
845 952
          "  esc          interrupt the reply",
846
          "  tab          switch model · shift+tab  change thinking",
953
          "  shift+tab    switch tier · tab  change thinking",
847 954
          "  ctrl+o       expand a tool call · ctrl+x  stop children",
848 955
          "  pgup/pgdn    scroll · ctrl+d  quit",
849 956
        ].join("\n"),

@@ -934,6 +1041,8 @@ export class CoderSession {

934 1041
    this.emit();
935 1042
936 1043
    try {
1044
      await this.applyPendingTier();
1045
937 1046
      // The reader's entry above keeps what they typed; the model receives the
938 1047
      // standing context ahead of it on the first turn only.
939 1048
      const sent =
packages/openagents-cli/src/coder-thread.ts modified +34 -5

@@ -67,6 +67,7 @@ import { merge } from "./coder-merge.js";

67 67
import type { ReplyChunk, ReplySource } from "./coder-session.js";
68 68
import type { CoderTool } from "./coder-tools.js";
69 69
import { systemPrompt, THREAD_LANE } from "./coder-system.js";
70
import { coderTierLabel, tierLabel } from "./coder-tiers.js";
70 71
import type { TranscriptSink } from "./coder-transcript.js";
71 72
import { THREADS_PATH } from "./constants.js";
72 73

@@ -219,6 +220,7 @@ export async function openThread(options: ThreadOptions): Promise<ThreadReplySou

219 220
    grantToken: Redacted.make(token),
220 221
    proxyUrl: resolveProxyUrl(url, options.origin),
221 222
    model,
223
    auto: options.model === undefined,
222 224
    budget: budgetOf(record(grant["limits"]), record(grant["limits"])),
223 225
  });
224 226
}

@@ -315,6 +317,9 @@ export async function remintThread(options: ResumeGrantOptions): Promise<ThreadR

315 317
    grantToken: Redacted.make(token),
316 318
    proxyUrl: resolveProxyUrl(url, options.origin),
317 319
    model,
320
    // A resumed thread continues on the model its grant pins; the tier it
321
    // shows is the pinned model's tier rather than a remembered "auto".
322
    auto: false,
318 323
    budget: budgetOf(record(grant["remaining"]), record(grant["limits"])),
319 324
  });
320 325
}

@@ -326,6 +331,12 @@ interface SourceState {

326 331
  readonly grantToken: Redacted.Redacted<string>;
327 332
  readonly proxyUrl: string;
328 333
  readonly model: string;
334
  /**
335
   * Opened without naming a model, so the server picks the lane. The proxy
336
   * body then names none either: a grant pinned to the server default with no
337
   * model in the body is the shape the server answers by choosing.
338
   */
339
  readonly auto: boolean;
329 340
  readonly budget: ThreadBudget;
330 341
}
331 342

@@ -416,9 +427,25 @@ export class ThreadReplySource implements ReplySource {

416 427
   * reply on screen.
417 428
   */
418 429
  get model(): string {
430
    return this.state.auto ? tierLabel("auto") : coderTierLabel(this.state.model);
431
  }
432
433
  /** The vendor id the grant pins, for records; never rendered. */
434
  get modelId(): string {
419 435
    return this.state.model;
420 436
  }
421 437
438
  /**
439
   * The wire transcript so far, without the system anchor.
440
   *
441
   * A tier switch hands this to the source that continues the conversation;
442
   * the next source composes its own anchor, so handing it two would read as
443
   * the reader having typed one.
444
   */
445
  history(): ReadonlyArray<WireMessage> {
446
    return this.transcript.filter((message) => message.role !== "system");
447
  }
448
422 449
  /** What is left to spend, in the width a status line has for it. */
423 450
  get budget(): string {
424 451
    return formatBudget(this.remaining);

@@ -466,7 +493,9 @@ export class ThreadReplySource implements ReplySource {

466 493
   */
467 494
  /** The tools as declared, in the shape ATIF records them. */
468 495
  toolDefinitions(): ReadonlyArray<Record<string, unknown>> {
469
    const family = toolFamilyOf(this.model);
496
    // The family follows the vendor id: the label is a Coder tier and
497
    // deliberately says nothing about which family answers.
498
    const family = toolFamilyOf(this.modelId);
470 499
    return this.tools.map((tool) => ({
471 500
      type: "function",
472 501
      function: {

@@ -493,7 +522,7 @@ export class ThreadReplySource implements ReplySource {

493 522
      "",
494 523
      declarations,
495 524
      "",
496
      describeBudget(toolResultBudget(toolFamilyOf(this.model))),
525
      describeBudget(toolResultBudget(toolFamilyOf(this.modelId))),
497 526
    ].join("\n");
498 527
  }
499 528

@@ -751,7 +780,7 @@ export class ThreadReplySource implements ReplySource {

751 780
    // re-sends everything it has already read spends its wall clock on reading
752 781
    // it again. The allowance is this model family's, and a cut result says so.
753 782
    // The `tool.ran` event above kept the fuller copy.
754
    results.set(call.id, budgetedResult(output, toolFamilyOf(this.model)));
783
    results.set(call.id, budgetedResult(output, toolFamilyOf(this.modelId)));
755 784
  }
756 785
757 786
  /**

@@ -810,7 +839,7 @@ export class ThreadReplySource implements ReplySource {

810 839
          accept: "text/event-stream, application/json",
811 840
        },
812 841
        body: JSON.stringify({
813
          model: this.state.model,
842
          ...(this.state.auto ? {} : { model: this.state.model }),
814 843
          stream: true,
815 844
          messages: this.transcript,
816 845
          ...(this.tools.length === 0 || this.mustAnswer

@@ -823,7 +852,7 @@ export class ThreadReplySource implements ReplySource {

823 852
                  type: "function",
824 853
                  function: {
825 854
                    name: tool.name,
826
                    description: declaredDescription(tool, toolFamilyOf(this.model)),
855
                    description: declaredDescription(tool, toolFamilyOf(this.modelId)),
827 856
                    parameters: tool.parameters,
828 857
                  },
829 858
                })),
packages/openagents-cli/src/coder-tiers.ts added +72

@@ -0,0 +1,72 @@

1
/**
2
 * Coder tiers: the only names a reader ever sees for what answers.
3
 *
4
 * The invariant (OpenAgentsInc/openagents#40, INVARIANTS.md "Coder Model
5
 * Naming"): vendor model names do not render in the coder. A session is
6
 * "OpenAgents Coder", and what varies is the tier — `Coder Auto` when the
7
 * server picks the lane, `Coder Flash` for the fast tier, `Coder Pro` for the
8
 * strong tier, `Coder Local` when a local model server answers. The vendor id
9
 * still exists — records and exports need a name a reader could run again —
10
 * but it travels as `modelId`, never as the label.
11
 *
12
 * The tier-to-model map lives here and nowhere else, so renaming a vendor
13
 * model is a one-line change and no display code ever holds a vendor string.
14
 */
15
16
export type CoderTierId = "auto" | "flash" | "pro" | "local";
17
18
/** The vendor model each pinned tier opens its thread on. */
19
export const TIER_MODELS: Readonly<Record<"flash" | "pro", string>> = {
20
  flash: "gemini-3.7-flash",
21
  pro: "gpt-5.6-luna",
22
};
23
24
const LABELS: Readonly<Record<CoderTierId, string>> = {
25
  auto: "Coder Auto",
26
  flash: "Coder Flash",
27
  pro: "Coder Pro",
28
  local: "Coder Local",
29
};
30
31
/** The label the status line shows for a tier. */
32
export const tierLabel = (tier: CoderTierId): string => LABELS[tier];
33
34
/**
35
 * The tier a vendor model id belongs to, or undefined for one no tier pins.
36
 *
37
 * `ollama:` names are the local lane wherever they appear, so a resumed or
38
 * delegated session is labelled by what it is rather than by what it typed.
39
 */
40
export const tierForModel = (modelId: string | undefined): CoderTierId | undefined => {
41
  if (modelId === undefined) return "auto";
42
  if (modelId.startsWith("ollama:")) return "local";
43
  if (modelId === TIER_MODELS.flash) return "flash";
44
  if (modelId === TIER_MODELS.pro) return "pro";
45
  return undefined;
46
};
47
48
/**
49
 * The display name for any model id a surface holds.
50
 *
51
 * A model outside the tier map is still not shown: it is "Coder", the bare
52
 * product name. That is the invariant's floor — an unknown id is exactly the
53
 * case where showing the id would leak a vendor name nobody chose to show.
54
 */
55
export const coderTierLabel = (modelId: string | undefined): string => {
56
  const tier = tierForModel(modelId);
57
  return tier === undefined ? "Coder" : LABELS[tier];
58
};
59
60
/** Shift+Tab's orbit: Auto → Flash → Pro → Local → Auto. */
61
export const nextTier = (tier: CoderTierId): CoderTierId => {
62
  switch (tier) {
63
    case "auto":
64
      return "flash";
65
    case "flash":
66
      return "pro";
67
    case "pro":
68
      return "local";
69
    case "local":
70
      return "auto";
71
  }
72
};
packages/openagents-cli/src/coder-ui.ts modified +8 -7

@@ -41,6 +41,7 @@ import { activityRows, fleetRows, latestActivities, taskActivity } from "./coder

41 41
import { renderMarkdown, visibleWidth, wrapStyled } from "./coder-markdown.js";
42 42
import type { CoderEntry, CoderSession, CoderSnapshot, CoderToolCall } from "./coder-session.js";
43 43
import type { CoderTask, CoderTaskStatus } from "./coder-tasks.js";
44
import { coderTierLabel } from "./coder-tiers.js";
44 45
import { RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
45 46
import type { SkillSelection } from "./coder-skills.js";
46 47

@@ -778,7 +779,7 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

778 779
      const body = Math.max(20, width - 4);
779 780
780 781
      rows.push(
781
        `${BOLD}${task.description}${RESET} ${DIM}${task.agent} · ${task.model} · ${task.status}${RESET}`,
782
        `${BOLD}${task.description}${RESET} ${DIM}${task.agent} · ${coderTierLabel(task.model)} · ${task.status}${RESET}`,
782 783
        "",
783 784
      );
784 785

@@ -795,7 +796,7 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

795 796
      for (const entry of entries) {
796 797
        switch (entry.kind) {
797 798
          case "started":
798
            rows.push(`${DIM}started in ${entry.cwd} on ${entry.model}${RESET}`, "");
799
            rows.push(`${DIM}started in ${entry.cwd} on ${coderTierLabel(entry.model)}${RESET}`, "");
799 800
            break;
800 801
801 802
          case "tool":

@@ -1363,10 +1364,10 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1363 1364
            continue;
1364 1365
          }
1365 1366
          // Shift+tab, in both spellings: the classic back-tab and the one the
1366
          // keyboard protocol reports. Tab moves the model, shift+tab moves how
1367
          // hard it is asked to think.
1367
          // keyboard protocol reports. Shift+tab moves the Coder tier; tab
1368
          // moves how hard the model is asked to think.
1368 1369
          if (sequence === "\x1b[Z" || sequence === "\x1b[9;2u") {
1369
            session.cycleReasoning();
1370
            session.cycleTier();
1370 1371
            dirty = true;
1371 1372
            continue;
1372 1373
          }

@@ -1491,8 +1492,8 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1491 1492
1492 1493
        // Tab is a printable character to the run scanner below, so it has to
1493 1494
        // be claimed here or it lands in the composer as literal whitespace.
1494
        if (char === "\t" && session.canCycleBackend) {
1495
          session.cycleBackend();
1495
        if (char === "\t" && session.canCycleReasoning) {
1496
          session.cycleReasoning();
1496 1497
          dirty = false;
1497 1498
          index += 1;
1498 1499
          continue;
packages/openagents-cli/test/coder-ollama.test.ts modified +7 -9

@@ -560,15 +560,13 @@ describe("what goes back to the model each round", () => {

560 560
});
561 561
562 562
describe("which lane a session opens on", () => {
563
  it("leads with Gemini 3.7 Flash when nobody names a backend", async () => {
564
    const { defaultBackendId, CODER_BACKENDS } = await import("../src/coder-backends.js");
565
566
    // Local used to be the default whenever a machine happened to be running
567
    // Ollama, which made the session's model a property of the laptop rather
568
    // than a choice.
569
    expect(defaultBackendId()).toBe("gemini-3.7-flash");
570
    // And the list still mirrors the server's enum in its own order: a
571
    // preference is named, not expressed by reordering an agreement.
563
  it("keeps the catalog mirroring the server's enum in its own order", async () => {
564
    const { CODER_BACKENDS } = await import("../src/coder-backends.js");
565
566
    // A session that names nothing opens unpinned — Coder Auto — so there is
567
    // no client-side default to assert any more. The list still mirrors the
568
    // server's enum in its own order: a preference is named, not expressed by
569
    // reordering an agreement.
572 570
    expect(CODER_BACKENDS.map((backend) => backend.id)).toEqual([
573 571
      "gemini-3.7-flash",
574 572
      "ox-alpha",
packages/openagents-cli/test/coder-resume.test.ts modified +3 -1

@@ -541,7 +541,9 @@ describe("remintThread", () => {

541 541
    expect(calls[0]?.method).toBe("POST");
542 542
    expect(calls[0]?.url).toBe(`${ORIGIN}/api/v1/threads/${THREAD_ID}/grants`);
543 543
    expect(source.threadId).toBe(THREAD_ID);
544
    expect(source.model).toBe("gpt-5.6-luna");
544
    // The label is the Coder tier; the vendor id survives only as modelId.
545
    expect(source.model).toBe("Coder Pro");
546
    expect(source.modelId).toBe("gpt-5.6-luna");
545 547
    // A fresh grant has spent nothing, so the budget opens at its ceilings.
546 548
    expect(source.budget).toContain("256 calls");
547 549
  });
packages/openagents-cli/test/coder-thread.test.ts modified +5 -1

@@ -145,7 +145,10 @@ describe("openThread", () => {

145 145
    });
146 146
147 147
    expect(source.threadId).toBe(THREAD_ID);
148
    expect(source.model).toBe("gpt-5.6-luna");
148
    // Opened without naming a model: the session is Coder Auto, and the
149
    // vendor id the grant pinned survives only as modelId.
150
    expect(source.model).toBe("Coder Auto");
151
    expect(source.modelId).toBe("gpt-5.6-luna");
149 152
    expect(calls[0]?.method).toBe("POST");
150 153
    expect(calls[0]?.url).toBe(`${ORIGIN}/api/v1/threads`);
151 154
    expect(calls[0]?.authorization).toBe(`Bearer ${ACCOUNT_TOKEN}`);

@@ -1115,6 +1118,7 @@ describe("ThreadReplySource toolDefinitions", () => {

1115 1118
      grantToken: Redacted.make(GRANT_TOKEN),
1116 1119
      proxyUrl: `${ORIGIN}/api/inference/proxy`,
1117 1120
      model: "gemini-3.7-flash",
1121
      auto: false,
1118 1122
      budget: { calls: 256, totalTokens: 1_000_000, costMicrousd: 2_000_000 },
1119 1123
    });
1120 1124
    source.useTools([shellTool(process.cwd())]);
packages/openagents-cli/test/coder-tiers.test.ts added +94

@@ -0,0 +1,94 @@

1
import { describe, expect, it } from "vitest";
2
3
import { CODER_BACKENDS } from "../src/coder-backends.js";
4
import { CoderSession, type ReplySource } from "../src/coder-session.js";
5
import {
6
  coderTierLabel,
7
  nextTier,
8
  TIER_MODELS,
9
  tierForModel,
10
  tierLabel,
11
} from "../src/coder-tiers.js";
12
13
// The invariant (INVARIANTS.md "Coder Model Naming"): a vendor model name
14
// never renders. Every display path resolves through these functions, so the
15
// suite that holds the invariant is the suite that pins their answers.
16
describe("coder tiers", () => {
17
  it("labels every tier as OpenAgents Coder, never a vendor name", () => {
18
    expect(tierLabel("auto")).toBe("Coder Auto");
19
    expect(tierLabel("flash")).toBe("Coder Flash");
20
    expect(tierLabel("pro")).toBe("Coder Pro");
21
    expect(tierLabel("local")).toBe("Coder Local");
22
  });
23
24
  it("maps the pinned tier models and the local lane", () => {
25
    expect(coderTierLabel(TIER_MODELS.flash)).toBe("Coder Flash");
26
    expect(coderTierLabel(TIER_MODELS.pro)).toBe("Coder Pro");
27
    expect(coderTierLabel("ollama:qwen3.8:27b-mtp-q8_0")).toBe("Coder Local");
28
    expect(coderTierLabel(undefined)).toBe("Coder Auto");
29
  });
30
31
  it("shows the bare product name for a model no tier pins", () => {
32
    const label = coderTierLabel("some-experimental-model");
33
    expect(label).toBe("Coder");
34
    expect(label).not.toContain("experimental");
35
  });
36
37
  it("cycles Auto to Flash to Pro to Local and around", () => {
38
    expect(nextTier("auto")).toBe("flash");
39
    expect(nextTier("flash")).toBe("pro");
40
    expect(nextTier("pro")).toBe("local");
41
    expect(nextTier("local")).toBe("auto");
42
  });
43
44
  it("keeps the tier map and the backend catalog in agreement", () => {
45
    for (const backend of CODER_BACKENDS) {
46
      const tier = tierForModel(backend.id);
47
      // A backend outside the tier map must carry the bare product label; one
48
      // inside it must carry its tier's label. Either way, no vendor name.
49
      expect(backend.label).toBe(tier === undefined ? "Coder" : tierLabel(tier));
50
      expect(backend.label.startsWith("Coder")).toBe(true);
51
    }
52
  });
53
54
  it("never leaks a vendor id through any label", () => {
55
    for (const id of [
56
      TIER_MODELS.flash,
57
      TIER_MODELS.pro,
58
      "ox-alpha",
59
      "ollama:llama3",
60
      "anything-else",
61
    ]) {
62
      const label = coderTierLabel(id);
63
      expect(label).not.toContain(id);
64
      expect(label.startsWith("Coder")).toBe(true);
65
    }
66
  });
67
});
68
69
// The acceptance from OpenAgentsInc/openagents#40, held at the session level:
70
// a fresh session is Coder Auto, and shift+tab's cycle walks Flash, Pro,
71
// Local. The interface renders snapshot().model verbatim, so pinning the
72
// snapshot pins the bottom bar.
73
const silent: ReplySource = {
74
  model: "Coder Auto",
75
  async *reply() {
76
    yield { type: "text", value: "ok" } as const;
77
  },
78
};
79
80
describe("session tier cycling", () => {
81
  it("starts as Coder Auto and flips through Flash, Pro, Local on cycleTier", () => {
82
    const session = new CoderSession(silent, "repo", "main", undefined, undefined, undefined, {
83
      initial: "auto",
84
      build: () => Promise.resolve(silent),
85
    });
86
87
    expect(session.snapshot().model).toBe("Coder Auto");
88
    expect(session.cycleTier().label).toBe("Coder Flash");
89
    expect(session.snapshot().model).toBe("Coder Flash");
90
    expect(session.cycleTier().label).toBe("Coder Pro");
91
    expect(session.cycleTier().label).toBe("Coder Local");
92
    expect(session.cycleTier().label).toBe("Coder Auto");
93
  });
94
});
packages/openagents-cli/test/coder-ui.test.ts modified +19 -13

@@ -196,7 +196,7 @@ describe("runCoderUi", () => {

196 196
    expect(rows.join("\n")).not.toContain("shared with");
197 197
  });
198 198
199
  describe("switching backend with tab", () => {
199
  describe("switching backend with shift+tab", () => {
200 200
    const driveSwitchable = async (keys: ReadonlyArray<string>) => {
201 201
      const stdin = new FakeIn();
202 202
      const stdout = new FakeOut();

@@ -219,7 +219,7 @@ describe("runCoderUi", () => {

219 219
    };
220 220
221 221
    it("moves to the next backend and says so", async () => {
222
      const { session, rows } = await driveSwitchable(["\t"]);
222
      const { session, rows } = await driveSwitchable(["\x1b[Z"]);
223 223
224 224
      const expected = CODER_BACKENDS[1]?.label ?? "";
225 225
      expect(session.snapshot().model).toBe(expected);

@@ -227,14 +227,15 @@ describe("runCoderUi", () => {

227 227
    });
228 228
229 229
    it("wraps around, so every backend is reachable from one key", async () => {
230
      const { session } = await driveSwitchable(CODER_BACKENDS.map(() => "\t"));
230
      const { session } = await driveSwitchable(CODER_BACKENDS.map(() => "\x1b[Z"));
231 231
      expect(session.snapshot().model).toBe(CODER_BACKENDS[0]?.label);
232 232
    });
233 233
234
    it("does not leave a tab in the composer", async () => {
235
      const { rows } = await driveSwitchable(["\t"]);
234
    it("does not leave a stray byte in the composer", async () => {
235
      const { rows } = await driveSwitchable(["\x1b[Z"]);
236 236
      const composer = rows.find((row) => row.includes(">")) ?? "";
237 237
      expect(composer).not.toContain("\t");
238
      expect(composer).not.toContain("[Z");
238 239
    });
239 240
  });
240 241

@@ -722,7 +723,7 @@ describe("changing how hard the model thinks", () => {

722 723
    await running;
723 724
  });
724 725
725
  it("cycles it on shift+tab, in both spellings", async () => {
726
  it("cycles it on tab, in both spellings", async () => {
726 727
    const stdin = new FakeIn();
727 728
    const stdout = new FakeOut();
728 729
    const session = new CoderSession(thinking(), "repo", "main");

@@ -731,17 +732,17 @@ describe("changing how hard the model thinks", () => {

731 732
      stdout: stdout as unknown as NodeJS.WriteStream,
732 733
    });
733 734
734
    // The classic back-tab.
735
    stdin.emit("data", "\x1b[Z");
735
    // The plain byte.
736
    stdin.emit("data", "\t");
736 737
    expect(session.snapshot().reasoning).toBe("low");
737 738
738
    // And the one the keyboard protocol reports.
739
    stdin.emit("data", "\x1b[9;2u");
739
    // And the one the keyboard protocol reports as a bare tab.
740
    stdin.emit("data", "\x1b[9u");
740 741
    expect(session.snapshot().reasoning).toBe("medium");
741 742
742 743
    // Four presses left four notes, three of which were no longer true.
743
    stdin.emit("data", "\x1b[Z");
744
    stdin.emit("data", "\x1b[Z");
744
    stdin.emit("data", "\t");
745
    stdin.emit("data", "\t");
745 746
    const notes = session
746 747
      .snapshot()
747 748
      .entries.filter((entry) => entry.text.startsWith("Reasoning set to"))

@@ -797,7 +798,7 @@ describe("quitting with the keyboard protocol on", () => {

797 798
    }
798 799
  });
799 800
800
  it("leaves an ordinary tab meaning tab", async () => {
801
  it("keeps the model still on a bare tab, now that shift+tab moves it", async () => {
801 802
    const stdin = new FakeIn();
802 803
    const stdout = new FakeOut();
803 804
    const session = new CoderSession(switchable([]), "repo", "main");

@@ -808,8 +809,13 @@ describe("quitting with the keyboard protocol on", () => {

808 809
809 810
    const before = session.snapshot().model;
810 811
    stdin.emit("data", "\x1b[9u");
812
    expect(session.snapshot().model).toBe(before);
813
814
    stdin.emit("data", "\x1b[Z");
811 815
    expect(session.snapshot().model).not.toBe(before);
812 816
817
    // The tab landed in the composer, and ctrl+d only quits an empty line.
818
    stdin.emit("data", "\x7f");
813 819
    stdin.emit("data", "\x04");
814 820
    await running;
815 821
  });

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