Resume a coder thread with --resume

f6366a80930a · AtlantisPleb · · parent b538c2b8b072

Resume a coder thread with --resume

openagents coder --resume shows a picker over the account's threads,
filtered to the current repository; --resume <id> and --resume --last
skip it, and --all drops the filter. The selected thread's transcript is
replayed whole through the events cursor: one settled session entry per
recorded fact for the interface, and the exact wire messages the live
turn loop would hold for the model, with reasoning kept off the wire and
tool results bounded by the same context-budget figure. Replay never
touches the transcript writer, so nothing already on the server is
posted twice, and the standing context is not paid for again on the
first new turn.

Continuation asks the server to re-mint the thread's authority at
POST /api/v3/threads/{id}/grants — the client half of the
Threads.mint_grant/1 fence, which revokes the active grants, bumps the
generation, and keeps the grant lineage on the same thread. That route
does not exist yet: GET /api/v3/threads/{id} reports the grant without
its token, so today the 404 is reported as grant_unavailable, naming the
server gap rather than pretending the thread is missing. A terminal
thread is refused by its status before anything is fetched, and the
repository filter parses the objective sentence this CLI composes,
because POST /api/v3/threads records no structured repository field.

Closes #24.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>
Closes
#24

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

Diff

5 files changed, +1283 -29

packages/openagents-cli/src/cli.ts modified +157 -22

@@ -44,7 +44,24 @@ import {

44 44
  parseOllamaModelFlag,
45 45
  resolveOllamaModel,
46 46
} from "./coder-ollama.js";
47
import { openThread, ThreadUnavailable, type ThreadReplySource } from "./coder-thread.js";
47
import {
48
  openThread,
49
  remintThread,
50
  ThreadUnavailable,
51
  type ThreadReplySource,
52
} from "./coder-thread.js";
53
import {
54
  assertResumable,
55
  fetchAllEvents,
56
  fetchThread,
57
  listThreads,
58
  pickLast,
59
  pickThread,
60
  replayEntries,
61
  replayWire,
62
  resumableThreads,
63
  type ThreadSummary,
64
} from "./coder-resume.js";
48 65
import { ThreadTranscriptWriter } from "./coder-transcript.js";
49 66
import { delegateTool, openagentsTool, shellTool, skillTool } from "./coder-tools.js";
50 67
import {

@@ -1426,7 +1443,23 @@ const apiCommand = Command.make(

1426 1443
const coderPrompt = Argument.string("prompt").pipe(
1427 1444
  Argument.optional,
1428 1445
  Argument.withDescription(
1429
    "Answer this prompt and exit instead of opening the interactive interface",
1446
    "Answer this prompt and exit instead of opening the interactive interface. " +
1447
      "With --resume, this names the thread id to continue",
1448
  ),
1449
);
1450
const coderResumeFlag = Flag.boolean("resume").pipe(
1451
  Flag.withDescription(
1452
    "Continue a thread of the account's instead of opening a new one. Bare --resume " +
1453
      "shows a picker over this repository's recent threads; `--resume <id>` names one; " +
1454
      "`--resume --last` continues the most recent without asking",
1455
  ),
1456
);
1457
const coderLastFlag = Flag.boolean("last").pipe(
1458
  Flag.withDescription("With --resume, continue the most recent thread without asking"),
1459
);
1460
const coderAllFlag = Flag.boolean("all").pipe(
1461
  Flag.withDescription(
1462
    "With --resume, list every thread on the account rather than this repository's",
1430 1463
  ),
1431 1464
);
1432 1465
const coderPlainFlag = Flag.boolean("plain").pipe(

@@ -1464,7 +1497,7 @@ const coderModelFlag = Flag.string("model").pipe(

1464 1497
 * reading `--json` branches on `thread_quota_reached` and a person reading the
1465 1498
 * terminal is told the limit and how many threads the account is holding.
1466 1499
 */
1467
const coderRefusal = (origin: string, cause: unknown) => {
1500
const coderRefusal = (origin: string, cause: unknown, operation = "coder.thread.open") => {
1468 1501
  if (!(cause instanceof ThreadUnavailable)) {
1469 1502
    return new InputError({ message: `The thread could not be opened: ${String(cause)}` });
1470 1503
  }

@@ -1472,7 +1505,7 @@ const coderRefusal = (origin: string, cause: unknown) => {

1472 1505
    return new NetworkRefused({ origin, message: cause.message });
1473 1506
  }
1474 1507
  return new ApiError({
1475
    operation: "coder.thread.open",
1508
    operation,
1476 1509
    status: cause.status,
1477 1510
    code: cause.code,
1478 1511
    message: cause.message,

@@ -1732,6 +1765,9 @@ const coderCommand = Command.make(

1732 1765
    prompt: coderPrompt,
1733 1766
    plain: coderPlainFlag,
1734 1767
    offline: coderOfflineFlag,
1768
    resume: coderResumeFlag,
1769
    last: coderLastFlag,
1770
    all: coderAllFlag,
1735 1771
    reasoning: coderReasoningFlag,
1736 1772
    model: coderModelFlag,
1737 1773
    childModel: childModelFlag,

@@ -1744,6 +1780,9 @@ const coderCommand = Command.make(

1744 1780
    prompt,
1745 1781
    plain,
1746 1782
    offline,
1783
    resume,
1784
    last,
1785
    all,
1747 1786
    reasoning,
1748 1787
    model,
1749 1788
    childModel,

@@ -1767,9 +1806,30 @@ const coderCommand = Command.make(

1767 1806
      // the model they already chose is a flag that carries no decision. The
1768 1807
      // hosted backends stay one `--model` away, and `--offline` asks for
1769 1808
      // neither.
1809
      // `--resume` continues a server thread, so the lanes that never touch
1810
      // one are refused up front rather than silently ignored: a resumed
1811
      // session that answered from the stand-in or a local model would show
1812
      // the history of a thread it is not continuing.
1813
      if ((last || all) && !resume) {
1814
        return yield* new InputError({
1815
          message: `--${last ? "last" : "all"} belongs to --resume.`,
1816
        });
1817
      }
1818
      if (resume && offline) {
1819
        return yield* new InputError({
1820
          message: "--resume reads the thread from the server; it cannot combine with --offline.",
1821
        });
1822
      }
1823
      if (resume && Option.isSome(model)) {
1824
        return yield* new InputError({
1825
          message:
1826
            "--resume continues the thread on the model its grant pins; --model cannot change it.",
1827
        });
1828
      }
1829
1770 1830
      const named = Option.getOrUndefined(model);
1771 1831
      const localModel =
1772
        named === undefined && !offline
1832
        named === undefined && !offline && !resume
1773 1833
          ? yield* Effect.promise(() => discoverOllamaModel())
1774 1834
          : undefined;
1775 1835

@@ -1828,22 +1888,92 @@ const coderCommand = Command.make(

1828 1888
            ),
1829 1889
          );
1830 1890
1891
      // `--resume`: pick the thread, replay its transcript through the events
1892
      // cursor, and re-mint its authority so the same thread continues on the
1893
      // same grant lineage. The replay is read-only — nothing here posts an
1894
      // event — and the picker is TTY-only: the non-interactive forms are
1895
      // `--resume <id>` and `--resume --last`.
1896
      const resumed = resume
1897
        ? yield* Effect.tryPromise({
1898
            try: async () => {
1899
              if (Option.isNone(stored)) {
1900
                throw new ThreadUnavailable(
1901
                  "scope_missing",
1902
                  "Resuming reads the account's threads. Run `openagents auth login` first.",
1903
                );
1904
              }
1905
              const api = { origin: endpoint.origin, token: Redacted.value(stored.value.token) };
1906
              const explicit = Option.getOrUndefined(prompt);
1907
1908
              let summary: ThreadSummary | undefined;
1909
              if (explicit !== undefined) {
1910
                summary = await fetchThread({ ...api, threadId: explicit });
1911
              } else {
1912
                const candidates = resumableThreads(
1913
                  await listThreads(api),
1914
                  workspace.repository,
1915
                  all,
1916
                );
1917
                if (candidates.length === 0) {
1918
                  throw new ThreadUnavailable(
1919
                    "nothing_to_resume",
1920
                    all
1921
                      ? "This account holds no threads to resume."
1922
                      : `No threads were opened from ${workspace.repository}. ` +
1923
                          "Use --all to list every thread on the account.",
1924
                  );
1925
                }
1926
                if (last) {
1927
                  summary = pickLast(candidates);
1928
                } else if (terminal.interactive && !plain && !flags.json) {
1929
                  summary = await pickThread(candidates, {
1930
                    stdin: process.stdin,
1931
                    stdout: process.stdout,
1932
                  });
1933
                  // An empty answer cancels, and cancelling is not a failure.
1934
                  if (summary === undefined) return undefined;
1935
                } else {
1936
                  throw new ThreadUnavailable(
1937
                    "picker_needs_terminal",
1938
                    "The picker needs a terminal. Use `--resume <id>` or `--resume --last`.",
1939
                  );
1940
                }
1941
              }
1942
              if (summary === undefined) return undefined;
1943
1944
              assertResumable(summary);
1945
              const events = await fetchAllEvents({ ...api, threadId: summary.id });
1946
              const source = await remintThread({ ...api, threadId: summary.id });
1947
              // The replayed history reaches the model transcript and the
1948
              // interface, never the transcript writer: the server already
1949
              // holds these events, and a resume must not post them twice.
1950
              source.preload(replayWire(events));
1951
              return { source, entries: replayEntries(events) };
1952
            },
1953
            catch: (cause) => coderRefusal(endpoint.origin, cause, "coder.thread.resume"),
1954
          })
1955
        : undefined;
1956
1957
      if (resume && resumed === undefined) return;
1958
1831 1959
      const thread =
1832
        Option.isSome(stored) && !wantsOllama
1833
          ? yield* Effect.tryPromise({
1834
              try: () =>
1835
                openThread({
1836
                  origin: endpoint.origin,
1837
                  token: Redacted.value(stored.value.token),
1838
                  objective: `openagents coder in ${workspace.repository} on ${workspace.branch}`,
1839
                  reasoning: Option.getOrUndefined(reasoning),
1840
                }),
1841
              // The server's own code and sentence, which is what turns a ninth
1842
              // concurrent session from an obscure failure into an instruction
1843
              // naming the ceiling and how many threads the account is holding.
1844
              catch: (cause) => coderRefusal(endpoint.origin, cause),
1845
            })
1846
          : undefined;
1960
        resumed !== undefined
1961
          ? resumed.source
1962
          : Option.isSome(stored) && !wantsOllama && !resume
1963
            ? yield* Effect.tryPromise({
1964
                try: () =>
1965
                  openThread({
1966
                    origin: endpoint.origin,
1967
                    token: Redacted.value(stored.value.token),
1968
                    objective: `openagents coder in ${workspace.repository} on ${workspace.branch}`,
1969
                    reasoning: Option.getOrUndefined(reasoning),
1970
                  }),
1971
                // The server's own code and sentence, which is what turns a ninth
1972
                // concurrent session from an obscure failure into an instruction
1973
                // naming the ceiling and how many threads the account is holding.
1974
                catch: (cause) => coderRefusal(endpoint.origin, cause),
1975
              })
1976
            : undefined;
1847 1977
1848 1978
      // A `--model ollama:<name>` session answers from the local Ollama server,
1849 1979
      // so it takes neither a thread nor the stand-in.

@@ -1904,6 +2034,10 @@ const coderCommand = Command.make(

1904 2034
        standingContext(skills.active(), process.cwd()),
1905 2035
      );
1906 2036
2037
      // The resumed thread's history goes on the session before anything new,
2038
      // so both interfaces open showing the conversation being continued.
2039
      if (resumed !== undefined) session.restore(resumed.entries);
2040
1907 2041
      // The thread lane writes its transcript to the server as the turn loop
1908 2042
      // runs — `POST /api/v3/threads/{id}/events`, on the account token that
1909 2043
      // opened the thread. The server copy is the only durable copy; the

@@ -1996,7 +2130,8 @@ const coderCommand = Command.make(

1996 2130
        );
1997 2131
      }
1998 2132
1999
      const oneShot = Option.getOrUndefined(prompt);
2133
      // With --resume the positional argument named the thread, not a prompt.
2134
      const oneShot = resume ? undefined : Option.getOrUndefined(prompt);
2000 2135
      const interactive = terminal.interactive && !plain && !flags.json && oneShot === undefined;
2001 2136
2002 2137
      const code = yield* Effect.promise(async () => {

@@ -2061,7 +2196,7 @@ const coderCommand = Command.make(

2061 2196
    }),
2062 2197
).pipe(
2063 2198
  Command.withDescription(
2064
    "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. The session can delegate: ask it to split work and it runs child coding agents on a thread of their own pinned to Ox Alpha, or launch a fan-out yourself with `/delegate [<n>x] <prompt>`, and the interface shows the fleet",
2199
    "Open a terminal coding session on a thread of its own, or continue one with --resume. 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. The session can delegate: ask it to split work and it runs child coding agents on a thread of their own pinned to Ox Alpha, or launch a fan-out yourself with `/delegate [<n>x] <prompt>`, and the interface shows the fleet",
2065 2200
  ),
2066 2201
);
2067 2202
packages/openagents-cli/src/coder-resume.ts added +464

@@ -0,0 +1,464 @@

1
/**
2
 * `openagents coder --resume`: back into a thread the account already holds.
3
 *
4
 * The shape is the Codex one decided in the openagents.com audit of
5
 * 2026-08-24: bare `--resume` shows a picker over recent threads filtered to
6
 * the current repository, `--resume <id>` names one directly, `--resume
7
 * --last` continues the most recent without asking, and `--all` drops the
8
 * repository filter. `GET /api/v3/threads` is the picker's list and
9
 * `GET /api/v3/threads/{id}/events` is the transcript it replays, paged
10
 * through the `after` cursor because the listing caps at fifty and a working
11
 * session passes fifty events inside an hour.
12
 *
13
 * Two reconstructions come out of one event stream, and they are different on
14
 * purpose:
15
 *
16
 * - **The session transcript** (`replayEntries`) is what the interface shows:
17
 *   one settled entry per recorded fact, in recorded order, so the reader
18
 *   scrolls the conversation they left.
19
 * - **The wire transcript** (`replayWire`) is what the model is answered
20
 *   against: exactly the messages the live turn loop would have accumulated —
21
 *   user turns as sent, tool exchanges as the paired
22
 *   `[tool call]`/`[tool result]` turns the proxy accepts, assistant text
23
 *   whole, and reasoning nowhere, because the live loop never puts a thought
24
 *   on the wire.
25
 *
26
 * Neither replay touches the transcript writer. The events being replayed are
27
 * the server's own; posting them again would double the record.
28
 *
29
 * The repository filter reads the objective. `POST /api/v3/threads` records
30
 * no structured repository or workspace field — the objective sentence is the
31
 * only place the opening session names where it ran — so the filter parses
32
 * back the exact sentence this CLI composes (`openagents coder in <repo> on
33
 * <branch>`). A thread opened with any other objective has no repository to
34
 * match and appears only under `--all`.
35
 */
36
37
import { createInterface } from "node:readline";
38
39
import type { CoderEntry } from "./coder-session.js";
40
import { boundedResult, ThreadUnavailable, type WireMessage } from "./coder-thread.js";
41
42
const THREADS_PATH = "/api/v3/threads";
43
44
/** The server's listing cap. Pages are read at exactly this size. */
45
const PAGE_LIMIT = 50;
46
47
/** One thread as `GET /api/v3/threads` reports it. */
48
export interface ThreadSummary {
49
  readonly id: string;
50
  readonly status: string;
51
  readonly objective: string;
52
  readonly eventCount: number;
53
  readonly startedAt: string | undefined;
54
  /** Parsed from the objective when this CLI composed it; otherwise absent. */
55
  readonly repository: string | undefined;
56
  readonly branch: string | undefined;
57
}
58
59
/** One event as `GET /api/v3/threads/{id}/events` reports it. */
60
export interface ThreadEvent {
61
  /** The cursor: a client continues from the last id it read. */
62
  readonly id: number;
63
  readonly eventType: string;
64
  readonly payload: Record<string, unknown>;
65
  readonly emittedAt: string | undefined;
66
}
67
68
/** The transport seam, so tests hand in a plain function. */
69
export type ResumeTransport = (input: URL, init?: RequestInit) => Promise<Response>;
70
71
export interface ResumeApiOptions {
72
  readonly origin: string;
73
  /** The account token. Listing and reading spend nothing. */
74
  readonly token: string;
75
  readonly fetch?: ResumeTransport | undefined;
76
}
77
78
/**
79
 * The repository and branch a thread's objective names, when this CLI named
80
 * them.
81
 *
82
 * This is deterministic parsing of a bounded field this same program wrote —
83
 * the session opener composes `openagents coder in <repo> on <branch>` — not
84
 * a guess at free text. Anything else parses to nothing and is simply a
85
 * thread without a repository.
86
 */
87
export function repositoryOf(
88
  objective: string,
89
): { readonly repository: string; readonly branch: string } | undefined {
90
  const match = /^openagents coder in (.+?) on (.+)$/.exec(objective);
91
  if (match === null) return undefined;
92
  const [, repository, branch] = match;
93
  if (repository === undefined || branch === undefined) return undefined;
94
  return { repository, branch };
95
}
96
97
/**
98
 * The threads the picker offers, newest first.
99
 *
100
 * Filtered to the named repository unless `all`, because a reader resuming
101
 * work is almost always resuming it where they are standing. Terminal threads
102
 * stay in the list: the CLI revokes its thread on a clean exit, so an
103
 * open-only list would usually be empty, and picking a terminal thread gets
104
 * the refusal that teaches why rather than a listing that hides it.
105
 */
106
export function resumableThreads(
107
  threads: ReadonlyArray<ThreadSummary>,
108
  repository: string,
109
  all: boolean,
110
): ReadonlyArray<ThreadSummary> {
111
  if (all) return threads;
112
  return threads.filter((thread) => thread.repository === repository);
113
}
114
115
/** The most recent candidate, which is what `--resume --last` takes. */
116
export function pickLast(threads: ReadonlyArray<ThreadSummary>): ThreadSummary | undefined {
117
  return threads[0];
118
}
119
120
/**
121
 * Refuse a thread that cannot be continued.
122
 *
123
 * A terminal thread holds no authority and its transcript is closed — the
124
 * server refuses both a re-mint and a new event — so resuming one could only
125
 * ever show history. The refusal names the status, because `cancelled` after
126
 * a clean exit and `failed` after an error call for different next steps.
127
 */
128
export function assertResumable(thread: ThreadSummary): void {
129
  if (thread.status === "open") return;
130
  throw new ThreadUnavailable(
131
    "thread_terminal",
132
    `Thread ${thread.id} is ${thread.status}: its transcript is closed and it holds no ` +
133
      "authority to re-grant. Start a new session with `openagents coder` instead.",
134
  );
135
}
136
137
/** The account's threads, newest first, as the server reports them. */
138
export async function listThreads(
139
  options: ResumeApiOptions,
140
): Promise<ReadonlyArray<ThreadSummary>> {
141
  const body = await get(
142
    options,
143
    new URL(`${THREADS_PATH}?limit=${String(PAGE_LIMIT)}`, options.origin),
144
    "The account's threads could not be listed",
145
  );
146
  const threads = body["threads"];
147
  if (!Array.isArray(threads)) return [];
148
  return threads.map((raw) => summaryOf(record(raw)));
149
}
150
151
/** One thread by id, for `--resume <id>` and for the status check. */
152
export async function fetchThread(
153
  options: ResumeApiOptions & { readonly threadId: string },
154
): Promise<ThreadSummary> {
155
  const body = await get(
156
    options,
157
    new URL(`${THREADS_PATH}/${options.threadId}`, options.origin),
158
    `Thread ${options.threadId} could not be read`,
159
  );
160
  return summaryOf(record(body["thread"]));
161
}
162
163
/**
164
 * The whole transcript, oldest first, through the cursor.
165
 *
166
 * Pages of `PAGE_LIMIT`, continued from the last event id read, until a page
167
 * comes back short. The cap is the server's; a session's history is exactly
168
 * the thing that outgrows it.
169
 */
170
export async function fetchAllEvents(
171
  options: ResumeApiOptions & { readonly threadId: string },
172
): Promise<ReadonlyArray<ThreadEvent>> {
173
  const collected: ThreadEvent[] = [];
174
  let after: number | undefined;
175
176
  for (;;) {
177
    const cursor = after === undefined ? "" : `&after=${String(after)}`;
178
    // Pages are read in order and each continues from the one before it.
179
    // eslint-disable-next-line no-await-in-loop
180
    const body = await get(
181
      options,
182
      new URL(
183
        `${THREADS_PATH}/${options.threadId}/events?limit=${String(PAGE_LIMIT)}${cursor}`,
184
        options.origin,
185
      ),
186
      `The transcript of thread ${options.threadId} could not be read`,
187
    );
188
189
    const raw = body["events"];
190
    const page = Array.isArray(raw) ? raw.map((value) => eventOf(record(value))) : [];
191
    collected.push(...page);
192
193
    const lastId = page.at(-1)?.id;
194
    if (page.length < PAGE_LIMIT || lastId === undefined) return collected;
195
    after = lastId;
196
  }
197
}
198
199
/**
200
 * The session transcript, rebuilt from the durable record.
201
 *
202
 * One settled entry per recorded fact, in recorded order. An interrupted
203
 * turn's answer carries the same `[interrupted]` marker the live interface
204
 * appended, because a replay that dropped it would show a turn that appears
205
 * to have finished. Event types outside the vocabulary are skipped rather
206
 * than refused: the transcript is append-only and a future writer may know
207
 * words this reader does not.
208
 */
209
export function replayEntries(events: ReadonlyArray<ThreadEvent>): ReadonlyArray<CoderEntry> {
210
  const entries: CoderEntry[] = [];
211
212
  for (const event of events) {
213
    const at = stampOf(event.emittedAt);
214
    const payload = event.payload;
215
216
    if (event.eventType === "turn.user") {
217
      entries.push({ role: "you", text: text(payload["text"]), settled: true, at });
218
    } else if (event.eventType === "turn.reasoning") {
219
      entries.push({ role: "reasoning", text: text(payload["text"]), settled: true, at });
220
    } else if (event.eventType === "tool.ran") {
221
      const name = text(payload["tool"]) || "tool";
222
      const failure = typeof payload["error"] === "string" ? payload["error"] : undefined;
223
      entries.push({
224
        role: "tool",
225
        text: name,
226
        settled: true,
227
        at,
228
        tool: {
229
          callId: text(payload["call_id"]),
230
          name,
231
          arguments: text(payload["arguments"]),
232
          output: typeof payload["output"] === "string" ? payload["output"] : undefined,
233
          error: failure,
234
          status: payload["status"] === "failed" || failure !== undefined ? "failed" : "succeeded",
235
        },
236
      });
237
    } else if (event.eventType === "turn.assistant") {
238
      const said = text(payload["text"]);
239
      const interrupted = payload["interrupted"] === true;
240
      const usage = record(payload["usage"]);
241
      const entry: CoderEntry = {
242
        role: "assistant",
243
        text: interrupted && said.length > 0 ? `${said}\n\n[interrupted]` : said,
244
        settled: true,
245
        at,
246
      };
247
      if (Object.keys(usage).length > 0) {
248
        entry.metrics = {
249
          promptTokens: count(usage["prompt_tokens"]),
250
          completionTokens: count(usage["completion_tokens"]),
251
          calls: count(usage["calls"]),
252
        };
253
      }
254
      entries.push(entry);
255
    }
256
  }
257
258
  return entries;
259
}
260
261
/**
262
 * The model-facing transcript, rebuilt in the shape the live loop feeds it.
263
 *
264
 * `turn.user` is a user message as sent — steered or not, the record holds
265
 * what reached the wire. `tool.ran` becomes the standard chat exchange: an
266
 * assistant message carrying the call in `tool_calls`, with the arguments as
267
 * the raw JSON string the record kept, then a `tool` message named by
268
 * `tool_call_id` with the result bounded by the same figure the live loop
269
 * uses, because this transcript is re-sent on every round and the bound is a
270
 * context-budget decision, not a property of the record. `turn.assistant` is
271
 * the turn's whole answer. `turn.reasoning` is deliberately absent: the live
272
 * loop never puts a thought on the wire.
273
 *
274
 * One call per assistant message, not one per round: the record does not
275
 * delimit rounds — a round of two concurrent calls and two rounds of one
276
 * land as the same two consecutive `tool.ran` events — and its assistant
277
 * prose is recorded once per turn, so regrouping here would be inventing a
278
 * structure the record does not hold. The provider only requires that every
279
 * `tool_calls` message is answered before the next assistant message, and
280
 * this shape keeps that invariant per call.
281
 */
282
export function replayWire(events: ReadonlyArray<ThreadEvent>): ReadonlyArray<WireMessage> {
283
  const messages: WireMessage[] = [];
284
285
  for (const event of events) {
286
    const payload = event.payload;
287
288
    if (event.eventType === "turn.user") {
289
      messages.push({ role: "user", content: text(payload["text"]) });
290
    } else if (event.eventType === "tool.ran") {
291
      const callId = text(payload["call_id"]);
292
      const outcome =
293
        typeof payload["output"] === "string"
294
          ? payload["output"]
295
          : typeof payload["error"] === "string"
296
            ? payload["error"]
297
            : "";
298
      messages.push({
299
        role: "assistant",
300
        content: "",
301
        tool_calls: [
302
          {
303
            id: callId,
304
            type: "function",
305
            function: {
306
              name: text(payload["tool"]) || "tool",
307
              arguments: text(payload["arguments"]),
308
            },
309
          },
310
        ],
311
      });
312
      messages.push({
313
        role: "tool",
314
        tool_call_id: callId,
315
        content: boundedResult(outcome),
316
      });
317
    } else if (event.eventType === "turn.assistant") {
318
      const said = text(payload["text"]);
319
      if (said.length > 0) messages.push({ role: "assistant", content: said });
320
    }
321
  }
322
323
  return messages;
324
}
325
326
/** One picker row: enough to choose by, in one line. */
327
export function describeThread(thread: ThreadSummary, index: number): string {
328
  const where = thread.repository === undefined ? thread.objective : thread.repository;
329
  const events = `${String(thread.eventCount)} event${thread.eventCount === 1 ? "" : "s"}`;
330
  const when =
331
    thread.startedAt === undefined ? "" : ` ${thread.startedAt.slice(0, 16).replace("T", " ")}`;
332
  return `${String(index + 1).padStart(3)}. ${thread.id.slice(0, 8)}  ${thread.status.padEnd(9)}  ${events.padEnd(10)}${when}  ${where}`;
333
}
334
335
/**
336
 * An answer to the picker, as a candidate index.
337
 *
338
 * Pure so the selection is testable without a terminal: a number from 1 to
339
 * `count` selects, anything else — empty, out of range, not a number —
340
 * cancels rather than guessing.
341
 */
342
export function parsePick(answer: string, candidates: number): number | undefined {
343
  const trimmed = answer.trim();
344
  if (!/^\d+$/.test(trimmed)) return undefined;
345
  const index = Number.parseInt(trimmed, 10) - 1;
346
  return index >= 0 && index < candidates ? index : undefined;
347
}
348
349
/**
350
 * Ask which thread to resume. TTY only — the non-interactive forms are
351
 * `--resume <id>` and `--resume --last`, and the caller enforces that.
352
 */
353
export async function pickThread(
354
  candidates: ReadonlyArray<ThreadSummary>,
355
  io: { readonly stdin: NodeJS.ReadableStream; readonly stdout: NodeJS.WritableStream },
356
): Promise<ThreadSummary | undefined> {
357
  io.stdout.write("Resume which thread?\n\n");
358
  candidates.forEach((thread, index) => {
359
    io.stdout.write(`${describeThread(thread, index)}\n`);
360
  });
361
  io.stdout.write("\n");
362
363
  const readline = createInterface({ input: io.stdin, output: io.stdout });
364
  try {
365
    const answer = await new Promise<string>((resolve) => {
366
      readline.question(`Thread [1-${String(candidates.length)}, enter cancels]: `, resolve);
367
    });
368
    const index = parsePick(answer, candidates.length);
369
    return index === undefined ? undefined : candidates[index];
370
  } finally {
371
    readline.close();
372
  }
373
}
374
375
// ── transport ─────────────────────────────────────────────────────────────
376
377
/** One authenticated GET, refused with the server's own code and sentence. */
378
async function get(
379
  options: ResumeApiOptions,
380
  url: URL,
381
  failure: string,
382
): Promise<Record<string, unknown>> {
383
  const transport = options.fetch ?? globalThis.fetch.bind(globalThis);
384
  const response = await transport(url, {
385
    headers: {
386
      authorization: `Bearer ${options.token}`,
387
      accept: "application/json",
388
    },
389
  }).catch((cause: unknown) => {
390
    throw new ThreadUnavailable(
391
      "network_refused",
392
      `The API at ${options.origin} could not be reached: ${String(cause)}`,
393
    );
394
  });
395
396
  const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
397
398
  if (response.status === 401 || response.status === 403) {
399
    throw new ThreadUnavailable(
400
      "scope_missing",
401
      "This token cannot read the account's threads. Run `openagents auth login` to sign in again.",
402
      response.status,
403
    );
404
  }
405
  if (response.status === 404) {
406
    throw new ThreadUnavailable(
407
      "thread_not_found",
408
      `${failure}: this account holds no such thread.`,
409
      response.status,
410
    );
411
  }
412
  if (response.status < 200 || response.status >= 300) {
413
    const code = typeof body["code"] === "string" ? body["code"] : `http_${response.status}`;
414
    const message = typeof body["message"] === "string" ? body["message"] : `${failure} (${code}).`;
415
    throw new ThreadUnavailable(code, message, response.status);
416
  }
417
418
  return body;
419
}
420
421
// ── parsing ───────────────────────────────────────────────────────────────
422
423
function summaryOf(raw: Record<string, unknown>): ThreadSummary {
424
  const objective = text(raw["objective"]);
425
  const named = repositoryOf(objective);
426
  return {
427
    id: text(raw["id"]),
428
    status: text(raw["status"]) || "unknown",
429
    objective,
430
    eventCount: count(raw["event_count"]),
431
    startedAt: typeof raw["started_at"] === "string" ? raw["started_at"] : undefined,
432
    repository: named?.repository,
433
    branch: named?.branch,
434
  };
435
}
436
437
function eventOf(raw: Record<string, unknown>): ThreadEvent {
438
  return {
439
    id: count(raw["id"]),
440
    eventType: text(raw["event_type"]),
441
    payload: record(raw["payload"]),
442
    emittedAt: typeof raw["emitted_at"] === "string" ? raw["emitted_at"] : undefined,
443
  };
444
}
445
446
function stampOf(emittedAt: string | undefined): number {
447
  if (emittedAt === undefined) return Date.now();
448
  const parsed = Date.parse(emittedAt);
449
  return Number.isFinite(parsed) ? parsed : Date.now();
450
}
451
452
function record(value: unknown): Record<string, unknown> {
453
  return typeof value === "object" && value !== null && !Array.isArray(value)
454
    ? (value as Record<string, unknown>)
455
    : {};
456
}
457
458
function text(value: unknown): string {
459
  return typeof value === "string" ? value : "";
460
}
461
462
function count(value: unknown): number {
463
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
464
}
packages/openagents-cli/src/coder-session.ts modified +26 -4

@@ -388,6 +388,12 @@ export class CoderSession {

388 388
  private readonly pending: string[] = [];
389 389
  private turnCount = 0;
390 390
  private unsubscribeTasks: (() => void) | undefined;
391
  /**
392
   * Set when `restore` has replayed a prior thread's transcript. The standing
393
   * context was already delivered on that thread's first turn and sits in the
394
   * replayed history, so the first new turn must not pay for it again.
395
   */
396
  private restored = false;
391 397
392 398
  constructor(
393 399
    private readonly source: ReplySource,

@@ -418,6 +424,24 @@ export class CoderSession {

418 424
    this.unsubscribeTasks = delegation?.registry.onChange(() => this.emit());
419 425
  }
420 426
427
  /**
428
   * Put a resumed thread's replayed transcript on the session, ahead of
429
   * anything new.
430
   *
431
   * The entries arrive settled — they are history, not a stream — and they do
432
   * not count as turns: `snapshot().turns` is what this process submitted, and
433
   * these were submitted by the process that recorded them. Restoring also
434
   * marks the standing context as already delivered, because the replayed
435
   * history carries it in its first turn.
436
   */
437
  restore(entries: ReadonlyArray<CoderEntry>): void {
438
    for (const entry of entries) {
439
      this.entries.push(copyEntry({ ...entry, settled: true }));
440
    }
441
    this.restored = true;
442
    this.emit();
443
  }
444
421 445
  snapshot(): CoderSnapshot {
422 446
    return {
423 447
      entries: this.entries.map(copyEntry),

@@ -586,9 +610,7 @@ export class CoderSession {

586 610
          model: this.source.modelId ?? this.source.model,
587 611
          toolDefinitions: this.source.toolDefinitions?.(),
588 612
          version: VERSION,
589
          ...(this.exports === undefined
590
            ? {}
591
            : { directory: this.exports.directory, copy: false }),
613
          ...(this.exports === undefined ? {} : { directory: this.exports.directory, copy: false }),
592 614
        });
593 615
        this.notice(
594 616
          `Exported ${String(written.steps)} step${written.steps === 1 ? "" : "s"} as ATIF to ${written.path}` +

@@ -706,7 +728,7 @@ export class CoderSession {

706 728
      // The reader's entry above keeps what they typed; the model receives the
707 729
      // standing context ahead of it on the first turn only.
708 730
      const sent =
709
        this.standing === undefined || this.turnCount > 1
731
        this.standing === undefined || this.turnCount > 1 || this.restored
710 732
          ? prompt
711 733
          : `${this.standing}\n\n---\n\n${prompt}`;
712 734
packages/openagents-cli/src/coder-thread.ts modified +120 -3

@@ -102,7 +102,12 @@ const bounded = (output: string, keep: number): string => {

102 102
  return `${output.slice(0, half)}\n\n[${String(cut)} characters omitted from the middle; run it again more narrowly if you need them]\n\n${output.slice(-half)}`;
103 103
};
104 104
105
const boundedResult = (output: string): string => bounded(output, TOOL_RESULT_KEPT);
105
/**
106
 * A tool result as the model transcript carries it. Exported for the replay in
107
 * `coder-resume.ts`, which must feed a resumed model exactly what the live
108
 * loop would have.
109
 */
110
export const boundedResult = (output: string): string => bounded(output, TOOL_RESULT_KEPT);
106 111
107 112
/** What the thread may still spend, as the server last reported it. */
108 113
export interface ThreadBudget {

@@ -217,6 +222,102 @@ export async function openThread(options: ThreadOptions): Promise<ThreadReplySou

217 222
  });
218 223
}
219 224
225
export interface ResumeGrantOptions {
226
  readonly origin: string;
227
  /** The account token that owns the thread. */
228
  readonly token: string;
229
  /** The open thread `--resume` is continuing. */
230
  readonly threadId: string;
231
}
232
233
/**
234
 * Continue an existing thread by asking the server to re-mint its authority.
235
 *
236
 * `OpenAgents.Threads.mint_grant/1` is the server's fence for exactly this:
237
 * it revokes every active grant naming the thread, bumps the thread's
238
 * generation, and mints fresh authority against the same thread — the grant
239
 * lineage a resume is supposed to continue. This client asks for that at
240
 * `POST /api/v3/threads/{id}/grants`.
241
 *
242
 * Today the server publishes no such route. `GET /api/v3/threads/{id}` reports
243
 * the grant's status and limits but never its token — the plaintext exists
244
 * exactly once, at minting — so there is no other honest way to spend an
245
 * existing thread. A 404 here is therefore the server saying it cannot yet
246
 * re-grant, and it is reported as exactly that (`grant_unavailable`) rather
247
 * than as the thread being missing: the caller has already fetched the thread
248
 * by the time it asks for authority.
249
 */
250
export async function remintThread(options: ResumeGrantOptions): Promise<ThreadReplySource> {
251
  const response = await fetch(
252
    new URL(`${THREADS_PATH}/${options.threadId}/grants`, options.origin),
253
    {
254
      method: "POST",
255
      headers: {
256
        authorization: `Bearer ${options.token}`,
257
        "content-type": "application/json",
258
        accept: "application/json",
259
      },
260
      body: JSON.stringify({}),
261
    },
262
  ).catch((cause: unknown) => {
263
    throw new ThreadUnavailable(
264
      "network_refused",
265
      `The API at ${options.origin} could not be reached: ${String(cause)}`,
266
    );
267
  });
268
269
  const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
270
271
  if (response.status === 401 || response.status === 403) {
272
    throw new ThreadUnavailable(
273
      "scope_missing",
274
      "This token cannot mint a grant for this thread. Run `openagents auth login` to sign in again.",
275
      response.status,
276
    );
277
  }
278
  if (response.status === 404) {
279
    throw new ThreadUnavailable(
280
      "grant_unavailable",
281
      "This server cannot hand back authority for an existing thread: " +
282
        "GET /api/v3/threads/{id} reports the grant without its token, and " +
283
        "POST /api/v3/threads/{id}/grants is not there to re-mint one. " +
284
        "The transcript is readable, but new turns cannot spend this thread " +
285
        "until the server can re-grant it.",
286
      response.status,
287
    );
288
  }
289
  if (response.status < 200 || response.status >= 300) {
290
    const code = typeof body["code"] === "string" ? body["code"] : `http_${response.status}`;
291
    const message =
292
      typeof body["message"] === "string"
293
        ? body["message"]
294
        : `The server refused to re-mint this thread's grant (${code}).`;
295
    throw new ThreadUnavailable(code, message, response.status);
296
  }
297
298
  const grant = record(body["grant"]);
299
  const token = string(grant["token"]);
300
  const url = string(grant["url"]);
301
  const model = string(grant["model"]);
302
303
  if (token === undefined || url === undefined || model === undefined) {
304
    throw new ThreadUnavailable(
305
      "malformed_thread",
306
      "The server re-minted this thread but did not return the grant needed to spend it.",
307
    );
308
  }
309
310
  return new ThreadReplySource({
311
    origin: options.origin,
312
    accountToken: options.token,
313
    threadId: string(record(body["thread"])["id"]) ?? options.threadId,
314
    grantToken: Redacted.make(token),
315
    proxyUrl: url,
316
    model,
317
    budget: budgetOf(record(grant["remaining"]), record(grant["limits"])),
318
  });
319
}
320
220 321
interface SourceState {
221 322
  readonly origin: string;
222 323
  readonly accountToken: string;

@@ -228,7 +329,7 @@ interface SourceState {

228 329
}
229 330
230 331
/** One call in an assistant message, in the chat-completions wire shape. */
231
interface WireToolCall {
332
export interface WireToolCall {
232 333
  readonly id: string;
233 334
  readonly type: "function";
234 335
  readonly function: { readonly name: string; readonly arguments: string };

@@ -241,8 +342,12 @@ interface WireToolCall {

241 342
 * the proxy replays them to the provider without interpreting them, and a
242 343
 * parse-and-reserialize here could reorder keys or normalize whitespace in a
243 344
 * string the provider expects byte for byte.
345
 *
346
 * Exported because a resume rebuilds this transcript from the thread's durable
347
 * events and hands it back through `preload`, and the two sides of that
348
 * exchange have to agree on the shape.
244 349
 */
245
type WireMessage =
350
export type WireMessage =
246 351
  | { readonly role: "user"; readonly content: string }
247 352
  | {
248 353
      readonly role: "assistant";

@@ -384,6 +489,18 @@ export class ThreadReplySource implements ReplySource {

384 489
    };
385 490
  }
386 491
492
  /**
493
   * Seed the model transcript with a resumed thread's replayed history.
494
   *
495
   * Straight onto the wire transcript and nowhere else: the durable copy on
496
   * the server already holds these turns, so the sink is deliberately not
497
   * touched — a resume must never re-post events the thread already carries.
498
   * Called once, before the first new turn.
499
   */
500
  preload(messages: ReadonlyArray<WireMessage>): void {
501
    for (const message of messages) this.transcript.push(message);
502
  }
503
387 504
  /** Take a message for the next step of the running turn. */
388 505
  steer(text: string): boolean {
389 506
    this.steered.push(text);
packages/openagents-cli/test/coder-resume.test.ts added +516

@@ -0,0 +1,516 @@

1
import { afterEach, describe, expect, it, vi } from "vitest";
2
3
import {
4
  assertResumable,
5
  fetchAllEvents,
6
  listThreads,
7
  parsePick,
8
  pickLast,
9
  replayEntries,
10
  replayWire,
11
  repositoryOf,
12
  resumableThreads,
13
  type ThreadEvent,
14
  type ThreadSummary,
15
} from "../src/coder-resume.js";
16
import { remintThread, ThreadUnavailable } from "../src/coder-thread.js";
17
import type { TranscriptSink } from "../src/coder-transcript.js";
18
19
const ORIGIN = "https://openagents.test";
20
const TOKEN = "oa_pat_account";
21
const THREAD_ID = "9bb19447-ecf4-4f1b-b44e-6b128664da9c";
22
23
const summary = (overrides: Partial<ThreadSummary> = {}): ThreadSummary => {
24
  const objective = overrides.objective ?? "openagents coder in openagents on main";
25
  const named = repositoryOf(objective);
26
  return {
27
    id: THREAD_ID,
28
    status: "open",
29
    objective,
30
    eventCount: 4,
31
    startedAt: "2026-08-24T12:00:00Z",
32
    repository: named?.repository,
33
    branch: named?.branch,
34
    ...overrides,
35
  };
36
};
37
38
/**
39
 * A recorded session, in the vocabulary the transcript writer posts: a first
40
 * turn that read a file, a steered second turn the reader interrupted. Every
41
 * replay assertion runs against this stream rather than one invented per test.
42
 */
43
const FIXTURE: ReadonlyArray<ThreadEvent> = [
44
  {
45
    id: 1,
46
    eventType: "turn.user",
47
    payload: { text: "standing context\n\n---\n\nwhat is in mix.exs?" },
48
    emittedAt: "2026-08-24T12:00:01Z",
49
  },
50
  {
51
    id: 2,
52
    eventType: "turn.reasoning",
53
    payload: { text: "I should read the file before answering." },
54
    emittedAt: "2026-08-24T12:00:02Z",
55
  },
56
  {
57
    id: 3,
58
    eventType: "tool.ran",
59
    payload: {
60
      call_id: "call-1",
61
      tool: "shell",
62
      arguments: `{"command":"cat mix.exs"}`,
63
      status: "succeeded",
64
      output: "defmodule OpenAgents.MixProject do",
65
    },
66
    emittedAt: "2026-08-24T12:00:03Z",
67
  },
68
  {
69
    id: 4,
70
    eventType: "turn.assistant",
71
    payload: {
72
      text: "It defines the OpenAgents application.",
73
      usage: { prompt_tokens: 120, completion_tokens: 30, total_tokens: 150, calls: 2 },
74
      tool_calls: 1,
75
    },
76
    emittedAt: "2026-08-24T12:00:04Z",
77
  },
78
  {
79
    id: 5,
80
    eventType: "turn.user",
81
    payload: { text: "also check the version", steered: true },
82
    emittedAt: "2026-08-24T12:00:05Z",
83
  },
84
  {
85
    id: 6,
86
    eventType: "tool.ran",
87
    payload: {
88
      call_id: "call-2",
89
      tool: "shell",
90
      arguments: `{"command":"cat VERSION"}`,
91
      status: "failed",
92
      error: "cat: VERSION: No such file or directory",
93
    },
94
    emittedAt: "2026-08-24T12:00:06Z",
95
  },
96
  {
97
    id: 7,
98
    eventType: "turn.assistant",
99
    payload: {
100
      text: "There is no VERSION file;",
101
      usage: { prompt_tokens: 200, completion_tokens: 8, total_tokens: 208, calls: 2 },
102
      tool_calls: 1,
103
      interrupted: true,
104
    },
105
    emittedAt: "2026-08-24T12:00:07Z",
106
  },
107
];
108
109
afterEach(() => {
110
  vi.unstubAllGlobals();
111
});
112
113
describe("repositoryOf", () => {
114
  it("parses back the objective this CLI composes", () => {
115
    expect(repositoryOf("openagents coder in openagents.com on coder-resume")).toEqual({
116
      repository: "openagents.com",
117
      branch: "coder-resume",
118
    });
119
  });
120
121
  it("parses nothing from an objective some other caller wrote", () => {
122
    expect(repositoryOf("delegated children of openagents coder in repo")).toBeUndefined();
123
    expect(repositoryOf("nightly triage run")).toBeUndefined();
124
  });
125
});
126
127
describe("resumableThreads", () => {
128
  const here = summary({ id: "a" });
129
  const elsewhere = summary({ id: "b", objective: "openagents coder in probe on main" });
130
  const children = summary({
131
    id: "c",
132
    objective: "delegated children of openagents coder in openagents",
133
  });
134
135
  it("filters to the current repository", () => {
136
    expect(resumableThreads([here, elsewhere, children], "openagents", false)).toEqual([here]);
137
  });
138
139
  it("keeps every thread under --all, including ones without a repository", () => {
140
    expect(resumableThreads([here, elsewhere, children], "openagents", true)).toEqual([
141
      here,
142
      elsewhere,
143
      children,
144
    ]);
145
  });
146
147
  it("keeps terminal threads listed, so picking one gets the refusal that teaches", () => {
148
    const cancelled = summary({ id: "d", status: "cancelled" });
149
    expect(resumableThreads([cancelled], "openagents", false)).toEqual([cancelled]);
150
  });
151
});
152
153
describe("pickLast", () => {
154
  it("takes the newest, which the server lists first", () => {
155
    const newest = summary({ id: "newest" });
156
    const older = summary({ id: "older" });
157
    expect(pickLast([newest, older])).toBe(newest);
158
  });
159
160
  it("takes nothing from nothing", () => {
161
    expect(pickLast([])).toBeUndefined();
162
  });
163
});
164
165
describe("parsePick", () => {
166
  it("selects a number inside the range", () => {
167
    expect(parsePick("2", 3)).toBe(1);
168
  });
169
170
  it("cancels on empty, out of range, and non-numbers", () => {
171
    expect(parsePick("", 3)).toBeUndefined();
172
    expect(parsePick("0", 3)).toBeUndefined();
173
    expect(parsePick("4", 3)).toBeUndefined();
174
    expect(parsePick("-1", 3)).toBeUndefined();
175
    expect(parsePick("q", 3)).toBeUndefined();
176
  });
177
});
178
179
describe("assertResumable", () => {
180
  it("passes an open thread", () => {
181
    expect(() => assertResumable(summary())).not.toThrow();
182
  });
183
184
  it("refuses a terminal thread by its status", () => {
185
    for (const status of ["cancelled", "succeeded", "failed"]) {
186
      let refusal: unknown;
187
      try {
188
        assertResumable(summary({ status }));
189
      } catch (cause) {
190
        refusal = cause;
191
      }
192
      expect(refusal).toBeInstanceOf(ThreadUnavailable);
193
      expect((refusal as ThreadUnavailable).code).toBe("thread_terminal");
194
      expect((refusal as ThreadUnavailable).message).toContain(status);
195
    }
196
  });
197
});
198
199
describe("listThreads", () => {
200
  it("reads the account's listing with the account token", async () => {
201
    const calls: Array<{ url: string; authorization: string }> = [];
202
    const transport = async (url: URL, init?: RequestInit) => {
203
      const headers = (init?.headers ?? {}) as Record<string, string>;
204
      calls.push({ url: url.toString(), authorization: headers["authorization"] ?? "" });
205
      return new Response(
206
        JSON.stringify({
207
          threads: [
208
            {
209
              id: THREAD_ID,
210
              status: "open",
211
              objective: "openagents coder in openagents on main",
212
              event_count: 7,
213
              started_at: "2026-08-24T12:00:00Z",
214
            },
215
          ],
216
        }),
217
        { status: 200 },
218
      );
219
    };
220
221
    const threads = await listThreads({ origin: ORIGIN, token: TOKEN, fetch: transport });
222
223
    expect(calls[0]?.url).toBe(`${ORIGIN}/api/v3/threads?limit=50`);
224
    expect(calls[0]?.authorization).toBe(`Bearer ${TOKEN}`);
225
    expect(threads).toHaveLength(1);
226
    expect(threads[0]?.repository).toBe("openagents");
227
    expect(threads[0]?.eventCount).toBe(7);
228
  });
229
});
230
231
describe("fetchAllEvents", () => {
232
  it("pages through the cursor until a page comes back short", async () => {
233
    // 120 events: three pages at the server's cap of fifty.
234
    const all = Array.from({ length: 120 }, (_ignored, index) => ({
235
      id: index + 1,
236
      schema: "thread_event.v1",
237
      event_type: "turn.user",
238
      payload: { text: `turn ${String(index + 1)}` },
239
      emitted_at: "2026-08-24T12:00:00Z",
240
    }));
241
242
    const urls: string[] = [];
243
    const transport = async (url: URL) => {
244
      urls.push(url.toString());
245
      const after = Number(url.searchParams.get("after") ?? "0");
246
      const limit = Number(url.searchParams.get("limit") ?? "50");
247
      const page = all.filter((event) => event.id > after).slice(0, limit);
248
      return new Response(
249
        JSON.stringify({ thread_id: THREAD_ID, event_count: all.length, events: page }),
250
        { status: 200 },
251
      );
252
    };
253
254
    const events = await fetchAllEvents({
255
      origin: ORIGIN,
256
      token: TOKEN,
257
      threadId: THREAD_ID,
258
      fetch: transport,
259
    });
260
261
    expect(events).toHaveLength(120);
262
    expect(events.map((event) => event.id)).toEqual(all.map((event) => event.id));
263
    expect(urls).toEqual([
264
      `${ORIGIN}/api/v3/threads/${THREAD_ID}/events?limit=50`,
265
      `${ORIGIN}/api/v3/threads/${THREAD_ID}/events?limit=50&after=50`,
266
      `${ORIGIN}/api/v3/threads/${THREAD_ID}/events?limit=50&after=100`,
267
    ]);
268
  });
269
270
  it("stops on an empty transcript", async () => {
271
    const transport = async () =>
272
      new Response(JSON.stringify({ thread_id: THREAD_ID, event_count: 0, events: [] }), {
273
        status: 200,
274
      });
275
    const events = await fetchAllEvents({
276
      origin: ORIGIN,
277
      token: TOKEN,
278
      threadId: THREAD_ID,
279
      fetch: transport,
280
    });
281
    expect(events).toEqual([]);
282
  });
283
});
284
285
describe("replayEntries", () => {
286
  it("rebuilds the session transcript in recorded order, one settled entry per fact", () => {
287
    const entries = replayEntries(FIXTURE);
288
289
    expect(entries.map((entry) => entry.role)).toEqual([
290
      "you",
291
      "reasoning",
292
      "tool",
293
      "assistant",
294
      "you",
295
      "tool",
296
      "assistant",
297
    ]);
298
    expect(entries.every((entry) => entry.settled)).toBe(true);
299
    expect(entries[0]?.at).toBe(Date.parse("2026-08-24T12:00:01Z"));
300
  });
301
302
  it("carries the tool exchange whole, with its outcome", () => {
303
    const entries = replayEntries(FIXTURE);
304
    const succeeded = entries[2]?.tool;
305
    const failed = entries[5]?.tool;
306
307
    expect(succeeded).toMatchObject({
308
      callId: "call-1",
309
      name: "shell",
310
      arguments: `{"command":"cat mix.exs"}`,
311
      output: "defmodule OpenAgents.MixProject do",
312
      status: "succeeded",
313
    });
314
    expect(failed).toMatchObject({
315
      callId: "call-2",
316
      error: "cat: VERSION: No such file or directory",
317
      status: "failed",
318
    });
319
  });
320
321
  it("marks the interrupted answer and keeps the turn's cost", () => {
322
    const entries = replayEntries(FIXTURE);
323
    const finished = entries[3];
324
    const interrupted = entries[6];
325
326
    expect(finished?.metrics).toEqual({ promptTokens: 120, completionTokens: 30, calls: 2 });
327
    expect(interrupted?.text).toBe("There is no VERSION file;\n\n[interrupted]");
328
  });
329
330
  it("skips event types outside the vocabulary rather than refusing the replay", () => {
331
    const entries = replayEntries([
332
      { id: 1, eventType: "thread.noted", payload: { note: "?" }, emittedAt: undefined },
333
      ...FIXTURE,
334
    ]);
335
    expect(entries).toHaveLength(7);
336
  });
337
});
338
339
describe("replayWire", () => {
340
  it("rebuilds the messages in the shape the live loop holds", () => {
341
    expect(replayWire(FIXTURE)).toEqual([
342
      { role: "user", content: "standing context\n\n---\n\nwhat is in mix.exs?" },
343
      {
344
        role: "assistant",
345
        content: "",
346
        tool_calls: [
347
          {
348
            id: "call-1",
349
            type: "function",
350
            function: { name: "shell", arguments: `{"command":"cat mix.exs"}` },
351
          },
352
        ],
353
      },
354
      { role: "tool", tool_call_id: "call-1", content: "defmodule OpenAgents.MixProject do" },
355
      { role: "assistant", content: "It defines the OpenAgents application." },
356
      { role: "user", content: "also check the version" },
357
      {
358
        role: "assistant",
359
        content: "",
360
        tool_calls: [
361
          {
362
            id: "call-2",
363
            type: "function",
364
            function: { name: "shell", arguments: `{"command":"cat VERSION"}` },
365
          },
366
        ],
367
      },
368
      { role: "tool", tool_call_id: "call-2", content: "cat: VERSION: No such file or directory" },
369
      { role: "assistant", content: "There is no VERSION file;" },
370
    ]);
371
  });
372
373
  it("keeps the recorded arguments as the raw JSON string", () => {
374
    const wire = replayWire(FIXTURE);
375
    const call = wire[1];
376
    expect(call?.role === "assistant" && call.tool_calls?.[0]?.function.arguments).toBe(
377
      `{"command":"cat mix.exs"}`,
378
    );
379
  });
380
381
  it("keeps reasoning off the wire, as the live loop does", () => {
382
    const wire = replayWire(FIXTURE);
383
    expect(wire.some((message) => message.content.includes("before answering"))).toBe(false);
384
  });
385
386
  it("bounds a stored tool result to the live loop's wire figure", () => {
387
    const wire = replayWire([
388
      {
389
        id: 1,
390
        eventType: "tool.ran",
391
        payload: {
392
          call_id: "call-1",
393
          tool: "shell",
394
          arguments: "{}",
395
          status: "succeeded",
396
          output: "x".repeat(10_000),
397
        },
398
        emittedAt: undefined,
399
      },
400
    ]);
401
    const result = wire[1];
402
    expect(result?.role).toBe("tool");
403
    expect(result?.content).toContain("characters omitted from the middle");
404
    expect(result?.content.length ?? 0).toBeLessThan(5_000);
405
  });
406
});
407
408
const sse = (frames: ReadonlyArray<string>) =>
409
  new Response([...frames.map((frame) => `data: ${frame}`), ""].join("\n\n"), {
410
    status: 200,
411
    headers: { "content-type": "text/event-stream" },
412
  });
413
414
describe("remintThread", () => {
415
  // The same `minted_view` shape `POST /api/v3/threads` returns for a grant at
416
  // minting: token, url, model, expires_at, limits — and no `remaining`,
417
  // because a freshly minted grant has spent nothing.
418
  const REMINTED = {
419
    thread: { id: THREAD_ID, status: "open", generation: 2 },
420
    grant: {
421
      token: "oa_grant_reminted",
422
      url: `${ORIGIN}/api/inference/proxy`,
423
      model: "gpt-5.6-luna",
424
      expires_at: "2026-08-24T23:59:59Z",
425
      limits: { max_calls: 256, max_total_tokens: 1_000_000, max_cost_microusd: 2_000_000 },
426
    },
427
  };
428
429
  interface Call {
430
    readonly method: string;
431
    readonly url: string;
432
    readonly body: Record<string, unknown>;
433
  }
434
435
  const stub = (mint: Response) => {
436
    const calls: Call[] = [];
437
    vi.stubGlobal(
438
      "fetch",
439
      vi.fn(async (target: URL | string, init?: RequestInit) => {
440
        const url = typeof target === "string" ? target : target.toString();
441
        const raw = typeof init?.body === "string" ? init.body : "{}";
442
        calls.push({
443
          method: init?.method ?? "GET",
444
          url,
445
          body: JSON.parse(raw) as Record<string, unknown>,
446
        });
447
        if (url.endsWith("/api/inference/proxy")) {
448
          return sse([
449
            `{"choices":[{"delta":{"content":"Continuing."},"index":0}]}`,
450
            `{"choices":[],"usage":{"completion_tokens":3,"prompt_tokens":50,"total_tokens":53}}`,
451
            "[DONE]",
452
          ]);
453
        }
454
        if (url.endsWith("/grants")) return mint.clone();
455
        return new Response(JSON.stringify({}), { status: 200 });
456
      }),
457
    );
458
    return calls;
459
  };
460
461
  it("reports a server that cannot re-grant an existing thread as exactly that", async () => {
462
    stub(new Response(JSON.stringify({ errors: {} }), { status: 404 }));
463
464
    await expect(
465
      remintThread({ origin: ORIGIN, token: TOKEN, threadId: THREAD_ID }),
466
    ).rejects.toMatchObject({
467
      name: "ThreadUnavailable",
468
      code: "grant_unavailable",
469
      message: expect.stringContaining("cannot hand back authority for an existing thread"),
470
    });
471
  });
472
473
  it("continues the same thread on the re-minted grant", async () => {
474
    const calls = stub(new Response(JSON.stringify(REMINTED), { status: 201 }));
475
476
    const source = await remintThread({ origin: ORIGIN, token: TOKEN, threadId: THREAD_ID });
477
478
    expect(calls[0]?.method).toBe("POST");
479
    expect(calls[0]?.url).toBe(`${ORIGIN}/api/v3/threads/${THREAD_ID}/grants`);
480
    expect(source.threadId).toBe(THREAD_ID);
481
    expect(source.model).toBe("gpt-5.6-luna");
482
    // A fresh grant has spent nothing, so the budget opens at its ceilings.
483
    expect(source.budget).toContain("256 calls");
484
  });
485
486
  it("answers the next turn against the replayed transcript without re-posting any of it", async () => {
487
    const calls = stub(new Response(JSON.stringify(REMINTED), { status: 201 }));
488
    const recorded: Array<{ eventType: string; payload: Record<string, unknown> }> = [];
489
    const sink: TranscriptSink = {
490
      record: (eventType, payload) => {
491
        recorded.push({ eventType, payload });
492
      },
493
    };
494
495
    const source = await remintThread({ origin: ORIGIN, token: TOKEN, threadId: THREAD_ID });
496
    const replayed = replayWire(FIXTURE);
497
    source.preload(replayed);
498
    source.useTranscript(sink);
499
500
    // Replaying wrote nothing: the server already holds these events.
501
    expect(recorded).toEqual([]);
502
503
    for await (const chunk of source.reply("carry on", new AbortController().signal)) {
504
      void chunk;
505
    }
506
507
    // The model was answered against the whole replayed history plus the new
508
    // prompt, in order.
509
    const turn = calls.find((call) => call.url.endsWith("/api/inference/proxy"));
510
    expect(turn?.body["messages"]).toEqual([...replayed, { role: "user", content: "carry on" }]);
511
512
    // Only the new turn reached the transcript writer.
513
    expect(recorded.map((event) => event.eventType)).toEqual(["turn.user", "turn.assistant"]);
514
    expect(recorded[0]?.payload["text"]).toBe("carry on");
515
  });
516
});

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