Record coder --dev sessions to the server's threads database

c33656fc4e9d · AtlantisPleb · · parent 4fa44449b875

Record coder --dev sessions to the server's threads database

Sessions run with `openagents coder --dev` answered from
ResponsesReplySource but opened no thread and attached no transcript
writer, so a dev session left no record in the threads database
(OpenAgentsInc/openagents#59).

With a stored credential and OPENAGENTS_THREAD_SYNC not off, the dev
lane now opens the same transcript-only thread the local lane opens —
no grant minted or spent — and attaches the shared
ThreadTranscriptWriter to the responses source. The source records the
established vocabulary as the turn loop runs: `turn.user`,
`turn.reasoning` (one event per block, never deltas), `tool.ran` with
bounded arguments and output, and one `turn.assistant` per turn. The
responses surface reports no token counts, so the answer's usage
carries only the call count rather than zeros a reader would take for
measurements. A tier switch on the dev lane hands the same writer to
the fresh source, so the record stays one sequence, and the plain-mode
`[oa:thread ...]` announcement now covers dev sessions as well.

Without a credential nothing changes: no thread is opened, no writer is
attached, and the turn runs exactly as before — degradation, never a
broken turn.

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

Diff

3 files changed, +310 -27

packages/openagents-cli/src/cli.ts modified +38 -10

@@ -2268,6 +2268,30 @@ const coderCommand = Command.make(

2268 2268
          })
2269 2269
        : undefined;
2270 2270
2271
      // The dev lane records too (OpenAgentsInc/openagents#59): with a
2272
      // credential and the switch not off, it opens the same transcript-only
2273
      // thread the local lane opens — no grant minted or spent, the replies
2274
      // come from the responses surface — so a dev session's history,
2275
      // reasoning, and tool executions land in the threads database exactly
2276
      // as a standard or ollama session's do. `openLocalThread` never throws:
2277
      // no credential or no reachable thread route degrades silently to an
2278
      // unrecorded session, never a broken turn.
2279
      const devThread =
2280
        responsesSource !== undefined && Option.isSome(stored) && threadSyncWanted(process.env)
2281
          ? yield* Effect.promise(() =>
2282
              openLocalThread({
2283
                origin: endpoint.origin,
2284
                token: Redacted.value(stored.value.token),
2285
                objective: `openagents coder in ${workspace.repository} on ${workspace.branch}`,
2286
                repository: workspace.repository,
2287
                // The product id, not a vendor string: the dev server picks
2288
                // what answers, so the record names the surface that did.
2289
                model: responsesSource.modelId,
2290
                reasoning: Option.getOrUndefined(reasoning),
2291
              }),
2292
            )
2293
          : undefined;
2294
2271 2295
      const source: ReplySource =
2272 2296
        wantsZen && zenAsked !== undefined && zenKey !== undefined
2273 2297
          ? new ZenReplySource({ model: zenAsked, key: zenKey })

@@ -2438,15 +2462,18 @@ const coderCommand = Command.make(

2438 2462
        responsesSource !== undefined
2439 2463
          ? {
2440 2464
              initial: "auto" as CoderTierId,
2441
              build: (_tier: CoderTierId, _history: ReadonlyArray<unknown>) =>
2442
                Promise.resolve<ReplySource>(
2443
                  new ResponsesReplySource({
2444
                    origin: endpoint.origin,
2445
                    ...(Option.isSome(stored)
2446
                      ? { token: Redacted.value(stored.value.token) }
2447
                      : {}),
2448
                  }),
2449
                ),
2465
              build: (_tier: CoderTierId, _history: ReadonlyArray<unknown>) => {
2466
                const fresh = new ResponsesReplySource({
2467
                  origin: endpoint.origin,
2468
                  ...(Option.isSome(stored) ? { token: Redacted.value(stored.value.token) } : {}),
2469
                });
2470
                // The switched source keeps writing to the same thread: the
2471
                // writer is shared, so the record stays one sequence. Safe to
2472
                // reference here — `build` runs on a tier switch, long after
2473
                // the writer below is constructed.
2474
                if (transcript !== undefined) fresh.useTranscript(transcript);
2475
                return Promise.resolve<ReplySource>(fresh);
2476
              },
2450 2477
            }
2451 2478
          : undefined;
2452 2479

@@ -2476,7 +2503,7 @@ const coderCommand = Command.make(

2476 2503
      // durable copy; the offline and stand-in lanes keep no record and
2477 2504
      // attach nothing. A failed post never reaches the turn loop: the
2478 2505
      // writer queues, retries, and says so once on the status line.
2479
      const transcriptThreadId = thread?.threadId ?? localThread?.threadId;
2506
      const transcriptThreadId = thread?.threadId ?? localThread?.threadId ?? devThread?.threadId;
2480 2507
      const transcript =
2481 2508
        transcriptThreadId !== undefined && Option.isSome(stored)
2482 2509
          ? new ThreadTranscriptWriter({

@@ -2491,6 +2518,7 @@ const coderCommand = Command.make(

2491 2518
      if (transcript !== undefined) {
2492 2519
        thread?.useTranscript(transcript);
2493 2520
        ollamaSource?.useTranscript(transcript);
2521
        responsesSource?.useTranscript(transcript);
2494 2522
      }
2495 2523
2496 2524
      // The machine-readable announcement (OpenAgentsInc/openagents#38): in
packages/openagents-cli/src/coder-responses.ts modified +94 -8

@@ -20,10 +20,28 @@

20 20
import type { ReplyChunk, ReplySource } from "./coder-session.js";
21 21
import { tierLabel } from "./coder-tiers.js";
22 22
import type { CoderTool } from "./coder-tools.js";
23
import type { TranscriptSink } from "./coder-transcript.js";
23 24
24 25
/** Default backoff ladder for transient responses API failures (5xx or network drops). */
25 26
const DEFAULT_RETRY_DELAYS_MS: ReadonlyArray<number> = [250, 500, 1000, 2000];
26 27
28
/**
29
 * How much of one tool's output reaches the durable `tool.ran` event.
30
 *
31
 * The same figure the thread and local lanes use: it bounds a record written
32
 * once, so it is set where every result a real session has produced fits
33
 * whole. What the model is re-sent each round is bounded separately.
34
 */
35
const EVENT_RESULT_KEPT = 64_000;
36
37
/** A long tool result, kept at both ends. */
38
const bounded = (output: string, keep: number): string => {
39
  if (output.length <= keep) return output;
40
  const half = Math.floor(keep / 2);
41
  const cut = output.length - keep;
42
  return `${output.slice(0, half)}\n\n[${String(cut)} of ${String(output.length)} characters omitted from the middle; run it again more narrowly if you need them]\n\n${output.slice(-half)}`;
43
};
44
27 45
export interface ResponsesOptions {
28 46
  /** The API origin, such as `http://localhost:4000`. */
29 47
  readonly origin: string;

@@ -56,6 +74,13 @@ export class ResponsesReplySource implements ReplySource {

56 74
  private tools: ReadonlyArray<CoderTool> = [];
57 75
  private standing: string | undefined;
58 76
  private readonly retryDelaysMs: ReadonlyArray<number>;
77
  /**
78
   * The transcript writer, when the session opened a transcript-only thread
79
   * for this lane (OpenAgentsInc/openagents#59). Absent, nothing is recorded —
80
   * a `--dev` session without a credential runs exactly as before, it just
81
   * leaves no record on the server.
82
   */
83
  private sink: TranscriptSink | undefined;
59 84
60 85
  constructor(private readonly options: ResponsesOptions) {
61 86
    this.retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;

@@ -79,17 +104,47 @@ export class ResponsesReplySource implements ReplySource {

79 104
    this.standing = standing;
80 105
  }
81 106
107
  /**
108
   * Attach the writer that puts this session's turns on the server.
109
   *
110
   * The same vocabulary the thread and local lanes record — `turn.user`,
111
   * `turn.reasoning`, `tool.ran`, `turn.assistant` — so `/threads/:id`, the
112
   * export, and a resume read a dev session exactly as they read a hosted
113
   * one. Set after construction because the writer needs the thread's id,
114
   * which does not exist until the transcript-only thread is opened.
115
   */
116
  useTranscript(sink: TranscriptSink): void {
117
    this.sink = sink;
118
  }
119
82 120
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
83 121
    this.items.push({ role: "user", content: prompt });
122
    this.sink?.record("turn.user", { text: prompt });
84 123
    let calls = 0;
124
    /** The answer so far, across steps, for the one `turn.assistant` event. */
125
    let turnText = "";
126
    /** How many tools this turn ran, reported on `turn.assistant`. */
127
    let turnToolCalls = 0;
85 128
86 129
    for (;;) {
87 130
      calls += 1;
88
      const { text, requested } = yield* this.once(signal);
131
      const { text, reasoning, requested } = yield* this.once(signal);
89 132
      if (text !== "") this.items.push({ role: "assistant", content: text });
90 133
134
      // One event per block, whole, never deltas: the record is what was
135
      // thought, not the pieces it arrived in.
136
      if (reasoning.length > 0) this.sink?.record("turn.reasoning", { text: reasoning });
137
138
      // Whatever the model said belongs to the thread even when the turn was
139
      // interrupted, or the next turn answers a question it cannot see it
140
      // half-answered.
141
      if (text.length > 0) {
142
        turnText = turnText.length === 0 ? text : `${turnText}\n\n${text}`;
143
      }
144
91 145
      if (requested.length === 0) {
92 146
        yield { type: "usage", calls };
147
        this.recordAnswer(turnText, turnToolCalls, calls, signal.aborted);
93 148
        return;
94 149
      }
95 150

@@ -112,9 +167,40 @@ export class ResponsesReplySource implements ReplySource {

112 167
          call_id: call.callId,
113 168
          output: outcome.output ?? outcome.error ?? "",
114 169
        });
170
        turnToolCalls += 1;
171
        // Call and result are one fact, so they are one event — the thread
172
        // lane's shape exactly, bounded far above the model-wire bound so the
173
        // record keeps what the model was fed a cut of.
174
        this.sink?.record("tool.ran", {
175
          call_id: call.callId,
176
          tool: call.name,
177
          arguments: bounded(call.args, EVENT_RESULT_KEPT),
178
          status: outcome.error === undefined ? "succeeded" : "failed",
179
          ...(outcome.error === undefined
180
            ? { output: bounded(outcome.output ?? "", EVENT_RESULT_KEPT) }
181
            : { error: bounded(outcome.error, EVENT_RESULT_KEPT) }),
182
        });
115 183
      }
116 184
    }
185
  }
117 186
187
  /**
188
   * Record the turn's answer, with what it cost.
189
   *
190
   * One event per turn, whatever the turn took to get there, the same shape
191
   * the thread lane records. This surface reports no token counts, so the
192
   * usage carries only the call count — absent figures stay absent rather
193
   * than being written as zeros a reader would take for measurements.
194
   */
195
  private recordAnswer(text: string, toolCalls: number, calls: number, interrupted: boolean): void {
196
    if (this.sink === undefined) return;
197
    if (text.length === 0 && toolCalls === 0) return;
198
    this.sink.record("turn.assistant", {
199
      text,
200
      usage: { calls },
201
      tool_calls: toolCalls,
202
      ...(interrupted ? { interrupted: true } : {}),
203
    });
118 204
  }
119 205
120 206
  /**

@@ -192,10 +278,11 @@ export class ResponsesReplySource implements ReplySource {

192 278
  /** One request: stream the events, yield what renders, return the rest. */
193 279
  private async *once(
194 280
    signal: AbortSignal,
195
  ): AsyncGenerator<ReplyChunk, { text: string; requested: Call[] }> {
281
  ): AsyncGenerator<ReplyChunk, { text: string; reasoning: string; requested: Call[] }> {
196 282
    const response = await this.request(signal);
197 283
198 284
    let text = "";
285
    let reasoning = "";
199 286
    const requested: Call[] = [];
200 287
201 288
    for await (const data of frames(response.body!, signal)) {

@@ -214,6 +301,7 @@ export class ResponsesReplySource implements ReplySource {

214 301
        case "response.reasoning_summary_text.delta": {
215 302
          const delta = event["delta"];
216 303
          if (typeof delta === "string" && delta.length > 0) {
304
            reasoning += delta;
217 305
            yield { type: "reasoning", value: delta };
218 306
          }
219 307
          break;

@@ -236,14 +324,11 @@ export class ResponsesReplySource implements ReplySource {

236 324
      }
237 325
    }
238 326
239
    return { text, requested };
327
    return { text, reasoning, requested };
240 328
  }
241 329
242 330
  /** Run one tool call; a missing tool or a throw is a result, not a crash. */
243
  private async run(
244
    call: Call,
245
    signal: AbortSignal,
246
  ): Promise<{ output?: string; error?: string }> {
331
  private async run(call: Call, signal: AbortSignal): Promise<{ output?: string; error?: string }> {
247 332
    const tool = this.tools.find((candidate) => candidate.name === call.name);
248 333
    if (tool === undefined) {
249 334
      return { error: `No tool named \`${call.name}\` is declared in this session.` };

@@ -251,7 +336,8 @@ export class ResponsesReplySource implements ReplySource {

251 336
    let args: Record<string, unknown>;
252 337
    try {
253 338
      const parsed: unknown = JSON.parse(call.args === "" ? "{}" : call.args);
254
      args = parsed !== null && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
339
      args =
340
        parsed !== null && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
255 341
    } catch {
256 342
      return { error: "The call's arguments were not valid JSON." };
257 343
    }
packages/openagents-cli/test/coder-responses.test.ts modified +178 -9

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";

2 2
3 3
import { ResponsesReplySource } from "../src/coder-responses.js";
4 4
import type { ReplyChunk } from "../src/coder-session.js";
5
import { ThreadTranscriptWriter } from "../src/coder-transcript.js";
5 6
6 7
const sse = (frames: ReadonlyArray<string>) =>
7 8
  new Response(

@@ -66,9 +67,7 @@ describe("ResponsesReplySource", () => {

66 67
    });
67 68
    // Both named: the pipeline negotiates on json, the answer is SSE.
68 69
    expect(calls[0]?.accept).toContain("application/json");
69
    const text = chunks
70
      .map((chunk) => (chunk.type === "text" ? chunk.value : ""))
71
      .join("");
70
    const text = chunks.map((chunk) => (chunk.type === "text" ? chunk.value : "")).join("");
72 71
    expect(text).toBe("Acknowledged.");
73 72
    expect(chunks.at(-1)).toMatchObject({ type: "usage", calls: 1 });
74 73
  });

@@ -134,9 +133,7 @@ describe("ResponsesReplySource", () => {

134 133
    });
135 134
    const chunks = await collect(source);
136 135
    expect(callCount).toBe(3);
137
    const text = chunks
138
      .map((chunk) => (chunk.type === "text" ? chunk.value : ""))
139
      .join("");
136
    const text = chunks.map((chunk) => (chunk.type === "text" ? chunk.value : "")).join("");
140 137
    expect(text).toBe("Acknowledged.");
141 138
  });
142 139

@@ -159,9 +156,7 @@ describe("ResponsesReplySource", () => {

159 156
    });
160 157
    const chunks = await collect(source);
161 158
    expect(callCount).toBe(2);
162
    const text = chunks
163
      .map((chunk) => (chunk.type === "text" ? chunk.value : ""))
164
      .join("");
159
    const text = chunks.map((chunk) => (chunk.type === "text" ? chunk.value : "")).join("");
165 160
    expect(text).toBe("Acknowledged.");
166 161
  });
167 162
});

@@ -229,3 +224,177 @@ describe("ResponsesReplySource tools", () => {

229 224
    expect(bodies[0]?.["tools"]).toBeDefined();
230 225
  });
231 226
});
227
228
/** A sink that just remembers, standing in for the writer. */
229
const recorder = () => {
230
  const events: Array<{ eventType: string; payload: Record<string, unknown> }> = [];
231
  return {
232
    events,
233
    record(eventType: string, payload: Record<string, unknown>) {
234
      events.push({ eventType, payload });
235
    },
236
  };
237
};
238
239
describe("the dev lane's durable transcript", () => {
240
  const callStream = [
241
    'event: response.reasoning_summary_text.delta\ndata: {"type":"response.reasoning_summary_text.delta","sequence_number":0,"delta":"Listing "}\n\n',
242
    'event: response.reasoning_summary_text.delta\ndata: {"type":"response.reasoning_summary_text.delta","sequence_number":1,"delta":"first."}\n\n',
243
    'event: response.output_item.done\ndata: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"shell","arguments":"{\\"command\\":\\"ls\\"}","status":"completed"}}\n\n',
244
    'event: response.completed\ndata: {"type":"response.completed","sequence_number":3}\n\n',
245
  ];
246
  const answerStream = [
247
    'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","sequence_number":0,"delta":"Two files."}\n\n',
248
    'event: response.completed\ndata: {"type":"response.completed","sequence_number":1}\n\n',
249
  ];
250
251
  it("records the turn in order: what was asked, the reasoning, each tool run, the answer", async () => {
252
    const streams = [callStream, answerStream];
253
    vi.stubGlobal(
254
      "fetch",
255
      vi.fn(async () => sse(streams.shift() ?? [])),
256
    );
257
258
    const source = new ResponsesReplySource({ origin: "http://localhost:4000", token: "oa_pat_x" });
259
    const sink = recorder();
260
    source.useTranscript(sink);
261
    source.useTools([
262
      {
263
        name: "shell",
264
        description: "run a command",
265
        parameters: { type: "object" },
266
        run: () => Promise.resolve("README.md\nsrc"),
267
      },
268
    ]);
269
270
    await collect(source);
271
272
    expect(sink.events.map((event) => event.eventType)).toEqual([
273
      "turn.user",
274
      "turn.reasoning",
275
      "tool.ran",
276
      "turn.assistant",
277
    ]);
278
    expect(sink.events[0]?.payload).toEqual({ text: "hello" });
279
    // One event per block, whole, never the deltas it arrived in.
280
    expect(sink.events[1]?.payload).toEqual({ text: "Listing first." });
281
    expect(sink.events[2]?.payload).toEqual({
282
      call_id: "call_1",
283
      tool: "shell",
284
      arguments: `{"command":"ls"}`,
285
      status: "succeeded",
286
      output: "README.md\nsrc",
287
    });
288
    // This surface reports no token counts, so the usage carries only the
289
    // call count rather than zeros a reader would take for measurements.
290
    expect(sink.events[3]?.payload).toEqual({
291
      text: "Two files.",
292
      usage: { calls: 2 },
293
      tool_calls: 1,
294
    });
295
  });
296
297
  it("records a failed tool run as failed, not as a crash", async () => {
298
    const streams = [callStream, answerStream];
299
    vi.stubGlobal(
300
      "fetch",
301
      vi.fn(async () => sse(streams.shift() ?? [])),
302
    );
303
304
    const source = new ResponsesReplySource({ origin: "http://localhost:4000" });
305
    const sink = recorder();
306
    source.useTranscript(sink);
307
    source.useTools([
308
      {
309
        name: "shell",
310
        description: "run a command",
311
        parameters: { type: "object" },
312
        run: () => Promise.reject(new Error("no such directory")),
313
      },
314
    ]);
315
316
    await collect(source);
317
318
    const ran = sink.events.find((event) => event.eventType === "tool.ran");
319
    expect(ran?.payload).toEqual({
320
      call_id: "call_1",
321
      tool: "shell",
322
      arguments: `{"command":"ls"}`,
323
      status: "failed",
324
      error: "no such directory",
325
    });
326
  });
327
328
  it("posts the recorded events to the thread's transcript on the server", async () => {
329
    const streams = [callStream, answerStream];
330
    vi.stubGlobal(
331
      "fetch",
332
      vi.fn(async () => sse(streams.shift() ?? [])),
333
    );
334
335
    // The writer's own transport is injected, so this test sees exactly what
336
    // a server at the other end would: the events route, in order.
337
    const posted: Array<{ url: string; body: Record<string, unknown> }> = [];
338
    const writer = new ThreadTranscriptWriter({
339
      origin: "http://localhost:4000",
340
      threadId: "9bb19447-ecf4-4f1b-b44e-6b128664da9c",
341
      token: "oa_pat_x",
342
      fetch: async (url, init) => {
343
        posted.push({
344
          url: url.toString(),
345
          body: JSON.parse(String(init?.body)) as Record<string, unknown>,
346
        });
347
        return new Response("{}", { status: 201 });
348
      },
349
    });
350
351
    const source = new ResponsesReplySource({ origin: "http://localhost:4000", token: "oa_pat_x" });
352
    source.useTranscript(writer);
353
    source.useTools([
354
      {
355
        name: "shell",
356
        description: "run a command",
357
        parameters: { type: "object" },
358
        run: () => Promise.resolve("README.md\nsrc"),
359
      },
360
    ]);
361
362
    await collect(source);
363
    await writer.close();
364
365
    expect(posted[0]?.url).toBe(
366
      "http://localhost:4000/api/v1/threads/9bb19447-ecf4-4f1b-b44e-6b128664da9c/events",
367
    );
368
    expect(posted.map((post) => post.body["event_type"])).toEqual([
369
      "turn.user",
370
      "turn.reasoning",
371
      "tool.ran",
372
      "turn.assistant",
373
    ]);
374
  });
375
376
  it("runs exactly as before when no transcript is attached, recording nothing", async () => {
377
    // The unauthenticated case: `--dev` without a credential opens no thread
378
    // and attaches no writer, and the turn must not know the difference.
379
    const streams = [callStream, answerStream];
380
    const fetchMock = vi.fn(async () => sse(streams.shift() ?? []));
381
    vi.stubGlobal("fetch", fetchMock);
382
383
    const source = new ResponsesReplySource({ origin: "http://localhost:4000" });
384
    source.useTools([
385
      {
386
        name: "shell",
387
        description: "run a command",
388
        parameters: { type: "object" },
389
        run: () => Promise.resolve("README.md\nsrc"),
390
      },
391
    ]);
392
393
    const chunks = await collect(source);
394
395
    // The turn ran whole — call, result, answer — and the only requests made
396
    // were the two responses calls; nothing went to a threads route.
397
    expect(chunks.at(-1)).toMatchObject({ type: "usage", calls: 2 });
398
    expect(fetchMock).toHaveBeenCalledTimes(2);
399
  });
400
});

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