Tell a thread session what it is, and survive one provider hiccup

ebf48d7e063f · AtlantisPleb · · parent 9d86be19e76a

Tell a thread session what it is, and survive one provider hiccup

Asked "who are you", a coder session answered "I'm ChatGPT". Asked what tools
it had, it listed "Repository skills" as a fifth tool that does not exist. Both
have the same cause: the thread lane sent no system message at all.

The local lane had an anchor — what the session is, and the declared tools as a
closed list — and the thread lane had nothing. Its `describeContext` said the
system message was "composed by the server for the thread's grant", which is not
true: the proxy splits system messages out of the request and passes them to the
provider, and composes none. The honest reading of that sentence was that the
lane sent no system message, which is exactly what it did.

The anchor moves to `coder-system.ts` and both lanes use it, differing only in
the sentence naming how the session answers. An anchor that drifts between lanes
is one that is wrong on at least one of them.

The standing context — workspace facts and active skills — moves into that
system message. It had been glued onto the reader's first prompt, which made it
a user turn: a model can argue with it, and the whole preamble ended up quoted
inside the first tool call, where it was visible as an argument to `repo_grep`.
A source that cannot take it still gets the old prefix.

Separately, a `502` from the proxy no longer loses the turn. The failure happens
before any of the stream reaches the client, so re-sending is the same call
rather than a duplicated one; dropping it on the first one is what put "The
model provider failed" on screen twice in a row with the work lost both times.
Three attempts, short fixed backoff, and only for 502, 503, and 504. A revoked,
expired, or exhausted grant is settled — retrying it tells the reader the same
thing three times — and every 4xx is a request this client would send again
unchanged. The ceiling is low because a retry re-spends budget where the failed
call was metered for partial usage.

The server now names the failure class beside `provider_failed`
(openagents.com 5b465f0), and that word is carried into the sentence a reader
sees.

Verified against a dev server: the same two questions from the report now
answer with what the session is and the four tools it actually has. 587 tests
pass.

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
  • added packages/openagents-cli/src/coder-system.ts
  • modified packages/openagents-cli/src/coder-thread.ts
  • modified packages/openagents-cli/test/coder-resume.test.ts
  • modified packages/openagents-cli/test/coder-thread.test.ts

Diff

6 files changed, +382 -108

packages/openagents-cli/src/coder-ollama.ts modified +15 -48

@@ -16,6 +16,7 @@ import type { Message as OllamaMessage, Tool as OllamaTool, ToolCall as OllamaTo

16 16
17 17
import { merge } from "./coder-merge.js";
18 18
import type { ReplyChunk, ReplySource } from "./coder-session.js";
19
import { LOCAL_LANE, systemPrompt } from "./coder-system.js";
19 20
import type { CoderTool } from "./coder-tools.js";
20 21
21 22
const DEFAULT_HOST = "http://127.0.0.1:11434";

@@ -207,52 +208,6 @@ export const parseOllamaModelFlag = (value: string): string | undefined => {

207 208
 */
208 209
type WireMessage = OllamaMessage;
209 210
210
/**
211
 * What the session tells a local model about itself.
212
 *
213
 * Derived from the tools actually declared rather than written out, so it
214
 * cannot claim a tool the session does not pass or miss one it does.
215
 *
216
 * The thread lane sends the server an objective at thread creation. The local
217
 * lane sent nothing, and a model with no system prompt has nothing anchoring
218
 * what it is: asked what tools it has, it answered from what a coding agent
219
 * usually has -- files, shell, search, web -- and none of that is declared
220
 * here. The invented answer then sat in the transcript, and the next turn read
221
 * it back as instruction. So the anchor is the tool list itself.
222
 */
223
const systemPrompt = (tools: ReadonlyArray<CoderTool>): string => {
224
  const lines = [
225
    "You are `openagents coder`, a coding assistant in a terminal. You answer from a model " +
226
      "running locally on this machine.",
227
    "",
228
  ];
229
230
  if (tools.length === 0) {
231
    lines.push(
232
      "You have no tools in this session: you cannot read or write files, run commands, or " +
233
        "reach anything outside this conversation. Answer from what the reader tells you, and " +
234
        "say plainly when something would need a tool you do not have.",
235
    );
236
  } else {
237
    lines.push(
238
      `You have ${String(tools.length)} tool${tools.length === 1 ? "" : "s"}, and no others:`,
239
      ...tools.map((tool) => `- \`${tool.name}\``),
240
      "",
241
      // Stated as a closed list rather than by naming the capabilities that are
242
      // absent. The absent ones change as tools are added -- this once said
243
      // there was no shell, and then there was one -- and a system message that
244
      // has to be edited when the tool list changes is one that will be wrong
245
      // in between.
246
      "That list is complete: a capability not on it is one you do not have, whatever a model " +
247
        "like you usually has. Read a tool's description before assuming what it covers. Where " +
248
        "a description says what a child agent can do, that is the child's capability and not " +
249
        "yours. Never say you ran something you did not run.",
250
    );
251
  }
252
253
  return lines.join("\n");
254
};
255
256 211
export class OllamaReplySource implements ReplySource {
257 212
  private readonly client: Ollama;
258 213
  private readonly host: string;

@@ -304,6 +259,13 @@ export class OllamaReplySource implements ReplySource {

304 259
   * the tools need things built after the source exists, such as the fleet a
305 260
   * `delegate` call submits to.
306 261
   */
262
  /** The session's workspace facts and active skills, for the system message. */
263
  private standing: string | undefined;
264
265
  useContext(standing: string): void {
266
    this.standing = standing;
267
  }
268
307 269
  useTools(tools: ReadonlyArray<CoderTool>): void {
308 270
    this.tools = tools;
309 271
  }

@@ -313,7 +275,9 @@ export class OllamaReplySource implements ReplySource {

313 275
   * declarations, rendered from the same values the request carries.
314 276
   */
315 277
  describeContext(): string {
316
    const parts = [`System message sent with every turn:\n\n${systemPrompt(this.tools)}`];
278
    const parts = [
279
      `System message sent with every turn:\n\n${systemPrompt(this.tools, LOCAL_LANE, this.standing)}`,
280
    ];
317 281
318 282
    parts.push(
319 283
      this.tools.length === 0

@@ -394,7 +358,10 @@ export class OllamaReplySource implements ReplySource {

394 358
    // Built on the first turn rather than in the constructor: the tools are
395 359
    // declared after construction, and the prompt is derived from them.
396 360
    if (this.transcript.length === 0) {
397
      this.transcript.push({ role: "system", content: systemPrompt(this.tools) });
361
      this.transcript.push({
362
        role: "system",
363
        content: systemPrompt(this.tools, LOCAL_LANE, this.standing),
364
      });
398 365
    }
399 366
400 367
    this.transcript.push({ role: "user", content: prompt });
packages/openagents-cli/src/coder-session.ts modified +30 -6

@@ -300,6 +300,20 @@ export interface ReplySource {

300 300
   * session then declares no tools rather than declaring tools nothing runs.
301 301
   */
302 302
  useTools?(tools: ReadonlyArray<CoderTool>): void;
303
  /**
304
   * Take the session's standing context — workspace facts and active skills.
305
   *
306
   * A source that implements this puts the text in its own system message,
307
   * where standing context belongs: it is instruction about the session, not
308
   * something the reader said. A source that does not leaves this undefined,
309
   * and the session falls back to prefixing the first prompt with it.
310
   *
311
   * The fallback is the weaker of the two. Glued to a prompt, the context is a
312
   * user turn: a model can argue with it, a stand-in echoes it back, and the
313
   * whole preamble ends up quoted inside the first tool call. It is kept only
314
   * because a source that composes no system message has nowhere better.
315
   */
316
  useContext?(standing: string): void;
303 317
  /**
304 318
   * Take a message mid-turn, to be read at the next step of the running turn.
305 319
   *

@@ -471,12 +485,12 @@ export class CoderSession {

471 485
    private readonly branch: string,
472 486
    private readonly delegation?: CoderDelegation,
473 487
    /**
474
     * Put in front of the first prompt, and nowhere else.
488
     * Workspace facts and the active skills, as text.
475 489
     *
476
     * A session told how to approach its work needs that before its first
477
     * decision. It goes ahead of the first turn rather than into every one:
478
     * after that it is in the transcript, and paying for it again each turn
479
     * buys nothing.
490
     * Handed to the source through `useContext` where the source composes a
491
     * system message, which is where this belongs. Where it does not, it goes
492
     * in front of the first prompt and nowhere else: after that it is in the
493
     * transcript, and paying for it again each turn buys nothing.
480 494
     */
481 495
    private readonly standing?: string,
482 496
    /**

@@ -492,6 +506,10 @@ export class CoderSession {

492 506
    // forwards. Without this the fleet block only moved when a chat chunk
493 507
    // happened to arrive.
494 508
    this.unsubscribeTasks = delegation?.registry.onChange(() => this.emit());
509
510
    // Handed over before the first turn, so a source that composes a system
511
    // message has the context when it composes one.
512
    if (standing !== undefined && standing.length > 0) this.source.useContext?.(standing);
495 513
  }
496 514
497 515
  /**

@@ -881,7 +899,13 @@ export class CoderSession {

881 899
      // The reader's entry above keeps what they typed; the model receives the
882 900
      // standing context ahead of it on the first turn only.
883 901
      const sent =
884
        this.standing === undefined || this.turnCount > 1 || this.restored
902
        this.standing === undefined ||
903
        this.turnCount > 1 ||
904
        this.restored ||
905
        // A source that took the context put it in its system message, where
906
        // it is sent with every turn. Prefixing the prompt too would send it
907
        // twice and read as the reader having typed it.
908
        this.source.useContext !== undefined
885 909
          ? prompt
886 910
          : `${this.standing}\n\n---\n\n${prompt}`;
887 911
packages/openagents-cli/src/coder-system.ts added +62

@@ -0,0 +1,62 @@

1
import type { CoderTool } from "./coder-tools.js";
2
3
/**
4
 * What a coder session tells the model about itself, on every lane.
5
 *
6
 * Derived from the tools actually declared rather than written out, so it
7
 * cannot claim a tool the session does not pass or miss one it does.
8
 *
9
 * A model with no system prompt has nothing anchoring what it is. The local
10
 * lane learned this first: asked what tools it had, it answered from what a
11
 * coding agent usually has — files, shell, search, web — and none of that was
12
 * declared. The thread lane had the same gap for longer and it read worse,
13
 * because the model there is a hosted one that knows a name for itself: asked
14
 * who it was, it said "I'm ChatGPT". The invented answer then sat in the
15
 * transcript and the next turn read it back as instruction.
16
 *
17
 * The `lane` sentence is the one true difference between the two. Everything
18
 * else is shared on purpose: an anchor that drifts between lanes is one that is
19
 * wrong on at least one of them.
20
 */
21
export const systemPrompt = (
22
  tools: ReadonlyArray<CoderTool>,
23
  lane: string,
24
  standing?: string,
25
): string => {
26
  const lines = [`You are \`openagents coder\`, a coding assistant in a terminal. ${lane}`, ""];
27
28
  if (tools.length === 0) {
29
    lines.push(
30
      "You have no tools in this session: you cannot read or write files, run commands, or " +
31
        "reach anything outside this conversation. Answer from what the reader tells you, and " +
32
        "say plainly when something would need a tool you do not have.",
33
    );
34
  } else {
35
    lines.push(
36
      `You have ${String(tools.length)} tool${tools.length === 1 ? "" : "s"}, and no others:`,
37
      ...tools.map((tool) => `- \`${tool.name}\``),
38
      "",
39
      // Stated as a closed list rather than by naming the capabilities that are
40
      // absent. The absent ones change as tools are added — this once said
41
      // there was no shell, and then there was one — and a system message that
42
      // has to be edited when the tool list changes is one that will be wrong
43
      // in between.
44
      "That list is complete: a capability not on it is one you do not have, whatever a model " +
45
        "like you usually has. Read a tool's description before assuming what it covers. Where " +
46
        "a description says what a child agent can do, that is the child's capability and not " +
47
        "yours. Never say you ran something you did not run.",
48
    );
49
  }
50
51
  if (standing !== undefined && standing.length > 0) lines.push("", standing);
52
53
  return lines.join("\n");
54
};
55
56
/** The lane sentence for a session answering from a model on this machine. */
57
export const LOCAL_LANE =
58
  "You answer from a model running locally on this machine through Ollama.";
59
60
/** The lane sentence for a session answering through the account's thread. */
61
export const THREAD_LANE =
62
  "You answer through the OpenAgents inference proxy, on a thread opened for this session.";
packages/openagents-cli/src/coder-thread.ts modified +110 -48

@@ -64,6 +64,7 @@ import type { ChildGrant } from "./coder-child-gateway.js";

64 64
import { merge } from "./coder-merge.js";
65 65
import type { ReplyChunk, ReplySource } from "./coder-session.js";
66 66
import type { CoderTool } from "./coder-tools.js";
67
import { systemPrompt, THREAD_LANE } from "./coder-system.js";
67 68
import type { TranscriptSink } from "./coder-transcript.js";
68 69
69 70
const THREADS_PATH = "/api/v3/threads";

@@ -355,6 +356,9 @@ export interface WireToolCall {

355 356
 * exchange have to agree on the shape.
356 357
 */
357 358
export type WireMessage =
359
  // The session's own anchor, first and once. The proxy passes system messages
360
  // through to the provider and composes none of its own.
361
  | { readonly role: "system"; readonly content: string }
358 362
  | { readonly role: "user"; readonly content: string }
359 363
  | {
360 364
      readonly role: "assistant";

@@ -428,6 +432,13 @@ export class ThreadReplySource implements ReplySource {

428 432
   * the delegate tool runs children on this grant, so it cannot exist until the
429 433
   * grant does.
430 434
   */
435
  /** The session's workspace facts and active skills, for the system message. */
436
  private standing: string | undefined;
437
438
  useContext(standing: string): void {
439
    this.standing = standing;
440
  }
441
431 442
  useTools(tools: ReadonlyArray<CoderTool>): void {
432 443
    this.tools = tools;
433 444
  }

@@ -448,11 +459,11 @@ export class ThreadReplySource implements ReplySource {

448 459
  /**
449 460
   * What this lane sends as standing context.
450 461
   *
451
   * The tool declarations are the client's and are reported in full. The system
452
   * message is not: the proxy is a completions surface and the server composes
453
   * what precedes the turn, so this says so rather than printing a prompt this
454
   * process never saw. A `/system` that guessed would be worse than one that
455
   * admits the boundary.
462
   * Both halves are this process's own and are reported in full. This once
463
   * said the server composed the system message, which was not true — the
464
   * proxy passes system messages through from the request and composes none —
465
   * and the honest reading of that claim was that the lane sent no system
466
   * message at all, which is exactly what it did.
456 467
   */
457 468
  /** The tools as declared, in the shape ATIF records them. */
458 469
  toolDefinitions(): ReadonlyArray<Record<string, unknown>> {

@@ -474,9 +485,7 @@ export class ThreadReplySource implements ReplySource {

474 485
            .join("\n\n")}`;
475 486
476 487
    return [
477
      "This session runs on a thread. The system message is composed by the server for the",
478
      "thread's grant and is not sent from this machine, so it cannot be shown here. What follows",
479
      "is what this process does send with every turn.",
488
      `System message sent with every turn:\n\n${systemPrompt(this.tools, THREAD_LANE, this.standing)}`,
480 489
      "",
481 490
      declarations,
482 491
    ].join("\n");

@@ -523,6 +532,21 @@ export class ThreadReplySource implements ReplySource {

523 532
    let turnText = "";
524 533
    /** How many tools this turn ran, reported on `turn.assistant`. */
525 534
    let turnToolCalls = 0;
535
    // The anchor goes on once, ahead of everything. Without it the model
536
    // answered "who are you" with the name of whatever it was underneath, and
537
    // listed tools from what a coding agent usually has rather than from what
538
    // this session declared.
539
    //
540
    // Composed at the first turn rather than at construction because the tools
541
    // and the context are both set after it, and a system message written
542
    // before them would name neither.
543
    if (!this.transcript.some((message) => message.role === "system")) {
544
      this.transcript.unshift({
545
        role: "system",
546
        content: systemPrompt(this.tools, THREAD_LANE, this.standing),
547
      });
548
    }
549
526 550
    this.transcript.push({ role: "user", content: prompt });
527 551
    this.sink?.record("turn.user", { text: prompt });
528 552

@@ -728,49 +752,80 @@ export class ThreadReplySource implements ReplySource {

728 752
   * because the turn has to run them and report each result, and a caller that
729 753
   * only saw a chunk could not.
730 754
   */
731
  private async *stream(signal: AbortSignal, collected: WireCall[]): AsyncIterable<ReplyChunk> {
732
    const response = await fetch(this.state.proxyUrl, {
733
      method: "POST",
734
      signal,
735
      headers: {
736
        authorization: `Bearer ${Redacted.value(this.state.grantToken)}`,
737
        "content-type": "application/json",
738
        // The body is an event stream and the refusals are JSON, and both have
739
        // to be acceptable: the `:api` pipeline negotiates on `json` and
740
        // answers `406` to a request that will only take `text/event-stream`.
741
        accept: "text/event-stream, application/json",
742
      },
743
      body: JSON.stringify({
744
        model: this.state.model,
745
        stream: true,
746
        messages: this.transcript,
747
        ...(this.tools.length === 0 || this.mustAnswer
748
          ? {}
749
          : {
750
              tools: this.tools.map((tool) => ({
751
                type: "function",
752
                function: {
753
                  name: tool.name,
754
                  description: tool.description,
755
                  parameters: tool.parameters,
756
                },
757
              })),
758
            }),
759
      }),
760
    }).catch((cause: unknown) => {
755
  /**
756
   * One call to the proxy, retried while the failure is one a retry can fix.
757
   *
758
   * A provider failure is a `502` the server produced *before* any of the
759
   * stream reached this client, so re-sending is the same call rather than a
760
   * duplicated one. Dropping the turn on the first one is what put "The model
761
   * provider failed" on screen twice in a row with the work lost both times.
762
   *
763
   * Only transient classes are retried. A revoked, expired, or exhausted grant
764
   * is settled — retrying it spends the reader's time to be told the same thing
765
   * three times — and every 4xx is a request this client would send again
766
   * unchanged. A retry does re-spend budget where the failed call was metered
767
   * for partial usage, which is why the ceiling is low.
768
   */
769
  private async callProxy(signal: AbortSignal): Promise<Response | undefined> {
770
    const attempts = 3;
771
772
    for (let attempt = 1; ; attempt += 1) {
773
      const response = await fetch(this.state.proxyUrl, {
774
        method: "POST",
775
        signal,
776
        headers: {
777
          authorization: `Bearer ${Redacted.value(this.state.grantToken)}`,
778
          "content-type": "application/json",
779
          // The body is an event stream and the refusals are JSON, and both have
780
          // to be acceptable: the `:api` pipeline negotiates on `json` and
781
          // answers `406` to a request that will only take `text/event-stream`.
782
          accept: "text/event-stream, application/json",
783
        },
784
        body: JSON.stringify({
785
          model: this.state.model,
786
          stream: true,
787
          messages: this.transcript,
788
          ...(this.tools.length === 0 || this.mustAnswer
789
            ? {}
790
            : {
791
                tools: this.tools.map((tool) => ({
792
                  type: "function",
793
                  function: {
794
                    name: tool.name,
795
                    description: tool.description,
796
                    parameters: tool.parameters,
797
                  },
798
                })),
799
              }),
800
        }),
801
      }).catch((cause: unknown) => {
802
        if (signal.aborted) return undefined;
803
        throw new ThreadUnavailable(
804
          "network_refused",
805
          `The inference proxy could not be reached: ${String(cause)}`,
806
        );
807
      });
808
809
      if (response === undefined || signal.aborted) return undefined;
810
      if (response.status >= 200 && response.status < 300) return response;
811
812
      const refusal = await proxyRefusal(response);
813
      const transient = response.status === 502 || response.status === 503 || response.status === 504;
814
      if (!transient || attempt >= attempts) throw refusal;
815
816
      // Short and fixed. The failure is on the provider's side and a reader is
817
      // watching a cursor; a long backoff reads as a hang.
818
      await new Promise((resolve) => setTimeout(resolve, 400 * attempt));
761 819
      if (signal.aborted) return undefined;
762
      throw new ThreadUnavailable(
763
        "network_refused",
764
        `The inference proxy could not be reached: ${String(cause)}`,
765
      );
766
    });
820
    }
821
  }
767 822
823
  private async *stream(signal: AbortSignal, collected: WireCall[]): AsyncIterable<ReplyChunk> {
824
    const response = await this.callProxy(signal);
768 825
    if (response === undefined || signal.aborted) return;
769
    if (response.status < 200 || response.status >= 300) {
770
      throw await proxyRefusal(response);
771
    }
772 826
    if (response.body === null) return;
773 827
828
774 829
    /** Tool call fragments by their wire index, assembled as frames arrive. */
775 830
    const calls = new Map<number, { id: string; name: string; args: string }>();
776 831

@@ -940,7 +995,8 @@ function accumulate(

940 995
/** The proxy's typed refusal, turned into a sentence a reader can act on. */
941 996
async function proxyRefusal(response: Response): Promise<ThreadUnavailable> {
942 997
  const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
943
  const code = string(record(body["error"])["code"]) ?? `http_${response.status}`;
998
  const error = record(body["error"]);
999
  const code = string(error["code"]) ?? `http_${response.status}`;
944 1000
945 1001
  const sentences: Record<string, string> = {
946 1002
    grant_revoked: "This thread was revoked. Start a new session to open another.",

@@ -951,9 +1007,15 @@ async function proxyRefusal(response: Response): Promise<ThreadUnavailable> {

951 1007
    provider_failed: "The model provider failed. The call was not completed.",
952 1008
  };
953 1009
1010
  // The server names the failure class on a provider failure. It is one bounded
1011
  // word, and it is the difference between a reader who knows the call ran out
1012
  // of context and one who only knows something went wrong.
1013
  const why = string(error["reason"]);
1014
  const sentence = sentences[code] ?? `The inference proxy refused the call (${code}).`;
1015
954 1016
  return new ThreadUnavailable(
955 1017
    code,
956
    sentences[code] ?? `The inference proxy refused the call (${code}).`,
1018
    why === undefined ? sentence : `${sentence} (${why})`,
957 1019
    response.status,
958 1020
  );
959 1021
}
packages/openagents-cli/test/coder-resume.test.ts modified +5 -1

@@ -547,7 +547,11 @@ describe("remintThread", () => {

547 547
    // The model was answered against the whole replayed history plus the new
548 548
    // prompt, in order.
549 549
    const turn = calls.find((call) => call.url.endsWith("/api/inference/proxy"));
550
    expect(turn?.body["messages"]).toEqual([...replayed, { role: "user", content: "carry on" }]);
550
    const sent = turn?.body["messages"] as Array<Record<string, unknown>>;
551
    expect(sent.filter((message) => message["role"] !== "system")).toEqual([
552
      ...replayed,
553
      { role: "user", content: "carry on" },
554
    ]);
551 555
552 556
    // Only the new turn reached the transcript writer.
553 557
    expect(recorded.map((event) => event.eventType)).toEqual(["turn.user", "turn.assistant"]);
packages/openagents-cli/test/coder-thread.test.ts modified +160 -5

@@ -105,6 +105,20 @@ const chunks = async (source: ThreadReplySource, prompt = "hello") => {

105 105
  return out;
106 106
};
107 107
108
/**
109
 * The conversation as the model receives it, without the session's anchor.
110
 *
111
 * Every session now opens with a system message naming what it is and what
112
 * tools it has. These assertions are about the shape of the conversation that
113
 * follows it, so they read past it rather than restating it; `anchorOf` is
114
 * where the anchor itself is checked.
115
 */
116
const conversation = (body: unknown) =>
117
  (body as Array<Record<string, unknown>>).filter((message) => message["role"] !== "system");
118
119
const anchorOf = (body: unknown) =>
120
  (body as Array<Record<string, unknown>>).find((message) => message["role"] === "system");
121
108 122
const textOf = (out: ReadonlyArray<ReplyChunk>) =>
109 123
  out.map((chunk) => (chunk.type === "text" ? chunk.value : "")).join("");
110 124

@@ -247,7 +261,7 @@ describe("ThreadReplySource", () => {

247 261
    await chunks(source, "second");
248 262
249 263
    const spends = calls.filter((call) => call.url.endsWith("/api/inference/proxy"));
250
    expect(spends[1]?.body["messages"]).toEqual([
264
    expect(conversation(spends[1]?.body["messages"])).toEqual([
251 265
      { role: "user", content: "first" },
252 266
      { role: "assistant", content: "Hello! Nice" },
253 267
      { role: "user", content: "second" },

@@ -364,7 +378,7 @@ describe("ThreadReplySource", () => {

364 378
      },
365 379
    ]);
366 380
    const second = proxied[1];
367
    expect(second?.body["messages"]).toEqual([
381
    expect(conversation(second?.body["messages"])).toEqual([
368 382
      { role: "user", content: "hello" },
369 383
      {
370 384
        role: "assistant",

@@ -423,7 +437,7 @@ describe("ThreadReplySource", () => {

423 437
    await chunks(source, "second");
424 438
425 439
    const spends = calls.filter((call) => call.url.endsWith("/api/inference/proxy"));
426
    expect(spends[1]?.body["messages"]).toEqual([
440
    expect(conversation(spends[1]?.body["messages"])).toEqual([
427 441
      { role: "user", content: "first" },
428 442
      { role: "assistant", content: "Answer." },
429 443
      { role: "user", content: "second" },

@@ -497,7 +511,7 @@ describe("ThreadReplySource", () => {

497 511
    await chunks(source, "run both");
498 512
499 513
    const spends = calls.filter((call) => call.url.endsWith("/api/inference/proxy"));
500
    expect(spends[1]?.body["messages"]).toEqual([
514
    expect(conversation(spends[1]?.body["messages"])).toEqual([
501 515
      { role: "user", content: "run both" },
502 516
      {
503 517
        role: "assistant",

@@ -527,7 +541,7 @@ describe("ThreadReplySource", () => {

527 541
    await chunks(source, "look");
528 542
529 543
    const spends = calls.filter((call) => call.url.endsWith("/api/inference/proxy"));
530
    const messages = spends[1]?.body["messages"] as Array<Record<string, unknown>>;
544
    const messages = conversation(spends[1]?.body["messages"]);
531 545
    expect(messages[1]).toMatchObject({ role: "assistant", content: "Looking now." });
532 546
    expect(messages[1]?.["tool_calls"]).toHaveLength(1);
533 547
  });

@@ -865,3 +879,144 @@ describe("the thread's durable transcript", () => {

865 879
    expect(notices).toHaveLength(1);
866 880
  });
867 881
});
882
883
describe("a provider failure mid-session", () => {
884
  const proxyCalls = (calls: ReadonlyArray<Call>) =>
885
    calls.filter((call) => call.url.endsWith("/api/inference/proxy"));
886
887
  it("carries the server's failure class into the sentence a reader sees", async () => {
888
    const calls = stub({
889
      proxy: [
890
        json(502, { error: { code: "provider_failed", reason: "context_length_exceeded" } }),
891
        json(502, { error: { code: "provider_failed", reason: "context_length_exceeded" } }),
892
        json(502, { error: { code: "provider_failed", reason: "context_length_exceeded" } }),
893
      ],
894
    });
895
    const source = await open();
896
897
    await expect(chunks(source)).rejects.toThrow(/context_length_exceeded/);
898
    // Non-vacuous: the generic sentence is still there, with the class beside it.
899
    expect(proxyCalls(calls)).toHaveLength(3);
900
  });
901
902
  it("retries a provider failure rather than losing the turn to one hiccup", async () => {
903
    const calls = stub({
904
      proxy: [json(502, { error: { code: "provider_failed" } }), sse([LIVE_SSE])],
905
    });
906
    const source = await open();
907
908
    expect(textOf(await chunks(source))).toBe("Hello! Nice");
909
    expect(proxyCalls(calls)).toHaveLength(2);
910
  });
911
912
  it("gives up after a bounded number of attempts", async () => {
913
    const calls = stub({
914
      proxy: [
915
        json(502, { error: { code: "provider_failed" } }),
916
        json(502, { error: { code: "provider_failed" } }),
917
        json(502, { error: { code: "provider_failed" } }),
918
        sse([LIVE_SSE]),
919
      ],
920
    });
921
    const source = await open();
922
923
    await expect(chunks(source)).rejects.toBeInstanceOf(ThreadUnavailable);
924
    // Stops at three; the fourth response, which would have succeeded, is never
925
    // asked for. A retry re-spends budget, so the ceiling has to bite.
926
    expect(proxyCalls(calls)).toHaveLength(3);
927
  });
928
929
  it("does not retry a settled grant", async () => {
930
    for (const [status, code] of [
931
      [403, "grant_revoked"],
932
      [403, "grant_expired"],
933
      [429, "grant_exhausted"],
934
      [401, "invalid_grant"],
935
    ] as const) {
936
      const calls = stub({
937
        proxy: [json(status, { error: { code } }), sse([LIVE_SSE])],
938
      });
939
      const source = await open();
940
941
      await expect(chunks(source)).rejects.toBeInstanceOf(ThreadUnavailable);
942
      // Retrying a settled refusal tells the reader the same thing three times.
943
      expect(proxyCalls(calls)).toHaveLength(1);
944
      vi.unstubAllGlobals();
945
    }
946
  });
947
});
948
949
describe("the session's anchor on the thread lane", () => {
950
  const proxied = (calls: ReadonlyArray<Call>) =>
951
    calls.filter((call) => call.url.endsWith("/api/inference/proxy"));
952
953
  const tool = (name: string) => ({
954
    name,
955
    description: `the ${name} tool`,
956
    parameters: { type: "object" as const },
957
    run: async () => "done",
958
  });
959
960
  it("says what the session is, so the model does not answer with the name underneath", async () => {
961
    // The gap this closes: asked "who are you", a thread session answered
962
    // "I'm ChatGPT" — the hosted model's own name — because nothing on this
963
    // lane had ever told it otherwise.
964
    const calls = stub({});
965
    const source = await open();
966
    await chunks(source);
967
968
    const anchor = anchorOf(proxied(calls)[0]?.body["messages"]);
969
    expect(anchor?.["content"]).toContain("`openagents coder`");
970
    expect(anchor?.["content"]).toContain("inference proxy");
971
  });
972
973
  it("names the declared tools as a closed list", async () => {
974
    const calls = stub({});
975
    const source = await open();
976
    source.useTools([tool("shell"), tool("delegate")]);
977
    await chunks(source);
978
979
    const content = String(anchorOf(proxied(calls)[0]?.body["messages"])?.["content"]);
980
    expect(content).toContain("You have 2 tools, and no others:");
981
    expect(content).toContain("`shell`");
982
    expect(content).toContain("`delegate`");
983
  });
984
985
  it("carries the standing context, rather than gluing it to what the reader typed", async () => {
986
    const calls = stub({});
987
    const source = await open();
988
    source.useContext("This session is working in /repo.");
989
    await chunks(source, "hello");
990
991
    const messages = proxied(calls)[0]?.body["messages"] as Array<Record<string, unknown>>;
992
    expect(String(anchorOf(messages)?.["content"])).toContain("This session is working in /repo.");
993
    // The reader's turn is the reader's words and nothing else. Prefixed onto
994
    // the prompt, the preamble read as something they had typed.
995
    expect(conversation(messages)).toEqual([{ role: "user", content: "hello" }]);
996
  });
997
998
  it("sends the anchor once, not on every turn", async () => {
999
    const calls = stub({});
1000
    const source = await open();
1001
    await chunks(source, "first");
1002
    await chunks(source, "second");
1003
1004
    const messages = proxied(calls)[1]?.body["messages"] as Array<Record<string, unknown>>;
1005
    expect(messages.filter((message) => message["role"] === "system")).toHaveLength(1);
1006
    expect(messages[0]?.["role"]).toBe("system");
1007
  });
1008
1009
  it("shows the reader the same text it sends", async () => {
1010
    stub({});
1011
    const source = await open();
1012
    source.useTools([tool("shell")]);
1013
    source.useContext("Workspace facts.");
1014
1015
    // `/system` reads the thing the model read. It used to say the server
1016
    // composed this, which was both untrue and hiding that nothing was sent.
1017
    const shown = source.describeContext();
1018
    expect(shown).toContain("`openagents coder`");
1019
    expect(shown).toContain("Workspace facts.");
1020
    expect(shown).not.toContain("composed by the server");
1021
  });
1022
});

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