Let a reader steer a running turn

9f40ae9f7ebb · AtlantisPleb · · parent 9b22493a7a97

Let a reader steer a running turn

Enter did nothing while a turn was running. Not queued, not refused with a
reason — the key was dropped. So `/export` was impossible mid-turn, and there
was no way to say anything to a model that had gone the wrong way except to
stop it.

Enter now always submits, and the session decides. An interface command runs at
once, because none of them go to the model. Anything else is queued: its entry
goes on the transcript immediately, so a reader sees what they said when they
said it, and it is sent when the turn ends. The session says it was queued and
how to send it sooner.

Two tests encoded the old decision — "refuses a second prompt rather than
queueing it" — and are rewritten rather than deleted, because the decision they
recorded is the one being reversed.

Escape now says it interrupted. Stopping the stream and saying nothing leaves a
reader looking at a settled reply with no way to tell whether the key worked,
which reads as the key not working.

And `fetch failed` is no longer the whole of a failure. Node puts what actually
happened in `cause` and reports the top of the chain, so a turn that lost the
local server showed two words. The chain is unwound now, and the Ollama source
names the host and the model it could not reach and says the conversation is
kept. It stays neutral about the reason, because a refused connection and a
model that does not exist arrive the same way and one of them is not the server
being down.

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

Diff

5 files changed, +225 -14

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

@@ -142,6 +142,7 @@ const systemPrompt = (tools: ReadonlyArray<CoderTool>): string => {

142 142
143 143
export class OllamaReplySource implements ReplySource {
144 144
  private readonly client: Ollama;
145
  private readonly host: string;
145 146
  private readonly modelName: string;
146 147
  private readonly transcript: WireMessage[] = [];
147 148
  private tools: ReadonlyArray<CoderTool> = [];

@@ -162,7 +163,8 @@ export class OllamaReplySource implements ReplySource {

162 163
  }
163 164
164 165
  constructor(options: OllamaOptions) {
165
    this.client = new Ollama({ host: options.host ?? DEFAULT_HOST });
166
    this.host = options.host ?? DEFAULT_HOST;
167
    this.client = new Ollama({ host: this.host });
166 168
    this.modelName = options.model;
167 169
  }
168 170

@@ -206,7 +208,31 @@ export class OllamaReplySource implements ReplySource {

206 208
    }));
207 209
  }
208 210
211
  /**
212
   * The turn, with the endpoint named if the server stops answering.
213
   *
214
   * A local server that has stopped, or dropped the connection part way through
215
   * a long turn, reports as `fetch failed` and nothing else. Which server and
216
   * which model is the part a reader needs, and so is knowing the transcript
217
   * survives.
218
   */
209 219
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
220
    try {
221
      yield* this.turn(prompt, signal);
222
    } catch (cause) {
223
      // Neutral about the reason, because the reason follows: a refused
224
      // connection and a model that does not exist are both reported here, and
225
      // claiming the server is down when it answered would send a reader to
226
      // check the wrong thing.
227
      throw new Error(
228
        `Ollama at ${this.host} could not answer for ${this.modelName}. ` +
229
          "This conversation is kept, so say `continue` once it can",
230
        { cause },
231
      );
232
    }
233
  }
234
235
  private async *turn(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
210 236
    // Built on the first turn rather than in the constructor: the tools are
211 237
    // declared after construction, and the prompt is derived from them.
212 238
    if (this.transcript.length === 0) {
packages/openagents-cli/src/coder-session.ts modified +60 -2

@@ -67,6 +67,29 @@ export interface CoderToolCall {

67 67
  status: "running" | "succeeded" | "failed";
68 68
}
69 69
70
/**
71
 * A failure, with the reason underneath it.
72
 *
73
 * Node reports a failed request as `fetch failed` and puts what actually
74
 * happened — the refused connection, the reset socket — in `cause`. Reporting
75
 * only the top of that chain tells a reader nothing they can act on, which is
76
 * how a session came to show `fetch failed` and nothing else.
77
 */
78
const describeFailure = (cause: unknown): string => {
79
  const seen = new Set<unknown>();
80
  const parts: string[] = [];
81
  let current = cause;
82
83
  while (current !== undefined && current !== null && !seen.has(current)) {
84
    seen.add(current);
85
    const text = current instanceof Error ? current.message : String(current);
86
    if (text.length > 0 && !parts.includes(text)) parts.push(text);
87
    current = current instanceof Error ? (current as { cause?: unknown }).cause : undefined;
88
  }
89
90
  return parts.length === 0 ? "The turn failed." : parts.join(": ");
91
};
92
70 93
/** What a turn cost, on the entry that closed it. */
71 94
export interface CoderMetrics {
72 95
  readonly promptTokens?: number | undefined;

@@ -303,6 +326,13 @@ export class CoderSession {

303 326
  private readonly entries: CoderEntry[] = [];
304 327
  private readonly listeners = new Set<() => void>();
305 328
  private controller: AbortController | undefined;
329
  /**
330
   * Prompts typed while a turn was running, in the order they were typed.
331
   *
332
   * Their entries are already on the transcript, so a reader sees what they
333
   * said the moment they said it; only the sending waits.
334
   */
335
  private readonly pending: string[] = [];
306 336
  private turnCount = 0;
307 337
  private unsubscribeTasks: (() => void) | undefined;
308 338

@@ -470,9 +500,32 @@ export class CoderSession {

470 500
      return;
471 501
    }
472 502
473
    if (this.controller !== undefined) return;
503
    // A turn is running, so this one waits its place rather than being dropped.
504
    // Typing while the model works is how a reader steers, and an interface
505
    // that silently ignores the key is one that cannot be steered at all.
506
    if (this.controller !== undefined) {
507
      this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
508
      this.pending.push(prompt);
509
      this.notice(
510
        this.pending.length === 1
511
          ? "Queued. It goes to the model when this turn ends; press escape to interrupt and send it now."
512
          : `Queued, ${String(this.pending.length)} waiting.`,
513
      );
514
      this.emit();
515
      return;
516
    }
474 517
475 518
    this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
519
    await this.run(prompt);
520
  }
521
522
  /**
523
   * Send one prompt and stream its reply.
524
   *
525
   * Split from `submit` so a queued prompt, whose entry is already on the
526
   * transcript, is sent without adding a second one.
527
   */
528
  private async run(prompt: string): Promise<void> {
476 529
    // An empty assistant entry from the start, so the interface shows a caret
477 530
    // rather than nothing while the first chunk is in flight. It is withdrawn
478 531
    // if the turn opens with reasoning or a tool call instead of text.

@@ -576,7 +629,7 @@ export class CoderSession {

576 629
      // A failed turn ends the turn, not the session. The reason belongs on the
577 630
      // transcript where the prompt that caused it is still visible, and any
578 631
      // text the source produced before failing is kept.
579
      const message = cause instanceof Error ? cause.message : String(cause);
632
      const message = describeFailure(cause);
580 633
      if (text !== undefined && text.text.length === 0) {
581 634
        this.entries.splice(this.entries.indexOf(text), 1);
582 635
        text = undefined;

@@ -592,6 +645,11 @@ export class CoderSession {

592 645
      this.controller = undefined;
593 646
      this.emit();
594 647
    }
648
649
    // Whatever was typed during the turn goes now, in order. Its entry is
650
    // already on the transcript, so this sends without adding another.
651
    const next = this.pending.shift();
652
    if (next !== undefined) await this.run(next);
595 653
  }
596 654
597 655
  /** Interrupt the running reply. No effect when nothing is running. */
packages/openagents-cli/src/coder-ui.ts modified +12 -5

@@ -737,7 +737,11 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

737 737
        render();
738 738
        return;
739 739
      }
740
      if (!session.interrupt()) composer = "";
740
      // Say that it happened. An interrupt that only stops the stream leaves a
741
      // reader looking at a settled reply with no way to tell whether the key
742
      // did anything, which reads as the key not working.
743
      if (session.interrupt()) session.notice("Interrupted.");
744
      else composer = "";
741 745
      render();
742 746
    };
743 747

@@ -845,10 +849,13 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

845 849
          index += 1;
846 850
          // Swallow a CRLF pair so a paste does not submit twice.
847 851
          if (char === "\r" && text[index] === "\n") index += 1;
848
          if (!session.running) {
849
            submit();
850
            dirty = false;
851
          }
852
          // Always. A turn already running is not a reason to drop what was
853
          // typed: an interface command runs at once, and anything else is
854
          // queued by the session and sent when the turn ends. Ignoring the key
855
          // is what made `/export` impossible mid-turn and steering impossible
856
          // at all.
857
          submit();
858
          dirty = false;
852 859
          continue;
853 860
        }
854 861
packages/openagents-cli/test/coder-session.test.ts modified +40 -6

@@ -71,17 +71,47 @@ describe("CoderSession", () => {

71 71
    expect(session.snapshot().running).toBe(false);
72 72
  });
73 73
74
  it("refuses a second prompt while one is running rather than queueing it", async () => {
75
    const session = new CoderSession(scripted(["a", "b"], 15), "repo", "main");
74
  it("queues a prompt typed during a turn, and sends it when the turn ends", async () => {
75
    const sent: string[] = [];
76
    const recording: ReplySource = {
77
      model: "scripted",
78
      async *reply(prompt) {
79
        sent.push(prompt);
80
        await new Promise((resolve) => setTimeout(resolve, 15));
81
        yield { type: "text", value: "ok" } as const;
82
      },
83
    };
84
    const session = new CoderSession(recording, "repo", "main");
85
76 86
    const first = session.submit("first");
77 87
    await session.submit("second");
78 88
    await first;
89
    // The queued turn starts as the first one ends.
90
    await new Promise((resolve) => setTimeout(resolve, 40));
91
92
    // Typing while the model works is how a reader steers. Dropping the key
93
    // was what made steering impossible.
94
    expect(sent).toEqual(["first", "second"]);
79 95
80 96
    const prompts = session
81 97
      .snapshot()
82 98
      .entries.filter((entry) => entry.role === "you")
83 99
      .map((entry) => entry.text);
84
    expect(prompts).toEqual(["first"]);
100
    // Shown the moment it was typed, not when it was sent, and shown once.
101
    expect(prompts).toEqual(["first", "second"]);
102
  });
103
104
  it("says a prompt was queued rather than accepting it silently", async () => {
105
    const session = new CoderSession(scripted(["a"], 15), "repo", "main");
106
    const first = session.submit("first");
107
    await session.submit("second");
108
    await first;
109
110
    const notices = session
111
      .snapshot()
112
      .entries.filter((entry) => entry.role === "notice")
113
      .map((entry) => entry.text);
114
    expect(notices.some((text) => text.startsWith("Queued."))).toBe(true);
85 115
  });
86 116
87 117
  it("ignores an empty prompt", async () => {

@@ -213,15 +243,19 @@ describe("CoderSession", () => {

213 243
    expect(session.snapshot().turns).toBe(1);
214 244
  });
215 245
216
  it("does not count a prompt it refused", async () => {
217
    const session = new CoderSession(scripted(["a"]), "repo", "main");
246
  it("counts a queued prompt only when its turn starts", async () => {
247
    const session = new CoderSession(scripted(["a"], 15), "repo", "main");
218 248
    await session.submit("   ");
219 249
    expect(session.snapshot().turns).toBe(0);
220 250
221 251
    const first = session.submit("first");
222 252
    await session.submit("second while the first runs");
223
    await first;
253
    // Waiting to be sent is not a turn in flight.
224 254
    expect(session.snapshot().turns).toBe(1);
255
256
    await first;
257
    await new Promise((resolve) => setTimeout(resolve, 40));
258
    expect(session.snapshot().turns).toBe(2);
225 259
  });
226 260
227 261
  it("carries workspace and model into the snapshot for the status line", () => {
packages/openagents-cli/test/coder-ui.test.ts modified +86

@@ -604,3 +604,89 @@ describe("the /reload command", () => {

604 604
    expect(session.snapshot().turns).toBe(0);
605 605
  });
606 606
});
607
608
describe("typing while a turn is running", () => {
609
  const held = (): { source: ReplySource; release: () => void; sent: string[] } => {
610
    const sent: string[] = [];
611
    let release = () => {};
612
    const gate = new Promise<void>((resolve) => {
613
      release = resolve;
614
    });
615
    return {
616
      sent,
617
      release: () => release(),
618
      source: {
619
        model: "scripted",
620
        async *reply(prompt: string) {
621
          sent.push(prompt);
622
          yield { type: "text", value: "working" } as const;
623
          await gate;
624
        },
625
      },
626
    };
627
  };
628
629
  it("runs an interface command at once rather than dropping the key", async () => {
630
    const stdin = new FakeIn();
631
    const stdout = new FakeOut();
632
    const { source: paused, release } = held();
633
    const session = new CoderSession(paused, "repo", "main");
634
    const running = runCoderUi(session, {
635
      stdin: stdin as unknown as NodeJS.ReadStream,
636
      stdout: stdout as unknown as NodeJS.WriteStream,
637
    });
638
639
    const turn = session.submit("go");
640
    await new Promise((resolve) => setTimeout(resolve, 0));
641
    expect(session.snapshot().running).toBe(true);
642
643
    // `/export` was impossible mid-turn: enter was ignored while running.
644
    stdin.emit("data", "/system");
645
    stdin.emit("data", "\r");
646
647
    const notices = session
648
      .snapshot()
649
      .entries.filter((entry) => entry.role === "notice")
650
      .map((entry) => entry.text);
651
    expect(notices.some((text) => text.includes("standing context") || text.length > 0)).toBe(true);
652
653
    release();
654
    await turn;
655
    stdin.emit("data", "\x04");
656
    await running;
657
  });
658
659
  it("queues ordinary text and sends it when the turn ends", async () => {
660
    const stdin = new FakeIn();
661
    const stdout = new FakeOut();
662
    const { source: paused, release, sent } = held();
663
    const session = new CoderSession(paused, "repo", "main");
664
    const running = runCoderUi(session, {
665
      stdin: stdin as unknown as NodeJS.ReadStream,
666
      stdout: stdout as unknown as NodeJS.WriteStream,
667
    });
668
669
    const turn = session.submit("go");
670
    await new Promise((resolve) => setTimeout(resolve, 0));
671
672
    stdin.emit("data", "steer me");
673
    stdin.emit("data", "\r");
674
675
    // Shown at once, sent later: a reader sees what they said when they said it.
676
    expect(
677
      session
678
        .snapshot()
679
        .entries.filter((entry) => entry.role === "you")
680
        .map((entry) => entry.text),
681
    ).toEqual(["go", "steer me"]);
682
    expect(sent).toEqual(["go"]);
683
684
    release();
685
    await turn;
686
    await new Promise((resolve) => setTimeout(resolve, 20));
687
    expect(sent).toEqual(["go", "steer me"]);
688
689
    stdin.emit("data", "\x04");
690
    await running;
691
  });
692
});

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