Report a local-lane coder session to the server by default

ebbed207b783 · AtlantisPleb · · parent 416a305bb202

Report a local-lane coder session to the server by default

`openagents coder --model ollama:<name>` kept everything local: nothing
reached the threads API, so a local session was invisible to resume, export,
/threads, and the Gym's live run transcripts (#39).

Now a local-model session that holds an api-url and a token opens a
transcript-only thread — POST /api/v1/threads with `lane: "local"` and the
vendor model string, no grant expected or used — and attaches the same
ThreadTranscriptWriter the thread lane uses, so `turn.user`,
`turn.reasoning`, `tool.ran`, and `turn.assistant` land on the server as the
session runs, in the thread lane's exact vocabulary. Inference stays entirely
on the local Ollama client; only the record travels, and the writer's
enqueue-and-pump contract keeps the turn loop from ever waiting on it.

Reporting is the default. `OPENAGENTS_THREAD_SYNC=off` is the env switch, and
`--offline` already ends with no credential, so it is the flag. No token, no
api-url, or an unreachable server degrades silently to local-only:
`openLocalThread` resolves undefined for every failure rather than throwing.

In `--plain` mode a session with a server thread — either lane — announces it
once on stderr as `[oa:thread <id>]`, the line the Gym adapter parses with
`\[oa:thread ([0-9a-fA-F-]{36})\]` to link a trial to its thread (#38).

Tests cover sync on by default, the off switch, silent degradation, the
announcement format, and the local lane's recorded vocabulary. Also
normalizes seven previously committed files the package formatter already
owned differently, so `pnpm verify` is green end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RfZq5s3rc6zpnBR75pTQaU
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-local-thread.ts
  • modified packages/openagents-cli/src/coder-ollama.ts
  • modified packages/openagents-cli/src/computer-up.ts
  • modified packages/openagents-cli/src/delegation-push.ts
  • modified packages/openagents-cli/test/coder-delegate-devin.test.ts
  • modified packages/openagents-cli/test/coder-delegate.test.ts
  • added packages/openagents-cli/test/coder-local-thread.test.ts
  • modified packages/openagents-cli/test/coder-ollama.test.ts
  • modified packages/openagents-cli/test/coder-thread.test.ts
  • modified packages/openagents-cli/test/delegation-push.test.ts

Diff

13 files changed, +647 -156

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": 2471,
7
    "filesScanned": 2472,
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:bf21afe12adf02f3bdb957627605f1726f8a01d04a21f9dd9ae891016754b4cb",
4
  "sourceDigest": "sha256:f9692575e1acde3a37330314bde3362b9552f997096a73f4d036c22470650d52",
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 (66 tracked test files)"
1879
          "ref": "packages/openagents-cli (67 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +66 -20

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

87 87
  resumableThreads,
88 88
  type ThreadSummary,
89 89
} from "./coder-resume.js";
90
import { openLocalThread, threadAnnouncement, threadSyncWanted } from "./coder-local-thread.js";
90 91
import { ThreadTranscriptWriter } from "./coder-transcript.js";
91 92
import { delegateTool, openagentsTool, shellTool, skillTool } from "./coder-tools.js";
92 93
import {

@@ -2238,20 +2239,51 @@ const coderCommand = Command.make(

2238 2239
            : undefined;
2239 2240
2240 2241
      // A `--model ollama:<name>` session answers from the local Ollama server,
2241
      // so it takes neither a thread nor the stand-in.
2242
      // so it takes neither a grant-bearing thread nor the stand-in. Held in a
2243
      // name of its own because the local lane still records: the transcript
2244
      // writer below attaches to it when the session opens a transcript-only
2245
      // thread.
2246
      const ollamaSource =
2247
        wantsOllama && ollamaName !== undefined
2248
          ? new OllamaReplySource({
2249
              model: ollamaName,
2250
              // The standard Ollama env var, honored so a session in a
2251
              // container can reach the Ollama server on its host —
2252
              // 127.0.0.1 inside a container is the container.
2253
              ...(process.env["OLLAMA_HOST"] ? { host: process.env["OLLAMA_HOST"] } : {}),
2254
              ...(Option.isSome(reasoning) ? { reasoning: reasoning.value } : {}),
2255
            })
2256
          : undefined;
2257
2242 2258
      const source: ReplySource =
2243 2259
        wantsZen && zenAsked !== undefined && zenKey !== undefined
2244 2260
          ? new ZenReplySource({ model: zenAsked, key: zenKey })
2245
          : wantsOllama && ollamaName !== undefined
2246
            ? new OllamaReplySource({
2247
                model: ollamaName,
2248
                // The standard Ollama env var, honored so a session in a
2249
                // container can reach the Ollama server on its host —
2250
                // 127.0.0.1 inside a container is the container.
2251
                ...(process.env["OLLAMA_HOST"] ? { host: process.env["OLLAMA_HOST"] } : {}),
2252
                ...(Option.isSome(reasoning) ? { reasoning: reasoning.value } : {}),
2253
              })
2254
            : (thread ?? new DummyReplySource());
2261
          : (ollamaSource ?? thread ?? new DummyReplySource());
2262
2263
      // The local lane reports by default (OpenAgentsInc/openagents#39): with
2264
      // a credential and the switch not off, it opens a transcript-only
2265
      // thread — lane "local", the vendor model string, no grant expected or
2266
      // used — so the session's record lands on the server while inference
2267
      // stays entirely local. `--offline` already ends with no credential
2268
      // here, so it is the flag that turns this off; OPENAGENTS_THREAD_SYNC=off
2269
      // is the env switch. `openLocalThread` never throws: no reachable
2270
      // server degrades silently to local-only.
2271
      const localThread =
2272
        ollamaSource !== undefined &&
2273
        ollamaName !== undefined &&
2274
        Option.isSome(stored) &&
2275
        threadSyncWanted(process.env)
2276
          ? yield* Effect.promise(() =>
2277
              openLocalThread({
2278
                origin: endpoint.origin,
2279
                token: Redacted.value(stored.value.token),
2280
                objective: `openagents coder in ${workspace.repository} on ${workspace.branch}`,
2281
                repository: workspace.repository,
2282
                model: `ollama:${ollamaName}`,
2283
                reasoning: Option.getOrUndefined(reasoning),
2284
              }),
2285
            )
2286
          : undefined;
2255 2287
2256 2288
      // Children get their own thread on their own model. The conversation
2257 2289
      // stays on the model it opened with, and a fan-out spends a budget the

@@ -2306,24 +2338,38 @@ const coderCommand = Command.make(

2306 2338
      // so both interfaces open showing the conversation being continued.
2307 2339
      if (resumed !== undefined) session.restore(resumed.entries);
2308 2340
2309
      // The thread lane writes its transcript to the server as the turn loop
2310
      // runs — `POST /api/v1/threads/{id}/events`, on the account token that
2311
      // opened the thread. The server copy is the only durable copy; the
2312
      // offline, Ollama, and stand-in lanes keep no record and attach nothing.
2313
      // A failed post never reaches the turn loop: the writer queues, retries,
2314
      // and says so once on the status line.
2341
      // The thread and local lanes write their transcript to the server as
2342
      // the turn loop runs — `POST /api/v1/threads/{id}/events`, on the
2343
      // account token that opened the thread. The server copy is the only
2344
      // durable copy; the offline and stand-in lanes keep no record and
2345
      // attach nothing. A failed post never reaches the turn loop: the
2346
      // writer queues, retries, and says so once on the status line.
2347
      const transcriptThreadId = thread?.threadId ?? localThread?.threadId;
2315 2348
      const transcript =
2316
        thread !== undefined && Option.isSome(stored)
2349
        transcriptThreadId !== undefined && Option.isSome(stored)
2317 2350
          ? new ThreadTranscriptWriter({
2318 2351
              origin: endpoint.origin,
2319
              threadId: thread.threadId,
2352
              threadId: transcriptThreadId,
2320 2353
              token: Redacted.value(stored.value.token),
2321 2354
              onTrouble: (message) => {
2322 2355
                session.notice(message);
2323 2356
              },
2324 2357
            })
2325 2358
          : undefined;
2326
      if (transcript !== undefined) thread?.useTranscript(transcript);
2359
      if (transcript !== undefined) {
2360
        thread?.useTranscript(transcript);
2361
        ollamaSource?.useTranscript(transcript);
2362
      }
2363
2364
      // The machine-readable announcement (OpenAgentsInc/openagents#38): in
2365
      // plain mode a session with a server thread — either lane — names it
2366
      // once on stderr, so a harness that captured the output can link the
2367
      // session to its record. Exactly this shape: the Gym adapter parses it
2368
      // with `\[oa:thread ([0-9a-fA-F-]{36})\]`. Absent when offline, which
2369
      // is not an error.
2370
      if (plain && transcriptThreadId !== undefined) {
2371
        process.stderr.write(`${threadAnnouncement(transcriptThreadId)}\n`);
2372
      }
2327 2373
2328 2374
      // The model is told what it can do rather than the reader being asked to
2329 2375
      // remember a slash command. A turn that needs three agents asks for them
packages/openagents-cli/src/coder-local-thread.ts added +114

@@ -0,0 +1,114 @@

1
/**
2
 * The local lane's transcript-only thread.
3
 *
4
 * A session on `--model ollama:<name>` answers entirely from the local Ollama
5
 * server, so the server is not in the turn loop at all — but the server is
6
 * where everything rehydrates from: resume, export, `/threads`, the Gym's live
7
 * run transcripts. A local session that reported nothing was invisible to all
8
 * of it. So when the session holds an api-url and a token, it opens a thread
9
 * with `"lane": "local"` — the same `POST /api/v1/threads` body, plus the lane
10
 * and the vendor model string — and streams its transcript there through the
11
 * same `ThreadTranscriptWriter` the thread lane uses. The response carries no
12
 * grant and none is asked for: inference stays on the local client, only the
13
 * record travels.
14
 *
15
 * Reporting is the default and must never cost the session anything: no token,
16
 * no api-url, or an unreachable server degrades silently to local-only, which
17
 * is why `openLocalThread` resolves `undefined` for every failure rather than
18
 * ever throwing. `OPENAGENTS_THREAD_SYNC=off` and `--offline` are the two ways
19
 * to say no on purpose.
20
 */
21
22
import { THREADS_PATH } from "./constants.js";
23
24
export interface LocalThreadOptions {
25
  readonly origin: string;
26
  /** The account token. Opens the thread and posts its events; no grant is minted. */
27
  readonly token: string;
28
  /** What this body of work is for. The server requires one. */
29
  readonly objective: string;
30
  /** The repository the work concerns, as `owner/name`. */
31
  readonly repository?: string | undefined;
32
  /** Recorded on the thread as its admitted execution shape. */
33
  readonly reasoning?: string | undefined;
34
  /**
35
   * The vendor model string, for example `ollama:qwen3.8:27b-mtp-q8_0`.
36
   *
37
   * Recorded on the thread so a reader of `/threads/:id` knows what answered.
38
   * Unlike the thread lane's model this pins no grant — there is nothing to
39
   * spend — it is the record's name for the model that ran locally.
40
   */
41
  readonly model: string;
42
}
43
44
/** What the local lane gets back: a thread to write to, and nothing to spend. */
45
export interface LocalThread {
46
  readonly threadId: string;
47
}
48
49
/**
50
 * Whether the session should report its transcript to the server at all.
51
 *
52
 * On unless someone said off. The one switch is `OPENAGENTS_THREAD_SYNC=off`;
53
 * everything else that ends with no sync — no token, no reachable server — is
54
 * degradation, not configuration, and is handled where it happens.
55
 */
56
export const threadSyncWanted = (env: Record<string, string | undefined>): boolean =>
57
  env["OPENAGENTS_THREAD_SYNC"]?.trim().toLowerCase() !== "off";
58
59
/**
60
 * The machine-readable thread announcement for `--plain` output.
61
 *
62
 * Exactly this shape and no other: the Gym adapter links a trial to its
63
 * thread by parsing captured coder output with
64
 * `\[oa:thread ([0-9a-fA-F-]{36})\]` (OpenAgentsInc/openagents#38), so the
65
 * format is a contract, not a log line.
66
 */
67
export const threadAnnouncement = (threadId: string): string => `[oa:thread ${threadId}]`;
68
69
/**
70
 * Open a transcript-only thread for a local-model session.
71
 *
72
 * `POST /api/v1/threads` with `"lane": "local"`. The response is a thread
73
 * without a grant — the server admits the record and mints no authority, and
74
 * this client neither expects nor uses one.
75
 *
76
 * Resolves `undefined` for every failure — the network refusing, a server
77
 * without the lane, a malformed body — because the local lane must never be
78
 * slower or louder for a server that is not there. The session runs exactly as
79
 * it would offline; only the record is lost, and only for this session.
80
 */
81
export const openLocalThread = async (
82
  options: LocalThreadOptions,
83
): Promise<LocalThread | undefined> => {
84
  try {
85
    const response = await fetch(new URL(THREADS_PATH, options.origin), {
86
      method: "POST",
87
      headers: {
88
        authorization: `Bearer ${options.token}`,
89
        "content-type": "application/json",
90
        accept: "application/json",
91
      },
92
      body: JSON.stringify({
93
        objective: options.objective,
94
        ...(options.repository === undefined ? {} : { repository: options.repository }),
95
        ...(options.reasoning === undefined ? {} : { reasoning: options.reasoning }),
96
        model: options.model,
97
        lane: "local",
98
      }),
99
    });
100
101
    if (response.status < 200 || response.status >= 300) return undefined;
102
103
    const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
104
    const thread = body["thread"];
105
    const id =
106
      typeof thread === "object" && thread !== null && !Array.isArray(thread)
107
        ? (thread as Record<string, unknown>)["id"]
108
        : undefined;
109
110
    return typeof id === "string" && id.length > 0 ? { threadId: id } : undefined;
111
  } catch {
112
    return undefined;
113
  }
114
};
packages/openagents-cli/src/coder-ollama.ts modified +110 -8

@@ -23,6 +23,7 @@ import { merge } from "./coder-merge.js";

23 23
import type { ReplyChunk, ReplySource } from "./coder-session.js";
24 24
import { LOCAL_LANE, systemPrompt } from "./coder-system.js";
25 25
import type { CoderTool } from "./coder-tools.js";
26
import type { TranscriptSink } from "./coder-transcript.js";
26 27
27 28
const DEFAULT_HOST = "http://127.0.0.1:11434";
28 29

@@ -51,11 +52,21 @@ const MAX_TOOL_STEPS = 100;

51 52
 */
52 53
const TOOL_RESULT_KEPT = 4_000;
53 54
55
/**
56
 * How much of one tool's output reaches the durable `tool.ran` event.
57
 *
58
 * The same figure the thread lane uses, for the same reason: the 4,000 above
59
 * is a context-budget decision re-spent on every round, and this bounds a
60
 * record written once, so it is set where every result a real session has
61
 * produced fits whole.
62
 */
63
const EVENT_RESULT_KEPT = 64_000;
64
54 65
/** A long tool result, kept at both ends. */
55
const bounded = (output: string): string => {
56
  if (output.length <= TOOL_RESULT_KEPT) return output;
57
  const half = Math.floor(TOOL_RESULT_KEPT / 2);
58
  const cut = output.length - TOOL_RESULT_KEPT;
66
const bounded = (output: string, keep = TOOL_RESULT_KEPT): string => {
67
  if (output.length <= keep) return output;
68
  const half = Math.floor(keep / 2);
69
  const cut = output.length - keep;
59 70
  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)}`;
60 71
};
61 72

@@ -223,6 +234,14 @@ export class OllamaReplySource implements ReplySource {

223 234
   * the difference between steering a model and waiting one out.
224 235
   */
225 236
  private steered: string[] = [];
237
  /**
238
   * Where the turn loop writes the durable transcript, when the session has
239
   * one. The local lane opens a transcript-only thread when it holds an
240
   * api-url and a token (coder-local-thread.ts); a session without either
241
   * attaches nothing and every call below is a no-op through optional
242
   * chaining. Inference never touches it — only the record travels.
243
   */
244
  private sink: TranscriptSink | undefined;
226 245
  private reasoningLevel: string;
227 246
  /** What the turn in flight has spent, so it is reported however it ends. */
228 247
  private spentIn = 0;

@@ -272,6 +291,19 @@ export class OllamaReplySource implements ReplySource {

272 291
    this.tools = tools;
273 292
  }
274 293
294
  /**
295
   * Attach the writer that puts this session's turns on the server.
296
   *
297
   * The same vocabulary the thread lane records — `turn.user`,
298
   * `turn.reasoning`, `tool.ran`, `turn.assistant` — so `/threads/:id`, the
299
   * export, and a resume read a local session exactly as they read a hosted
300
   * one. Set after construction because the writer needs the thread's id,
301
   * which does not exist until the transcript-only thread is opened.
302
   */
303
  useTranscript(sink: TranscriptSink): void {
304
    this.sink = sink;
305
  }
306
275 307
  /**
276 308
   * Everything standing that goes to the model: the system message and the tool
277 309
   * declarations, rendered from the same values the request carries.

@@ -378,6 +410,7 @@ export class OllamaReplySource implements ReplySource {

378 410
    }
379 411
380 412
    this.transcript.push({ role: "user", content: prompt });
413
    this.sink?.record("turn.user", { text: prompt });
381 414
382 415
    // A turn is a loop, not a single call: the model may answer, or it may ask
383 416
    // for tools and then answer once it has seen what they returned. The

@@ -386,8 +419,16 @@ export class OllamaReplySource implements ReplySource {

386 419
    this.spentOut = 0;
387 420
    this.calls = 0;
388 421
422
    /** The answer so far, across rounds, for the one `turn.assistant` event. */
423
    let turnText = "";
424
    /** How many tools this turn ran, reported on `turn.assistant`. */
425
    let turnToolCalls = 0;
426
389 427
    for (let step = 0; step < MAX_TOOL_STEPS; step += 1) {
390
      if (signal.aborted) return;
428
      if (signal.aborted) {
429
        this.recordAnswer(turnText, turnToolCalls, true);
430
        return;
431
      }
391 432
392 433
      // Anything the reader said since the last step joins here, before the
393 434
      // model is asked again. It reads as an ordinary turn in the conversation,

@@ -395,6 +436,9 @@ export class OllamaReplySource implements ReplySource {

395 436
      const steered = this.steered.splice(0);
396 437
      for (const said of steered) {
397 438
        this.transcript.push({ role: "user", content: said });
439
        // Steered mid-turn rather than asked between turns, and the record
440
        // says so, or a replay would show a question the answer ignores.
441
        this.sink?.record("turn.user", { text: said, steered: true });
398 442
      }
399 443
      // The interface dims a steered message until this says it was read.
400 444
      if (steered.length > 0) yield { type: "steered", texts: steered };

@@ -479,7 +523,21 @@ export class OllamaReplySource implements ReplySource {

479 523
        signal.removeEventListener("abort", onAbort);
480 524
      }
481 525
482
      if (signal.aborted) return;
526
      // One event per block, whole, never deltas: the record is what was
527
      // thought, not the pieces it arrived in.
528
      if (reasoning.length > 0) this.sink?.record("turn.reasoning", { text: reasoning });
529
530
      // Whatever the model said belongs to the thread even when the turn was
531
      // interrupted, or the next turn answers a question it cannot see it
532
      // half-answered.
533
      if (assistant.length > 0) {
534
        turnText = turnText.length === 0 ? assistant : `${turnText}\n\n${assistant}`;
535
      }
536
537
      if (signal.aborted) {
538
        this.recordAnswer(turnText, turnToolCalls, true);
539
        return;
540
      }
483 541
484 542
      // Whatever the model said before asking is kept with the calls, and so is
485 543
      // what it thought. Reasoning is part of the turn, not decoration on it: a

@@ -494,16 +552,47 @@ export class OllamaReplySource implements ReplySource {

494 552
        ...(calls.length === 0 ? {} : { tool_calls: calls }),
495 553
      });
496 554
497
      if (calls.length === 0) return;
555
      if (calls.length === 0) {
556
        this.recordAnswer(turnText, turnToolCalls, false);
557
        return;
558
      }
559
      turnToolCalls += calls.length;
498 560
499 561
      // Concurrently. A model asking for two tools in one turn is saying they do
500 562
      // not depend on each other, and running them in order anyway makes a fan-out
501 563
      // to two models cost the sum of both.
502
      if (signal.aborted) return;
564
      if (signal.aborted) {
565
        this.recordAnswer(turnText, turnToolCalls, true);
566
        return;
567
      }
503 568
      yield* merge(calls.map((call) => this.invoke(call, signal)));
504 569
    }
505 570
  }
506 571
572
  /**
573
   * Record the turn's answer, with what it cost.
574
   *
575
   * One event per turn, whatever the turn took to get there, the same shape
576
   * the thread lane records. An interrupted turn is recorded too, marked as
577
   * such, because whatever streamed before Escape was said and the next
578
   * reader of this thread will be answered against it.
579
   */
580
  private recordAnswer(text: string, toolCalls: number, interrupted: boolean): void {
581
    if (this.sink === undefined) return;
582
    if (text.length === 0 && this.calls === 0) return;
583
    this.sink.record("turn.assistant", {
584
      text,
585
      usage: {
586
        prompt_tokens: this.spentIn,
587
        completion_tokens: this.spentOut,
588
        total_tokens: this.spentIn + this.spentOut,
589
        calls: this.calls,
590
      },
591
      tool_calls: toolCalls,
592
      ...(interrupted ? { interrupted: true } : {}),
593
    });
594
  }
595
507 596
  /**
508 597
   * Run one call, report it, and put the result on the transcript.
509 598
   *

@@ -541,6 +630,19 @@ export class OllamaReplySource implements ReplySource {

541 630
542 631
    yield { type: "tool_result", callId, output, error: failure };
543 632
633
    // Call and result are one fact, so they are one event — the thread lane's
634
    // shape exactly, bounded far above the model-wire bound so the record
635
    // keeps what the model was fed a cut of.
636
    this.sink?.record("tool.ran", {
637
      call_id: callId,
638
      tool: name,
639
      arguments: bounded(JSON.stringify(args, undefined, 2), EVENT_RESULT_KEPT),
640
      status: failure === undefined ? "succeeded" : "failed",
641
      ...(failure === undefined
642
        ? { output: bounded(output, EVENT_RESULT_KEPT) }
643
        : { error: bounded(failure, EVENT_RESULT_KEPT) }),
644
    });
645
544 646
    this.transcript.push({ role: "tool", content: bounded(output), tool_name: name });
545 647
  }
546 648
}
packages/openagents-cli/src/computer-up.ts modified +2 -5

@@ -159,8 +159,7 @@ export const computerUpLayer = Layer.effect(

159 159
          branch = asString(payload.assignment_branch);
160 160
        } else if (typeof raw === "object" && raw !== null) {
161 161
          const cred = recordValue(raw);
162
          const candidate =
163
            cred.token ?? cred.value ?? cred.password ?? cred.access_token;
162
          const candidate = cred.token ?? cred.value ?? cred.password ?? cred.access_token;
164 163
          token = asString(candidate);
165 164
          repository = asString(cred.repository ?? payload.assignment_repository);
166 165
          branch = asString(cred.branch ?? payload.assignment_branch);

@@ -422,9 +421,7 @@ export const computerUpLayer = Layer.effect(

422 421
            roots: config.roots,
423 422
            curatedExecute: config.curatedExecute ?? [],
424 423
            env: environment,
425
            ...(forgeCredentials !== undefined
426
              ? { forgeCredentials, forgeOrigin: origin }
427
              : {}),
424
            ...(forgeCredentials !== undefined ? { forgeCredentials, forgeOrigin: origin } : {}),
428 425
            timeoutMs: numberField(
429 426
              payload,
430 427
              ["timeout_ms", "timeout"],
packages/openagents-cli/src/delegation-push.ts modified +38 -45

@@ -45,47 +45,48 @@ const toCanonical = (value: string, branch: string): string | undefined => {

45 45
  return undefined;
46 46
};
47 47
48
export const validateRefspec = Effect.fn("DelegationPush.validateRefspec")(
49
  function* (refspec: string, branch: string) {
50
    if (refspec === "") {
51
      return yield* new InputError({ message: "The refspec is empty." });
52
    }
53
    if (/[\s,]/u.test(refspec)) {
54
      return yield* new InputError({
55
        message: `Multi-ref push is not allowed: ${redactSecret(refspec)}`,
56
      });
57
    }
58
    if (refspec.startsWith("+") || refspec.startsWith("-")) {
48
export const validateRefspec = Effect.fn("DelegationPush.validateRefspec")(function* (
49
  refspec: string,
50
  branch: string,
51
) {
52
  if (refspec === "") {
53
    return yield* new InputError({ message: "The refspec is empty." });
54
  }
55
  if (/[\s,]/u.test(refspec)) {
56
    return yield* new InputError({
57
      message: `Multi-ref push is not allowed: ${redactSecret(refspec)}`,
58
    });
59
  }
60
  if (refspec.startsWith("+") || refspec.startsWith("-")) {
61
    return yield* new InputError({
62
      message: `Force or option refspecs are not allowed: ${redactSecret(refspec)}`,
63
    });
64
  }
65
  const target = canonicalBranch(branch);
66
  const colon = refspec.indexOf(":");
67
  if (colon >= 0) {
68
    const src = refspec.slice(0, colon);
69
    const dst = refspec.slice(colon + 1);
70
    if (dst === "") {
59 71
      return yield* new InputError({
60
        message: `Force or option refspecs are not allowed: ${redactSecret(refspec)}`,
72
        message: `A refspec with an empty destination is not allowed: ${redactSecret(refspec)}`,
61 73
      });
62 74
    }
63
    const target = canonicalBranch(branch);
64
    const colon = refspec.indexOf(":");
65
    if (colon >= 0) {
66
      const src = refspec.slice(0, colon);
67
      const dst = refspec.slice(colon + 1);
68
      if (dst === "") {
69
        return yield* new InputError({
70
          message: `A refspec with an empty destination is not allowed: ${redactSecret(refspec)}`,
71
        });
72
      }
73
      const srcCanonical = toCanonical(src, branch);
74
      const dstCanonical = toCanonical(dst, branch);
75
      if (srcCanonical === undefined || dstCanonical === undefined || srcCanonical !== dstCanonical) {
76
        return yield* new InputError({
77
          message: `Refspec ${redactSecret(refspec)} is not the assigned branch ${target}.`,
78
        });
79
      }
80
      return;
81
    }
82
    if (toCanonical(refspec, branch) === undefined) {
75
    const srcCanonical = toCanonical(src, branch);
76
    const dstCanonical = toCanonical(dst, branch);
77
    if (srcCanonical === undefined || dstCanonical === undefined || srcCanonical !== dstCanonical) {
83 78
      return yield* new InputError({
84 79
        message: `Refspec ${redactSecret(refspec)} is not the assigned branch ${target}.`,
85 80
      });
86 81
    }
87
  },
88
);
82
    return;
83
  }
84
  if (toCanonical(refspec, branch) === undefined) {
85
    return yield* new InputError({
86
      message: `Refspec ${redactSecret(refspec)} is not the assigned branch ${target}.`,
87
    });
88
  }
89
});
89 90
90 91
const getRemoteUrl = (
91 92
  directory: string,

@@ -114,9 +115,7 @@ const getRemoteUrl = (

114 115
    catch: (cause) =>
115 116
      new GitExecutionError({
116 117
        operation: "git remote get-url",
117
        message: redactSecret(
118
          cause instanceof Error ? cause.message : String(cause),
119
        ),
118
        message: redactSecret(cause instanceof Error ? cause.message : String(cause)),
120 119
      }),
121 120
  });
122 121

@@ -137,11 +136,7 @@ const runGit = (

137 136
          stdio: ["ignore", "pipe", "pipe"],
138 137
        });
139 138
        child.on("error", (cause) =>
140
          reject(
141
            new Error(
142
              `git ${operation} could not start: ${redactSecret(cause.message)}`,
143
            ),
144
          ),
139
          reject(new Error(`git ${operation} could not start: ${redactSecret(cause.message)}`)),
145 140
        );
146 141
        child.stderr.on("data", (chunk: Buffer) => {
147 142
          if (stderr.length < 16_384) {

@@ -158,9 +153,7 @@ const runGit = (

158 153
    catch: (cause) =>
159 154
      new GitExecutionError({
160 155
        operation: `git ${operation}`,
161
        message: redactSecret(
162
          cause instanceof Error ? cause.message : String(cause),
163
        ),
156
        message: redactSecret(cause instanceof Error ? cause.message : String(cause)),
164 157
      }),
165 158
  });
166 159
packages/openagents-cli/test/coder-delegate-devin.test.ts modified +13 -8

@@ -12,13 +12,15 @@ import { DevinHarness, type DelegateEvent } from "../src/coder-delegate.js";

12 12
 * stdio, replying to `initialize`, `session/new`, and `session/prompt`, and
13 13
 * sending whatever `session/update` notifications the test asks for.
14 14
 */
15
const fakeAgent = (options: {
16
  readonly updates?: ReadonlyArray<Record<string, unknown>>;
17
  readonly answer?: string;
18
  readonly failPrompt?: string;
19
  readonly hang?: boolean;
20
  readonly recordArgsTo?: string;
21
} = {}): string => {
15
const fakeAgent = (
16
  options: {
17
    readonly updates?: ReadonlyArray<Record<string, unknown>>;
18
    readonly answer?: string;
19
    readonly failPrompt?: string;
20
    readonly hang?: boolean;
21
    readonly recordArgsTo?: string;
22
  } = {},
23
): string => {
22 24
  const directory = mkdtempSync(join(tmpdir(), "devin-acp-"));
23 25
  const script = join(directory, "agent.mjs");
24 26

@@ -78,7 +80,10 @@ const collect = async (

78 80
): Promise<ReadonlyArray<DelegateEvent>> => {
79 81
  const events: DelegateEvent[] = [];
80 82
  const transcriptPath = join(mkdtempSync(join(tmpdir(), "devin-t-")), "child.jsonl");
81
  for await (const event of harness.run({ prompt: "do the thing", cwd, transcriptPath }, new AbortController().signal)) {
83
  for await (const event of harness.run(
84
    { prompt: "do the thing", cwd, transcriptPath },
85
    new AbortController().signal,
86
  )) {
82 87
    events.push(event);
83 88
  }
84 89
  return events;
packages/openagents-cli/test/coder-delegate.test.ts modified +14 -9

@@ -362,14 +362,14 @@ describe("fleet rendering", () => {

362 362
      agent: "opencode",
363 363
      model: "fake/model",
364 364
      cwd: "/tmp",
365
        background: true,
366
      },
367
      // A fixed start, because the completion below is a fixed instant too and
368
      // the pair is what makes the duration deterministic. Started at
369
      // `Date.now()` and completed at epoch 4000, the child finished
370
      // fifty-six years before it began and the duration clamped to zero.
371
      1_000,
372
    );
365
      background: true,
366
    },
367
    // A fixed start, because the completion below is a fixed instant too and
368
    // the pair is what makes the duration deterministic. Started at
369
    // `Date.now()` and completed at epoch 4000, the child finished
370
    // fifty-six years before it began and the duration clamped to zero.
371
    1_000,
372
  );
373 373
  registry.start(task.id, new AbortController());
374 374
  registry.recordToolUse(task.id, { toolName: "bash", target: "pnpm test" });
375 375
  registry.recordTokens(task.id, { input: 8000, output: 214 });

@@ -553,7 +553,12 @@ describe("retrying a child whose provider dropped", () => {

553 553
          yield { type: "session", sessionId: options.session };
554 554
        }
555 555
        for (let index = 0; index < (options.toolsBeforeFailing ?? 0); index += 1) {
556
          yield { type: "tool", callId: `c${String(seen)}-${String(index)}`, name: "read", target: "f" };
556
          yield {
557
            type: "tool",
558
            callId: `c${String(seen)}-${String(index)}`,
559
            name: "read",
560
            target: "f",
561
          };
557 562
        }
558 563
        if (seen <= failures) {
559 564
          yield { type: "error", message };
packages/openagents-cli/test/coder-local-thread.test.ts added +131

@@ -0,0 +1,131 @@

1
import { afterEach, describe, expect, it, vi } from "vitest";
2
3
import {
4
  openLocalThread,
5
  threadAnnouncement,
6
  threadSyncWanted,
7
} from "../src/coder-local-thread.js";
8
9
const ORIGIN = "https://openagents.test";
10
const TOKEN = "oa_pat_account";
11
const THREAD_ID = "9bb19447-ecf4-4f1b-b44e-6b128664da9c";
12
13
const json = (status: number, body: unknown) =>
14
  new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
15
16
afterEach(() => {
17
  vi.unstubAllGlobals();
18
});
19
20
describe("whether the local lane reports at all", () => {
21
  it("is on by default", () => {
22
    expect(threadSyncWanted({})).toBe(true);
23
  });
24
25
  it("is off when OPENAGENTS_THREAD_SYNC says off", () => {
26
    expect(threadSyncWanted({ OPENAGENTS_THREAD_SYNC: "off" })).toBe(false);
27
    expect(threadSyncWanted({ OPENAGENTS_THREAD_SYNC: "OFF" })).toBe(false);
28
  });
29
30
  it("stays on for any other value, so only the documented word disables it", () => {
31
    expect(threadSyncWanted({ OPENAGENTS_THREAD_SYNC: "on" })).toBe(true);
32
    expect(threadSyncWanted({ OPENAGENTS_THREAD_SYNC: "" })).toBe(true);
33
  });
34
});
35
36
describe("opening a transcript-only thread", () => {
37
  it("posts the local lane and the vendor model, and takes the thread without a grant", async () => {
38
    const fetchMock = vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => {
39
      expect(String(input)).toBe(`${ORIGIN}/api/v1/threads`);
40
      expect(init?.method).toBe("POST");
41
      const headers = init?.headers as Record<string, string>;
42
      expect(headers["authorization"]).toBe(`Bearer ${TOKEN}`);
43
      const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
44
      expect(body).toEqual({
45
        objective: "openagents coder in octavia/project on main",
46
        repository: "octavia/project",
47
        model: "ollama:qwen3.8:27b-mtp-q8_0",
48
        lane: "local",
49
      });
50
      // The server admits the record and mints no authority: a thread, no
51
      // grant. The client must neither expect nor use one.
52
      return json(201, { thread: { id: THREAD_ID, status: "open" } });
53
    });
54
    vi.stubGlobal("fetch", fetchMock);
55
56
    const opened = await openLocalThread({
57
      origin: ORIGIN,
58
      token: TOKEN,
59
      objective: "openagents coder in octavia/project on main",
60
      repository: "octavia/project",
61
      model: "ollama:qwen3.8:27b-mtp-q8_0",
62
    });
63
64
    expect(fetchMock).toHaveBeenCalledTimes(1);
65
    expect(opened).toEqual({ threadId: THREAD_ID });
66
  });
67
68
  it("carries the reasoning level when the session named one", async () => {
69
    const fetchMock = vi.fn(async (_input: URL | RequestInfo, init?: RequestInit) => {
70
      const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
71
      expect(body["reasoning"]).toBe("high");
72
      return json(201, { thread: { id: THREAD_ID } });
73
    });
74
    vi.stubGlobal("fetch", fetchMock);
75
76
    await openLocalThread({
77
      origin: ORIGIN,
78
      token: TOKEN,
79
      objective: "work",
80
      model: "ollama:qwen3.8",
81
      reasoning: "high",
82
    });
83
84
    expect(fetchMock).toHaveBeenCalledTimes(1);
85
  });
86
87
  it("degrades silently when the server cannot be reached", async () => {
88
    vi.stubGlobal(
89
      "fetch",
90
      vi.fn(async () => {
91
        throw new TypeError("fetch failed");
92
      }),
93
    );
94
95
    await expect(
96
      openLocalThread({ origin: ORIGIN, token: TOKEN, objective: "work", model: "ollama:q" }),
97
    ).resolves.toBeUndefined();
98
  });
99
100
  it("degrades silently when the server refuses", async () => {
101
    vi.stubGlobal(
102
      "fetch",
103
      vi.fn(async () => json(422, { code: "lane_unknown", message: "no such lane" })),
104
    );
105
106
    await expect(
107
      openLocalThread({ origin: ORIGIN, token: TOKEN, objective: "work", model: "ollama:q" }),
108
    ).resolves.toBeUndefined();
109
  });
110
111
  it("degrades silently when the response carries no thread id", async () => {
112
    vi.stubGlobal(
113
      "fetch",
114
      vi.fn(async () => json(201, {})),
115
    );
116
117
    await expect(
118
      openLocalThread({ origin: ORIGIN, token: TOKEN, objective: "work", model: "ollama:q" }),
119
    ).resolves.toBeUndefined();
120
  });
121
});
122
123
describe("the thread announcement", () => {
124
  it("is exactly the line the Gym adapter parses", () => {
125
    const line = threadAnnouncement(THREAD_ID);
126
    expect(line).toBe(`[oa:thread ${THREAD_ID}]`);
127
    // The adapter's regex, verbatim (OpenAgentsInc/openagents#38). The format
128
    // is a contract: a drifted line unlinks every trial from its thread.
129
    expect(/\[oa:thread ([0-9a-fA-F-]{36})\]/.exec(line)?.[1]).toBe(THREAD_ID);
130
  });
131
});
packages/openagents-cli/test/coder-ollama.test.ts modified +118

@@ -576,3 +576,121 @@ describe("which lane a session opens on", () => {

576 576
    ]);
577 577
  });
578 578
});
579
580
const recorder = () => {
581
  const events: Array<{ eventType: string; payload: Record<string, unknown> }> = [];
582
  return {
583
    events,
584
    record(eventType: string, payload: Record<string, unknown>) {
585
      events.push({ eventType, payload });
586
    },
587
  };
588
};
589
590
describe("the local lane's durable transcript", () => {
591
  const ROUNDS = [
592
    [
593
      chunk({ thinking: "Delegate it." }),
594
      chunk({
595
        content: "",
596
        tool_calls: [{ function: { name: "delegate", arguments: { prompt: "say PONG" } } }],
597
      }),
598
      { message: {}, done: true, prompt_eval_count: 100, eval_count: 10 },
599
    ],
600
    [
601
      {
602
        message: { content: "They said PONG." },
603
        done: true,
604
        prompt_eval_count: 200,
605
        eval_count: 20,
606
      },
607
    ],
608
  ] as never;
609
610
  it("records the turn in order: what was asked, the reasoning, each tool run, the answer", async () => {
611
    const calls: Record<string, unknown>[] = [];
612
    const { source } = sourceWith(ROUNDS);
613
    const sink = recorder();
614
    source.useTranscript(sink);
615
    source.useTools([delegate(calls)]);
616
617
    await collect(source, "delegate this");
618
619
    // The thread lane's vocabulary exactly, so `/threads/:id`, the export,
620
    // and a resume read a local session as they read a hosted one.
621
    expect(sink.events.map((event) => event.eventType)).toEqual([
622
      "turn.user",
623
      "turn.reasoning",
624
      "tool.ran",
625
      "turn.assistant",
626
    ]);
627
    expect(sink.events[0]?.payload).toEqual({ text: "delegate this" });
628
    expect(sink.events[1]?.payload).toEqual({ text: "Delegate it." });
629
    expect(sink.events[2]?.payload).toEqual({
630
      call_id: "delegate-1",
631
      tool: "delegate",
632
      arguments: JSON.stringify({ prompt: "say PONG" }, undefined, 2),
633
      status: "succeeded",
634
      output: "child 1 said PONG",
635
    });
636
  });
637
638
  it("records the answer with the turn's summed usage and its call count", async () => {
639
    const calls: Record<string, unknown>[] = [];
640
    const { source } = sourceWith(ROUNDS);
641
    const sink = recorder();
642
    source.useTranscript(sink);
643
    source.useTools([delegate(calls)]);
644
645
    await collect(source, "delegate this");
646
647
    const answer = sink.events.find((event) => event.eventType === "turn.assistant");
648
    // Two model calls in one turn: the record holds their sum with the
649
    // count, not the last round's figures presented as the turn's.
650
    expect(answer?.payload).toEqual({
651
      text: "They said PONG.",
652
      usage: { prompt_tokens: 300, completion_tokens: 30, total_tokens: 330, calls: 2 },
653
      tool_calls: 1,
654
    });
655
  });
656
657
  it("records a failed tool as one event carrying its error", async () => {
658
    const { source } = sourceWith(ROUNDS);
659
    const sink = recorder();
660
    source.useTranscript(sink);
661
    source.useTools([
662
      {
663
        name: "delegate",
664
        description: "Run a prompt on child agents.",
665
        parameters: { type: "object" },
666
        run: () => Promise.reject(new Error("the fleet is full")),
667
      },
668
    ]);
669
670
    await collect(source, "delegate this");
671
672
    const ran = sink.events.find((event) => event.eventType === "tool.ran");
673
    expect(ran?.payload).toMatchObject({
674
      call_id: "delegate-1",
675
      tool: "delegate",
676
      status: "failed",
677
      error: "the fleet is full",
678
    });
679
  });
680
681
  it("records nothing when no thread is attached, exactly as before", async () => {
682
    const calls: Record<string, unknown>[] = [];
683
    const { source } = sourceWith(ROUNDS);
684
    source.useTools([delegate(calls)]);
685
686
    // A session without a token, api-url, or reachable server attaches no
687
    // sink, and the turn loop must not know the difference.
688
    const chunks = await collect(source, "delegate this");
689
690
    expect(calls).toEqual([{ prompt: "say PONG" }]);
691
    expect(chunks.filter((piece) => piece.type !== "usage").at(-1)).toEqual({
692
      type: "text",
693
      value: "They said PONG.",
694
    });
695
  });
696
});
packages/openagents-cli/test/coder-thread.test.ts modified +11 -5

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

1 1
import { afterEach, describe, expect, it, vi } from "vitest";
2 2
3 3
import type { ReplyChunk } from "../src/coder-session.js";
4
import { openThread, resolveProxyUrl, ThreadReplySource, ThreadUnavailable } from "../src/coder-thread.js";
4
import {
5
  openThread,
6
  resolveProxyUrl,
7
  ThreadReplySource,
8
  ThreadUnavailable,
9
} from "../src/coder-thread.js";
5 10
import { Redacted } from "effect";
6 11
import { shellTool } from "../src/coder-tools.js";
7 12
import { ThreadTranscriptWriter } from "../src/coder-transcript.js";

@@ -789,9 +794,7 @@ describe("the thread's durable transcript", () => {

789 794
    // the same report the local lane yields, so the ATIF export's step
790 795
    // metrics and final totals hold on every lane.
791 796
    const usage = received.filter((chunk) => chunk.type === "usage");
792
    expect(usage).toEqual([
793
      { type: "usage", promptTokens: 100, completionTokens: 16, calls: 2 },
794
    ]);
797
    expect(usage).toEqual([{ type: "usage", promptTokens: 100, completionTokens: 16, calls: 2 }]);
795 798
  });
796 799
797 800
  it("records a failed tool as one event carrying its error", async () => {

@@ -1103,7 +1106,10 @@ describe("ThreadReplySource toolDefinitions", () => {

1103 1106
describe("resolveProxyUrl", () => {
1104 1107
  it("resolves the grant's path against the client's origin", () => {
1105 1108
    expect(
1106
      resolveProxyUrl("http://localhost:4000/api/inference/proxy", "http://host.docker.internal:4000"),
1109
      resolveProxyUrl(
1110
        "http://localhost:4000/api/inference/proxy",
1111
        "http://host.docker.internal:4000",
1112
      ),
1107 1113
    ).toBe("http://host.docker.internal:4000/api/inference/proxy");
1108 1114
  });
1109 1115
packages/openagents-cli/test/delegation-push.test.ts modified +27 -53

@@ -1,21 +1,10 @@

1 1
import { Effect, Redacted } from "effect";
2
import {
3
  chmodSync,
4
  mkdtempSync,
5
  readFileSync,
6
  readdirSync,
7
  rmSync,
8
  writeFileSync,
9
} from "node:fs";
2
import { chmodSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
10 3
import { join } from "node:path";
11 4
import { tmpdir } from "node:os";
12 5
import { afterEach, beforeEach, describe, expect, it } from "vitest";
13 6
14
import {
15
  pushDelegated,
16
  redactSecret,
17
  validateRefspec,
18
} from "../src/delegation-push.js";
7
import { pushDelegated, redactSecret, validateRefspec } from "../src/delegation-push.js";
19 8
20 9
const CANARY = "oa_assignment_canary_12345";
21 10

@@ -67,9 +56,7 @@ describe("delegated push refspec validation", () => {

67 56
      Effect.runPromise(validateRefspec("refs/heads/feature-1", "feature-1")),
68 57
    ).resolves.toBeUndefined();
69 58
    await expect(
70
      Effect.runPromise(
71
        validateRefspec("refs/heads/feature-1:refs/heads/feature-1", "feature-1"),
72
      ),
59
      Effect.runPromise(validateRefspec("refs/heads/feature-1:refs/heads/feature-1", "feature-1")),
73 60
    ).resolves.toBeUndefined();
74 61
    await expect(
75 62
      Effect.runPromise(validateRefspec("feature-1:feature-1", "feature-1")),

@@ -77,49 +64,41 @@ describe("delegated push refspec validation", () => {

77 64
  });
78 65
79 66
  it("refuses an unauthorized branch, a mismatched src:dst, and invalid forms", async () => {
80
    await expect(
81
      Effect.runPromise(validateRefspec("main", "feature-1")),
82
    ).rejects.toThrow(/not the assigned branch/);
67
    await expect(Effect.runPromise(validateRefspec("main", "feature-1"))).rejects.toThrow(
68
      /not the assigned branch/,
69
    );
83 70
    await expect(
84 71
      Effect.runPromise(validateRefspec("refs/heads/main", "feature-1")),
85 72
    ).rejects.toThrow(/not the assigned branch/);
86 73
    await expect(
87
      Effect.runPromise(
88
        validateRefspec("refs/heads/feature-1:refs/heads/main", "feature-1"),
89
      ),
90
    ).rejects.toThrow(/not the assigned branch/);
91
    await expect(
92
      Effect.runPromise(validateRefspec("refs/tags/v1", "feature-1")),
74
      Effect.runPromise(validateRefspec("refs/heads/feature-1:refs/heads/main", "feature-1")),
93 75
    ).rejects.toThrow(/not the assigned branch/);
94
    await expect(
95
      Effect.runPromise(validateRefspec("", "feature-1")),
96
    ).rejects.toThrow(/empty/);
97
    await expect(
98
      Effect.runPromise(validateRefspec("+feature-1", "feature-1")),
99
    ).rejects.toThrow(/Force or option/);
100
    await expect(
101
      Effect.runPromise(validateRefspec("feature-1:", "feature-1")),
102
    ).rejects.toThrow(/empty destination/);
76
    await expect(Effect.runPromise(validateRefspec("refs/tags/v1", "feature-1"))).rejects.toThrow(
77
      /not the assigned branch/,
78
    );
79
    await expect(Effect.runPromise(validateRefspec("", "feature-1"))).rejects.toThrow(/empty/);
80
    await expect(Effect.runPromise(validateRefspec("+feature-1", "feature-1"))).rejects.toThrow(
81
      /Force or option/,
82
    );
83
    await expect(Effect.runPromise(validateRefspec("feature-1:", "feature-1"))).rejects.toThrow(
84
      /empty destination/,
85
    );
103 86
  });
104 87
105 88
  it("refuses a multi-ref push", async () => {
106
    await expect(
107
      Effect.runPromise(validateRefspec("feature-1 main", "feature-1")),
108
    ).rejects.toThrow(/Multi-ref/);
109
    await expect(
110
      Effect.runPromise(validateRefspec("feature-1,main", "feature-1")),
111
    ).rejects.toThrow(/Multi-ref/);
89
    await expect(Effect.runPromise(validateRefspec("feature-1 main", "feature-1"))).rejects.toThrow(
90
      /Multi-ref/,
91
    );
92
    await expect(Effect.runPromise(validateRefspec("feature-1,main", "feature-1"))).rejects.toThrow(
93
      /Multi-ref/,
94
    );
112 95
  });
113 96
});
114 97
115 98
describe("delegated push credential redaction", () => {
116 99
  it("redacts known credential patterns", () => {
117
    expect(redactSecret("token oa_assignment_canary_12345 here")).not.toContain(
118
      CANARY,
119
    );
120
    expect(redactSecret("token oa_assignment_canary_12345 here")).toContain(
121
      "[REDACTED]",
122
    );
100
    expect(redactSecret("token oa_assignment_canary_12345 here")).not.toContain(CANARY);
101
    expect(redactSecret("token oa_assignment_canary_12345 here")).toContain("[REDACTED]");
123 102
    const auth = redactSecret("Authorization: Bearer abc.def");
124 103
    expect(auth).not.toContain("abc.def");
125 104
    expect(auth).toContain("[REDACTED]");

@@ -133,10 +112,7 @@ describe("delegated push lifecycle", () => {

133 112
134 113
  const cleanupTemp = (): void => {
135 114
    for (const name of readdirSync(tmpdir())) {
136
      if (
137
        name.startsWith("oa-delegation-push-") ||
138
        name.startsWith("oa-delegation-test-root-")
139
      ) {
115
      if (name.startsWith("oa-delegation-push-") || name.startsWith("oa-delegation-test-root-")) {
140 116
        try {
141 117
          rmSync(join(tmpdir(), name), { recursive: true, force: true });
142 118
        } catch {

@@ -254,9 +230,7 @@ describe("delegated push lifecycle", () => {

254 230
    expect(message).toContain("git push failed");
255 231
    expect(message).not.toContain(CANARY);
256 232
    expect(message).toContain("[REDACTED]");
257
    expect(
258
      readdirSync(tmpdir()).some((n) => n.startsWith("oa-delegation-push-")),
259
    ).toBe(false);
233
    expect(readdirSync(tmpdir()).some((n) => n.startsWith("oa-delegation-push-"))).toBe(false);
260 234
  });
261 235
});
262 236

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