Fold a run of notices about one setting into the last one

8fe785ea4067 · AtlantisPleb · · parent 32bfc1bf36e2

Fold a run of notices about one setting into the last one

Cycling the reasoning level four times left four notes saying what it had been
set to, three of which were no longer true, and the reader had to read to the
bottom to find the one that was.

A notice may now say what it supersedes. Where the last entry is a notice about
the same thing, it is replaced rather than stacked under; where anything else
has happened since, the new notice stands on its own, because what it would have
replaced is no longer the last thing said. Switching the model gets the same
treatment for the same reason.

Notices with nothing to supersede are untouched: two interruptions are two
events, not one restated.

419 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/coder-session.ts
  • modified packages/openagents-cli/test/coder-session.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

3 files changed, +79 -4

packages/openagents-cli/src/coder-session.ts modified +33 -4

@@ -117,6 +117,13 @@ export interface CoderEntry {

117 117
  readonly tool?: CoderToolCall;
118 118
  /** Set on the entry a turn ended on, when the source reported the cost. */
119 119
  metrics?: CoderMetrics;
120
  /**
121
   * What this notice replaces, when it replaces one.
122
   *
123
   * Two notices about the same setting are one notice: the second is what is
124
   * true and the first is what used to be.
125
   */
126
  readonly supersedes?: string;
120 127
}
121 128
122 129
/** Everything a renderer needs. No renderer reads anything else. */

@@ -469,7 +476,7 @@ export class CoderSession {

469 476
      return { changed: false, level: this.source.reasoning?.level };
470 477
    }
471 478
    const level = this.source.cycleReasoning();
472
    this.notice(`Reasoning set to ${level}.`);
479
    this.notice(`Reasoning set to ${level}.`, "reasoning");
473 480
    return { changed: true, level };
474 481
  }
475 482

@@ -481,7 +488,7 @@ export class CoderSession {

481 488
    }
482 489
483 490
    const label = this.source.cycleBackend();
484
    this.notice(`Model switched to ${label}.`);
491
    this.notice(`Model switched to ${label}.`, "model");
485 492
    return { switched: true, label };
486 493
  }
487 494

@@ -490,8 +497,30 @@ export class CoderSession {

490 497
    return () => this.listeners.delete(listener);
491 498
  }
492 499
493
  notice(text: string): void {
494
    this.entries.push({ role: "notice", text, settled: true, at: Date.now() });
500
  notice(text: string, supersedes?: string): void {
501
    // A notice that supersedes another replaces it rather than stacking under
502
    // it. Cycling the reasoning level four times left four notes saying what it
503
    // had been set to, three of which were no longer true, and the reader had
504
    // to read to the bottom to find the one that was.
505
    const last = this.entries.at(-1);
506
    if (
507
      supersedes !== undefined &&
508
      last !== undefined &&
509
      last.role === "notice" &&
510
      last.supersedes === supersedes
511
    ) {
512
      last.text = text;
513
      this.emit();
514
      return;
515
    }
516
517
    this.entries.push({
518
      role: "notice",
519
      text,
520
      settled: true,
521
      at: Date.now(),
522
      ...(supersedes === undefined ? {} : { supersedes }),
523
    });
495 524
    this.emit();
496 525
  }
497 526
packages/openagents-cli/test/coder-session.test.ts modified +37

@@ -448,3 +448,40 @@ describe("a turn's cost", () => {

448 448
    expect(assistant?.metrics).toEqual({ promptTokens: 12, completionTokens: 34, calls: 2 });
449 449
  });
450 450
});
451
452
describe("notices that replace one another", () => {
453
  it("keeps the last of a run about the same setting, and only the last", async () => {
454
    const session = new CoderSession(scripted(["a"]), "repo", "main");
455
456
    session.notice("Reasoning set to low.", "reasoning");
457
    session.notice("Reasoning set to high.", "reasoning");
458
    session.notice("Reasoning set to off.", "reasoning");
459
460
    expect(session.snapshot().entries.map((entry) => entry.text)).toEqual([
461
      "Reasoning set to off.",
462
    ]);
463
  });
464
465
  it("does not fold two notices that are about different things", async () => {
466
    const session = new CoderSession(scripted(["a"]), "repo", "main");
467
468
    session.notice("Reasoning set to low.", "reasoning");
469
    session.notice("Model switched to Ox Alpha.", "model");
470
    session.notice("Reasoning set to high.", "reasoning");
471
472
    // The model notice separates them, so the second reasoning notice is a new
473
    // one rather than a replacement: what it replaced is no longer the last
474
    // thing said.
475
    expect(session.snapshot().entries).toHaveLength(3);
476
  });
477
478
  it("leaves an ordinary notice alone", async () => {
479
    const session = new CoderSession(scripted(["a"]), "repo", "main");
480
481
    session.notice("Interrupted.");
482
    session.notice("Interrupted.");
483
484
    // Two interruptions are two events, not one restated.
485
    expect(session.snapshot().entries).toHaveLength(2);
486
  });
487
});
packages/openagents-cli/test/coder-ui.test.ts modified +9

@@ -827,6 +827,15 @@ describe("changing how hard the model thinks", () => {

827 827
    stdin.emit("data", "\x1b[9;2u");
828 828
    expect(session.snapshot().reasoning).toBe("medium");
829 829
830
    // Four presses left four notes, three of which were no longer true.
831
    stdin.emit("data", "\x1b[Z");
832
    stdin.emit("data", "\x1b[Z");
833
    const notes = session
834
      .snapshot()
835
      .entries.filter((entry) => entry.text.startsWith("Reasoning set to"))
836
      .map((entry) => entry.text);
837
    expect(notes).toEqual(["Reasoning set to off."]);
838
830 839
    stdin.emit("data", "\x04");
831 840
    await running;
832 841
  });

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