Run the coder on its own thread, not the account conversation

5f36e4c83912 · AtlantisPleb · · parent f57737b51cd2

Run the coder on its own thread, not the account conversation

Until this, a prompt typed into `openagents coder` was a message in the owner's
`/chat`. There was one route a user token could reach a model through, and it
wrote into the account's single conversation: the prompt appeared in the web
console, `AccountTurns.provider_history/2` replayed every completed run in that
conversation into the next browser question with no limit, and the partial
unique index admitting one streaming run per conversation meant a second
checkout was refused `turn_in_progress` while the first was answering. Terminal
work became browser context, and browser context became terminal context.

`0ebeccc` built the thread and its grant and `7e5f7b1` opened the door to both.
This walks through it. A session opens a thread with `POST /api/v3/threads`,
spends that thread's grant against `POST /api/inference/proxy`, reads what it
has left with `GET`, and revokes with `DELETE` on the way out so a closed
terminal does not hold one of the account's eight slots for an hour. The two
chat paths are deleted; `coder-chat-api.ts` does not survive.

The event vocabulary the interface renders is unchanged. `coder-session.ts`
still consumes `text | reasoning | tool_call | tool_result`, the Markdown
renderer and the tool entries are untouched, and the new source translates
chat-completions SSE into that same union: `delta.content` becomes `text`, and
`delta.tool_calls` fragments are folded by their wire index into one
`tool_call`. The parser reads the response body as a stream rather than as one
string, so the day the proxy sends its frames as it produces them, this side
needs no change.

Two of those four members no longer arrive, and both are losses worth naming.
`OpenAgents.Providers.ProviderEvent` has no reasoning member, so the proxy has
nothing to translate and the dim-italic reasoning entry never appears against a
live model. And the chat lane ran tools on the server, where this proxy is a
bare completions surface that forwards the tools a caller declares -- this one
declares none and has no runtime to execute them -- so no tool call is
requested and no result comes back. The mapping for both is written and tested
against the shape the proxy does emit; nothing triggers it yet.

The status row carries the budget beside the model and the state, because an
agent that exhausts 256 calls mid-edit without ever having shown a number is an
agent that lost the work. It also stopped claiming the turn is streaming: the
proxy builds the whole body and sends it once, so a reply arrives after several
silent seconds in a single frame, and the line now says only how long the turn
has been running. The budget is read back from the server after every turn,
including an interrupted one, since interrupting aborts the client and not the
call the proxy already bought.

The grant pins the model and `POST /api/v3/threads` publishes no model
parameter, so `--model` and Tab cannot change which model answers a thread. The
status row names the model the grant pins, which is the one that produced the
text above it, and a named `--model` gets a notice saying it had no effect
rather than a label that quietly disagrees with the reply. `coder-backends.ts`,
the flag, and the cycling stay for the day a thread can pin its own.

Verified against a local server: two concurrent sessions each answered on their
own thread and their own budget, `GET /api/v3/chat/events` stayed empty
throughout, no `account_chat_runs` row was written by any coder turn, every
thread the CLI opened is `cancelled` with its grant `revoked`, and a ninth
concurrent session was refused with the server's `thread_quota_reached` naming
the ceiling and the account's own count.

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/cli.ts
  • deleted packages/openagents-cli/src/coder-chat-api.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • added packages/openagents-cli/src/coder-thread.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • deleted packages/openagents-cli/test/coder-chat-api.test.ts
  • added packages/openagents-cli/test/coder-thread.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

8 files changed, +963 -702

packages/openagents-cli/src/cli.ts modified +83 -29

@@ -18,8 +18,8 @@ import { BrowserLauncher } from "./browser-launcher.js";

18 18
import { runCoderPlain } from "./coder-plain.js";
19 19
import { CoderSession, DummyReplySource } from "./coder-session.js";
20 20
import { runCoderUi } from "./coder-ui.js";
21
import { backendIds, findBackend } from "./coder-backends.js";
22
import { ChatApiReplySource } from "./coder-chat-api.js";
21
import { backendIds } from "./coder-backends.js";
22
import { openThread, ThreadUnavailable } from "./coder-thread.js";
23 23
import { describeWorkspace } from "./coder-workspace.js";
24 24
import { ComputerClient } from "./computer-client.js";
25 25
import { ComputerUp } from "./computer-up.js";

@@ -39,6 +39,7 @@ import {

39 39
  ComputerPairingInProgress,
40 40
  ComputerReconnectExhausted,
41 41
  InputError,
42
  NetworkRefused,
42 43
} from "./errors.js";
43 44
import { CredentialStore } from "./credential-store.js";
44 45
import {

@@ -1401,13 +1402,40 @@ const coderReasoningFlag = Flag.choice("reasoning", [

1401 1402
  "medium",
1402 1403
  "high",
1403 1404
  "max",
1404
]).pipe(Flag.optional, Flag.withDescription("Reasoning effort the server passes to the provider"));
1405
// The accepted values come from the same list the status line and Tab read, so
1406
// a backend cannot be offered by one and refused by another.
1407
const coderModelFlag = Flag.choice("model", backendIds() as string[]).pipe(
1405
]).pipe(
1408 1406
  Flag.optional,
1409
  Flag.withDescription("The backend that answers the turn"),
1407
  Flag.withDescription("Reasoning effort recorded on the thread as its admitted execution shape"),
1410 1408
);
1409
// The accepted values are still the chat API's published backends. A thread's
1410
// grant pins its own model and `POST /api/v3/threads` publishes no model
1411
// parameter, so naming one here cannot change which model answers; the session
1412
// says so rather than letting the flag look like it worked.
1413
const coderModelFlag = Flag.choice("model", backendIds() as string[]).pipe(
1414
  Flag.optional,
1415
  Flag.withDescription("A chat API backend. The thread's grant pins its own model"),
1416
);
1417
1418
/**
1419
 * Turn a refused thread into an error the CLI already knows how to print.
1420
 *
1421
 * The server's code and sentence are carried through unchanged, so a caller
1422
 * reading `--json` branches on `thread_quota_reached` and a person reading the
1423
 * terminal is told the limit and how many threads the account is holding.
1424
 */
1425
const coderRefusal = (origin: string, cause: unknown) => {
1426
  if (!(cause instanceof ThreadUnavailable)) {
1427
    return new InputError({ message: `The thread could not be opened: ${String(cause)}` });
1428
  }
1429
  if (cause.code === "network_refused") {
1430
    return new NetworkRefused({ origin, message: cause.message });
1431
  }
1432
  return new ApiError({
1433
    operation: "coder.thread.open",
1434
    status: cause.status,
1435
    code: cause.code,
1436
    message: cause.message,
1437
  });
1438
};
1411 1439
1412 1440
const coderCommand = Command.make(
1413 1441
  "coder",

@@ -1425,10 +1453,10 @@ const coderCommand = Command.make(

1425 1453
      const workspace = describeWorkspace();
1426 1454
      const endpoint = yield* resolveApiEndpoint(endpointOverrides(flags));
1427 1455
1428
      // Replies come from the account chat API, so the CLI never holds a
1429
      // provider key and a thread costs exactly what the server metered.
1430
      // Without a credential it falls back to the stand-in and says so rather
1431
      // than failing.
1456
      // The session opens a thread of its own and spends that thread's grant,
1457
      // so the CLI still holds no provider key and nothing typed here reaches
1458
      // the account's conversation. Without a credential it falls back to the
1459
      // stand-in and says so rather than failing.
1432 1460
      const stored = offline
1433 1461
        ? Option.none()
1434 1462
        : yield* findToken(endpoint.origin).pipe(

@@ -1437,16 +1465,23 @@ const coderCommand = Command.make(

1437 1465
            ),
1438 1466
          );
1439 1467
1440
      const chosen = Option.getOrUndefined(model);
1441
      const source = Option.isSome(stored)
1442
        ? new ChatApiReplySource({
1443
            origin: endpoint.origin,
1444
            token: Redacted.value(stored.value.token),
1445
            reasoning: Option.getOrUndefined(reasoning),
1446
            backend: chosen === undefined ? undefined : findBackend(chosen),
1468
      const thread = Option.isSome(stored)
1469
        ? yield* Effect.tryPromise({
1470
            try: () =>
1471
              openThread({
1472
                origin: endpoint.origin,
1473
                token: Redacted.value(stored.value.token),
1474
                objective: `openagents coder in ${workspace.repository} on ${workspace.branch}`,
1475
                reasoning: Option.getOrUndefined(reasoning),
1476
              }),
1477
            // The server's own code and sentence, which is what turns a ninth
1478
            // concurrent session from an obscure failure into an instruction
1479
            // naming the ceiling and how many threads the account is holding.
1480
            catch: (cause) => coderRefusal(endpoint.origin, cause),
1447 1481
          })
1448
        : new DummyReplySource();
1482
        : undefined;
1449 1483
1484
      const source = thread ?? new DummyReplySource();
1450 1485
      const session = new CoderSession(source, workspace.repository, workspace.branch);
1451 1486
1452 1487
      if (Option.isNone(stored) && !offline) {

@@ -1456,18 +1491,37 @@ const coderCommand = Command.make(

1456 1491
        );
1457 1492
      }
1458 1493
1494
      // A grant pins the model the proxy will use, and the thread route takes
1495
      // no model parameter, so a named backend cannot reach this turn. Saying
1496
      // nothing would leave a reader with a flag that appeared to work.
1497
      if (thread !== undefined && Option.isSome(model)) {
1498
        session.notice(
1499
          `This thread's grant pins ${thread.model}. \`--model\` names a chat API ` +
1500
            "backend, which the inference proxy does not route to, so it had no effect.",
1501
        );
1502
      }
1503
1459 1504
      const oneShot = Option.getOrUndefined(prompt);
1460 1505
      const interactive = terminal.interactive && !plain && !flags.json && oneShot === undefined;
1461 1506
1462
      const code = yield* Effect.promise(() =>
1463
        interactive
1464
          ? runCoderUi(session, { stdin: process.stdin, stdout: process.stdout })
1465
          : runCoderPlain(session, {
1466
              stdin: process.stdin,
1467
              stdout: process.stdout,
1468
              prompt: oneShot,
1469
            }),
1470
      );
1507
      const code = yield* Effect.promise(async () => {
1508
        try {
1509
          return interactive
1510
            ? await runCoderUi(session, { stdin: process.stdin, stdout: process.stdout })
1511
            : await runCoderPlain(session, {
1512
                stdin: process.stdin,
1513
                stdout: process.stdout,
1514
                prompt: oneShot,
1515
              });
1516
        } finally {
1517
          // An account holds eight open threads at once. A terminal that closed
1518
          // without giving its slot back would hold one until the authority
1519
          // expired an hour later, which is the ninth session refused for a
1520
          // session nobody is in. A process killed outright still leaves it to
1521
          // the server's expiry reap.
1522
          if (thread !== undefined) await thread.revoke();
1523
        }
1524
      });
1471 1525
1472 1526
      if (code !== 0) {
1473 1527
        process.exitCode = code;

@@ -1475,7 +1529,7 @@ const coderCommand = Command.make(

1475 1529
    }),
1476 1530
).pipe(
1477 1531
  Command.withDescription(
1478
    "Open a terminal coding session. Replies come from Ox Alpha through the account chat API; --offline answers from a built-in stand-in instead",
1532
    "Open a terminal coding session on a thread of its own. Replies come from the thread's grant through the inference proxy, so nothing typed here reaches /chat; --offline answers from a built-in stand-in instead",
1479 1533
  ),
1480 1534
);
1481 1535
packages/openagents-cli/src/coder-chat-api.ts deleted -321

@@ -1,321 +0,0 @@

1
/**
2
 * A reply source backed by the account chat API.
3
 *
4
 * The CLI never holds a provider key and never talks to a model vendor. It
5
 * submits a turn and reads the durable event log the server writes, so a coder
6
 * thread costs exactly what the server metered and leaves the same receipts the
7
 * web surface leaves.
8
 *
9
 * Which model answers is the server's `model` parameter, and every backend
10
 * answers with the same events, so this file has no branch per backend: the
11
 * backend is a value it sends and a label it reports.
12
 *
13
 * Two properties of the shipped contract shape this file:
14
 *
15
 * - The server records one conversation per account (`DATA-002`) and one active
16
 *   turn per conversation (`TURN-001`). A coder session therefore shares the
17
 *   account's conversation rather than opening its own, and a second turn while
18
 *   one is running is refused with `turn_in_progress` rather than queued.
19
 * - `GET /api/v3/chat/events` returns the conversation's whole event log, not a
20
 *   stream. This polls it and yields what is new, which is why the reply
21
 *   appears in pieces rather than at once.
22
 */
23
24
import { type CoderBackend, defaultBackend, nextBackend } from "./coder-backends.js";
25
import type { ReplyChunk } from "./coder-session.js";
26
27
const SUBMIT_PATH = "/api/v3/chat/turns";
28
const EVENTS_PATH = "/api/v3/chat/events";
29
30
const POLL_INTERVAL_MS = 250;
31
/** Give up rather than poll forever when a turn never reaches a terminal event. */
32
const TURN_TIMEOUT_MS = 300_000;
33
34
export interface ChatApiOptions {
35
  readonly origin: string;
36
  readonly token: string;
37
  /** Reasoning effort the server passes to the provider. */
38
  readonly reasoning?: string | undefined;
39
  /** The backend that answers. Defaults to the first in the published list. */
40
  readonly backend?: CoderBackend | undefined;
41
}
42
43
interface ChatEvent {
44
  readonly id?: string;
45
  readonly run_id?: string;
46
  readonly sequence?: number;
47
  readonly type?: string;
48
  readonly payload?: Record<string, unknown>;
49
  /**
50
   * The server's own projection of the tool call this event belongs to, which
51
   * every `tool_call_*` event carries. It already holds pretty-printed
52
   * arguments, the extracted result, and a structured error, so reading it
53
   * rather than the raw payload keeps the CLI and the web surface showing the
54
   * same tool call.
55
   */
56
  readonly tool_call?: ToolCallView;
57
}
58
59
interface ToolCallView {
60
  readonly call_id?: string;
61
  readonly name?: string;
62
  readonly arguments?: string;
63
  readonly output?: string | null;
64
  readonly error?: { readonly code?: string | null; readonly message?: string | null } | null;
65
  readonly status?: string;
66
}
67
68
export class ChatApiUnavailable extends Error {
69
  constructor(
70
    readonly code: string,
71
    message: string,
72
  ) {
73
    super(message);
74
    this.name = "ChatApiUnavailable";
75
  }
76
}
77
78
/**
79
 * Submit a turn and yield what the server records, in the order it records it.
80
 *
81
 * A turn interleaves reasoning, tool calls, and assistant text. Every one of
82
 * those becomes a chunk here. An earlier version yielded only `text_delta`,
83
 * which made a tool call invisible and joined the sentence before it to the
84
 * sentence after it.
85
 */
86
export class ChatApiReplySource {
87
  private backend: CoderBackend;
88
89
  constructor(private readonly options: ChatApiOptions) {
90
    this.backend = options.backend ?? defaultBackend();
91
  }
92
93
  /** The label the status line shows, which is the current backend's. */
94
  get model(): string {
95
    return this.backend.label;
96
  }
97
98
  /** The id the next turn sends as `model`. */
99
  get backendId(): string {
100
    return this.backend.id;
101
  }
102
103
  /**
104
   * Move to the next backend and return its label.
105
   *
106
   * This changes only what the next turn asks for. A turn already running was
107
   * submitted with the backend it named and keeps it, because the server has
108
   * already accepted that turn and cannot be told to change its mind.
109
   */
110
  cycleBackend(): string {
111
    this.backend = nextBackend(this.backend);
112
    return this.backend.label;
113
  }
114
115
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
116
    const seen = await this.latestSequence();
117
    const runId = await this.submit(prompt, signal);
118
    const startedAt = Date.now();
119
    const delivered = new Map<string, number>();
120
    if (runId !== undefined) delivered.set(runId, seen.get(runId) ?? -1);
121
122
    while (!signal.aborted) {
123
      if (Date.now() - startedAt > TURN_TIMEOUT_MS) {
124
        throw new ChatApiUnavailable(
125
          "turn_timed_out",
126
          "The turn produced no terminal event within five minutes.",
127
        );
128
      }
129
130
      const events = await this.events(signal);
131
      if (signal.aborted) return;
132
133
      // A submit that answered without a run id still identifies its run in the
134
      // log; take the newest run not already accounted for.
135
      const target = runId ?? newestRun(events, seen);
136
      if (target === undefined) {
137
        await sleep(POLL_INTERVAL_MS, signal);
138
        continue;
139
      }
140
141
      const floor = delivered.get(target) ?? seen.get(target) ?? -1;
142
      let highest = floor;
143
      let finished = false;
144
145
      for (const event of events) {
146
        if (event.run_id !== target) continue;
147
        const sequence = typeof event.sequence === "number" ? event.sequence : -1;
148
        if (sequence <= floor) continue;
149
        highest = Math.max(highest, sequence);
150
151
        if (event.type === "text_delta") {
152
          const value = event.payload?.["value"];
153
          if (typeof value === "string" && value.length > 0) yield { type: "text", value };
154
        } else if (event.type === "reasoning_delta") {
155
          const value = event.payload?.["value"];
156
          if (typeof value === "string" && value.length > 0) yield { type: "reasoning", value };
157
        } else if (event.type === "tool_call_started") {
158
          const call = toolCall(event);
159
          if (call !== undefined) yield call;
160
        } else if (event.type === "tool_call_completed" || event.type === "tool_call_failed") {
161
          const result = toolResult(event);
162
          if (result !== undefined) yield result;
163
        } else if (event.type === "response_completed") {
164
          finished = true;
165
        } else if (event.type === "response_failed") {
166
          // The server names the terminal events `response_completed` and
167
          // `response_failed`, and reports why in `reason` with a stable
168
          // `code` beside it.
169
          const reason = event.payload?.["reason"];
170
          const code = event.payload?.["code"];
171
          throw new ChatApiUnavailable(
172
            typeof code === "string" ? code : "turn_failed",
173
            typeof reason === "string" ? reason : "The turn failed on the server.",
174
          );
175
        }
176
      }
177
178
      delivered.set(target, highest);
179
      if (finished) return;
180
      await sleep(POLL_INTERVAL_MS, signal);
181
    }
182
  }
183
184
  /** Highest sequence per run before submitting, so old events are not replayed. */
185
  private async latestSequence(): Promise<Map<string, number>> {
186
    const seen = new Map<string, number>();
187
    for (const event of await this.events()) {
188
      if (typeof event.run_id !== "string") continue;
189
      const sequence = typeof event.sequence === "number" ? event.sequence : -1;
190
      seen.set(event.run_id, Math.max(seen.get(event.run_id) ?? -1, sequence));
191
    }
192
    return seen;
193
  }
194
195
  private async submit(prompt: string, signal: AbortSignal): Promise<string | undefined> {
196
    const response = await fetch(new URL(SUBMIT_PATH, this.options.origin), {
197
      method: "POST",
198
      signal,
199
      headers: {
200
        authorization: `Bearer ${this.options.token}`,
201
        "content-type": "application/json",
202
        accept: "application/json",
203
      },
204
      body: JSON.stringify({
205
        message: prompt,
206
        model: this.backend.id,
207
        ...(this.options.reasoning === undefined ? {} : { reasoning: this.options.reasoning }),
208
      }),
209
    });
210
211
    const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
212
213
    if (response.status === 401 || response.status === 403) {
214
      throw new ChatApiUnavailable(
215
        "scope_missing",
216
        "This token cannot reach the chat API. Sign in again with the chat:account scope.",
217
      );
218
    }
219
    if (response.status === 409) {
220
      throw new ChatApiUnavailable(
221
        "turn_in_progress",
222
        "The account already has a turn running. One turn runs at a time.",
223
      );
224
    }
225
    if (response.status === 429) {
226
      throw new ChatApiUnavailable("rate_limited", "The chat API is rate limiting this account.");
227
    }
228
    if (response.status < 200 || response.status >= 300) {
229
      const code = typeof body["error"] === "string" ? body["error"] : `http_${response.status}`;
230
      throw new ChatApiUnavailable(code, `The chat API refused the turn (${code}).`);
231
    }
232
233
    const turn = body["turn"];
234
    if (turn !== null && typeof turn === "object") {
235
      const id = (turn as Record<string, unknown>)["id"];
236
      if (typeof id === "string") return id;
237
    }
238
    return undefined;
239
  }
240
241
  private async events(signal?: AbortSignal): Promise<ReadonlyArray<ChatEvent>> {
242
    const response = await fetch(new URL(EVENTS_PATH, this.options.origin), {
243
      ...(signal === undefined ? {} : { signal }),
244
      headers: {
245
        authorization: `Bearer ${this.options.token}`,
246
        accept: "application/json",
247
      },
248
    });
249
250
    if (response.status === 401 || response.status === 403) {
251
      throw new ChatApiUnavailable(
252
        "scope_missing",
253
        "This token cannot read chat events. Sign in again with the chat:account scope.",
254
      );
255
    }
256
    if (response.status < 200 || response.status >= 300) return [];
257
258
    const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
259
    const events = body["events"];
260
    return Array.isArray(events) ? (events as ReadonlyArray<ChatEvent>) : [];
261
  }
262
}
263
264
/** The start of a tool call, read from the server's projection of it. */
265
function toolCall(event: ChatEvent): Extract<ReplyChunk, { type: "tool_call" }> | undefined {
266
  const view = event.tool_call;
267
  const callId = view?.call_id ?? stringField(event.payload, "call_id");
268
  if (callId === undefined) return undefined;
269
  return {
270
    type: "tool_call",
271
    callId,
272
    name: view?.name ?? stringField(event.payload, "name") ?? "tool",
273
    arguments: view?.arguments ?? stringField(event.payload, "arguments") ?? "",
274
  };
275
}
276
277
/** The outcome of a tool call. `error` decides whether it succeeded. */
278
function toolResult(event: ChatEvent): Extract<ReplyChunk, { type: "tool_result" }> | undefined {
279
  const view = event.tool_call;
280
  const callId = view?.call_id ?? stringField(event.payload, "call_id");
281
  if (callId === undefined) return undefined;
282
283
  const message = view?.error?.message ?? stringField(event.payload, "error");
284
  const error = message ?? (event.type === "tool_call_failed" ? "The tool failed." : undefined);
285
  const output = view?.output ?? stringField(event.payload, "output");
286
287
  return { type: "tool_result", callId, output: output ?? undefined, error };
288
}
289
290
function stringField(
291
  payload: Record<string, unknown> | undefined,
292
  key: string,
293
): string | undefined {
294
  const value = payload?.[key];
295
  return typeof value === "string" && value.length > 0 ? value : undefined;
296
}
297
298
/** The newest run in the log that the pre-submit snapshot did not know about. */
299
function newestRun(
300
  events: ReadonlyArray<ChatEvent>,
301
  seen: ReadonlyMap<string, number>,
302
): string | undefined {
303
  for (let index = events.length - 1; index >= 0; index -= 1) {
304
    const runId = events[index]?.run_id;
305
    if (typeof runId === "string" && !seen.has(runId)) return runId;
306
  }
307
  return undefined;
308
}
309
310
function sleep(ms: number, signal: AbortSignal): Promise<void> {
311
  return new Promise((resolve) => {
312
    if (signal.aborted) return resolve();
313
    const timer = setTimeout(finish, ms);
314
    signal.addEventListener("abort", finish, { once: true });
315
    function finish() {
316
      clearTimeout(timer);
317
      signal.removeEventListener("abort", finish);
318
      resolve();
319
    }
320
  });
321
}
packages/openagents-cli/src/coder-session.ts modified +13 -4

@@ -70,6 +70,13 @@ export interface CoderSnapshot {

70 70
   * this process never saw.
71 71
   */
72 72
  readonly turns: number;
73
  /**
74
   * What the source may still spend, or undefined for a source that spends
75
   * nothing. A budget first shown when it runs out is a budget that already
76
   * cost somebody the work it was funding, so the status line carries it from
77
   * the first frame.
78
   */
79
  readonly budget: string | undefined;
73 80
}
74 81
75 82
/** Where reply chunks come from. One implementation today; ACP is the next. */

@@ -77,11 +84,12 @@ export interface ReplySource {

77 84
  /** The label the status line shows for the reply source. */
78 85
  readonly model: string;
79 86
  /**
80
   * One sentence about where this source's turns are recorded, shown once at
81
   * the start of a session. A source whose turns are private to this process
82
   * leaves it unset; a source that writes into a conversation shared with
83
   * another surface has to say so, because nothing else on screen would.
87
   * What this source may still spend, already formatted for the status line,
88
   * or undefined for a source that meters nothing. Read on every snapshot, so
89
   * a source spending against a ceiling reports the figure it is at rather
90
   * than the one it opened with.
84 91
   */
92
  readonly budget?: string | undefined;
85 93
  /**
86 94
   * Move to the next backend and return its new label.
87 95
   *

@@ -219,6 +227,7 @@ export class CoderSession {

219 227
      branch: this.branch,
220 228
      model: this.source.model,
221 229
      turns: this.turnCount,
230
      budget: this.source.budget,
222 231
    };
223 232
  }
224 233
packages/openagents-cli/src/coder-thread.ts added +511

@@ -0,0 +1,511 @@

1
/**
2
 * A reply source backed by a thread of the caller's own, and the grant that
3
 * thread mints.
4
 *
5
 * `openagents coder` used to submit through `POST /api/v3/chat/turns` and poll
6
 * `GET /api/v3/chat/events`. That was the only route a user token could reach a
7
 * model through, and the server records one conversation per account, so every
8
 * prompt a person typed in a terminal landed in the same conversation `/chat`
9
 * reads, contended for the one streaming slot that conversation admits, and
10
 * became provider context for the next question asked in the browser. This
11
 * replaces both paths.
12
 *
13
 * `POST /api/v3/threads` opens a thread and returns a grant. The grant is the
14
 * bearer for `POST /api/inference/proxy`, an OpenAI-compatible
15
 * `/chat/completions` surface that meters against the thread's own budget and
16
 * keeps the provider credential on the server, so the CLI still holds no
17
 * provider key. `DELETE /api/v3/threads/{id}` revokes the thread on exit, which
18
 * matters because an account may hold only eight open threads at once and a
19
 * closed terminal would otherwise hold a slot until the authority expired.
20
 *
21
 * Three properties of that proxy shape this file, and each is a real loss
22
 * against the event log this replaces:
23
 *
24
 * - **It answers in one piece.** The proxy builds the whole SSE body and sends
25
 *   it once, so the frames below all arrive together. The parser is written
26
 *   against the stream anyway rather than against `await response.text()`, so
27
 *   chunked delivery becomes visible here the day the server sends it, with no
28
 *   change on this side.
29
 * - **It carries no reasoning.** `OpenAgents.Providers.ProviderEvent` has no
30
 *   reasoning member at all — the union is `response_started`, `text_delta`,
31
 *   `tool_call`, `usage`, `response_completed`, `failed`, `cancelled` — and the
32
 *   proxy drops everything it cannot name. The chat event log had
33
 *   `reasoning_delta`; nothing on this path does. So no `reasoning` chunk is
34
 *   ever produced here, and the interface's dim-italic reasoning entry, which
35
 *   the stand-in behind `--offline` still exercises, never appears against a
36
 *   live model.
37
 * - **No tool runs.** The chat lane ran tools on the server and reported each
38
 *   one. The proxy is a bare completions surface: it forwards the `tools` a
39
 *   caller declares and returns the calls the model asks for, and the caller
40
 *   executes them. This CLI declares none and has no tool runtime, so the
41
 *   `tool_calls` translation below is the honest mapping of a frame that does
42
 *   not arrive today. It is kept because the frame is part of the surface and
43
 *   the alternative is discovering the mapping is missing on the day tools land.
44
 *
45
 * Nothing here announces any of that on screen. `2c15c6ed20` removed the
46
 * `scopeNotice` seam with the reasoning that a session private to its own
47
 * thread has nothing to announce, and a banner at the top of every session
48
 * teaches the constraint rather than the design. The losses above are real and
49
 * belong in the issue that decides what to do about them, not in a permanent
50
 * line above the first prompt.
51
 *
52
 * The grant is a bearer credential. It is held `Redacted` so that an accidental
53
 * interpolation prints a placeholder, it never reaches the transcript, and it
54
 * is never passed as an argument to anything this process spawns.
55
 */
56
57
import { Redacted } from "effect";
58
59
import type { ReplyChunk, ReplySource } from "./coder-session.js";
60
61
const THREADS_PATH = "/api/v3/threads";
62
63
/** What the thread may still spend, as the server last reported it. */
64
export interface ThreadBudget {
65
  readonly calls: number;
66
  readonly totalTokens: number;
67
  readonly costMicrousd: number;
68
}
69
70
export interface ThreadOptions {
71
  readonly origin: string;
72
  /** The account token. Opens, reads, and revokes the thread; never spends it. */
73
  readonly token: string;
74
  /** What this body of work is for. The server requires one. */
75
  readonly objective: string;
76
  /** Recorded on the thread as its admitted execution shape. */
77
  readonly reasoning?: string | undefined;
78
}
79
80
export class ThreadUnavailable extends Error {
81
  constructor(
82
    readonly code: string,
83
    message: string,
84
    /** The HTTP status behind the code, or 0 when the request never landed. */
85
    readonly status = 0,
86
  ) {
87
    super(message);
88
    this.name = "ThreadUnavailable";
89
  }
90
}
91
92
/**
93
 * Open a thread and take its grant.
94
 *
95
 * A refusal here is reported with the server's own code and sentence. The
96
 * account cap is the one a person meets: the ninth concurrent session is
97
 * refused `thread_quota_reached` with a message naming the limit and how many
98
 * threads the account is holding, which is what tells them to close one rather
99
 * than to retry.
100
 */
101
export async function openThread(options: ThreadOptions): Promise<ThreadReplySource> {
102
  const response = await fetch(new URL(THREADS_PATH, options.origin), {
103
    method: "POST",
104
    headers: {
105
      authorization: `Bearer ${options.token}`,
106
      "content-type": "application/json",
107
      accept: "application/json",
108
    },
109
    body: JSON.stringify({
110
      objective: options.objective,
111
      ...(options.reasoning === undefined ? {} : { reasoning: options.reasoning }),
112
    }),
113
  }).catch((cause: unknown) => {
114
    throw new ThreadUnavailable(
115
      "network_refused",
116
      `The API at ${options.origin} could not be reached: ${String(cause)}`,
117
    );
118
  });
119
120
  const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
121
122
  if (response.status === 401 || response.status === 403) {
123
    throw new ThreadUnavailable(
124
      "scope_missing",
125
      "This token cannot open a thread. Sign in again with the chat:account scope.",
126
      response.status,
127
    );
128
  }
129
  if (response.status < 200 || response.status >= 300) {
130
    // The envelope names the code and the sentence. Passing both through is
131
    // what turns a ninth session from an obscure failure into an instruction.
132
    const code = typeof body["code"] === "string" ? body["code"] : `http_${response.status}`;
133
    const message =
134
      typeof body["message"] === "string"
135
        ? body["message"]
136
        : `The server refused to open a thread (${code}).`;
137
    throw new ThreadUnavailable(code, message, response.status);
138
  }
139
140
  const thread = record(body["thread"]);
141
  const grant = record(body["grant"]);
142
  const id = string(thread["id"]);
143
  const token = string(grant["token"]);
144
  const url = string(grant["url"]);
145
  const model = string(grant["model"]);
146
147
  if (id === undefined || token === undefined || url === undefined || model === undefined) {
148
    throw new ThreadUnavailable(
149
      "malformed_thread",
150
      "The server opened a thread but did not return the grant needed to spend it.",
151
    );
152
  }
153
154
  return new ThreadReplySource({
155
    origin: options.origin,
156
    accountToken: options.token,
157
    threadId: id,
158
    grantToken: Redacted.make(token),
159
    proxyUrl: url,
160
    model,
161
    budget: budgetOf(record(grant["limits"]), record(grant["limits"])),
162
  });
163
}
164
165
interface SourceState {
166
  readonly origin: string;
167
  readonly accountToken: string;
168
  readonly threadId: string;
169
  readonly grantToken: Redacted.Redacted<string>;
170
  readonly proxyUrl: string;
171
  readonly model: string;
172
  readonly budget: ThreadBudget;
173
}
174
175
/** One chat-completions message, which is what the proxy takes as its input. */
176
interface WireMessage {
177
  readonly role: "user" | "assistant";
178
  readonly content: string;
179
}
180
181
export class ThreadReplySource implements ReplySource {
182
  readonly threadId: string;
183
  /**
184
   * The thread's transcript, keyed on the thread by construction: this array
185
   * exists only inside the source that holds that thread's grant, so the
186
   * context a turn is answered against is the thread's and nothing else's.
187
   * The account conversation is not read and not written.
188
   */
189
  private readonly transcript: WireMessage[] = [];
190
  private remaining: ThreadBudget;
191
192
  constructor(private readonly state: SourceState) {
193
    this.threadId = state.threadId;
194
    this.remaining = state.budget;
195
  }
196
197
  /**
198
   * The model the grant pins.
199
   *
200
   * Not a backend the client chose. The proxy takes the model from the grant so
201
   * a request body cannot select another, and the thread route deliberately
202
   * publishes no model parameter, so this is the one name that is true of the
203
   * reply on screen.
204
   */
205
  get model(): string {
206
    return this.state.model;
207
  }
208
209
  /** What is left to spend, in the width a status line has for it. */
210
  get budget(): string {
211
    return formatBudget(this.remaining);
212
  }
213
214
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
215
    this.transcript.push({ role: "user", content: prompt });
216
217
    let assistant = "";
218
    try {
219
      for await (const chunk of this.stream(signal)) {
220
        if (signal.aborted) break;
221
        if (chunk.type === "text") assistant += chunk.value;
222
        yield chunk;
223
      }
224
    } finally {
225
      // Whatever the model said belongs to the thread even when the turn was
226
      // interrupted, or the next turn answers a question it cannot see it
227
      // half-answered.
228
      if (assistant.length > 0) this.transcript.push({ role: "assistant", content: assistant });
229
      // Read the budget on the way out of every turn, including an interrupted
230
      // one. Interrupting is a client-side abort: the proxy had already bought
231
      // the call and metered it, so a status line that kept the figure it
232
      // opened with would under-report the spend by exactly the turns a reader
233
      // cut short.
234
      await this.refresh();
235
    }
236
  }
237
238
  /**
239
   * Revoke the thread and its grant.
240
   *
241
   * Best effort by design: this runs while the process is leaving, and a
242
   * network failure on the way out must not turn a finished session into an
243
   * error. The server retires elapsed authority on its own, so the worst case
244
   * of a failed revoke is a slot held until the thread expires rather than one
245
   * held forever.
246
   */
247
  async revoke(): Promise<void> {
248
    await fetch(new URL(`${THREADS_PATH}/${this.state.threadId}`, this.state.origin), {
249
      method: "DELETE",
250
      headers: {
251
        authorization: `Bearer ${this.state.accountToken}`,
252
        accept: "application/json",
253
      },
254
    }).catch(() => undefined);
255
  }
256
257
  /** Spend one call against the proxy and translate what comes back. */
258
  private async *stream(signal: AbortSignal): AsyncIterable<ReplyChunk> {
259
    const response = await fetch(this.state.proxyUrl, {
260
      method: "POST",
261
      signal,
262
      headers: {
263
        authorization: `Bearer ${Redacted.value(this.state.grantToken)}`,
264
        "content-type": "application/json",
265
        // The body is an event stream and the refusals are JSON, and both have
266
        // to be acceptable: the `:api` pipeline negotiates on `json` and
267
        // answers `406` to a request that will only take `text/event-stream`.
268
        accept: "text/event-stream, application/json",
269
      },
270
      body: JSON.stringify({
271
        model: this.state.model,
272
        stream: true,
273
        messages: this.transcript,
274
      }),
275
    }).catch((cause: unknown) => {
276
      if (signal.aborted) return undefined;
277
      throw new ThreadUnavailable(
278
        "network_refused",
279
        `The inference proxy could not be reached: ${String(cause)}`,
280
      );
281
    });
282
283
    if (response === undefined || signal.aborted) return;
284
    if (response.status < 200 || response.status >= 300) {
285
      throw await proxyRefusal(response);
286
    }
287
    if (response.body === null) return;
288
289
    /** Tool call fragments by their wire index, assembled as frames arrive. */
290
    const calls = new Map<number, { id: string; name: string; args: string }>();
291
292
    for await (const frame of frames(response.body, signal)) {
293
      if (signal.aborted) return;
294
      if (frame === "[DONE]") break;
295
296
      const payload = parse(frame);
297
      if (payload === undefined) continue;
298
299
      const usage = record(payload["usage"]);
300
      if (Object.keys(usage).length > 0) this.spend(usage);
301
302
      const choices = payload["choices"];
303
      if (!Array.isArray(choices)) continue;
304
305
      for (const choice of choices) {
306
        const delta = record(record(choice)["delta"]);
307
308
        const content = delta["content"];
309
        if (typeof content === "string" && content.length > 0) {
310
          yield { type: "text", value: content };
311
        }
312
313
        const toolCalls = delta["tool_calls"];
314
        if (Array.isArray(toolCalls)) accumulate(calls, toolCalls);
315
      }
316
    }
317
318
    for (const call of calls.values()) {
319
      yield { type: "tool_call", callId: call.id, name: call.name, arguments: call.args };
320
    }
321
  }
322
323
  /**
324
   * Take the turn's own usage off the budget immediately.
325
   *
326
   * The authoritative numbers come from the server a moment later, but a status
327
   * line that only moves after a second request would show a stale budget for
328
   * exactly as long as the reader is looking at the reply that spent it.
329
   */
330
  private spend(usage: Record<string, unknown>): void {
331
    const total = number(usage["total_tokens"]);
332
    this.remaining = {
333
      calls: Math.max(0, this.remaining.calls - 1),
334
      totalTokens: Math.max(0, this.remaining.totalTokens - total),
335
      costMicrousd: this.remaining.costMicrousd,
336
    };
337
  }
338
339
  /** Read what the server says the thread has left. Failure keeps the estimate. */
340
  private async refresh(): Promise<void> {
341
    const response = await fetch(
342
      new URL(`${THREADS_PATH}/${this.state.threadId}`, this.state.origin),
343
      {
344
        headers: {
345
          authorization: `Bearer ${this.state.accountToken}`,
346
          accept: "application/json",
347
        },
348
      },
349
    ).catch(() => undefined);
350
351
    if (response === undefined || response.status < 200 || response.status >= 300) return;
352
    const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
353
    const grant = record(body["grant"]);
354
    const remaining = record(grant["remaining"]);
355
    if (Object.keys(remaining).length === 0) return;
356
    this.remaining = budgetOf(remaining, record(grant["limits"]));
357
  }
358
}
359
360
/** Frames of an SSE body, yielded as the body arrives rather than after it. */
361
async function* frames(
362
  body: ReadableStream<Uint8Array>,
363
  signal: AbortSignal,
364
): AsyncIterable<string> {
365
  const reader = body.getReader();
366
  const decoder = new TextDecoder();
367
  let buffer = "";
368
369
  try {
370
    for (;;) {
371
      // A stream is read in order and each read depends on the one before it,
372
      // so there is no set of promises here to run together.
373
      // eslint-disable-next-line no-await-in-loop
374
      const { done, value } = await reader.read();
375
      if (done || signal.aborted) break;
376
      buffer += decoder.decode(value, { stream: true });
377
378
      for (;;) {
379
        const boundary = buffer.indexOf("\n\n");
380
        if (boundary < 0) break;
381
        const frame = buffer.slice(0, boundary);
382
        buffer = buffer.slice(boundary + 2);
383
        const data = dataOf(frame);
384
        if (data !== undefined) yield data;
385
      }
386
    }
387
  } finally {
388
    reader.releaseLock();
389
  }
390
}
391
392
/** The `data:` payload of one frame, or nothing for a comment or a keep-alive. */
393
function dataOf(frame: string): string | undefined {
394
  const lines = frame.split("\n");
395
  const parts: string[] = [];
396
  for (const line of lines) {
397
    const trimmed = line.endsWith("\r") ? line.slice(0, -1) : line;
398
    if (!trimmed.startsWith("data:")) continue;
399
    parts.push(trimmed.slice(5).trimStart());
400
  }
401
  return parts.length === 0 ? undefined : parts.join("\n");
402
}
403
404
function parse(frame: string): Record<string, unknown> | undefined {
405
  try {
406
    const value: unknown = JSON.parse(frame);
407
    return typeof value === "object" && value !== null
408
      ? (value as Record<string, unknown>)
409
      : undefined;
410
  } catch {
411
    return undefined;
412
  }
413
}
414
415
/**
416
 * Fold `tool_calls` fragments into whole calls.
417
 *
418
 * Chat-completions splits one call across frames and identifies the pieces by
419
 * `index`, so a name and its arguments can arrive separately.
420
 */
421
function accumulate(
422
  calls: Map<number, { id: string; name: string; args: string }>,
423
  fragments: ReadonlyArray<unknown>,
424
): void {
425
  for (const fragment of fragments) {
426
    const piece = record(fragment);
427
    const index = number(piece["index"]);
428
    const current = calls.get(index) ?? { id: "", name: "tool", args: "" };
429
    const fn = record(piece["function"]);
430
431
    calls.set(index, {
432
      id: string(piece["id"]) ?? current.id,
433
      name: string(fn["name"]) ?? current.name,
434
      args: current.args + (string(fn["arguments"]) ?? ""),
435
    });
436
  }
437
}
438
439
/** The proxy's typed refusal, turned into a sentence a reader can act on. */
440
async function proxyRefusal(response: Response): Promise<ThreadUnavailable> {
441
  const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
442
  const code = string(record(body["error"])["code"]) ?? `http_${response.status}`;
443
444
  const sentences: Record<string, string> = {
445
    grant_revoked: "This thread was revoked. Start a new session to open another.",
446
    grant_expired: "This thread's authority expired. Start a new session to open another.",
447
    grant_exhausted: "This thread spent its budget. Start a new session to open another.",
448
    grant_budget_reached: "This thread reached its budget ceiling and cannot buy another call.",
449
    invalid_grant: "The inference proxy did not recognize this thread's grant.",
450
    provider_failed: "The model provider failed. The call was not completed.",
451
  };
452
453
  return new ThreadUnavailable(
454
    code,
455
    sentences[code] ?? `The inference proxy refused the call (${code}).`,
456
    response.status,
457
  );
458
}
459
460
/**
461
 * The budget, read from `remaining` when the server has reported one and from
462
 * `limits` at the moment of minting, when nothing has been spent yet.
463
 */
464
function budgetOf(
465
  remaining: Record<string, unknown>,
466
  limits: Record<string, unknown>,
467
): ThreadBudget {
468
  return {
469
    calls: number(remaining["calls"] ?? limits["max_calls"]),
470
    totalTokens: number(remaining["total_tokens"] ?? limits["max_total_tokens"]),
471
    costMicrousd: number(remaining["cost_microusd"] ?? limits["max_cost_microusd"]),
472
  };
473
}
474
475
/**
476
 * The budget in the width a status line has for it.
477
 *
478
 * An agent that exhausts its budget mid-edit without ever having shown one is
479
 * an agent that lost the work, so all three ceilings are named: the call count
480
 * is what usually runs out first, and the other two are what a long turn or an
481
 * expensive model runs into instead.
482
 */
483
export function formatBudget(budget: ThreadBudget): string {
484
  return `${budget.calls} calls · ${compact(budget.totalTokens)} tok · ${dollars(budget.costMicrousd)}`;
485
}
486
487
function compact(tokens: number): string {
488
  // The threshold is where the K form would round to four digits, so a ceiling
489
  // of a million reads `1.0M` before a turn is spent and `1.0M` after it.
490
  if (tokens >= 999_500) return `${(tokens / 1_000_000).toFixed(1)}M`;
491
  if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`;
492
  return `${tokens}`;
493
}
494
495
function dollars(microusd: number): string {
496
  return `$${(microusd / 1_000_000).toFixed(2)}`;
497
}
498
499
function record(value: unknown): Record<string, unknown> {
500
  return typeof value === "object" && value !== null && !Array.isArray(value)
501
    ? (value as Record<string, unknown>)
502
    : {};
503
}
504
505
function string(value: unknown): string | undefined {
506
  return typeof value === "string" && value.length > 0 ? value : undefined;
507
}
508
509
function number(value: unknown): number {
510
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
511
}
packages/openagents-cli/src/coder-ui.ts modified +23 -7

@@ -15,7 +15,7 @@

15 15
 *     ┌──────────────────────────────┐
16 16
 *     │ transcript, scrollable       │
17 17
 *     ├──────────────────────────────┤
18
 *     │ status  repo · branch · model│
18
 *     │ status  repo · branch · model · budget│
19 19
 *     ├──────────────────────────────┤
20 20
 *     │ composer                     │
21 21
 *     └──────────────────────────────┘

@@ -343,10 +343,26 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

343 343
      const rule = `${DIM}${"─".repeat(Math.max(0, width))}${RESET}`;
344 344
      const inner = Math.max(10, width - 4);
345 345
346
      // The elapsed time and nothing else. This said `streaming` until the
347
      // reply source became the inference proxy, which builds the whole body
348
      // and sends it once: a turn that shows one block after four silent
349
      // seconds was never streaming, and the status line must not say it was.
346 350
      const activity = snapshot.running
347
        ? `${YELLOW}●${RESET} working… ${DIM}(${elapsed(runningSince, Date.now())} · streaming)${RESET}`
351
        ? `${YELLOW}●${RESET} working… ${DIM}(${elapsed(runningSince, Date.now())})${RESET}`
348 352
        : `${DIM}○ ready${RESET}`;
349
      const where = `${DIM}${snapshot.repository} · ${snapshot.branch} · ${snapshot.model}${RESET}`;
353
      // Dropped from the left as the terminal narrows, because that is the
354
      // order of what a reader cannot recover elsewhere: they can see which
355
      // checkout they are in, they can ask git for the branch, and nothing on
356
      // screen but this says which model answers or what the thread has left.
357
      const facts = [snapshot.repository, snapshot.branch, snapshot.model];
358
      if (snapshot.budget !== undefined) facts.push(snapshot.budget);
359
      let where = "";
360
      for (let from = 0; from < facts.length; from += 1) {
361
        const candidate = `${DIM}${facts.slice(from).join(" · ")}${RESET}`;
362
        if (visibleWidth(activity) + visibleWidth(candidate) + 2 > inner) continue;
363
        where = candidate;
364
        break;
365
      }
350 366
      rows.push(`  ${justify(activity, where, inner)}`);
351 367
      rows.push(rule);
352 368
      rows.push(`  › ${composer}`);

@@ -370,10 +386,10 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

370 386
      if (lines.length > transcriptHeight) keys.push("pgup/pgdn to scroll");
371 387
      if (focusedTool(snapshot) !== undefined) keys.push("ctrl+o to expand");
372 388
373
      // `this run` is not decoration. The count is this process's, and the
374
      // source may be writing into a conversation that already holds turns
375
      // from `/chat` and from earlier runs, so an unlabelled number would read
376
      // as the conversation's and contradict what the model remembers.
389
      // `this run` is not decoration. The count is this process's, and a
390
      // source that is not the thread — the stand-in behind `--offline` — has
391
      // no ceiling the number could be read against, so an unlabelled figure
392
      // would invite the reader to compare it with a budget beside it.
377 393
      const replies = `${snapshot.turns} ${snapshot.turns === 1 ? "reply" : "replies"} this run`;
378 394
      const counter =
379 395
        anchor !== undefined
packages/openagents-cli/test/coder-chat-api.test.ts deleted -339

@@ -1,339 +0,0 @@

1
import { afterEach, describe, expect, it, vi } from "vitest";
2
3
import { CODER_BACKENDS, defaultBackend, findBackend } from "../src/coder-backends.js";
4
import { ChatApiReplySource, ChatApiUnavailable } from "../src/coder-chat-api.js";
5
import type { ReplyChunk } from "../src/coder-session.js";
6
7
const json = (status: number, body: unknown) =>
8
  new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
9
10
const chunks = async (source: ChatApiReplySource, prompt = "hello") => {
11
  const out: ReplyChunk[] = [];
12
  for await (const chunk of source.reply(prompt, new AbortController().signal)) out.push(chunk);
13
  return out;
14
};
15
16
/** The assistant text a turn produced, which most of these tests assert on. */
17
const collect = async (source: ChatApiReplySource, prompt = "hello") =>
18
  (await chunks(source, prompt))
19
    .map((chunk) => (chunk.type === "text" ? chunk.value : ""))
20
    .join("");
21
22
const source = () =>
23
  new ChatApiReplySource({ origin: "https://openagents.test", token: "test-token" });
24
25
afterEach(() => {
26
  vi.unstubAllGlobals();
27
});
28
29
/** Answer `GET /chat/events` from a script and `POST /chat/turns` with a run id. */
30
const stubFetch = (pages: ReadonlyArray<ReadonlyArray<unknown>>, submit = json(202, {})) => {
31
  let page = 0;
32
  const calls: string[] = [];
33
  vi.stubGlobal(
34
    "fetch",
35
    vi.fn((url: URL, init?: RequestInit) => {
36
      const path = url.pathname;
37
      calls.push(`${init?.method ?? "GET"} ${path}`);
38
      if (path.endsWith("/chat/turns")) return Promise.resolve(submit.clone());
39
      const events = pages[Math.min(page, pages.length - 1)] ?? [];
40
      page += 1;
41
      return Promise.resolve(json(200, { events }));
42
    }),
43
  );
44
  return calls;
45
};
46
47
describe("ChatApiReplySource", () => {
48
  it("yields text deltas for the submitted run and stops at completed", async () => {
49
    stubFetch(
50
      [
51
        [],
52
        [
53
          { run_id: "run-1", sequence: 1, type: "text_delta", payload: { value: "Hello" } },
54
          { run_id: "run-1", sequence: 2, type: "text_delta", payload: { value: " there" } },
55
          { run_id: "run-1", sequence: 3, type: "response_completed", payload: {} },
56
        ],
57
      ],
58
      json(202, { turn: { id: "run-1" } }),
59
    );
60
61
    expect(await collect(source())).toBe("Hello there");
62
  });
63
64
  it("does not replay events that existed before the turn was submitted", async () => {
65
    stubFetch(
66
      [
67
        [{ run_id: "run-0", sequence: 9, type: "text_delta", payload: { value: "OLD" } }],
68
        [
69
          { run_id: "run-0", sequence: 9, type: "text_delta", payload: { value: "OLD" } },
70
          { run_id: "run-1", sequence: 1, type: "text_delta", payload: { value: "new" } },
71
          { run_id: "run-1", sequence: 2, type: "response_completed", payload: {} },
72
        ],
73
      ],
74
      json(202, { turn: { id: "run-1" } }),
75
    );
76
77
    expect(await collect(source())).toBe("new");
78
  });
79
80
  it("does not repeat a delta already delivered on an earlier poll", async () => {
81
    stubFetch(
82
      [
83
        [],
84
        [{ run_id: "run-1", sequence: 1, type: "text_delta", payload: { value: "one" } }],
85
        [
86
          { run_id: "run-1", sequence: 1, type: "text_delta", payload: { value: "one" } },
87
          { run_id: "run-1", sequence: 2, type: "text_delta", payload: { value: " two" } },
88
          { run_id: "run-1", sequence: 3, type: "response_completed", payload: {} },
89
        ],
90
      ],
91
      json(202, { turn: { id: "run-1" } }),
92
    );
93
94
    expect(await collect(source())).toBe("one two");
95
  });
96
97
  it("yields reasoning deltas beside the text, in the order they were recorded", async () => {
98
    stubFetch(
99
      [
100
        [],
101
        [
102
          { run_id: "run-1", sequence: 1, type: "reasoning_delta", payload: { value: "thinking" } },
103
          { run_id: "run-1", sequence: 2, type: "text_delta", payload: { value: "said" } },
104
          { run_id: "run-1", sequence: 3, type: "response_completed", payload: {} },
105
        ],
106
      ],
107
      json(202, { turn: { id: "run-1" } }),
108
    );
109
110
    expect(await chunks(source())).toEqual([
111
      { type: "reasoning", value: "thinking" },
112
      { type: "text", value: "said" },
113
    ]);
114
  });
115
116
  it("surfaces a tool call from the projection the event carries", async () => {
117
    stubFetch(
118
      [
119
        [],
120
        [
121
          {
122
            run_id: "run-1",
123
            sequence: 1,
124
            type: "tool_call_started",
125
            payload: { call_id: "c1", name: "repo_grep", arguments: '{"pattern":"x"}' },
126
            tool_call: {
127
              call_id: "c1",
128
              name: "repo_grep",
129
              arguments: '{\n  "pattern": "x"\n}',
130
              output: null,
131
              error: null,
132
              status: "running",
133
            },
134
          },
135
          {
136
            run_id: "run-1",
137
            sequence: 2,
138
            type: "tool_call_completed",
139
            payload: { call_id: "c1", output: "{}" },
140
            tool_call: {
141
              call_id: "c1",
142
              name: "repo_grep",
143
              arguments: '{\n  "pattern": "x"\n}',
144
              output: '{\n  "matches": []\n}',
145
              error: null,
146
              status: "succeeded",
147
            },
148
          },
149
          { run_id: "run-1", sequence: 3, type: "response_completed", payload: {} },
150
        ],
151
      ],
152
      json(202, { turn: { id: "run-1" } }),
153
    );
154
155
    // The pretty-printed projection is what the browser shows, so the CLI
156
    // reads it rather than re-deriving the call from the raw payload.
157
    expect(await chunks(source())).toEqual([
158
      {
159
        type: "tool_call",
160
        callId: "c1",
161
        name: "repo_grep",
162
        arguments: '{\n  "pattern": "x"\n}',
163
      },
164
      { type: "tool_result", callId: "c1", output: '{\n  "matches": []\n}', error: undefined },
165
    ]);
166
  });
167
168
  it("reports a failed tool call with the server's message", async () => {
169
    stubFetch(
170
      [
171
        [],
172
        [
173
          {
174
            run_id: "run-1",
175
            sequence: 1,
176
            type: "tool_call_failed",
177
            payload: { call_id: "c1", error: "The tool is not authorized for this data scope." },
178
            tool_call: {
179
              call_id: "c1",
180
              name: "conversation_search",
181
              arguments: "{}",
182
              output: null,
183
              error: { code: null, message: "The tool is not authorized for this data scope." },
184
              status: "failed",
185
            },
186
          },
187
          { run_id: "run-1", sequence: 2, type: "response_completed", payload: {} },
188
        ],
189
      ],
190
      json(202, { turn: { id: "run-1" } }),
191
    );
192
193
    expect(await chunks(source())).toEqual([
194
      {
195
        type: "tool_result",
196
        callId: "c1",
197
        output: undefined,
198
        error: "The tool is not authorized for this data scope.",
199
      },
200
    ]);
201
  });
202
203
  it("reads a tool call from the raw payload when no projection is attached", async () => {
204
    stubFetch(
205
      [
206
        [],
207
        [
208
          {
209
            run_id: "run-1",
210
            sequence: 1,
211
            type: "tool_call_started",
212
            payload: { call_id: "c1", name: "repo_grep", arguments: '{"pattern":"x"}' },
213
          },
214
          { run_id: "run-1", sequence: 2, type: "response_completed", payload: {} },
215
        ],
216
      ],
217
      json(202, { turn: { id: "run-1" } }),
218
    );
219
220
    expect(await chunks(source())).toEqual([
221
      { type: "tool_call", callId: "c1", name: "repo_grep", arguments: '{"pattern":"x"}' },
222
    ]);
223
  });
224
225
  it("reports a missing scope rather than an empty reply", async () => {
226
    stubFetch([[]], json(401, { error: "invalid_api_token" }));
227
    await expect(collect(source())).rejects.toThrow(ChatApiUnavailable);
228
    await expect(collect(source())).rejects.toThrow(/chat:account/);
229
  });
230
231
  it("names the one-turn-at-a-time rule when the server refuses a concurrent turn", async () => {
232
    stubFetch([[]], json(409, { error: "turn_in_progress" }));
233
    await expect(collect(source())).rejects.toThrow(/one turn runs at a time/i);
234
  });
235
236
  it("surfaces a failed turn with the server's reason", async () => {
237
    stubFetch(
238
      [
239
        [],
240
        [
241
          {
242
            run_id: "run-1",
243
            sequence: 1,
244
            type: "response_failed",
245
            payload: { reason: "provider refused", code: "invalid_response" },
246
          },
247
        ],
248
      ],
249
      json(202, { turn: { id: "run-1" } }),
250
    );
251
    await expect(collect(source())).rejects.toThrow(/provider refused/);
252
  });
253
254
  it("sends the prompt and the reasoning effort the caller chose", async () => {
255
    const seen: Array<Record<string, unknown>> = [];
256
    let submitted = false;
257
    vi.stubGlobal(
258
      "fetch",
259
      vi.fn((url: URL, init?: RequestInit) => {
260
        if (url.pathname.endsWith("/chat/turns")) {
261
          seen.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
262
          submitted = true;
263
          return Promise.resolve(json(202, { turn: { id: "run-1" } }));
264
        }
265
        // The run does not exist until it is submitted, which is what the
266
        // pre-submit snapshot relies on.
267
        return Promise.resolve(
268
          json(200, {
269
            events: submitted
270
              ? [{ run_id: "run-1", sequence: 1, type: "response_completed", payload: {} }]
271
              : [],
272
          }),
273
        );
274
      }),
275
    );
276
277
    const configured = new ChatApiReplySource({
278
      origin: "https://openagents.test",
279
      token: "test-token",
280
      reasoning: "high",
281
    });
282
    await collect(configured, "do the thing");
283
284
    expect(seen[0]).toEqual({
285
      message: "do the thing",
286
      model: defaultBackend().id,
287
      reasoning: "high",
288
    });
289
  });
290
291
  it("reports the label of the backend it is set to", () => {
292
    expect(source().model).toBe(defaultBackend().label);
293
    expect(source().backendId).toBe(defaultBackend().id);
294
  });
295
296
  it("sends the backend it was constructed with", async () => {
297
    const seen: Array<Record<string, unknown>> = [];
298
    let page = 0;
299
    vi.stubGlobal(
300
      "fetch",
301
      vi.fn((url: URL, init?: RequestInit) => {
302
        if (url.pathname.endsWith("/chat/turns")) {
303
          seen.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
304
          return Promise.resolve(json(202, { turn: { id: "run-1" } }));
305
        }
306
        // The source snapshots the log before it submits, so the first read
307
        // has to predate the turn or it counts the reply as already seen.
308
        const events =
309
          page++ === 0
310
            ? []
311
            : [
312
                { run_id: "run-1", sequence: 1, type: "text_delta", payload: { value: "hi" } },
313
                { run_id: "run-1", sequence: 2, type: "response_completed", payload: {} },
314
              ];
315
        return Promise.resolve(json(200, { events }));
316
      }),
317
    );
318
319
    const gemini = new ChatApiReplySource({
320
      origin: "https://openagents.test",
321
      token: "test-token",
322
      backend: findBackend("gemini-3.7-flash"),
323
    });
324
325
    expect(gemini.model).toBe("Gemini 3.7 Flash");
326
    expect(await collect(gemini, "hello")).toBe("hi");
327
    expect(seen[0]?.["model"]).toBe("gemini-3.7-flash");
328
  });
329
330
  it("cycles through every backend and wraps back to the first", () => {
331
    const cycling = source();
332
    const labels = CODER_BACKENDS.map(() => cycling.cycleBackend());
333
334
    // Every backend is reachable, and the last cycle returns to the start, so
335
    // a third entry needs no second key.
336
    expect(new Set(labels).size).toBe(CODER_BACKENDS.length);
337
    expect(cycling.model).toBe(defaultBackend().label);
338
  });
339
});
packages/openagents-cli/test/coder-thread.test.ts added +287

@@ -0,0 +1,287 @@

1
import { afterEach, describe, expect, it, vi } from "vitest";
2
3
import type { ReplyChunk } from "../src/coder-session.js";
4
import { openThread, ThreadUnavailable, type ThreadReplySource } from "../src/coder-thread.js";
5
6
const ORIGIN = "https://openagents.test";
7
const ACCOUNT_TOKEN = "oa_pat_account";
8
const GRANT_TOKEN = "oa_grant_secret";
9
const THREAD_ID = "9bb19447-ecf4-4f1b-b44e-6b128664da9c";
10
11
const json = (status: number, body: unknown) =>
12
  new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
13
14
const CREATED = {
15
  thread: { id: THREAD_ID, status: "open" },
16
  grant: {
17
    token: GRANT_TOKEN,
18
    url: `${ORIGIN}/api/inference/proxy`,
19
    model: "gpt-5.6-luna",
20
    expires_at: "2026-08-23T23:59:59Z",
21
    limits: { max_calls: 256, max_total_tokens: 1_000_000, max_cost_microusd: 2_000_000 },
22
  },
23
};
24
25
/**
26
 * The body the running server returns, byte for byte, for a one-sentence
27
 * prompt. Every assertion about the translation is against this shape rather
28
 * than against one invented for the test.
29
 */
30
const LIVE_SSE = [
31
  `data: {"choices":[{"delta":{"content":"Hello"},"index":0}]}`,
32
  `data: {"choices":[{"delta":{"content":"!"},"index":0}]}`,
33
  `data: {"choices":[{"delta":{"content":" Nice"},"index":0}]}`,
34
  `data: {"choices":[{"delta":{},"finish_reason":"stop","index":0}]}`,
35
  `data: {"choices":[],"usage":{"completion_tokens":11,"prompt_tokens":12,"total_tokens":23}}`,
36
  `data: [DONE]`,
37
  "",
38
].join("\n\n");
39
40
/** An SSE response whose body arrives in the given pieces, in order. */
41
const sse = (pieces: ReadonlyArray<string>) =>
42
  new Response(
43
    new ReadableStream<Uint8Array>({
44
      start(controller) {
45
        const encoder = new TextEncoder();
46
        for (const piece of pieces) controller.enqueue(encoder.encode(piece));
47
        controller.close();
48
      },
49
    }),
50
    { status: 200, headers: { "content-type": "text/event-stream" } },
51
  );
52
53
interface Call {
54
  readonly method: string;
55
  readonly url: string;
56
  readonly authorization: string;
57
  readonly body: Record<string, unknown>;
58
}
59
60
/**
61
 * Answer the three routes a session uses: open, spend, read.
62
 *
63
 * `proxy` is consumed one response per call, so a test can give one turn a
64
 * body and the next a refusal.
65
 */
66
const stub = (options: {
67
  readonly create?: Response;
68
  readonly proxy?: ReadonlyArray<Response>;
69
  readonly show?: Response;
70
  readonly remove?: Response;
71
}) => {
72
  const calls: Call[] = [];
73
  const proxy = [...(options.proxy ?? [])];
74
75
  vi.stubGlobal(
76
    "fetch",
77
    vi.fn(async (target: URL | string, init?: RequestInit) => {
78
      const url = typeof target === "string" ? target : target.toString();
79
      const headers = (init?.headers ?? {}) as Record<string, string>;
80
      const raw = typeof init?.body === "string" ? init.body : "{}";
81
      calls.push({
82
        method: init?.method ?? "GET",
83
        url,
84
        authorization: headers["authorization"] ?? "",
85
        body: JSON.parse(raw) as Record<string, unknown>,
86
      });
87
88
      if (url.endsWith("/api/inference/proxy")) return proxy.shift() ?? sse([LIVE_SSE]);
89
      if ((init?.method ?? "GET") === "POST") return (options.create ?? json(201, CREATED)).clone();
90
      if ((init?.method ?? "GET") === "DELETE") return (options.remove ?? json(200, {})).clone();
91
      return (options.show ?? json(200, { thread: {}, grant: {} })).clone();
92
    }),
93
  );
94
95
  return calls;
96
};
97
98
const open = () =>
99
  openThread({ origin: ORIGIN, token: ACCOUNT_TOKEN, objective: "coder in repo on main" });
100
101
const chunks = async (source: ThreadReplySource, prompt = "hello") => {
102
  const out: ReplyChunk[] = [];
103
  for await (const chunk of source.reply(prompt, new AbortController().signal)) out.push(chunk);
104
  return out;
105
};
106
107
const textOf = (out: ReadonlyArray<ReplyChunk>) =>
108
  out.map((chunk) => (chunk.type === "text" ? chunk.value : "")).join("");
109
110
afterEach(() => {
111
  vi.unstubAllGlobals();
112
});
113
114
describe("openThread", () => {
115
  it("opens a thread with an objective and reports the model its grant pins", async () => {
116
    const calls = stub({});
117
    const source = await openThread({
118
      origin: ORIGIN,
119
      token: ACCOUNT_TOKEN,
120
      objective: "coder in repo on main",
121
      reasoning: "high",
122
    });
123
124
    expect(source.threadId).toBe(THREAD_ID);
125
    expect(source.model).toBe("gpt-5.6-luna");
126
    expect(calls[0]?.method).toBe("POST");
127
    expect(calls[0]?.url).toBe(`${ORIGIN}/api/v3/threads`);
128
    expect(calls[0]?.authorization).toBe(`Bearer ${ACCOUNT_TOKEN}`);
129
    expect(calls[0]?.body).toEqual({ objective: "coder in repo on main", reasoning: "high" });
130
  });
131
132
  it("starts with the ceilings the grant was minted with", async () => {
133
    stub({});
134
    expect((await open()).budget).toBe("256 calls · 1.0M tok · $2.00");
135
  });
136
137
  it("carries the server's typed refusal when the account holds its last thread", async () => {
138
    const sentence =
139
      "This account holds 8 open threads and the configured maximum is 8. " +
140
      "Revoke a thread with DELETE /api/v3/threads/{thread_id} before opening another.";
141
    stub({
142
      create: json(429, {
143
        message: sentence,
144
        code: "thread_quota_reached",
145
        status: 429,
146
        errors: { threads: [sentence] },
147
      }),
148
    });
149
150
    await expect(open()).rejects.toMatchObject({
151
      code: "thread_quota_reached",
152
      status: 429,
153
      message: sentence,
154
    });
155
  });
156
157
  it("names the scope a token is missing rather than the status it returned", async () => {
158
    stub({ create: json(403, { code: "forbidden" }) });
159
    await expect(open()).rejects.toMatchObject({ code: "scope_missing" });
160
  });
161
});
162
163
describe("ThreadReplySource", () => {
164
  it("translates the proxy's content deltas into text chunks", async () => {
165
    stub({});
166
    const out = await chunks(await open());
167
168
    expect(out.every((chunk) => chunk.type === "text")).toBe(true);
169
    expect(textOf(out)).toBe("Hello! Nice");
170
  });
171
172
  it("parses a body split across reads in the middle of a frame", async () => {
173
    // The proxy sends the whole body at once today. This is the same body cut
174
    // where a chunked sender would cut it, so stage 4 needs no change here.
175
    const at = LIVE_SSE.indexOf("Nice") + 2;
176
    stub({ proxy: [sse([LIVE_SSE.slice(0, at), LIVE_SSE.slice(at)])] });
177
178
    expect(textOf(await chunks(await open()))).toBe("Hello! Nice");
179
  });
180
181
  it("spends the grant at the proxy and never sends the account token there", async () => {
182
    const calls = stub({});
183
    await chunks(await open());
184
185
    const spend = calls.find((call) => call.url.endsWith("/api/inference/proxy"));
186
    expect(spend?.authorization).toBe(`Bearer ${GRANT_TOKEN}`);
187
    expect(calls.filter((call) => call.authorization.includes(ACCOUNT_TOKEN))).toHaveLength(2);
188
  });
189
190
  it("answers the next turn against the thread's own transcript", async () => {
191
    const calls = stub({ proxy: [sse([LIVE_SSE]), sse([LIVE_SSE])] });
192
    const source = await open();
193
    await chunks(source, "first");
194
    await chunks(source, "second");
195
196
    const spends = calls.filter((call) => call.url.endsWith("/api/inference/proxy"));
197
    expect(spends[1]?.body["messages"]).toEqual([
198
      { role: "user", content: "first" },
199
      { role: "assistant", content: "Hello! Nice" },
200
      { role: "user", content: "second" },
201
    ]);
202
  });
203
204
  it("takes the turn's usage off the budget and then reads the server's figure", async () => {
205
    stub({
206
      show: json(200, {
207
        thread: { id: THREAD_ID },
208
        grant: {
209
          limits: { max_calls: 256, max_total_tokens: 1_000_000, max_cost_microusd: 2_000_000 },
210
          remaining: { calls: 255, total_tokens: 999_977, cost_microusd: 1_999_100 },
211
        },
212
      }),
213
    });
214
215
    const source = await open();
216
    await chunks(source);
217
    expect(source.budget).toBe("255 calls · 1.0M tok · $2.00");
218
  });
219
220
  it("assembles tool call fragments identified by their wire index", async () => {
221
    stub({
222
      proxy: [
223
        sse([
224
          [
225
            `data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"repo_grep","arguments":"{\\"pattern\\":"}}]}}]}`,
226
            `data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"thread\\"}"}}]}}]}`,
227
            `data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
228
            `data: [DONE]`,
229
            "",
230
          ].join("\n\n"),
231
        ]),
232
      ],
233
    });
234
235
    expect(await chunks(await open())).toEqual([
236
      {
237
        type: "tool_call",
238
        callId: "call-1",
239
        name: "repo_grep",
240
        arguments: `{"pattern":"thread"}`,
241
      },
242
    ]);
243
  });
244
245
  it("reports a revoked grant with a sentence rather than a status", async () => {
246
    stub({ proxy: [json(403, { error: { code: "grant_revoked" } })] });
247
    const failure = await chunks(await open()).catch((cause: unknown) => cause);
248
249
    expect(failure).toBeInstanceOf(ThreadUnavailable);
250
    expect(failure).toMatchObject({
251
      code: "grant_revoked",
252
      message: "This thread was revoked. Start a new session to open another.",
253
    });
254
  });
255
256
  it("stops without throwing when the turn is interrupted", async () => {
257
    stub({});
258
    const source = await open();
259
    const controller = new AbortController();
260
    controller.abort();
261
262
    const out: ReplyChunk[] = [];
263
    for await (const chunk of source.reply("hello", controller.signal)) out.push(chunk);
264
    expect(out).toEqual([]);
265
  });
266
267
  it("revokes the thread with the account token", async () => {
268
    const calls = stub({});
269
    await (await open()).revoke();
270
271
    const removal = calls.find((call) => call.method === "DELETE");
272
    expect(removal?.url).toBe(`${ORIGIN}/api/v3/threads/${THREAD_ID}`);
273
    expect(removal?.authorization).toBe(`Bearer ${ACCOUNT_TOKEN}`);
274
  });
275
276
  it("leaves the session usable when revoking cannot reach the server", async () => {
277
    vi.stubGlobal(
278
      "fetch",
279
      vi.fn(async (target: URL | string, init?: RequestInit) => {
280
        if ((init?.method ?? "GET") === "DELETE") throw new Error("socket closed");
281
        return json(201, CREATED);
282
      }),
283
    );
284
285
    await expect((await open()).revoke()).resolves.toBeUndefined();
286
  });
287
});
packages/openagents-cli/test/coder-ui.test.ts modified +46 -2

@@ -84,10 +84,14 @@ function screen(written: string): ReadonlyArray<string> {

84 84
  return rows;
85 85
}
86 86
87
const drive = async (chunks: ReadonlyArray<ReplyChunk>, prompt = "go") => {
87
const drive = async (
88
  chunks: ReadonlyArray<ReplyChunk>,
89
  prompt = "go",
90
  from: ReplySource = source(chunks),
91
) => {
88 92
  const stdin = new FakeIn();
89 93
  const stdout = new FakeOut();
90
  const session = new CoderSession(source(chunks), "repo", "main");
94
  const session = new CoderSession(from, "repo", "main");
91 95
  const running = runCoderUi(session, {
92 96
    stdin: stdin as unknown as NodeJS.ReadStream,
93 97
    stdout: stdout as unknown as NodeJS.WriteStream,

@@ -266,4 +270,44 @@ describe("runCoderUi", () => {

266 270
      expect(composer).not.toContain("\t");
267 271
    });
268 272
  });
273
274
  it("puts the thread's remaining budget in the status row beside the model", async () => {
275
    const metered: ReplySource = { ...source([{ type: "text", value: "hi" }]) };
276
    Object.defineProperty(metered, "budget", { get: () => "255 calls · 1.0M tok · $2.00" });
277
278
    const { rows } = await drive([{ type: "text", value: "hi" }], "go", metered);
279
    const status = rows.find((row) => row.includes("repo · main"));
280
281
    expect(status).toContain("scripted · 255 calls · 1.0M tok · $2.00");
282
  });
283
284
  it("says only how long a turn has run, never that it is streaming", async () => {
285
    // The inference proxy builds the whole body and sends it once, so a status
286
    // line that claimed streaming would be describing something the reader can
287
    // see is not happening.
288
    const { rows } = await drive([{ type: "text", value: "hi" }]);
289
    expect(rows.some((row) => row.includes("streaming"))).toBe(false);
290
  });
291
292
  it("drops the repository before the budget when the row will not fit", async () => {
293
    const metered: ReplySource = { ...source([{ type: "text", value: "hi" }]) };
294
    Object.defineProperty(metered, "budget", { get: () => "255 calls · 1.0M tok · $2.00" });
295
296
    const stdin = new FakeIn();
297
    const stdout = new FakeOut();
298
    stdout.columns = 56;
299
    const session = new CoderSession(metered, "a-long-repository-name", "main");
300
    const running = runCoderUi(session, {
301
      stdin: stdin as unknown as NodeJS.ReadStream,
302
      stdout: stdout as unknown as NodeJS.WriteStream,
303
    });
304
    await session.submit("go");
305
    const rows = screen(stdout.written);
306
    stdin.emit("data", "\x04");
307
    await running;
308
309
    const status = rows.find((row) => row.includes("calls"));
310
    expect(status).not.toContain("a-long-repository-name");
311
    expect(status).toContain("$2.00");
312
  });
269 313
});

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