Send the dev lane's turns to the OpenResponses surface

8f738b70be32 · AtlantisPleb · · parent 4773472fe2f0

Send the dev lane's turns to the OpenResponses surface

openagents coder --dev now answers from POST /api/v1/responses — the
OpenResponses stub on the dev server — streaming the semantic event
sequence and rendering the text deltas. No model stands behind it yet;
the client loop is built against the event grammar first, so swapping
the stub for a real loop changes the server and nothing here. An
explicit --api-url beside --dev now wins over the local default, and
the no-credential stand-in notice stays off the dev lane, whose
replies do not come from the stand-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1mRkPGYmTVvMKqAzmQvy5
Co-Authored-By
Claude Fable 5 <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 docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/coder-responses.ts
  • added packages/openagents-cli/test/coder-responses.test.ts

Diff

5 files changed, +220 -7

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2483,
7
    "filesScanned": 2484,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:f6441bc52df1b0a94c3842b9ca752847c53ab41dcd78869b2b2bb6a8abdf2d11",
4
  "sourceDigest": "sha256:ce37c3926fa88f843846d795ee0ff46b2ab151b93d2e3a99ccaade74dc749f43",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (74 tracked test files)"
1879
          "ref": "packages/openagents-cli (75 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +20 -4

@@ -110,6 +110,7 @@ import { fileURLToPath } from "node:url";

110 110
import { rebuild, RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
111 111
import { loadSkillSelection, standingContext } from "./coder-skills.js";
112 112
import { startDevServer } from "./coder-dev-server.js";
113
import { ResponsesReplySource } from "./coder-responses.js";
113 114
import { TIER_MODELS, tierForModel, tierUnavailable, type CoderTierId } from "./coder-tiers.js";
114 115
import { ZenReplySource, zenCredential } from "./coder-zen.js";
115 116
import { describeWorkspace } from "./coder-workspace.js";

@@ -1934,9 +1935,11 @@ const coderCommand = Command.make(

1934 1935
      // session at a server on this machine. A deploy can take half an hour,
1935 1936
      // and iterating against a server nobody has deployed to yet is the
1936 1937
      // difference between a change being testable now and after lunch.
1938
      // `--dev` picks the local profile; an explicit `--api-url` beside it
1939
      // still wins, so a session can point at a server on another port.
1937 1940
      const endpoint = yield* resolveApiEndpoint(
1938 1941
        dev
1939
          ? { profile: Option.some("local" as Profile), apiUrl: Option.none() }
1942
          ? { ...endpointOverrides(flags), profile: Option.some("local" as Profile) }
1940 1943
          : endpointOverrides(flags),
1941 1944
      );
1942 1945

@@ -2209,7 +2212,7 @@ const coderCommand = Command.make(

2209 2212
      const thread =
2210 2213
        resumed !== undefined
2211 2214
          ? resumed.source
2212
          : Option.isSome(stored) && !wantsOllama && !wantsZen && !resume
2215
          : Option.isSome(stored) && !wantsOllama && !wantsZen && !resume && !dev
2213 2216
            ? yield* Effect.tryPromise({
2214 2217
                try: () =>
2215 2218
                  openThread({

@@ -2250,10 +2253,21 @@ const coderCommand = Command.make(

2250 2253
            })
2251 2254
          : undefined;
2252 2255
2256
      // The dev lane answers from the OpenResponses surface on the dev
2257
      // server rather than a grant-bearing thread: today that is the
2258
      // acknowledgement stub, and the point is that this loop is built
2259
      // against the event grammar before a model stands behind it.
2260
      const responsesSource = dev
2261
        ? new ResponsesReplySource({
2262
            origin: endpoint.origin,
2263
            ...(Option.isSome(stored) ? { token: Redacted.value(stored.value.token) } : {}),
2264
          })
2265
        : undefined;
2266
2253 2267
      const source: ReplySource =
2254 2268
        wantsZen && zenAsked !== undefined && zenKey !== undefined
2255 2269
          ? new ZenReplySource({ model: zenAsked, key: zenKey })
2256
          : (ollamaSource ?? thread ?? new DummyReplySource());
2270
          : (responsesSource ?? ollamaSource ?? thread ?? new DummyReplySource());
2257 2271
2258 2272
      // The local lane reports by default (OpenAgentsInc/openagents#39): with
2259 2273
      // a credential and the switch not off, it opens a transcript-only

@@ -2603,7 +2617,9 @@ const coderCommand = Command.make(

2603 2617
        session.notice(`This session cannot delegate: ${childThread.reason}`);
2604 2618
      }
2605 2619
2606
      if (Option.isNone(stored) && !offline && !wantsOllama && !wantsZen) {
2620
      // Not on the dev lane: its replies come from the responses surface,
2621
      // with or without a credential, so the stand-in sentence would be false.
2622
      if (Option.isNone(stored) && !offline && !wantsOllama && !wantsZen && !dev) {
2607 2623
        session.notice(
2608 2624
          "No stored credential, so replies come from the built-in stand-in. " +
2609 2625
            `Run \`${loginCommandFor(endpoint)}\` to reach a real model.`,
packages/openagents-cli/src/coder-responses.ts added +116

@@ -0,0 +1,116 @@

1
/**
2
 * A reply source speaking the OpenResponses surface at `POST /api/v1/responses`.
3
 *
4
 * The dev lane's source: `openagents coder --dev` sends each prompt as an
5
 * OpenResponses request with `stream: true` and renders the semantic events
6
 * that come back — today, from the server's acknowledgement stub, which
7
 * answers every prompt "Acknowledged." in two deltas. No model stands behind
8
 * it yet; the point is that the client-side turn loop is built against the
9
 * OpenResponses event grammar before a provider is, so swapping the stub for
10
 * a real loop changes the server and nothing here.
11
 *
12
 * Only `response.output_text.delta` is rendered. The rest of the sequence —
13
 * created, item and part boundaries, completed — is parsed and passed over,
14
 * which is exactly what the grammar is for: a client reads the events it
15
 * understands and survives the ones it does not.
16
 */
17
18
import type { ReplyChunk, ReplySource } from "./coder-session.js";
19
import { tierLabel } from "./coder-tiers.js";
20
21
export interface ResponsesOptions {
22
  /** The API origin, such as `http://localhost:4000`. */
23
  readonly origin: string;
24
  /** The account bearer, sent when held; the stub also answers without one. */
25
  readonly token?: string | undefined;
26
}
27
28
export class ResponsesReplySource implements ReplySource {
29
  constructor(private readonly options: ResponsesOptions) {}
30
31
  /** The dev lane defers to the server, which is what Coder Auto names. */
32
  get model(): string {
33
    return tierLabel("auto");
34
  }
35
36
  /** The product id: no vendor stands behind the stub, so none is named. */
37
  get modelId(): string {
38
    return "openagents-coder";
39
  }
40
41
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
42
    const response = await fetch(new URL("/api/v1/responses", this.options.origin), {
43
      method: "POST",
44
      headers: {
45
        ...(this.options.token === undefined
46
          ? {}
47
          : { authorization: `Bearer ${this.options.token}` }),
48
        "content-type": "application/json",
49
        // Both named: the pipeline negotiates on json, the answer is SSE.
50
        accept: "text/event-stream, application/json",
51
      },
52
      body: JSON.stringify({ input: prompt, stream: true }),
53
      signal,
54
    });
55
56
    if (!response.ok || response.body === null) {
57
      throw new Error(
58
        `The responses API at ${this.options.origin} answered HTTP ${String(response.status)}.`,
59
      );
60
    }
61
62
    let calls = 0;
63
    for await (const data of frames(response.body, signal)) {
64
      const event = parse(data);
65
      if (event === undefined) continue;
66
      if (event["type"] === "response.output_text.delta") {
67
        const delta = event["delta"];
68
        if (typeof delta === "string" && delta.length > 0) {
69
          yield { type: "text", value: delta };
70
        }
71
      }
72
      if (event["type"] === "response.completed") calls += 1;
73
    }
74
75
    yield { type: "usage", promptTokens: 0, completionTokens: 0, calls: Math.max(calls, 1) };
76
  }
77
}
78
79
/** Each SSE frame's `data:` payload, in order. */
80
async function* frames(
81
  body: ReadableStream<Uint8Array>,
82
  signal: AbortSignal,
83
): AsyncIterable<string> {
84
  const decoder = new TextDecoder();
85
  const reader = body.getReader();
86
  let buffer = "";
87
  try {
88
    for (;;) {
89
      const { done, value } = await reader.read();
90
      if (done || signal.aborted) break;
91
      buffer += decoder.decode(value, { stream: true });
92
      for (;;) {
93
        const boundary = buffer.indexOf("\n\n");
94
        if (boundary < 0) break;
95
        const frame = buffer.slice(0, boundary);
96
        buffer = buffer.slice(boundary + 2);
97
        for (const line of frame.split("\n")) {
98
          if (line.startsWith("data: ")) yield line.slice(6);
99
        }
100
      }
101
    }
102
  } finally {
103
    reader.releaseLock();
104
  }
105
}
106
107
const parse = (data: string): Record<string, unknown> | undefined => {
108
  try {
109
    const value: unknown = JSON.parse(data);
110
    return value !== null && typeof value === "object"
111
      ? (value as Record<string, unknown>)
112
      : undefined;
113
  } catch {
114
    return undefined;
115
  }
116
};
packages/openagents-cli/test/coder-responses.test.ts added +81

@@ -0,0 +1,81 @@

1
import { afterEach, describe, expect, it, vi } from "vitest";
2
3
import { ResponsesReplySource } from "../src/coder-responses.js";
4
import type { ReplyChunk } from "../src/coder-session.js";
5
6
const sse = (frames: ReadonlyArray<string>) =>
7
  new Response(
8
    new ReadableStream<Uint8Array>({
9
      start(controller) {
10
        const encoder = new TextEncoder();
11
        for (const frame of frames) controller.enqueue(encoder.encode(frame));
12
        controller.close();
13
      },
14
    }),
15
    { status: 200, headers: { "content-type": "text/event-stream" } },
16
  );
17
18
/** The stub's own sequence, as the server sends it. */
19
const STREAM = [
20
  'event: response.created\ndata: {"type":"response.created","sequence_number":0}\n\n',
21
  'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","sequence_number":1,"delta":"Acknow"}\n\n',
22
  'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","sequence_number":2,"delta":"ledged."}\n\n',
23
  'event: response.completed\ndata: {"type":"response.completed","sequence_number":3}\n\n',
24
];
25
26
afterEach(() => {
27
  vi.unstubAllGlobals();
28
});
29
30
const collect = async (source: ResponsesReplySource) => {
31
  const out: ReplyChunk[] = [];
32
  for await (const chunk of source.reply("hello", new AbortController().signal)) out.push(chunk);
33
  return out;
34
};
35
36
describe("ResponsesReplySource", () => {
37
  it("streams the request and concatenates the deltas it is sent", async () => {
38
    const calls: Array<{ url: string; body: Record<string, unknown>; accept: string }> = [];
39
    vi.stubGlobal(
40
      "fetch",
41
      vi.fn(async (target: URL | string, init?: RequestInit) => {
42
        const headers = (init?.headers ?? {}) as Record<string, string>;
43
        calls.push({
44
          url: target.toString(),
45
          body: JSON.parse(typeof init?.body === "string" ? init.body : "{}") as Record<
46
            string,
47
            unknown
48
          >,
49
          accept: headers["accept"] ?? "",
50
        });
51
        return sse(STREAM);
52
      }),
53
    );
54
55
    const source = new ResponsesReplySource({ origin: "http://localhost:4000", token: "oa_pat_x" });
56
    const chunks = await collect(source);
57
58
    expect(calls[0]?.url).toBe("http://localhost:4000/api/v1/responses");
59
    expect(calls[0]?.body).toEqual({ input: "hello", stream: true });
60
    // Both named: the pipeline negotiates on json, the answer is SSE.
61
    expect(calls[0]?.accept).toContain("application/json");
62
    const text = chunks
63
      .map((chunk) => (chunk.type === "text" ? chunk.value : ""))
64
      .join("");
65
    expect(text).toBe("Acknowledged.");
66
    expect(chunks.at(-1)).toMatchObject({ type: "usage", calls: 1 });
67
  });
68
69
  it("labels itself as Coder Auto and records the product id", () => {
70
    const source = new ResponsesReplySource({ origin: "http://localhost:4000" });
71
    expect(source.model).toBe("Coder Auto");
72
    expect(source.modelId).toBe("openagents-coder");
73
  });
74
75
  it("reports a refusal with the origin and the status", async () => {
76
    vi.stubGlobal("fetch", vi.fn(async () => new Response("no", { status: 503 })));
77
    const source = new ResponsesReplySource({ origin: "http://localhost:4000" });
78
79
    await expect(collect(source)).rejects.toThrow("http://localhost:4000 answered HTTP 503");
80
  });
81
});

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