Take a short model name, render reasoning as Markdown, hold the clock

7be4b94e7fac · AtlantisPleb · · parent fa7e8405607e

Take a short model name, render reasoning as Markdown, hold the clock

**`ollama:qwen3.8` reaches `qwen3.8:27b-mtp-q8_0`.** An Ollama name carries its
size and quantisation after a colon, and a reader names the model they pulled.
Sent unresolved that gets `model not found` from a server which has the model,
which reads as the model being missing. Exact names are taken exactly and never
reinterpreted; otherwise the family prefix decides.

Only the family prefix. A test written to check that `qwen3.8` cannot mean
`qwen3.85` failed: a looser fallback was matching it. Running a different model
because it shares a few characters with the one that was asked for is worse than
saying nothing matched, so nothing matching now lists what is installed.

**Reasoning renders its Markdown.** It was plain text on the theory that
emphasis nested in italic reads worse than the source. The source is what a
reader actually got: models write `**#160**` and numbered lists in their
reasoning, and unrendered markup is harder to read than rendered markup in any
style. `renderMarkdown` takes a base style and restores it after every reset the
markup emits, because otherwise the first bold span ends the dim italic for the
rest of the row.

**The elapsed clock no longer restarts mid-turn.** It was reset on every
submission, so `/export` during a turn put it back to zero — which reads as the
turn having restarted when nothing happened to it. It resets when a turn begins
and not when something is typed into one that is already running.

403 tests pass.

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
  • modified packages/openagents-cli/src/coder-markdown.ts
  • modified packages/openagents-cli/src/coder-ollama.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/test/coder-ollama.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

6 files changed, +185 -18

packages/openagents-cli/src/cli.ts modified +29 -2

@@ -33,6 +33,7 @@ import {

33 33
  isOllamaModelFlag,
34 34
  OllamaReplySource,
35 35
  parseOllamaModelFlag,
36
  resolveOllamaModel,
36 37
} from "./coder-ollama.js";
37 38
import { openThread, ThreadUnavailable, type ThreadReplySource } from "./coder-thread.js";
38 39
import { delegateTool, openagentsTool, shellTool, skillTool } from "./coder-tools.js";

@@ -1686,8 +1687,34 @@ const coderCommand = Command.make(

1686 1687
        named === undefined && !offline ? yield* Effect.promise(() => discoverOllamaModel()) : undefined;
1687 1688
1688 1689
      const wantsOllama = named === undefined ? localModel !== undefined : isOllamaModelFlag(named);
1689
      const ollamaName =
1690
        named === undefined ? localModel : isOllamaModelFlag(named) ? parseOllamaModelFlag(named) : undefined;
1690
      const askedFor =
1691
        named === undefined
1692
          ? localModel
1693
          : isOllamaModelFlag(named)
1694
            ? parseOllamaModelFlag(named)
1695
            : undefined;
1696
1697
      // A name is resolved against what is installed, so `ollama:qwen3.8`
1698
      // reaches `qwen3.8:27b-mtp-q8_0`. An Ollama name carries its size and
1699
      // quantisation after a colon, and a reader names the model they pulled;
1700
      // sending that unresolved gets `model not found` from a server that has
1701
      // it, which reads as the model being missing. The discovered default is
1702
      // already a real name and needs no round trip.
1703
      const resolved =
1704
        wantsOllama && askedFor !== undefined && named !== undefined
1705
          ? yield* Effect.promise(() => resolveOllamaModel(askedFor))
1706
          : undefined;
1707
1708
      if (resolved !== undefined && resolved.model === undefined) {
1709
        return yield* new InputError({
1710
          message:
1711
            resolved.installed.length === 0
1712
              ? `No Ollama model matches ${askedFor}, and none are installed. Pull one with \`ollama pull\`.`
1713
              : `No Ollama model matches ${askedFor}. Installed: ${resolved.installed.join(", ")}.`,
1714
        });
1715
      }
1716
1717
      const ollamaName = resolved?.model ?? askedFor;
1691 1718
1692 1719
      // Any other `--model` value still has to name a published backend. The
1693 1720
      // flag takes a string so an `ollama:` prefix can reach the local server,
packages/openagents-cli/src/coder-markdown.ts modified +16 -2

@@ -54,7 +54,18 @@ export function wrapStyled(text: string, width: number, style: string): Readonly

54 54
}
55 55
56 56
/** Render Markdown source as styled rows no wider than `width`. */
57
export function renderMarkdown(text: string, width: number): ReadonlyArray<string> {
57
export function renderMarkdown(
58
  text: string,
59
  width: number,
60
  /**
61
   * A style every row sits in, restored after each reset the markup emits.
62
   *
63
   * Without this a caller cannot render Markdown inside a style of its own:
64
   * the first bold or code span ends with a reset, and everything after it on
65
   * that row loses the dim or the italic the caller asked for.
66
   */
67
  base = "",
68
): ReadonlyArray<string> {
58 69
  const rows: string[] = [];
59 70
  /** The fence marker that opened the current code block, if one is open. */
60 71
  let fence: string | undefined;

@@ -81,7 +92,10 @@ export function renderMarkdown(text: string, width: number): ReadonlyArray<strin

81 92
    rows.push(...blockRows(line, width));
82 93
  }
83 94
84
  return rows;
95
  if (base.length === 0) return rows;
96
  // Reapplied after every reset the markup wrote, and opened again on each row,
97
  // because a row is painted on its own and carries no style from the last.
98
  return rows.map((row) => `${base}${row.replaceAll(RESET, `${RESET}${base}`)}${RESET}`);
85 99
}
86 100
87 101
/** One non-fenced source line as one or more rendered rows. */
packages/openagents-cli/src/coder-ollama.ts modified +51 -9

@@ -55,27 +55,69 @@ export const discoverOllamaModel = async (

55 55
  host: string = DEFAULT_HOST,
56 56
  timeoutMs = 300,
57 57
): Promise<string | undefined> => {
58
  const deadline = AbortSignal.timeout(timeoutMs);
58
  return (await installedModels(host, timeoutMs))[0];
59
};
60
61
/**
62
 * The installed models, most recently modified first.
63
 *
64
 * Shared by discovery and by resolution, so the two cannot disagree about what
65
 * is on the machine.
66
 */
67
const installedModels = async (
68
  host: string,
69
  timeoutMs: number,
70
): Promise<ReadonlyArray<string>> => {
59 71
  try {
60
    const response = await fetch(new URL("/api/tags", host), { signal: deadline });
61
    if (!response.ok) return undefined;
72
    const response = await fetch(new URL("/api/tags", host), {
73
      signal: AbortSignal.timeout(timeoutMs),
74
    });
75
    if (!response.ok) return [];
62 76
    const body = (await response.json()) as {
63 77
      models?: ReadonlyArray<{ name?: unknown; modified_at?: unknown }>;
64 78
    };
65 79
    const models = (body.models ?? []).filter(
66 80
      (model): model is { name: string; modified_at?: string } => typeof model.name === "string",
67 81
    );
68
    if (models.length === 0) return undefined;
69
    // Sorting a fresh array, so nothing shared is mutated.
70 82
    // eslint-disable-next-line unicorn/no-array-sort -- the spread is the copy
71
    return [...models].sort((left, right) =>
72
      String(right.modified_at ?? "").localeCompare(String(left.modified_at ?? "")),
73
    )[0]?.name;
83
    return [...models]
84
      .sort((left, right) =>
85
        String(right.modified_at ?? "").localeCompare(String(left.modified_at ?? "")),
86
      )
87
      .map((model) => model.name);
74 88
  } catch {
75
    return undefined;
89
    return [];
76 90
  }
77 91
};
78 92
93
/**
94
 * The installed model a name means, or undefined when none does.
95
 *
96
 * An Ollama name carries its size and quantisation after a colon —
97
 * `qwen3.8:27b-mtp-q8_0` — and a reader naming the model they pulled says
98
 * `qwen3.8`. Sending that unresolved gets `model not found` from a server that
99
 * has the model, which reads as the model being missing.
100
 *
101
 * Exact first, so a full name is never reinterpreted. Then the family prefix,
102
 * which is what a short name means. The most recently modified wins where
103
 * several match, the same rule the default uses.
104
 */
105
export const resolveOllamaModel = async (
106
  name: string,
107
  host: string = DEFAULT_HOST,
108
  timeoutMs = 2_000,
109
): Promise<{ readonly model?: string; readonly installed: ReadonlyArray<string> }> => {
110
  const installed = await installedModels(host, timeoutMs);
111
  if (installed.includes(name)) return { model: name, installed };
112
113
  // `qwen3.8` means `qwen3.8:…` and nothing else. A looser prefix would let it
114
  // mean `qwen3.85:…`, which is a different model: running one because it
115
  // shares a few characters with the one that was asked for is worse than
116
  // saying nothing matched and listing what is there.
117
  const family = installed.find((candidate) => candidate.startsWith(`${name}:`));
118
  return { ...(family === undefined ? {} : { model: family }), installed };
119
};
120
79 121
/** True when `--model` names an Ollama source. */
80 122
export const isOllamaModelFlag = (value: string): boolean => value.startsWith("ollama:");
81 123
packages/openagents-cli/src/coder-ui.ts modified +12 -5

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

353 353
        return toolRows(entry.tool, width, expanded.has(entry.tool.callId));
354 354
      }
355 355
      if (entry.text.length === 0 && !entry.settled) return ["…"];
356
      // Reasoning is dim italic rather than Markdown. The styling already says
357
      // what the text is, and emphasis nested inside italic reads worse than
358
      // the source it came from.
359
      if (entry.role === "reasoning") return wrapStyled(entry.text, width, `${DIM}${ITALIC}`);
356
      // Reasoning is Markdown too, rendered inside dim italic. It was plain
357
      // text on the theory that emphasis nested in italic reads worse than the
358
      // source — but the source is what a reader actually got: models write
359
      // `**#160**` and numbered lists in their reasoning, and unrendered
360
      // markup is harder to read than rendered markup in any style.
361
      if (entry.role === "reasoning") {
362
        return renderMarkdown(entry.text, width, `${DIM}${ITALIC}`);
363
      }
360 364
      if (entry.role === "assistant") return renderMarkdown(entry.text, width);
361 365
      return wrapStyled(entry.text, width, entry.role === "notice" ? DIM : "");
362 366
    };

@@ -681,7 +685,10 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

681 685
      const prompt = composer;
682 686
      composer = "";
683 687
      anchor = undefined;
684
      runningSince = Date.now();
688
      // Only when a turn actually begins. Resetting on every submission made
689
      // `/export` mid-turn put the elapsed clock back to zero, which reads as
690
      // the turn having restarted when nothing happened to it at all.
691
      if (!session.running) runningSince = Date.now();
685 692
      render();
686 693
687 694
      // The elapsed time has to advance between chunks, not only when one
packages/openagents-cli/test/coder-ollama.test.ts modified +50

@@ -4,6 +4,7 @@ import type { ReplyChunk } from "../src/coder-session.js";

4 4
import type { CoderTool } from "../src/coder-tools.js";
5 5
import {
6 6
  discoverOllamaModel,
7
  resolveOllamaModel,
7 8
  isOllamaModelFlag,
8 9
  OllamaReplySource,
9 10
  parseOllamaModelFlag,

@@ -339,3 +340,52 @@ describe("finding a local model to default to", () => {

339 340
    await expect(discoverOllamaModel()).resolves.toBeUndefined();
340 341
  });
341 342
});
343
344
describe("naming a model by the part a reader remembers", () => {
345
  const serve = (names: ReadonlyArray<string>) =>
346
    vi.spyOn(globalThis, "fetch").mockResolvedValue(
347
      new Response(
348
        JSON.stringify({
349
          models: names.map((name, at) => ({
350
            name,
351
            modified_at: `2026-0${String(at + 1)}-01T00:00:00Z`,
352
          })),
353
        }),
354
        { status: 200, headers: { "content-type": "application/json" } },
355
      ),
356
    );
357
358
  afterEach(() => {
359
    vi.restoreAllMocks();
360
  });
361
362
  it("resolves a family name to the installed tag", async () => {
363
    serve(["qwen3.8:27b-mtp-q8_0"]);
364
365
    // An Ollama name carries its size and quantisation after a colon, and a
366
    // reader names the model they pulled.
367
    await expect(resolveOllamaModel("qwen3.8")).resolves.toMatchObject({
368
      model: "qwen3.8:27b-mtp-q8_0",
369
    });
370
  });
371
372
  it("takes a full name exactly, without reinterpreting it", async () => {
373
    serve(["qwen3.8:27b-mtp-q8_0", "qwen3.8:7b"]);
374
375
    await expect(resolveOllamaModel("qwen3.8:7b")).resolves.toMatchObject({ model: "qwen3.8:7b" });
376
  });
377
378
  it("does not let one family stand for another", async () => {
379
    serve(["qwen3.85:7b"]);
380
381
    // `qwen3.8` means `qwen3.8:…`, and must not mean `qwen3.85:…`.
382
    const found = await resolveOllamaModel("qwen3.8");
383
    expect(found.model).not.toBe("qwen3.85:7b");
384
  });
385
386
  it("reports what is installed when nothing matches", async () => {
387
    serve(["llama3:8b"]);
388
389
    await expect(resolveOllamaModel("qwen3.8")).resolves.toEqual({ installed: ["llama3:8b"] });
390
  });
391
});
packages/openagents-cli/test/coder-ui.test.ts modified +27

@@ -748,3 +748,30 @@ describe("typing while a turn is running", () => {

748 748
    await running;
749 749
  });
750 750
});
751
752
describe("reasoning in the transcript", () => {
753
  it("renders the Markdown a model writes in it", async () => {
754
    const stdin = new FakeIn();
755
    const stdout = new FakeOut();
756
    const session = new CoderSession(
757
      source([{ type: "reasoning", value: "1. **#160** is the bug" }]),
758
      "repo",
759
      "main",
760
    );
761
    const running = runCoderUi(session, {
762
      stdin: stdin as unknown as NodeJS.ReadStream,
763
      stdout: stdout as unknown as NodeJS.WriteStream,
764
    });
765
766
    await session.submit("go");
767
    const rows = screen(stdout.written).join("\n");
768
769
    // Models write `**bold**` and numbered lists in their reasoning, and
770
    // unrendered markup is harder to read than rendered markup in any style.
771
    expect(rows).toContain("#160 is the bug");
772
    expect(rows).not.toContain("**#160**");
773
774
    stdin.emit("data", "\x04");
775
    await running;
776
  });
777
});

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