Choose the child model in the delegate call

e66c1d21d7b8 · AtlantisPleb · · parent b807f814e6c5

Choose the child model in the delegate call

Asked "are you able to delegate to ox alpha", a session answered no — correctly,
and while holding exactly that lane. Its `delegate` tool took `prompt`, `count`
and `description` and nothing else, so the model the children ran on was fixed
when the session opened. The `superdelegate` skill meanwhile documented
`--child-model ox-alpha` as if it were reachable, so the skill and the tool
disagreed and the tool was right.

`delegate` now takes a `model`, enumerated from the lanes the session can
actually reach. A fan-out of straightforward fixes and a fan-out of design
questions are different work and want different models, and a session that had
to be restarted to change lanes is a session that will not change them. Where a
session can only run children one way the parameter is not offered at all: a
knob that goes nowhere is worse than no knob, which is the failure this started
from.

Lanes are named by the names people use. Ox Alpha's slug is
`opencode/x-preview-f-free` — it says neither `ox` nor `alpha` — so `ox-alpha`
and `gemini` resolve to their slugs, slugs still resolve to themselves, and the
same words work on `--child-model`, which is what the skill had always claimed.

They also report by those names. The first working version routed correctly and
labelled the fleet with the slug, and the session under test would not claim its
request had been honoured: it had asked for `ox-alpha` and been answered
`x-preview-f-free`, and could not tell routing from a silent fallback. It was
right not to. A lane now reports the name a reader would recognise however it
was reached, and the same session quotes back `opencode (ox-alpha)`.

One registry serves every lane, so children of two models render as one fleet
and stop together, and a lane is built once and reused rather than starting a
second fleet that competes with the first for the same concurrency cap.

433 tests pass, nine of them on naming and choosing a lane.

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/skills/superdelegate/SKILL.md
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-delegate.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-tools.ts
  • added packages/openagents-cli/test/coder-delegate-lanes.test.ts

Diff

8 files changed, +294 -45

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": 2438,
7
    "filesScanned": 2439,
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:154444d74279069de014797ee8c9943bc18f625702640bc4dbdb6bd1d5e82f18",
4
  "sourceDigest": "sha256:b66f940c59b2d89100cc77723ed92cd43247cc8b3b868330a5c2d39cdb6b33d9",
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 (38 tracked test files)"
1879
          "ref": "packages/openagents-cli (39 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/skills/superdelegate/SKILL.md modified +15 -9

@@ -23,12 +23,19 @@ conversation and cannot ask questions, so the prompt carries everything. Every

23 23
child gets the same prompt and is told its own number separately — write for
24 24
whichever child is reading rather than naming one.
25 25
26
`--child-model devin` runs the children on the Devin CLI instead, and
27
`devin:<mode>` picks a permission mode other than the default `dangerous`. A
28
Devin child brings its own credentials and model rather than spending this
29
session's grant. Prefer this over running `devin` yourself through `shell`: a
30
fleet child renders, stops with `ctrl+x`, and does not block the turn, while a
31
shell child is one opaque call that freezes the session until it ends.
26
**The lane is chosen per call.** `delegate` takes a `model`, and its enum is
27
the lanes this session can reach: `ox-alpha`, `gemini`, `devin`, and the model
28
slugs behind them. The same names work on the CLI as `--child-model`. A fan-out
29
of straightforward fixes and a fan-out of design questions are different work
30
and want different models, and there is no need to restart a session to change
31
lanes.
32
33
`devin` runs the children on the Devin CLI, and `devin:<mode>` picks a
34
permission mode other than the default `dangerous`. A Devin child brings its own
35
credentials and model rather than spending this session's grant. Prefer it over
36
running `devin` yourself through `shell`: a fleet child renders, stops with
37
`ctrl+x`, and does not block the turn, while a shell child is one opaque call
38
that freezes the session until it ends.
32 39
33 40
## Burning through a backlog
34 41

@@ -66,9 +73,8 @@ child its own git worktree so their edits cannot meet.

66 73
67 74
- **Devin** for straightforward engineering: a named fix, a test to write, a
68 75
  migration, a rename, a documented change with a clear shape.
69
- **opencode on Ox Alpha** (`--child-model ox-alpha`) for high-concept and
70
  strategic work: design, architecture, anything where the shape of the answer
71
  is the question.
76
- **Ox Alpha** (`model: "ox-alpha"`) for high-concept and strategic work:
77
  design, architecture, anything where the shape of the answer is the question.
72 78
73 79
If you cannot tell which, it is the second kind.
74 80
packages/openagents-cli/src/cli.ts modified +67 -24

@@ -20,11 +20,14 @@ import { startChildGateway } from "./coder-child-gateway.js";

20 20
import { writeChildHarnessConfig } from "./coder-child-config.js";
21 21
import type { DelegationOutcome } from "./coder-delegate.js";
22 22
import {
23
  CHILD_MODELS,
24
  childLaneName,
23 25
  DelegateFleet,
24 26
  DevinHarness,
25 27
  describePrompt,
26 28
  firstAvailableChildModel,
27 29
  OpencodeHarness,
30
  resolveChildLane,
28 31
} from "./coder-delegate.js";
29 32
import { fleetPlainLines } from "./coder-fleet.js";
30 33
import { runCoderPlain } from "./coder-plain.js";

@@ -1590,20 +1593,61 @@ async function buildDelegation(options: {

1590 1593
  const command = options.command ?? process.env["OPENAGENTS_DELEGATE_COMMAND"];
1591 1594
  const namedConfig = options.configPath ?? process.env["OPENAGENTS_DELEGATE_CONFIG"];
1592 1595
1596
  // One registry for the session, whatever a call chooses to run on, so the
1597
  // children of two models still render as one fleet and stop together.
1598
  const registry = new CoderTaskRegistry();
1599
1600
  // Labelled by the name that was asked for. A caller who names `ox-alpha` and
1601
  // is answered `x-preview-f-free` cannot tell whether the request was honoured
1602
  // or silently fell back, and one that was asked exactly this said so rather
1603
  // than guess.
1604
  const laneFor = (choice: string) => {
1605
    const harness = /^devin(:.+)?$/.test(choice)
1606
      ? new DevinHarness(
1607
          choice.startsWith("devin:") ? { permissionMode: choice.slice(6) } : {},
1608
        )
1609
      : new OpencodeHarness({
1610
          model: choice,
1611
          ...(command === undefined ? {} : { command }),
1612
          ...(namedConfig === undefined ? {} : { configPath: namedConfig }),
1613
          autoApprove: options.autoApprove,
1614
        });
1615
1616
    return {
1617
      fleet: new DelegateFleet(registry, harness, {
1618
        maxConcurrent: Math.max(1, options.concurrency),
1619
        cwd: options.cwd,
1620
      }),
1621
      label: `${harness.agent} (${childLaneName(harness.model)})`,
1622
    };
1623
  };
1624
1625
  // Cached, so a second call on the same model reuses its fleet rather than
1626
  // starting a second one that competes with the first for the same cap.
1627
  const lanes = new Map<string, { fleet: DelegateFleet; label: string }>();
1628
  const fleetFor = (choice: string) => {
1629
    const lane = resolveChildLane(choice);
1630
    if (lane === undefined) return undefined;
1631
    const existing = lanes.get(lane);
1632
    if (existing !== undefined) return existing;
1633
    const built = laneFor(lane);
1634
    lanes.set(lane, built);
1635
    return built;
1636
  };
1637
1593 1638
  // `--child-model devin` runs children on the Devin CLI instead. It brings its
1594 1639
  // own credentials and its own model, so it needs neither this session's grant
1595 1640
  // nor a gateway, and it is refused up front when it is not installed rather
1596 1641
  // than once per child.
1597
  if (named !== undefined && /^devin(:(.+))?$/.test(named.trim())) {
1598
    const mode = /^devin:(.+)$/.exec(named.trim())?.[1];
1599
    const harness = new DevinHarness(mode === undefined ? {} : { permissionMode: mode });
1600
    const registry = new CoderTaskRegistry();
1601
    const fleet = new DelegateFleet(registry, harness, {
1602
      maxConcurrent: Math.max(1, options.concurrency),
1603
      cwd: options.cwd,
1604
    });
1642
  // An alias on the flag too, so `--child-model ox-alpha` means what it says
1643
  // and the skill that documents it is not documenting a thing that fails.
1644
  const askedFor = named === undefined ? undefined : resolveChildLane(named);
1645
1646
  if (askedFor !== undefined && /^devin(:(.+))?$/.test(askedFor)) {
1647
    const mode = /^devin:(.+)$/.exec(askedFor)?.[1];
1648
    const lane = laneFor(mode === undefined ? "devin" : `devin:${mode}`);
1605 1649
    return {
1606
      delegation: { registry, fleet, label: `${harness.agent} (${harness.model})` },
1650
      delegation: { registry, ...lane, models: CHILD_MODELS, fleetFor },
1607 1651
      close: () => Promise.resolve(),
1608 1652
    };
1609 1653
  }

@@ -1622,25 +1666,19 @@ async function buildDelegation(options: {

1622 1666
      : undefined;
1623 1667
1624 1668
  if (free !== undefined) {
1625
    const harness = new OpencodeHarness({
1626
      model: free,
1627
      ...(command === undefined ? {} : { command }),
1628
      ...(namedConfig === undefined ? {} : { configPath: namedConfig }),
1629
      autoApprove: options.autoApprove,
1630
    });
1631
    const registry = new CoderTaskRegistry();
1632
    const fleet = new DelegateFleet(registry, harness, {
1633
      maxConcurrent: Math.max(1, options.concurrency),
1634
      cwd: options.cwd,
1635
    });
1669
    const lane = fleetFor(free) ?? laneFor(free);
1636 1670
    return {
1637
      delegation: { registry, fleet, label: `${harness.agent} (${free})` },
1671
      delegation: { registry, ...lane, models: CHILD_MODELS, fleetFor },
1638 1672
      close: () => Promise.resolve(),
1639 1673
    };
1640 1674
  }
1641 1675
1642 1676
  if (named !== undefined && named.trim().length > 0) {
1643
    model = named;
1677
    // `--child-model ox-alpha` means the lane, not a literal model name the
1678
    // harness has never heard of. Resolved here so the flag and the tool's
1679
    // `model` parameter accept the same words, and an unrecognised name is
1680
    // still passed through for the harness to refuse by name.
1681
    model = askedFor ?? named;
1644 1682
    configPath = namedConfig;
1645 1683
    close = () => Promise.resolve();
1646 1684
  } else if (options.grant !== undefined) {

@@ -1665,13 +1703,18 @@ async function buildDelegation(options: {

1665 1703
    configPath,
1666 1704
    autoApprove: options.autoApprove,
1667 1705
  });
1668
  const registry = new CoderTaskRegistry();
1669 1706
  const fleet = new DelegateFleet(registry, harness, {
1670 1707
    maxConcurrent: Math.max(1, options.concurrency),
1671 1708
    cwd: options.cwd,
1672 1709
  });
1673 1710
  return {
1674
    delegation: { registry, fleet, label: `${harness.agent} (${model})` },
1711
    delegation: {
1712
      registry,
1713
      fleet,
1714
      label: `${harness.agent} (${model})`,
1715
      models: CHILD_MODELS,
1716
      fleetFor,
1717
    },
1675 1718
    close,
1676 1719
  };
1677 1720
}
packages/openagents-cli/src/coder-delegate.ts modified +49

@@ -516,6 +516,55 @@ export const FREE_CHILD_MODELS: ReadonlyArray<string> = [

516 516
  "opencode/gemini-3.5-flash",
517 517
];
518 518
519
/**
520
 * What a lane is called, and what it resolves to.
521
 *
522
 * The names are the ones people use. Ox Alpha's slug is
523
 * `opencode/x-preview-f-free` — it says neither `ox` nor `alpha`, because
524
 * opencode's normalization maps `x-preview-f` to `ox-alpha` elsewhere — so a
525
 * session offered only the slug is a session nobody can ask for Ox Alpha by
526
 * name. Asked "can you delegate to ox alpha", one answered no while holding
527
 * exactly that lane under a name it could not connect to the question.
528
 *
529
 * A slug still resolves to itself, so nothing that already worked stops.
530
 */
531
export const CHILD_LANE_ALIASES: Readonly<Record<string, string>> = {
532
  "ox-alpha": "opencode/x-preview-f-free",
533
  gemini: "opencode/gemini-3.7-flash",
534
};
535
536
/**
537
 * Every lane a `delegate` call may name, by the name a caller would use.
538
 *
539
 * Devin is here because it brings its own credentials rather than spending this
540
 * session's grant. Offered as an enum so a call chooses from what exists rather
541
 * than from what it remembers.
542
 */
543
export const CHILD_MODELS: ReadonlyArray<string> = [
544
  ...Object.keys(CHILD_LANE_ALIASES),
545
  ...FREE_CHILD_MODELS,
546
  "devin",
547
];
548
549
/**
550
 * The name a lane is known by, given its slug.
551
 *
552
 * So a lane reached by its slug still reports the name a reader would recognise,
553
 * whichever way it was reached.
554
 */
555
export const childLaneName = (lane: string): string =>
556
  Object.entries(CHILD_LANE_ALIASES).find(([, slug]) => slug === lane)?.[0] ?? lane;
557
558
/** The lane a name means, whether it is an alias, a slug, or Devin. */
559
export const resolveChildLane = (name: string): string | undefined => {
560
  const asked = name.trim();
561
  if (asked.length === 0) return undefined;
562
  if (/^devin(:.+)?$/.test(asked)) return asked;
563
  const aliased = CHILD_LANE_ALIASES[asked];
564
  if (aliased !== undefined) return aliased;
565
  return FREE_CHILD_MODELS.includes(asked) ? asked : undefined;
566
};
567
519 568
/**
520 569
 * The first preferred model the harness offers, or undefined when it lists none.
521 570
 *
packages/openagents-cli/src/coder-session.ts modified +24 -2

@@ -161,14 +161,36 @@ export interface CoderSnapshot {

161 161
}
162 162
163 163
/** What the session needs in order to delegate. Absent means it cannot. */
164
export interface CoderDelegation {
165
  readonly registry: CoderTaskRegistry;
164
export interface CoderDelegationFleet {
166 165
  /** Usually a `DelegateFleet`. Narrow on purpose, so tests can stand in. */
167 166
  readonly fleet: { submit(request: DelegationRequest): Promise<DelegationOutcome> };
168 167
  /** Shown when the reader asks for help, and in the launch notice. */
169 168
  readonly label: string;
170 169
}
171 170
171
export interface CoderDelegation extends CoderDelegationFleet {
172
  readonly registry: CoderTaskRegistry;
173
  /**
174
   * The models a call may name, for the tool to offer and to validate against.
175
   *
176
   * Empty when the session can only run children one way, and the tool then
177
   * offers no choice rather than one that goes nowhere.
178
   */
179
  readonly models?: ReadonlyArray<string>;
180
  /**
181
   * A fleet running children on `model`, or the default when none is named.
182
   *
183
   * Built here rather than at session start because the choice belongs to the
184
   * call: a fan-out of straightforward fixes and a fan-out of design questions
185
   * are different work and want different models, and a session that had to be
186
   * restarted to change lanes is a session that will not change them.
187
   *
188
   * Returns undefined for a model this session cannot reach, so the tool can
189
   * say which ones it can rather than failing a child at launch.
190
   */
191
  fleetFor?(model: string): CoderDelegationFleet | undefined;
192
}
193
172 194
/** Where reply chunks come from. One implementation today; ACP is the next. */
173 195
export interface ReplySource {
174 196
  /** The label the status line shows for the reply source. */
packages/openagents-cli/src/coder-tools.ts modified +45 -7

@@ -19,6 +19,7 @@ import { existsSync } from "node:fs";

19 19
import { fileURLToPath } from "node:url";
20 20
21 21
import type { CoderDelegation } from "./coder-session.js";
22
import type { CoderTaskRegistry } from "./coder-tasks.js";
22 23
import {
23 24
  DEFAULT_TIMEOUT_MS,
24 25
  MAXIMUM_TIMEOUT_MS,

@@ -60,6 +61,7 @@ const CHILD_RESULT_LIMIT = 2_000;

60 61
 * the renderer reads the registry, not this call.
61 62
 */
62 63
export function delegateTool(delegation: CoderDelegation): CoderTool {
64
  const models = delegation.fleetFor === undefined ? [] : (delegation.models ?? []);
63 65
  return {
64 66
    name: "delegate",
65 67
    description:

@@ -73,7 +75,10 @@ export function delegateTool(delegation: CoderDelegation): CoderTool {

73 75
      'for whichever child reads it: say "read the file at your own number" rather than naming ' +
74 76
      'one child ("you are child 1"), which gives every child the same work and wastes the ' +
75 77
      "fan-out. Prefer one call with a count over several calls. At most " +
76
      `${String(MAX_DELEGATE_COUNT)} children.`,
78
      `${String(MAX_DELEGATE_COUNT)} children.` +
79
      (models.length === 0
80
        ? ""
81
        : ` Children run on ${delegation.label} unless \`model\` names another: ${models.join(", ")}.`),
77 82
    parameters: {
78 83
      type: "object",
79 84
      properties: {

@@ -93,6 +98,18 @@ export function delegateTool(delegation: CoderDelegation): CoderTool {

93 98
          type: "string",
94 99
          description: "Three to five words naming the task, shown in the fleet.",
95 100
        },
101
        ...(models.length === 0
102
          ? {}
103
          : {
104
              model: {
105
                type: "string",
106
                enum: [...models],
107
                description:
108
                  "Which model the children run on. Defaults to " +
109
                  `${delegation.label}. Straightforward engineering suits a fast model; ` +
110
                  "work whose shape is the question suits a stronger one.",
111
              },
112
            }),
96 113
      },
97 114
      required: ["prompt"],
98 115
      additionalProperties: false,

@@ -103,6 +120,20 @@ export function delegateTool(delegation: CoderDelegation): CoderTool {

103 120
        return "No children were started: `prompt` is required and must say what the child does.";
104 121
      }
105 122
123
      // The lane is chosen per call. A session that had to be restarted to
124
      // change models is a session that will not change them.
125
      const named = typeof args["model"] === "string" ? args["model"].trim() : "";
126
      const lane =
127
        named.length === 0
128
          ? { fleet: delegation.fleet, label: delegation.label }
129
          : delegation.fleetFor?.(named);
130
131
      if (lane === undefined) {
132
        return models.length === 0
133
          ? `This session runs children one way only, on ${delegation.label}, so \`model\` cannot be chosen here.`
134
          : `There is no \`${named}\` lane. This session can run children on: ${models.join(", ")}.`;
135
      }
136
106 137
      const requested = typeof args["count"] === "number" ? Math.trunc(args["count"]) : 1;
107 138
      const count = Math.min(MAX_DELEGATE_COUNT, Math.max(1, requested));
108 139
      const described = typeof args["description"] === "string" ? args["description"].trim() : "";

@@ -116,14 +147,14 @@ export function delegateTool(delegation: CoderDelegation): CoderTool {

116 147
      try {
117 148
        const outcomes = await Promise.all(
118 149
          Array.from({ length: count }, (_unused, index) =>
119
            delegation.fleet.submit({
150
            lane.fleet.submit({
120 151
              description,
121 152
              prompt: identify(prompt, index + 1, count),
122 153
              background: true,
123 154
            }),
124 155
          ),
125 156
        );
126
        return report(outcomes, delegation);
157
        return report(outcomes, delegation.registry, lane.label);
127 158
      } finally {
128 159
        signal.removeEventListener("abort", onAbort);
129 160
      }

@@ -145,7 +176,14 @@ function identify(prompt: string, index: number, count: number): string {

145 176
}
146 177
147 178
/** Every child's outcome, in the order they were launched. */
148
function report(outcomes: ReadonlyArray<DelegationOutcome>, delegation: CoderDelegation): string {
179
// The registry is the session's and is shared by every lane, so the children of
180
// two models still render as one fleet. The label is the lane's own, because
181
// that is what answered.
182
function report(
183
  outcomes: ReadonlyArray<DelegationOutcome>,
184
  registry: CoderTaskRegistry,
185
  label: string,
186
): string {
149 187
  const lines: string[] = [];
150 188
  let completed = 0;
151 189

@@ -155,7 +193,7 @@ function report(outcomes: ReadonlyArray<DelegationOutcome>, delegation: CoderDel

155 193
      continue;
156 194
    }
157 195
158
    const task = delegation.registry.get(outcome.taskId);
196
    const task = registry.get(outcome.taskId);
159 197
    const label = `${outcome.taskId}${task === undefined ? "" : ` ${task.description}`}`;
160 198
    if (outcome.status === "completed") {
161 199
      completed += 1;

@@ -168,12 +206,12 @@ function report(outcomes: ReadonlyArray<DelegationOutcome>, delegation: CoderDel

168 206
    } else {
169 207
      lines.push(`${label} stopped before finishing.`);
170 208
    }
171
    delegation.registry.markRead(outcome.taskId);
209
    registry.markRead(outcome.taskId);
172 210
  }
173 211
174 212
  const header =
175 213
    `${String(completed)} of ${String(outcomes.length)} ` +
176
    `${outcomes.length === 1 ? "child" : "children"} completed on ${delegation.label}.`;
214
    `${outcomes.length === 1 ? "child" : "children"} completed on ${label}.`;
177 215
  return [header, "", ...lines].join("\n");
178 216
}
179 217
packages/openagents-cli/test/coder-delegate-lanes.test.ts added +91

@@ -0,0 +1,91 @@

1
import { describe, expect, it } from "vitest";
2
3
import {
4
  CHILD_LANE_ALIASES,
5
  CHILD_MODELS,
6
  childLaneName,
7
  resolveChildLane,
8
} from "../src/coder-delegate.js";
9
import { delegateTool } from "../src/coder-tools.js";
10
import { CoderTaskRegistry } from "../src/coder-tasks.js";
11
import type { CoderDelegation } from "../src/coder-session.js";
12
13
describe("naming a lane", () => {
14
  it("resolves the name a person uses to the slug the harness knows", () => {
15
    // Ox Alpha's slug says neither `ox` nor `alpha`, so a session offered only
16
    // the slug is one nobody can ask for Ox Alpha by name.
17
    expect(resolveChildLane("ox-alpha")).toBe("opencode/x-preview-f-free");
18
    expect(CHILD_LANE_ALIASES["ox-alpha"]).toBe("opencode/x-preview-f-free");
19
  });
20
21
  it("resolves a slug to itself, so nothing that worked stops", () => {
22
    expect(resolveChildLane("opencode/x-preview-f-free")).toBe("opencode/x-preview-f-free");
23
  });
24
25
  it("carries a Devin permission mode through", () => {
26
    expect(resolveChildLane("devin")).toBe("devin");
27
    expect(resolveChildLane("devin:auto")).toBe("devin:auto");
28
  });
29
30
  it("refuses a name no lane answers to", () => {
31
    expect(resolveChildLane("gpt-9")).toBeUndefined();
32
    expect(resolveChildLane("  ")).toBeUndefined();
33
  });
34
35
  it("reports a lane by the name a reader would recognise", () => {
36
    // Reached by slug or by alias, it reports the same name, so a caller can
37
    // tell whether the lane they asked for is the lane that answered.
38
    expect(childLaneName("opencode/x-preview-f-free")).toBe("ox-alpha");
39
    expect(childLaneName("opencode/gemini-3.7-flash")).toBe("gemini");
40
  });
41
42
  it("offers the names first, since those are what a call would say", () => {
43
    expect(CHILD_MODELS.slice(0, 2)).toEqual(["ox-alpha", "gemini"]);
44
    expect(CHILD_MODELS).toContain("devin");
45
  });
46
});
47
48
describe("choosing a lane in the tool call", () => {
49
  const delegation = (options: { lanes?: boolean } = {}): CoderDelegation => {
50
    const registry = new CoderTaskRegistry();
51
    const fleet = { submit: () => Promise.resolve({ taskId: "t", status: "completed" } as never) };
52
    const base = { registry, fleet, label: "opencode (ox-alpha)" };
53
    return options.lanes === false
54
      ? base
55
      : {
56
          ...base,
57
          models: CHILD_MODELS,
58
          fleetFor: (name: string) =>
59
            resolveChildLane(name) === undefined
60
              ? undefined
61
              : { fleet, label: `opencode (${name})` },
62
        };
63
  };
64
65
  it("offers the lanes as an enum, so a call names one that exists", () => {
66
    const { parameters, description } = delegateTool(delegation());
67
    const model = (parameters["properties"] as Record<string, { enum?: string[] }>)["model"];
68
69
    expect(model?.enum).toContain("ox-alpha");
70
    expect(model?.enum).toContain("devin");
71
    expect(description).toContain("ox-alpha");
72
  });
73
74
  it("offers no choice when the session runs children one way", () => {
75
    // A knob that goes nowhere is worse than no knob: one session answered
76
    // "no, I cannot delegate to ox alpha" while holding exactly that lane.
77
    const { parameters } = delegateTool(delegation({ lanes: false }));
78
79
    expect(parameters["properties"]).not.toHaveProperty("model");
80
  });
81
82
  it("says which lanes exist when a call names one that does not", async () => {
83
    const output = await delegateTool(delegation()).run(
84
      { prompt: "go", model: "gpt-9" },
85
      new AbortController().signal,
86
    );
87
88
    expect(output).toContain("no `gpt-9` lane");
89
    expect(output).toContain("ox-alpha");
90
  });
91
});

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