Send the thread's repository and prefer it over objective parsing on resume

06d16d9b3272 · AtlantisPleb · · parent bbbc962f0190

Send the thread's repository and prefer it over objective parsing on resume

openagents.com #210 gives POST /api/v3/threads a structured repository
field and returns it in every thread view. The coder sends the repository
it already computes for its objective sentence, and the resume summary
takes the server's field first, falling back to parsing the sentence only
for threads opened before the field existed. The picker's repository
filter therefore acts on the thread's own record rather than on prose the
CLI wrote and hoped to recognize later.

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>

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

Diff

5 files changed, +91 -8

packages/openagents-cli/src/cli.ts modified +4

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

1967 1967
                    origin: endpoint.origin,
1968 1968
                    token: Redacted.value(stored.value.token),
1969 1969
                    objective: `openagents coder in ${workspace.repository} on ${workspace.branch}`,
1970
                    // The structured field beside the sentence: the server
1971
                    // records it on the thread, and `--resume` filters on it
1972
                    // rather than parsing the objective back.
1973
                    repository: workspace.repository,
1970 1974
                    reasoning: Option.getOrUndefined(reasoning),
1971 1975
                  }),
1972 1976
                // The server's own code and sentence, which is what turns a ninth
packages/openagents-cli/src/coder-resume.ts modified +18 -8

@@ -26,12 +26,13 @@

26 26
 * Neither replay touches the transcript writer. The events being replayed are
27 27
 * the server's own; posting them again would double the record.
28 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`.
29
 * The repository filter prefers the thread's own field. Since
30
 * openagents.com #210, `POST /api/v3/threads` records a structured
31
 * `repository` and every thread view returns it, so a summary takes that
32
 * first. Threads opened before the field existed carry none, so the objective
33
 * sentence this CLI composes (`openagents coder in <repo> on <branch>`) is
34
 * still parsed back as the fallback. A thread with neither has no repository
35
 * to match and appears only under `--all`.
35 36
 */
36 37
37 38
import { createInterface } from "node:readline";

@@ -51,7 +52,10 @@ export interface ThreadSummary {

51 52
  readonly objective: string;
52 53
  readonly eventCount: number;
53 54
  readonly startedAt: string | undefined;
54
  /** Parsed from the objective when this CLI composed it; otherwise absent. */
55
  /**
56
   * The thread's own `repository` field when the server reports one, else
57
   * parsed from the objective when this CLI composed it; otherwise absent.
58
   */
55 59
  readonly repository: string | undefined;
56 60
  readonly branch: string | undefined;
57 61
}

@@ -423,13 +427,19 @@ async function get(

423 427
function summaryOf(raw: Record<string, unknown>): ThreadSummary {
424 428
  const objective = text(raw["objective"]);
425 429
  const named = repositoryOf(objective);
430
  // The structured field first: it is the thread's own record of where it
431
  // ran, written at open. The parsed sentence remains only for threads opened
432
  // before the server recorded one.
433
  const recorded = raw["repository"];
434
  const repository =
435
    typeof recorded === "string" && recorded.length > 0 ? recorded : named?.repository;
426 436
  return {
427 437
    id: text(raw["id"]),
428 438
    status: text(raw["status"]) || "unknown",
429 439
    objective,
430 440
    eventCount: count(raw["event_count"]),
431 441
    startedAt: typeof raw["started_at"] === "string" ? raw["started_at"] : undefined,
432
    repository: named?.repository,
442
    repository,
433 443
    branch: named?.branch,
434 444
  };
435 445
}
packages/openagents-cli/src/coder-thread.ts modified +7

@@ -122,6 +122,12 @@ export interface ThreadOptions {

122 122
  readonly token: string;
123 123
  /** What this body of work is for. The server requires one. */
124 124
  readonly objective: string;
125
  /**
126
   * The repository the work concerns, as `owner/name`. Recorded structurally
127
   * on the thread so `--resume` filters the picker on the server's field
128
   * rather than parsing the objective sentence back (openagents.com #210).
129
   */
130
  readonly repository?: string | undefined;
125 131
  /** Recorded on the thread as its admitted execution shape. */
126 132
  readonly reasoning?: string | undefined;
127 133
  /**

@@ -167,6 +173,7 @@ export async function openThread(options: ThreadOptions): Promise<ThreadReplySou

167 173
    },
168 174
    body: JSON.stringify({
169 175
      objective: options.objective,
176
      ...(options.repository === undefined ? {} : { repository: options.repository }),
170 177
      ...(options.reasoning === undefined ? {} : { reasoning: options.reasoning }),
171 178
      ...(options.model === undefined ? {} : { model: options.model }),
172 179
    }),
packages/openagents-cli/test/coder-resume.test.ts modified +40

@@ -226,6 +226,46 @@ describe("listThreads", () => {

226 226
    expect(threads[0]?.repository).toBe("openagents");
227 227
    expect(threads[0]?.eventCount).toBe(7);
228 228
  });
229
230
  it("prefers the thread's own repository field over the parsed objective", async () => {
231
    const transport = async () =>
232
      new Response(
233
        JSON.stringify({
234
          threads: [
235
            {
236
              id: THREAD_ID,
237
              status: "open",
238
              // The sentence names one repository, the field another. The
239
              // field wins: it is the thread's own record, written at open.
240
              objective: "openagents coder in renamed-checkout on main",
241
              repository: "OpenAgentsInc/openagents.com",
242
              event_count: 2,
243
              started_at: "2026-08-24T12:00:00Z",
244
            },
245
            {
246
              id: "older-thread-before-the-field",
247
              status: "open",
248
              // A thread opened before the server recorded the field still
249
              // resolves through the sentence this CLI composed.
250
              objective: "openagents coder in openagents on main",
251
              repository: null,
252
              event_count: 1,
253
              started_at: "2026-08-23T12:00:00Z",
254
            },
255
          ],
256
        }),
257
        { status: 200 },
258
      );
259
260
    const threads = await listThreads({ origin: ORIGIN, token: TOKEN, fetch: transport });
261
262
    expect(threads[0]?.repository).toBe("OpenAgentsInc/openagents.com");
263
    expect(threads[1]?.repository).toBe("openagents");
264
265
    // And the picker filter acts on the structured field, so the renamed
266
    // sentence does not hide the thread from its repository.
267
    expect(resumableThreads(threads, "OpenAgentsInc/openagents.com", false)).toEqual([threads[0]]);
268
  });
229 269
});
230 270
231 271
describe("fetchAllEvents", () => {
packages/openagents-cli/test/coder-thread.test.ts modified +22

@@ -130,6 +130,28 @@ describe("openThread", () => {

130 130
    expect(calls[0]?.body).toEqual({ objective: "coder in repo on main", reasoning: "high" });
131 131
  });
132 132
133
  it("sends the repository beside the objective, so resume can filter structurally", async () => {
134
    const calls = stub({});
135
    await openThread({
136
      origin: ORIGIN,
137
      token: ACCOUNT_TOKEN,
138
      objective: "openagents coder in OpenAgentsInc/openagents.com on main",
139
      repository: "OpenAgentsInc/openagents.com",
140
    });
141
142
    expect(calls[0]?.body).toEqual({
143
      objective: "openagents coder in OpenAgentsInc/openagents.com on main",
144
      repository: "OpenAgentsInc/openagents.com",
145
    });
146
  });
147
148
  it("omits the repository key entirely when none is named", async () => {
149
    const calls = stub({});
150
    await open();
151
152
    expect(calls[0]?.body).toEqual({ objective: "coder in repo on main" });
153
  });
154
133 155
  it("names the model the thread's grant should pin, so children can run on another", async () => {
134 156
    const calls = stub({});
135 157
    await openThread({

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