Count the turn in flight, and say whose replies the count is

a2615b70c8c4 · AtlantisPleb · · parent b93309390bae

Count the turn in flight, and say whose replies the count is

The status line read `0 replies` under a visibly streaming reply, in a
conversation the model demonstrably remembered. Two causes, and the number was
wrong in two different directions at once.

`CoderSession` incremented `turnCount` in the `finally` block of `submit`, so
the first reply of a run rendered `0` for its whole duration. A turn that is
happening is a turn: the count now moves when the turn starts. A prompt the
session refused — empty, or a second one while the first runs — still does not
count, because it never started.

The count is also this process's, not the conversation's, and those differ. The
server records one conversation per account, so `OxAlphaReplySource` submits
into the conversation `/chat` writes to and every earlier `coder` run wrote to.
A fresh process reporting a small number for a conversation with hundreds of
events reads as a fault in the model's memory rather than in the counter. The
count is kept local, because reading the conversation's own total would put a
second network dependency in the status line and would mean something
different for each reply source, but it is now labelled `this run` so the
reader knows which of the two it is looking at.

A source now declares where its turns are recorded. `OxAlphaReplySource` says
its conversation is shared with `/chat` and with earlier runs, and both the
interface and the piped path show that once at the start. The stand-in says
nothing, because its turns really are private to the process. This is the
`DATA-002` shape the Thread work replaces; until a thread exists the interface
must not imply the session is private to this terminal.

The bar also hid the counter it was supposed to show. `justify` dropped the
right side when the two halves collided, so at 100 columns a full hint list
pushed the reply count off the row entirely. Hints are reminders of keys that
work whether or not they are printed; the counter is state the reader is trying
to read. Hints are now listed in the order a reader needs them and dropped from
the end until the row fits, and the counter is never what gives way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified packages/openagents-cli/src/coder-ox.ts
  • modified packages/openagents-cli/src/coder-plain.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/test/coder-ox.test.ts
  • modified packages/openagents-cli/test/coder-session.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

7 files changed, +187 -8

packages/openagents-cli/src/coder-ox.ts modified +13

@@ -81,6 +81,19 @@ export class OxAlphaUnavailable extends Error {

81 81
export class OxAlphaReplySource {
82 82
  readonly model: string;
83 83
84
  /**
85
   * The server records one conversation per account, so a turn submitted here
86
   * lands in the same conversation `/chat` writes to and every earlier
87
   * `openagents coder` run wrote to. That is why the model remembers a
88
   * question this terminal never asked, and there is nothing else on screen
89
   * that would tell a reader so. The Thread work replaces this; until a thread
90
   * exists the interface must not imply the session is private to this
91
   * terminal.
92
   */
93
  readonly scopeNotice =
94
    "This conversation is the account's one conversation, shared with /chat " +
95
    "and with earlier coder runs, so the model remembers turns from all of them.";
96
84 97
  constructor(private readonly options: OxAlphaOptions) {
85 98
    this.model = options.model ?? "stealth/ox-alpha";
86 99
  }
packages/openagents-cli/src/coder-plain.ts modified +6

@@ -65,6 +65,12 @@ export async function runCoderPlain(

65 65
    }
66 66
  };
67 67
68
  // Where the turns are recorded is a property of the session, not of the
69
  // interface, so the piped path says it too rather than leaving it to the
70
  // one reader who happens to be on a TTY.
71
  const scope = session.snapshot().scope;
72
  if (scope !== undefined) stdout.write(`${scope}\n`);
73
68 74
  const unsubscribe = session.onChange(flush);
69 75
  flush();
70 76
packages/openagents-cli/src/coder-session.ts modified +26 -2

@@ -60,14 +60,34 @@ export interface CoderSnapshot {

60 60
  readonly repository: string;
61 61
  readonly branch: string;
62 62
  readonly model: string;
63
  /** Replies produced this session, shown where a real grant shows call count. */
63
  /**
64
   * Turns this process has submitted, counted from the moment one starts.
65
   *
66
   * A turn in flight is counted, because a status line that reads `0` under a
67
   * visibly streaming reply contradicts what the reader can see. The number is
68
   * deliberately about this process and nothing else, and the renderer says so:
69
   * the source may be writing into a conversation that already holds turns
70
   * this process never saw.
71
   */
64 72
  readonly turns: number;
73
  /**
74
   * What a reader needs to know about where this source records its turns, or
75
   * undefined when there is nothing to say. Shown once, at the start.
76
   */
77
  readonly scope: string | undefined;
65 78
}
66 79
67 80
/** Where reply chunks come from. One implementation today; ACP is the next. */
68 81
export interface ReplySource {
69 82
  /** The label the status line shows for the reply source. */
70 83
  readonly model: string;
84
  /**
85
   * One sentence about where this source's turns are recorded, shown once at
86
   * the start of a session. A source whose turns are private to this process
87
   * leaves it unset; a source that writes into a conversation shared with
88
   * another surface has to say so, because nothing else on screen would.
89
   */
90
  readonly scopeNotice?: string;
71 91
  /**
72 92
   * Yield the reply to `prompt` in chunks. Rendering appends each chunk as it
73 93
   * arrives, so a slow source shows partial text rather than nothing.

@@ -197,6 +217,7 @@ export class CoderSession {

197 217
      branch: this.branch,
198 218
      model: this.source.model,
199 219
      turns: this.turnCount,
220
      scope: this.source.scopeNotice,
200 221
    };
201 222
  }
202 223

@@ -246,6 +267,10 @@ export class CoderSession {

246 267
247 268
    const controller = new AbortController();
248 269
    this.controller = controller;
270
    // Counted here rather than on completion. A turn that is happening is a
271
    // turn, and counting it only once it settled is what made the status line
272
    // read `0 replies` under a reply the reader was watching arrive.
273
    this.turnCount += 1;
249 274
    this.emit();
250 275
251 276
    try {

@@ -319,7 +344,6 @@ export class CoderSession {

319 344
        if (entry.tool?.status === "running") entry.tool.status = "failed";
320 345
      }
321 346
      this.controller = undefined;
322
      this.turnCount += 1;
323 347
      this.emit();
324 348
    }
325 349
  }
packages/openagents-cli/src/coder-ui.ts modified +31 -6

@@ -89,6 +89,23 @@ function justify(left: string, right: string, width: number): string {

89 89
  return left + " ".repeat(width - used) + right;
90 90
}
91 91
92
/**
93
 * Lay out the key hints against the counter, dropping hints from the end until
94
 * the row fits.
95
 *
96
 * The counter is state the reader is trying to read; the hints are reminders
97
 * of keys that work whether or not they are printed. So the hints are what
98
 * gives way. Padding the two apart and dropping the counter instead is how a
99
 * wide-enough terminal still managed to hide the reply count.
100
 */
101
function hints(keys: ReadonlyArray<string>, right: string, width: number): string {
102
  for (let count = keys.length; count > 0; count -= 1) {
103
    const left = `${DIM}${keys.slice(0, count).join(" · ")}${RESET}`;
104
    if (visibleWidth(left) + visibleWidth(right) + 2 <= width) return justify(left, right, width);
105
  }
106
  return right;
107
}
108
92 109
/** Human-readable elapsed time, in the shape a status line wants. */
93 110
function elapsed(sinceMs: number, nowMs: number): string {
94 111
  const seconds = Math.max(0, Math.round((nowMs - sinceMs) / 1000));

@@ -335,9 +352,10 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

335 352
      rows.push(`  › ${composer}`);
336 353
      rows.push(rule);
337 354
338
      // Every key named here does something in the state it is named in. An
339
      // earlier version offered "esc esc to interrupt" while idle, where there
340
      // was nothing to interrupt.
355
      // Every key named here does something in the state it is named in, and
356
      // they are listed in the order a reader needs them, because a narrow row
357
      // drops them from the end. An earlier version offered "esc esc to
358
      // interrupt" while idle, where there was nothing to interrupt.
341 359
      const keys: string[] = [];
342 360
      if (snapshot.running) {
343 361
        keys.push("esc to interrupt", "ctrl+c to stop");

@@ -349,13 +367,18 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

349 367
      if (lines.length > transcriptHeight) keys.push("pgup/pgdn to scroll");
350 368
      if (focusedTool(snapshot) !== undefined) keys.push("ctrl+o to expand");
351 369
370
      // `this run` is not decoration. The count is this process's, and the
371
      // source may be writing into a conversation that already holds turns
372
      // from `/chat` and from earlier runs, so an unlabelled number would read
373
      // as the conversation's and contradict what the model remembers.
374
      const replies = `${snapshot.turns} ${snapshot.turns === 1 ? "reply" : "replies"} this run`;
352 375
      const counter =
353 376
        anchor !== undefined
354 377
          ? `${YELLOW}scrolled${RESET}${DIM} · ↑${above} · ↓${below}${RESET}`
355 378
          : above > 0
356
            ? `${DIM}↑${above} above · ${snapshot.turns} ${snapshot.turns === 1 ? "reply" : "replies"}${RESET}`
357
            : `${DIM}${snapshot.turns} ${snapshot.turns === 1 ? "reply" : "replies"}${RESET}`;
358
      rows.push(`  ${justify(`${DIM}${keys.join(" · ")}${RESET}`, counter, inner)}`);
379
            ? `${DIM}↑${above} above · ${replies}${RESET}`
380
            : `${DIM}${replies}${RESET}`;
381
      rows.push(`  ${hints(keys, counter, inner)}`);
359 382
360 383
      paint(rows, transcriptHeight + 3, 4 + composer.length + 1);
361 384
    };

@@ -574,6 +597,8 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

574 597
      "openagents coder — development build. Type a message and press enter. " +
575 598
        "Ctrl+D quits, Esc interrupts a reply.",
576 599
    );
600
    const scope = session.snapshot().scope;
601
    if (scope !== undefined) session.notice(scope);
577 602
    render();
578 603
  });
579 604
}
packages/openagents-cli/test/coder-ox.test.ts modified +7

@@ -286,4 +286,11 @@ describe("OxAlphaReplySource", () => {

286 286
  it("reports the model it runs on", () => {
287 287
    expect(source().model).toBe("stealth/ox-alpha");
288 288
  });
289
290
  it("says that its turns land in the account's one shared conversation", () => {
291
    // The server records one conversation per account, so this source cannot
292
    // let the interface imply the session is private to one terminal.
293
    expect(source().scopeNotice).toContain("/chat");
294
    expect(source().scopeNotice).toContain("remembers");
295
  });
289 296
});
packages/openagents-cli/test/coder-session.test.ts modified +38

@@ -197,6 +197,44 @@ describe("CoderSession", () => {

197 197
    expect(again?.tool?.output).toBeUndefined();
198 198
  });
199 199
200
  it("counts the turn while it is streaming rather than only once it settles", async () => {
201
    const session = new CoderSession(scripted(["a", "b", "c"], 15), "repo", "main");
202
    const seen: number[] = [];
203
    session.onChange(() => {
204
      if (session.snapshot().running) seen.push(session.snapshot().turns);
205
    });
206
207
    await session.submit("go");
208
209
    // A reply the reader can watch arrive is a turn that has happened.
210
    expect(seen.length).toBeGreaterThan(0);
211
    expect(seen.every((turns) => turns === 1)).toBe(true);
212
    expect(session.snapshot().turns).toBe(1);
213
  });
214
215
  it("does not count a prompt it refused", async () => {
216
    const session = new CoderSession(scripted(["a"]), "repo", "main");
217
    await session.submit("   ");
218
    expect(session.snapshot().turns).toBe(0);
219
220
    const first = session.submit("first");
221
    await session.submit("second while the first runs");
222
    await first;
223
    expect(session.snapshot().turns).toBe(1);
224
  });
225
226
  it("carries the source's scope notice, and nothing when the source has none", () => {
227
    const local = new CoderSession(scripted([]), "repo", "main");
228
    expect(local.snapshot().scope).toBeUndefined();
229
230
    const shared = new CoderSession(
231
      { ...scripted([]), scopeNotice: "shared with /chat" },
232
      "repo",
233
      "main",
234
    );
235
    expect(shared.snapshot().scope).toBe("shared with /chat");
236
  });
237
200 238
  it("carries workspace and model into the snapshot for the status line", () => {
201 239
    const session = new CoderSession(new DummyReplySource(), "openagents", "main");
202 240
    const snapshot = session.snapshot();
packages/openagents-cli/test/coder-ui.test.ts modified +66

@@ -147,6 +147,72 @@ describe("runCoderUi", () => {

147 147
    expect(painted).toContain("\x1b[2m\x1b[3mI should check first.\x1b[0m");
148 148
  });
149 149
150
  it("counts the streaming turn, so the bar never reads zero under a live reply", async () => {
151
    const stdin = new FakeIn();
152
    const stdout = new FakeOut();
153
    let release = () => {};
154
    const held = new Promise<void>((resolve) => {
155
      release = resolve;
156
    });
157
    const paused: ReplySource = {
158
      model: "scripted",
159
      async *reply() {
160
        yield { type: "text", value: "still arriving" } as const;
161
        await held;
162
      },
163
    };
164
165
    const session = new CoderSession(paused, "repo", "main");
166
    const running = runCoderUi(session, {
167
      stdin: stdin as unknown as NodeJS.ReadStream,
168
      stdout: stdout as unknown as NodeJS.WriteStream,
169
    });
170
    const turn = session.submit("go");
171
    await new Promise((resolve) => setTimeout(resolve, 0));
172
173
    expect(session.snapshot().running).toBe(true);
174
    const bar = screen(stdout.written).at(-1) ?? "";
175
    expect(bar).toContain("1 reply this run");
176
    expect(bar).not.toContain("0 replies");
177
178
    release();
179
    await turn;
180
    stdin.emit("data", "\x04");
181
    await running;
182
  });
183
184
  it("labels the count as this run's, because the conversation is not", async () => {
185
    const { rows } = await drive([{ type: "text", value: "hello" }]);
186
    expect(rows.at(-1)).toContain("1 reply this run");
187
  });
188
189
  it("says once where the turns are recorded when the source is not private", async () => {
190
    const stdin = new FakeIn();
191
    const stdout = new FakeOut();
192
    const session = new CoderSession(
193
      { ...source([{ type: "text", value: "hi" }]), scopeNotice: "shared with /chat" },
194
      "repo",
195
      "main",
196
    );
197
    const running = runCoderUi(session, {
198
      stdin: stdin as unknown as NodeJS.ReadStream,
199
      stdout: stdout as unknown as NodeJS.WriteStream,
200
    });
201
    await session.submit("go");
202
    const rows = screen(stdout.written);
203
    stdin.emit("data", "\x04");
204
    await running;
205
206
    const notes = rows.filter((row) => row.includes("shared with /chat"));
207
    expect(notes).toHaveLength(1);
208
    expect(notes[0]).toContain("note");
209
  });
210
211
  it("says nothing about scope when the source keeps its turns to itself", async () => {
212
    const { rows } = await drive([{ type: "text", value: "hello" }]);
213
    expect(rows.join("\n")).not.toContain("shared with");
214
  });
215
150 216
  it("offers no key in the bottom bar that does nothing in that state", async () => {
151 217
    const { rows } = await drive([{ type: "text", value: "hello" }]);
152 218
    const bar = rows.at(-1) ?? "";

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