Show a steered message as unread until the model is given it

56248a04481c · AtlantisPleb · · parent dbfef5aca9e1

Show a steered message as unread until the model is given it

A message typed while the model is working is read at its next step, which can
be seconds later and several tool calls down. The transcript showed it where it
was typed, settled, identical to a message that had already been answered, with
a notice under it saying "Steering: the model reads this at its next step."

Two things wrong with that. The notice is a sentence describing what the entry
itself should have been showing. And the position is a lie: the message sat
above tool calls the model made before it had ever seen it, so reading down the
transcript gave the wrong order of events.

It is dim and italic now until the source says it handed the message over — the
same styling reasoning gets, for the same reason: on screen, but not part of the
conversation yet. At that moment it stops being dim and moves to where it was
actually read, which is the end of the transcript at that instant. A message
steered during three tool calls now sits after them, where the model saw it.

Every source that can steer reports it, through a `steered` chunk yielded as it
splices the message into its own transcript. That is the only place that knows,
and it is the same instant on all three lanes.

The notice is gone. The entry carries the fact.

One thing this could have broken: the streaming entries are the ones being
appended to, and moving an entry out from under them would have grown the next
chunk into an entry that was no longer last. They are reopened on the move.

713 tests pass, including two rewritten steering tests — one for the pending
state, one for the position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TRDRrfL1khQhQtNr3SRrA
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-ollama.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-thread.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/src/coder-zen.ts
  • modified packages/openagents-cli/test/coder-steer.test.ts

Diff

6 files changed, +143 -8

packages/openagents-cli/src/coder-ollama.ts modified +4 -1

@@ -386,9 +386,12 @@ export class OllamaReplySource implements ReplySource {

386 386
      // Anything the reader said since the last step joins here, before the
387 387
      // model is asked again. It reads as an ordinary turn in the conversation,
388 388
      // because that is what it is.
389
      for (const said of this.steered.splice(0)) {
389
      const steered = this.steered.splice(0);
390
      for (const said of steered) {
390 391
        this.transcript.push({ role: "user", content: said });
391 392
      }
393
      // The interface dims a steered message until this says it was read.
394
      if (steered.length > 0) yield { type: "steered", texts: steered };
392 395
393 396
      const calls: OllamaToolCall[] = [];
394 397
      let assistant = "";
packages/openagents-cli/src/coder-session.ts modified +57 -3

@@ -55,6 +55,20 @@ export type ReplyChunk =

55 55
      readonly callId: string;
56 56
      readonly output: string | undefined;
57 57
      readonly error: string | undefined;
58
    }
59
  | {
60
      /**
61
       * What the reader steered, at the moment the model was given it.
62
       *
63
       * A steered message is typed while the model is working and read at its
64
       * next step, which can be seconds later and several tool calls down. The
65
       * transcript showed it where it was typed, settled, indistinguishable
66
       * from a message that had been answered — so a reader could not tell
67
       * what the model had actually seen. The source says when it hands one
68
       * over, and the entry moves to that point and stops looking pending.
69
       */
70
      readonly type: "steered";
71
      readonly texts: ReadonlyArray<string>;
58 72
    };
59 73
60 74
/** The tool half of a `tool` entry. Grows when the outcome arrives. */

@@ -169,6 +183,14 @@ export interface CoderEntry {

169 183
  settled: boolean;
170 184
  /** Present on a `tool` entry only. */
171 185
  readonly tool?: CoderToolCall;
186
  /**
187
   * Set on a steered `you` entry the model has not been given yet.
188
   *
189
   * The interface dims it. It is the difference between "you said this" and
190
   * "you said this and it was heard", and a turn is long enough that the two
191
   * are not the same fact.
192
   */
193
  pending?: boolean;
172 194
  /** Set on the entry a turn ended on, when the source reported the cost. */
173 195
  metrics?: CoderMetrics;
174 196
  /**

@@ -838,18 +860,28 @@ export class CoderSession {

838 860
    // Typing while the model works is how a reader steers, and an interface
839 861
    // that silently ignores the key is one that cannot be steered at all.
840 862
    if (this.controller !== undefined) {
841
      this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
842
843 863
      // Steering by default: a source that runs a loop of model calls reads
844 864
      // this at its next step, so the model sees it while it is still working.
845 865
      // A reader who wants the turn finished first asks for `queue`, and a
846 866
      // source that cannot steer holds it to the end either way.
847 867
      if (mode === "steer" && this.source.steer?.(prompt) === true) {
848
        this.notice("Steering: the model reads this at its next step.");
868
        // Pending until the source says it handed it over. It used to land
869
        // here settled, with a notice under it saying the model would read it
870
        // later — which is a sentence describing what the entry itself should
871
        // have been showing.
872
        this.entries.push({
873
          role: "you",
874
          text: prompt,
875
          settled: true,
876
          at: Date.now(),
877
          pending: true,
878
        });
849 879
        this.emit();
850 880
        return;
851 881
      }
852 882
883
      this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
884
853 885
      this.pending.push(prompt);
854 886
      this.notice(
855 887
        this.pending.length === 1

@@ -956,6 +988,28 @@ export class CoderSession {

956 988
              ...(provenance === undefined ? {} : { plugin: provenance }),
957 989
            },
958 990
          });
991
        } else if (chunk.type === "steered") {
992
          // Moved to where the model was actually given it, which is here: the
993
          // source yields this as it splices the message into the transcript,
994
          // so the current end of the entries is that point in the chain.
995
          for (const said of chunk.texts) {
996
            const at = this.entries.findIndex(
997
              (entry) => entry.role === "you" && entry.pending === true && entry.text === said,
998
            );
999
            if (at === -1) continue;
1000
1001
            const [entry] = this.entries.splice(at, 1);
1002
            if (entry === undefined) continue;
1003
            entry.pending = false;
1004
            this.entries.push(entry);
1005
          }
1006
1007
          // The streaming entries are the ones being appended to, and the
1008
          // splice moved the transcript out from under them. Reopening keeps
1009
          // the next chunk from growing an entry that is no longer last.
1010
          text = undefined;
1011
          reasoning = undefined;
1012
          this.emit();
959 1013
        } else if (chunk.type === "usage") {
960 1014
          // Onto the entry the turn ended on, which is the step a reader of the
961 1015
          // trajectory would attribute the cost to. `calls` says how many LLM
packages/openagents-cli/src/coder-thread.ts modified +4 -1

@@ -561,12 +561,15 @@ export class ThreadReplySource implements ReplySource {

561 561
      for (let step = 0; ; step += 1) {
562 562
        // Anything the reader said since the last step joins here, before the
563 563
        // model is asked again.
564
        for (const said of this.steered.splice(0)) {
564
        const steered = this.steered.splice(0);
565
        for (const said of steered) {
565 566
          this.transcript.push({ role: "user", content: said });
566 567
          // Steered mid-turn rather than asked between turns, and the record
567 568
          // says so, or a replay would show a question the answer ignores.
568 569
          this.sink?.record("turn.user", { text: said, steered: true });
569 570
        }
571
        // The interface dims a steered message until this says it was read.
572
        if (steered.length > 0) yield { type: "steered", texts: steered };
570 573
571 574
        const calls: WireCall[] = [];
572 575
        let assistant = "";
packages/openagents-cli/src/coder-ui.ts modified +6

@@ -495,6 +495,12 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

495 495
        return renderMarkdown(entry.text, width, `${DIM}${ITALIC}`);
496 496
      }
497 497
      if (entry.role === "assistant") return renderMarkdown(entry.text, width);
498
      // A steered message the model has not been given yet, dim and italic:
499
      // the same styling reasoning gets, for the same reason — it is on screen
500
      // but it is not part of the conversation yet.
501
      if (entry.role === "you" && entry.pending === true) {
502
        return wrapStyled(entry.text, width, `${DIM}${ITALIC}`);
503
      }
498 504
      return wrapStyled(entry.text, width, entry.role === "notice" ? DIM : "");
499 505
    };
500 506
packages/openagents-cli/src/coder-zen.ts modified +4 -1

@@ -167,9 +167,12 @@ export class ZenReplySource implements ReplySource {

167 167
168 168
      // Read between two model calls rather than at the end of the turn, which
169 169
      // is the difference between steering a model and waiting one out.
170
      for (const said of this.steered.splice(0)) {
170
      const steered = this.steered.splice(0);
171
      for (const said of steered) {
171 172
        this.transcript.push({ role: "user", content: said });
172 173
      }
174
      // The interface dims a steered message until this says it was read.
175
      if (steered.length > 0) yield { type: "steered", texts: steered };
173 176
174 177
      const calls: Map<number, { id: string; name: string; args: string }> = new Map();
175 178
      let assistant = "";
packages/openagents-cli/test/coder-steer.test.ts modified +68 -2

@@ -51,7 +51,73 @@ describe("steering a running turn", () => {

51 51
    // It arrived at a later step, not at the start.
52 52
    const first = requests[0]?.["messages"] as ReadonlyArray<Record<string, unknown>>;
53 53
    expect(first.map((m) => m["content"])).not.toContain("actually, do it the other way");
54
    expect(session.snapshot().entries.filter((e) => e.role === "notice").map((e) => e.text))
55
      .toContainEqual(expect.stringContaining("Steering"));
54
    // The message itself says it was steered, rather than a notice under it
55
    // saying the model will read it later.
56
    const entries = session.snapshot().entries;
57
    const steered = entries.filter((e) => e.role === "you" && e.text.startsWith("actually,"));
58
    expect(steered).toHaveLength(1);
59
    expect(steered[0]?.pending).toBe(false);
60
61
    // And it sits where the model was given it, not where it was typed: after
62
    // the tool call it arrived during.
63
    const at = entries.findIndex((e) => e.text.startsWith("actually,"));
64
    const tool = entries.findIndex((e) => e.role === "tool");
65
    expect(tool).toBeGreaterThanOrEqual(0);
66
    expect(at).toBeGreaterThan(tool);
67
68
    // No notice at all: the entry carries the fact now.
69
    expect(entries.filter((e) => e.role === "notice").map((e) => e.text)).not.toContainEqual(
70
      expect.stringContaining("Steering"),
71
    );
72
  });
73
74
  it("shows a steered message as pending until the model is given it", async () => {
75
    const gate: Array<() => void> = [];
76
    let round = 0;
77
78
    const source = new OllamaReplySource({ model: "m" });
79
    (source as unknown as { client: unknown }).client = {
80
      chat: async () => {
81
        const mine = round++;
82
        const pieces =
83
          mine === 0
84
            ? [
85
                chunk({ content: "", tool_calls: [{ function: { name: "t", arguments: {} } }] }),
86
                chunk({}, true),
87
              ]
88
            : [chunk({ content: "done" }, true)];
89
        return Object.assign(
90
          (async function* () {
91
            if (mine === 0) await new Promise<void>((r) => gate.push(r));
92
            for (const p of pieces) yield p;
93
          })(),
94
          { abort: () => {} },
95
        );
96
      },
97
    };
98
    source.useTools([
99
      { name: "t", description: "d", parameters: {}, run: () => Promise.resolve("ok") },
100
    ]);
101
102
    const session = new CoderSession(source, "repo", "main");
103
    const turn = session.submit("original question");
104
    await new Promise((r) => setTimeout(r, 10));
105
106
    await session.submit("steer me");
107
108
    // Typed, on screen, and not yet part of the conversation. This is the gap
109
    // the dimming exists for: the model has not seen it, and a settled entry
110
    // said it had.
111
    const waiting = session
112
      .snapshot()
113
      .entries.find((e) => e.role === "you" && e.text === "steer me");
114
    expect(waiting?.pending).toBe(true);
115
116
    gate.shift()?.();
117
    await turn;
118
    await new Promise((r) => setTimeout(r, 20));
119
120
    const read = session.snapshot().entries.find((e) => e.text === "steer me");
121
    expect(read?.pending).toBe(false);
56 122
  });
57 123
});

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