Record plugin lifecycle and invocation provenance in ATIF exports

f65066b3cf2f · AtlantisPleb · · parent 022ebbd9330a

Record plugin lifecycle and invocation provenance in ATIF exports

A trajectory reader could not tell a plugin ran, which plugin, or which
exact artifact: the load survived only as free text in extra.notices with
a truncated digest, and a plugin-backed call was indistinguishable from
any other tool call. Per OpenAgentsInc/openagents#32:

- `/plugin load` (success or refusal) now lands on the session as a typed
  occurrence — a side-channel on the snapshot, not a transcript entry, so
  no renderer changes — and `/export` writes it as a `source: "system"`
  step ordered by timestamp among the turns, with `observation.results[0]`
  carrying `source_call_id: null` and a typed `extra`:
  `{event, code?, plugin: {name, version, artifact_digest, bytes, abi,
  timeout_ms, capabilities, manifest_path, tool_name}}`.
- A loaded plugin registers its tool, and every call of that tool is
  stamped at entry-creation with `{name, version, artifactDigest}`, so a
  reload mid-session cannot rewrite what earlier calls ran. The export
  writes it as `tool_calls[].extra.plugin` with the full digest.
- Digests are whole (`sha256:` + 64 hex) everywhere machine-read; the
  prose notices stay truncated, and extra.notices is unchanged.

`pluginIdentity()` is a small read-only accessor on coder-plugins.ts; the
host itself is untouched.

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

Diff

6 files changed, +568 -20

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

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

68 68
  describeLoad,
69 69
  isRefusal,
70 70
  loadPluginFromManifest,
71
  pluginIdentity,
71 72
  pluginTool,
72 73
  type LoadedPlugin,
73 74
} from "./coder-plugins.js";

@@ -2086,17 +2087,48 @@ const coderCommand = Command.make(

2086 2087
      declareTools();
2087 2088
2088 2089
      const loadPlugin = (manifestPath: string): string => {
2089
        const outcome = loadPluginFromManifest(resolvePath(process.cwd(), manifestPath));
2090
        if (!isRefusal(outcome)) {
2091
          // Reloading a name replaces it: a demo iterates on one plugin, and
2092
          // two tools with one name would be a declaration the model cannot
2093
          // tell apart.
2094
          const at = plugins.findIndex((held) => held.manifest.name === outcome.manifest.name);
2095
          if (at >= 0) plugins.splice(at, 1);
2096
          plugins.push(outcome);
2097
          declareTools();
2090
        const manifestFile = resolvePath(process.cwd(), manifestPath);
2091
        const outcome = loadPluginFromManifest(manifestFile);
2092
        const described = describeLoad(outcome);
2093
        // The load is recorded both ways on purpose: the notice the interface
2094
        // shows stays interface chatter, and the typed occurrence on the
2095
        // session is what `/export` writes as a `source: "system"` step — a
2096
        // capability-surface change with the full digest, ordered among the
2097
        // turns. Refusals are recorded too; a trajectory that omits the load
2098
        // that failed cannot explain the session that follows it.
2099
        if (isRefusal(outcome)) {
2100
          session.recordPluginEvent({
2101
            message: described,
2102
            event: "plugin_load_refused",
2103
            code: outcome.code,
2104
            plugin: { manifestPath: manifestFile },
2105
          });
2106
          return described;
2098 2107
        }
2099
        return describeLoad(outcome);
2108
        // Reloading a name replaces it: a demo iterates on one plugin, and
2109
        // two tools with one name would be a declaration the model cannot
2110
        // tell apart.
2111
        const at = plugins.findIndex((held) => held.manifest.name === outcome.manifest.name);
2112
        if (at >= 0) plugins.splice(at, 1);
2113
        plugins.push(outcome);
2114
        declareTools();
2115
        const identity = pluginIdentity(outcome);
2116
        session.recordPluginEvent({
2117
          message: described,
2118
          event: "plugin_loaded",
2119
          plugin: {
2120
            name: identity.name,
2121
            version: identity.version,
2122
            artifactDigest: identity.artifactDigest,
2123
            bytes: identity.bytes,
2124
            abi: identity.abi,
2125
            timeoutMs: identity.timeoutMs,
2126
            capabilities: identity.capabilities,
2127
            manifestPath: manifestFile,
2128
            toolName: identity.toolName,
2129
          },
2130
        });
2131
        return described;
2100 2132
      };
2101 2133
2102 2134
      // Delegation is off rather than quietly running children on the
packages/openagents-cli/src/coder-export.ts modified +100 -6

@@ -14,6 +14,11 @@

14 14
 * Notices are the interface talking to the reader, not the model, so they are
15 15
 * not steps -- they are recorded under `extra` where a reader can still see
16 16
 * them without a consumer mistaking them for turns.
17
 *
18
 * Plugin loads are the exception that proves the rule: the notice stays a
19
 * notice, but the act itself changed what the agent could do, so it also
20
 * exports as a `source: "system"` step carrying the typed record -- which
21
 * plugin, which exact artifact, what bounds -- in its observation's `extra`.
17 22
 */
18 23
19 24
import { spawnSync } from "node:child_process";

@@ -21,7 +26,7 @@ import { mkdirSync, writeFileSync } from "node:fs";

21 26
import { homedir } from "node:os";
22 27
import { join } from "node:path";
23 28
24
import type { CoderEntry, CoderSnapshot } from "./coder-session.js";
29
import type { CoderEntry, CoderPluginEvent, CoderSnapshot } from "./coder-session.js";
25 30
26 31
const SCHEMA_VERSION = "ATIF-v1.7";
27 32

@@ -50,8 +55,18 @@ interface AtifStep {

50 55
    tool_call_id: string;
51 56
    function_name: string;
52 57
    arguments: Record<string, unknown>;
58
    /** Per-call metadata the format leaves open. Plugin provenance goes here. */
59
    extra?: Record<string, unknown>;
53 60
  }>;
54
  observation?: { results: ReadonlyArray<{ source_call_id: string; content: string }> };
61
  observation?: {
62
    results: ReadonlyArray<{
63
      /** Null for a system-initiated operation, which no tool call sourced. */
64
      source_call_id: string | null;
65
      content: string;
66
      /** Result-level metadata the format leaves open. */
67
      extra?: Record<string, unknown>;
68
    }>;
69
  };
55 70
}
56 71
57 72
/**

@@ -126,13 +141,71 @@ const metricsOf = (entry: CoderEntry): Partial<AtifStep> => {

126 141
  };
127 142
};
128 143
144
/**
145
 * The typed half of a plugin lifecycle step, in the shape ATIF's `extra`
146
 * fields carry it.
147
 *
148
 * The digest is written whole. The notices truncate it for a human eye;
149
 * anything a machine reads gets the full `sha256:<hex>`, because a truncated
150
 * digest identifies nothing.
151
 */
152
const pluginEventExtra = (event: CoderPluginEvent): Record<string, unknown> => {
153
  const plugin = event.plugin;
154
  return {
155
    event: event.event,
156
    ...(event.code === undefined ? {} : { code: event.code }),
157
    plugin: {
158
      ...(plugin.name === undefined ? {} : { name: plugin.name }),
159
      ...(plugin.version === undefined ? {} : { version: plugin.version }),
160
      ...(plugin.artifactDigest === undefined ? {} : { artifact_digest: plugin.artifactDigest }),
161
      ...(plugin.bytes === undefined ? {} : { bytes: plugin.bytes }),
162
      ...(plugin.abi === undefined ? {} : { abi: plugin.abi }),
163
      ...(plugin.timeoutMs === undefined ? {} : { timeout_ms: plugin.timeoutMs }),
164
      ...(plugin.capabilities === undefined ? {} : { capabilities: plugin.capabilities }),
165
      manifest_path: plugin.manifestPath,
166
      ...(plugin.toolName === undefined ? {} : { tool_name: plugin.toolName }),
167
    },
168
  };
169
};
170
129 171
/** Fold the transcript into ATIF steps. */
130
const stepsOf = (entries: ReadonlyArray<CoderEntry>, model: string): ReadonlyArray<AtifStep> => {
172
const stepsOf = (
173
  entries: ReadonlyArray<CoderEntry>,
174
  pluginEvents: ReadonlyArray<CoderPluginEvent>,
175
  model: string,
176
): ReadonlyArray<AtifStep> => {
131 177
  const steps: AtifStep[] = [];
132 178
  /** Reasoning arrives before the turn it belongs to and attaches to it. */
133 179
  let pendingReasoning: string | undefined;
180
  /** The next plugin event still waiting for its place among the turns. */
181
  let nextEvent = 0;
182
183
  // A plugin load is a system-initiated capability change, and ATIF v1.5+
184
  // gives it a home: a `source: "system"` step whose observation carries the
185
  // typed record. It lands where it happened — between the turns on either
186
  // side of it — so a consumer replaying the steps sees the capability appear
187
  // before the call that used it.
188
  const emitEventsThrough = (at: number) => {
189
    while (nextEvent < pluginEvents.length) {
190
      const event = pluginEvents[nextEvent];
191
      if (event === undefined || event.at > at) break;
192
      steps.push({
193
        step_id: steps.length + 1,
194
        timestamp: new Date(event.at).toISOString(),
195
        source: "system",
196
        message: event.message,
197
        observation: {
198
          results: [
199
            { source_call_id: null, content: event.message, extra: pluginEventExtra(event) },
200
          ],
201
        },
202
      });
203
      nextEvent += 1;
204
    }
205
  };
134 206
135 207
  for (const entry of entries) {
208
    emitEventsThrough(entry.at);
136 209
    const timestamp = new Date(entry.at).toISOString();
137 210
138 211
    if (entry.role === "notice") continue;

@@ -150,7 +223,7 @@ const stepsOf = (entries: ReadonlyArray<CoderEntry>, model: string): ReadonlyArr

150 223
    }
151 224
152 225
    if (entry.role === "tool" && entry.tool !== undefined) {
153
      const { callId, name, arguments: args, output, error } = entry.tool;
226
      const { callId, name, arguments: args, output, error, plugin } = entry.tool;
154 227
      steps.push({
155 228
        step_id: steps.length + 1,
156 229
        timestamp,

@@ -159,7 +232,25 @@ const stepsOf = (entries: ReadonlyArray<CoderEntry>, model: string): ReadonlyArr

159 232
        model_name: model,
160 233
        ...(pendingReasoning === undefined ? {} : { reasoning_content: pendingReasoning }),
161 234
        tool_calls: [
162
          { tool_call_id: callId, function_name: name, arguments: argumentsOf(args) },
235
          {
236
            tool_call_id: callId,
237
            function_name: name,
238
            arguments: argumentsOf(args),
239
            // A plugin-backed call names the exact artifact that answered it,
240
            // digest whole, so a trace feeds usage attribution the same way a
241
            // thread's `tool.ran` event does.
242
            ...(plugin === undefined
243
              ? {}
244
              : {
245
                  extra: {
246
                    plugin: {
247
                      name: plugin.name,
248
                      version: plugin.version,
249
                      artifact_digest: plugin.artifactDigest,
250
                    },
251
                  },
252
                }),
253
          },
163 254
        ],
164 255
        observation: {
165 256
          results: [{ source_call_id: callId, content: error ?? output ?? "" }],

@@ -187,6 +278,9 @@ const stepsOf = (entries: ReadonlyArray<CoderEntry>, model: string): ReadonlyArr

187 278
    }
188 279
  }
189 280
281
  // A load after the last turn still happened in this session.
282
  emitEventsThrough(Number.POSITIVE_INFINITY);
283
190 284
  return steps;
191 285
};
192 286

@@ -248,7 +342,7 @@ export function exportTrajectory(

248 342
): ExportedTrajectory {
249 343
  const at = options.now ?? new Date();
250 344
  const directory = options.directory ?? join(homedir(), ".openagents", "exports");
251
  const steps = stepsOf(snapshot.entries, options.model);
345
  const steps = stepsOf(snapshot.entries, snapshot.pluginEvents ?? [], options.model);
252 346
253 347
  const document = {
254 348
    schema_version: SCHEMA_VERSION,
packages/openagents-cli/src/coder-plugins.ts modified +42

@@ -393,6 +393,48 @@ export function pluginTool(plugin: LoadedPlugin): CoderTool {

393 393
  };
394 394
}
395 395
396
/**
397
 * The loaded plugin's identity, for provenance records.
398
 *
399
 * Everything a receipt or a trajectory needs to say which exact artifact ran:
400
 * the digest here is the full `sha256:<hex>` the host verified, never the
401
 * truncated form the prose notices show.
402
 */
403
export interface PluginIdentity {
404
  readonly name: string;
405
  readonly version: string;
406
  /** The verified digest, full `sha256:<hex>`. */
407
  readonly artifactDigest: string;
408
  /** The artifact's size in bytes. */
409
  readonly bytes: number;
410
  readonly abi: { readonly entry: string; readonly alloc: string };
411
  readonly timeoutMs: number;
412
  readonly capabilities: {
413
    readonly mounts: ReadonlyArray<unknown>;
414
    readonly hosts: ReadonlyArray<unknown>;
415
  };
416
  /** The tool the plugin materializes, which is the manifest's name. */
417
  readonly toolName: string;
418
}
419
420
/** Read a loaded plugin's identity, as a provenance record understands it. */
421
export function pluginIdentity(plugin: LoadedPlugin): PluginIdentity {
422
  const { manifest } = plugin;
423
  return {
424
    name: manifest.name,
425
    version: manifest.version,
426
    artifactDigest: plugin.digest,
427
    bytes: plugin.wasm.length,
428
    abi: { entry: manifest.abi.entry, alloc: manifest.abi.alloc },
429
    timeoutMs: manifest.capabilities.timeout_ms,
430
    capabilities: {
431
      mounts: manifest.capabilities.mounts,
432
      hosts: manifest.capabilities.hosts,
433
    },
434
    toolName: manifest.name,
435
  };
436
}
437
396 438
/** What `/plugin load` reports, for a notice or a plain line. */
397 439
export function describeLoad(outcome: LoadedPlugin | PluginRefusal): string {
398 440
  if (isRefusal(outcome)) {
packages/openagents-cli/src/coder-session.ts modified +103

@@ -65,6 +65,60 @@ export interface CoderToolCall {

65 65
  output: string | undefined;
66 66
  error: string | undefined;
67 67
  status: "running" | "succeeded" | "failed";
68
  /**
69
   * Which plugin backed this call, when one did.
70
   *
71
   * Stamped when the call entry is opened, not looked up at export: a plugin
72
   * reloaded mid-session changes what later calls ran, and a call must carry
73
   * the identity of the artifact that actually answered it.
74
   */
75
  readonly plugin?: CoderPluginProvenance;
76
}
77
78
/**
79
 * The identity a plugin-backed tool call carries.
80
 *
81
 * The digest is the full `sha256:<hex>` the host verified. Prose may truncate
82
 * it; anything machine-read carries the whole thing.
83
 */
84
export interface CoderPluginProvenance {
85
  readonly name: string;
86
  readonly version: string;
87
  readonly artifactDigest: string;
88
}
89
90
/**
91
 * A plugin lifecycle occurrence: a `/plugin load` that succeeded or refused.
92
 *
93
 * Not a transcript entry. The notice a load produces is the interface talking
94
 * to the reader and stays a notice; this is the typed record of the same act,
95
 * kept on the side so `/export` can write it as a `source: "system"` step — a
96
 * capability-surface change a trajectory consumer can machine-read — without
97
 * any renderer having to learn a new entry kind.
98
 */
99
export interface CoderPluginEvent {
100
  /** When it happened, in epoch milliseconds, for ordering among the turns. */
101
  readonly at: number;
102
  /** The human notice, exactly as the interface showed it. */
103
  readonly message: string;
104
  readonly event: "plugin_loaded" | "plugin_load_refused";
105
  /** The refusal code, on refusals. */
106
  readonly code?: string | undefined;
107
  readonly plugin: {
108
    readonly name?: string | undefined;
109
    readonly version?: string | undefined;
110
    /** Full `sha256:<hex>`, never truncated. */
111
    readonly artifactDigest?: string | undefined;
112
    readonly bytes?: number | undefined;
113
    readonly abi?: { readonly entry: string; readonly alloc: string } | undefined;
114
    readonly timeoutMs?: number | undefined;
115
    readonly capabilities?:
116
      | { readonly mounts: ReadonlyArray<unknown>; readonly hosts: ReadonlyArray<unknown> }
117
      | undefined;
118
    /** Always known: the path the load was asked for, even when it refused. */
119
    readonly manifestPath: string;
120
    readonly toolName?: string | undefined;
121
  };
68 122
}
69 123
70 124
/**

@@ -158,6 +212,15 @@ export interface CoderSnapshot {

158 212
   * then no renderer draws a fleet at all.
159 213
   */
160 214
  readonly tasks: ReadonlyArray<CoderTask>;
215
  /**
216
   * Plugin loads and refusals, oldest first.
217
   *
218
   * A side-channel rather than transcript entries: renderers ignore it, and
219
   * `/export` merges it into the steps by timestamp. Optional so a snapshot
220
   * built by hand — a test fixture above all — does not have to say "no
221
   * plugins" to be a snapshot.
222
   */
223
  readonly pluginEvents?: ReadonlyArray<CoderPluginEvent>;
161 224
}
162 225
163 226
/** What the session needs in order to delegate. Absent means it cannot. */

@@ -394,6 +457,13 @@ export class CoderSession {

394 457
   * replayed history, so the first new turn must not pay for it again.
395 458
   */
396 459
  private restored = false;
460
  /** Plugin loads and refusals, in the order they happened. */
461
  private readonly pluginEvents: CoderPluginEvent[] = [];
462
  /**
463
   * Which plugin currently backs each tool name, so a tool entry can be
464
   * stamped with its provenance the moment the call arrives.
465
   */
466
  private readonly pluginTools = new Map<string, CoderPluginProvenance>();
397 467
398 468
  constructor(
399 469
    private readonly source: ReplySource,

@@ -453,9 +523,37 @@ export class CoderSession {

453 523
      turns: this.turnCount,
454 524
      budget: this.source.budget,
455 525
      tasks: this.delegation?.registry.list() ?? [],
526
      pluginEvents: this.pluginEvents.map((event) => ({
527
        ...event,
528
        plugin: { ...event.plugin },
529
      })),
456 530
    };
457 531
  }
458 532
533
  /**
534
   * Record a plugin load or refusal as a typed occurrence.
535
   *
536
   * The caller keeps showing its notice however it does — this is the record,
537
   * not the display. A successful load also registers the tool it declared,
538
   * so every later call of that tool carries the plugin's identity; loading a
539
   * name again re-registers it, because the calls after a reload ran the new
540
   * artifact.
541
   */
542
  recordPluginEvent(event: Omit<CoderPluginEvent, "at">): void {
543
    this.pluginEvents.push({ at: Date.now(), ...event });
544
    const { toolName, name, version, artifactDigest } = event.plugin;
545
    if (
546
      event.event === "plugin_loaded" &&
547
      toolName !== undefined &&
548
      name !== undefined &&
549
      version !== undefined &&
550
      artifactDigest !== undefined
551
    ) {
552
      this.pluginTools.set(toolName, { name, version, artifactDigest });
553
    }
554
    this.emit();
555
  }
556
459 557
  /** Whether `/delegate` does anything, which is what the interface reads. */
460 558
  get canDelegate(): boolean {
461 559
    return this.delegation !== undefined;

@@ -758,6 +856,10 @@ export class CoderSession {

758 856
          settle(reasoning);
759 857
          text = undefined;
760 858
          reasoning = undefined;
859
          // A call to a plugin-backed tool carries the plugin's identity from
860
          // the moment it opens, so the record says which artifact answered
861
          // even if the plugin is reloaded before the export.
862
          const provenance = this.pluginTools.get(chunk.name);
761 863
          this.entries.push({
762 864
            role: "tool",
763 865
            text: chunk.name,

@@ -770,6 +872,7 @@ export class CoderSession {

770 872
              output: undefined,
771 873
              error: undefined,
772 874
              status: "running",
875
              ...(provenance === undefined ? {} : { plugin: provenance }),
773 876
            },
774 877
          });
775 878
        } else if (chunk.type === "usage") {
packages/openagents-cli/test/coder-export.test.ts modified +192 -4

@@ -4,7 +4,7 @@ import { join } from "node:path";

4 4
import { describe, expect, it } from "vitest";
5 5
6 6
import { exportTrajectory } from "../src/coder-export.js";
7
import type { CoderEntry, CoderSnapshot } from "../src/coder-session.js";
7
import type { CoderEntry, CoderPluginEvent, CoderSnapshot } from "../src/coder-session.js";
8 8
9 9
const AT = Date.parse("2026-08-24T14:00:00.000Z");
10 10

@@ -15,7 +15,10 @@ const entry = (partial: Partial<CoderEntry> & Pick<CoderEntry, "role">): CoderEn

15 15
  ...partial,
16 16
});
17 17
18
const snapshot = (entries: ReadonlyArray<CoderEntry>): CoderSnapshot =>
18
const snapshot = (
19
  entries: ReadonlyArray<CoderEntry>,
20
  pluginEvents?: ReadonlyArray<CoderPluginEvent>,
21
): CoderSnapshot =>
19 22
  ({
20 23
    entries,
21 24
    repository: "openagents.com",

@@ -24,11 +27,15 @@ const snapshot = (entries: ReadonlyArray<CoderEntry>): CoderSnapshot =>

24 27
    turns: 1,
25 28
    running: false,
26 29
    tasks: [],
30
    ...(pluginEvents === undefined ? {} : { pluginEvents }),
27 31
  }) as unknown as CoderSnapshot;
28 32
29
const write = (entries: ReadonlyArray<CoderEntry>) => {
33
const write = (
34
  entries: ReadonlyArray<CoderEntry>,
35
  pluginEvents?: ReadonlyArray<CoderPluginEvent>,
36
) => {
30 37
  const directory = mkdtempSync(join(tmpdir(), "coder-export-"));
31
  const result = exportTrajectory(snapshot(entries), {
38
  const result = exportTrajectory(snapshot(entries, pluginEvents), {
32 39
    model: "Ollama qwen",
33 40
    version: "0.3.5",
34 41
    now: new Date(AT),

@@ -43,6 +50,9 @@ const write = (entries: ReadonlyArray<CoderEntry>) => {

43 50
  };
44 51
};
45 52
53
/** A full digest, as the host verifies it: `sha256:` and 64 hex characters. */
54
const FULL_DIGEST = `sha256:${"7c724f993da2".padEnd(64, "0")}`;
55
46 56
describe("exporting a conversation as ATIF", () => {
47 57
  it("writes the envelope the rest of the system reads", () => {
48 58
    const { document, result } = write([entry({ role: "you", text: "hello" })]);

@@ -242,3 +252,181 @@ describe("exporting a conversation as ATIF", () => {

242 252
    expect(name).toMatch(/^2026-08-24T14-00-00-000Z-openagents\.com-atif\.json$/);
243 253
  });
244 254
});
255
256
describe("plugin provenance in the export", () => {
257
  const refusedLoad: CoderPluginEvent = {
258
    at: AT + 1_000,
259
    message: "Plugin not loaded (digest_mismatch): the artifact is not the one described",
260
    event: "plugin_load_refused",
261
    code: "digest_mismatch",
262
    plugin: { manifestPath: "/work/demo/plugin.json" },
263
  };
264
265
  const successfulLoad: CoderPluginEvent = {
266
    at: AT + 2_000,
267
    message: "Loaded plugin `word_stats` v0.1.0 — digest verified (sha256:7c724f993da2…).",
268
    event: "plugin_loaded",
269
    plugin: {
270
      name: "word_stats",
271
      version: "0.1.0",
272
      artifactDigest: FULL_DIGEST,
273
      bytes: 18_432,
274
      abi: { entry: "handle_packet", alloc: "packet_alloc" },
275
      timeoutMs: 2_000,
276
      capabilities: { mounts: [], hosts: [] },
277
      manifestPath: "/work/demo/plugin.json",
278
      toolName: "word_stats",
279
    },
280
  };
281
282
  const pluginCall = entry({
283
    role: "tool",
284
    text: "word_stats",
285
    at: AT + 4_000,
286
    tool: {
287
      callId: "call-p1",
288
      name: "word_stats",
289
      arguments: '{"text":"one two"}',
290
      output: '{"ok":{"words":2}}',
291
      error: undefined,
292
      status: "succeeded",
293
      plugin: { name: "word_stats", version: "0.1.0", artifactDigest: FULL_DIGEST },
294
    },
295
  });
296
297
  const plainCall = entry({
298
    role: "tool",
299
    text: "shell",
300
    at: AT + 5_000,
301
    tool: {
302
      callId: "call-s1",
303
      name: "shell",
304
      arguments: '{"command":"ls"}',
305
      output: "README.md",
306
      error: undefined,
307
      status: "succeeded",
308
    },
309
  });
310
311
  const fixture = () =>
312
    write(
313
      [
314
        entry({ role: "you", text: "load it", at: AT }),
315
        entry({ role: "notice", text: "Loaded plugin `word_stats` v0.1.0.", at: AT + 2_500 }),
316
        entry({ role: "you", text: "count the words", at: AT + 3_000 }),
317
        pluginCall,
318
        plainCall,
319
      ],
320
      [refusedLoad, successfulLoad],
321
    );
322
323
  it("writes loads and refusals as system steps, in order among the turns", () => {
324
    const { document } = fixture();
325
326
    const steps = document["steps"] as ReadonlyArray<Record<string, unknown>>;
327
    expect(steps.map((step) => step["source"])).toEqual([
328
      "user",
329
      "system",
330
      "system",
331
      "user",
332
      "agent",
333
      "agent",
334
    ]);
335
    expect(steps.map((step) => step["step_id"])).toEqual([1, 2, 3, 4, 5, 6]);
336
    expect(steps[1]).toMatchObject({ message: refusedLoad.message });
337
    expect(steps[2]).toMatchObject({ message: successfulLoad.message });
338
  });
339
340
  it("types the refusal on the system step's observation", () => {
341
    const { document } = fixture();
342
343
    const steps = document["steps"] as ReadonlyArray<Record<string, unknown>>;
344
    expect(steps[1]).toMatchObject({
345
      observation: {
346
        results: [
347
          {
348
            source_call_id: null,
349
            content: refusedLoad.message,
350
            extra: {
351
              event: "plugin_load_refused",
352
              code: "digest_mismatch",
353
              plugin: { manifest_path: "/work/demo/plugin.json" },
354
            },
355
          },
356
        ],
357
      },
358
    });
359
  });
360
361
  it("types the load, with the full digest, on the system step's observation", () => {
362
    const { document } = fixture();
363
364
    const steps = document["steps"] as ReadonlyArray<Record<string, unknown>>;
365
    const loadStep = steps[2] as Record<string, unknown>;
366
    const observation = loadStep["observation"] as Record<string, unknown>;
367
    const [result] = observation["results"] as ReadonlyArray<Record<string, unknown>>;
368
    expect(result).toMatchObject({
369
      source_call_id: null,
370
      extra: {
371
        event: "plugin_loaded",
372
        plugin: {
373
          name: "word_stats",
374
          version: "0.1.0",
375
          artifact_digest: FULL_DIGEST,
376
          bytes: 18_432,
377
          abi: { entry: "handle_packet", alloc: "packet_alloc" },
378
          timeout_ms: 2_000,
379
          capabilities: { mounts: [], hosts: [] },
380
          manifest_path: "/work/demo/plugin.json",
381
          tool_name: "word_stats",
382
        },
383
      },
384
    });
385
    // The prose may truncate the digest; the machine-read record never does.
386
    const extra = result?.["extra"] as { plugin: { artifact_digest: string } };
387
    expect(extra.plugin.artifact_digest).toMatch(/^sha256:[0-9a-f]{64}$/);
388
  });
389
390
  it("stamps a plugin-backed call with its provenance, and no other call", () => {
391
    const { document } = fixture();
392
393
    const steps = document["steps"] as ReadonlyArray<Record<string, unknown>>;
394
    const [pluginStep, plainStep] = steps.slice(4);
395
    expect(pluginStep).toMatchObject({
396
      tool_calls: [
397
        {
398
          tool_call_id: "call-p1",
399
          function_name: "word_stats",
400
          extra: {
401
            plugin: { name: "word_stats", version: "0.1.0", artifact_digest: FULL_DIGEST },
402
          },
403
        },
404
      ],
405
    });
406
    const [plainToolCall] = (plainStep as Record<string, unknown>)["tool_calls"] as ReadonlyArray<
407
      Record<string, unknown>
408
    >;
409
    expect(plainToolCall?.["extra"]).toBeUndefined();
410
  });
411
412
  it("keeps interface chatter in extra.notices, unchanged", () => {
413
    const { document } = fixture();
414
415
    expect((document["extra"] as Record<string, unknown>)["notices"]).toEqual([
416
      expect.objectContaining({ text: "Loaded plugin `word_stats` v0.1.0." }),
417
    ]);
418
  });
419
420
  it("counts system steps in the totals", () => {
421
    const { document } = fixture();
422
423
    expect(document["final_metrics"]).toMatchObject({ total_steps: 6 });
424
  });
425
426
  it("writes a load after the last turn, rather than dropping it", () => {
427
    const { document } = write([entry({ role: "you", text: "ask", at: AT })], [successfulLoad]);
428
429
    const steps = document["steps"] as ReadonlyArray<Record<string, unknown>>;
430
    expect(steps.map((step) => step["source"])).toEqual(["user", "system"]);
431
  });
432
});
packages/openagents-cli/test/coder-session.test.ts modified +89

@@ -485,3 +485,92 @@ describe("notices that replace one another", () => {

485 485
    expect(session.snapshot().entries).toHaveLength(2);
486 486
  });
487 487
});
488
489
describe("plugin occurrences", () => {
490
  const digest = `sha256:${"ab".repeat(32)}`;
491
492
  const loaded = {
493
    message: "Loaded plugin `word_stats` v0.1.0.",
494
    event: "plugin_loaded" as const,
495
    plugin: {
496
      name: "word_stats",
497
      version: "0.1.0",
498
      artifactDigest: digest,
499
      bytes: 1024,
500
      abi: { entry: "handle_packet", alloc: "packet_alloc" },
501
      timeoutMs: 2000,
502
      capabilities: { mounts: [], hosts: [] },
503
      manifestPath: "/work/demo/plugin.json",
504
      toolName: "word_stats",
505
    },
506
  };
507
508
  it("records loads and refusals on the snapshot, not the transcript", () => {
509
    const session = new CoderSession(scripted(["a"]), "repo", "main");
510
511
    session.recordPluginEvent({
512
      message: "Plugin not loaded (digest_mismatch): wrong artifact.",
513
      event: "plugin_load_refused",
514
      code: "digest_mismatch",
515
      plugin: { manifestPath: "/work/demo/plugin.json" },
516
    });
517
    session.recordPluginEvent(loaded);
518
519
    const { entries, pluginEvents } = session.snapshot();
520
    // A renderer draws entries; the occurrences are the export's to read.
521
    expect(entries).toHaveLength(0);
522
    expect(pluginEvents?.map((event) => event.event)).toEqual([
523
      "plugin_load_refused",
524
      "plugin_loaded",
525
    ]);
526
    expect(pluginEvents?.[1]?.plugin.artifactDigest).toBe(digest);
527
    expect(pluginEvents?.[0]?.at).toBeGreaterThan(0);
528
  });
529
530
  it("stamps a call of the loaded tool with the plugin's identity", async () => {
531
    const session = new CoderSession(
532
      source([
533
        { type: "tool_call", callId: "c1", name: "word_stats", arguments: "{}" },
534
        { type: "tool_result", callId: "c1", output: "{}", error: undefined },
535
        { type: "tool_call", callId: "c2", name: "shell", arguments: "{}" },
536
        { type: "tool_result", callId: "c2", output: "", error: undefined },
537
      ]),
538
      "repo",
539
      "main",
540
    );
541
    session.recordPluginEvent(loaded);
542
543
    await session.submit("count");
544
545
    const tools = session.snapshot().entries.filter((entry) => entry.role === "tool");
546
    expect(tools[0]?.tool?.plugin).toEqual({
547
      name: "word_stats",
548
      version: "0.1.0",
549
      artifactDigest: digest,
550
    });
551
    // A tool no plugin backs carries no provenance to mistake for some.
552
    expect(tools[1]?.tool?.plugin).toBeUndefined();
553
  });
554
555
  it("does not register a tool from a refused load", async () => {
556
    const session = new CoderSession(
557
      source([
558
        { type: "tool_call", callId: "c1", name: "word_stats", arguments: "{}" },
559
        { type: "tool_result", callId: "c1", output: "{}", error: undefined },
560
      ]),
561
      "repo",
562
      "main",
563
    );
564
    session.recordPluginEvent({
565
      message: "Plugin not loaded (manifest_unreadable): no such file.",
566
      event: "plugin_load_refused",
567
      code: "manifest_unreadable",
568
      plugin: { manifestPath: "/work/demo/plugin.json" },
569
    });
570
571
    await session.submit("count");
572
573
    const tool = session.snapshot().entries.find((entry) => entry.role === "tool");
574
    expect(tool?.tool?.plugin).toBeUndefined();
575
  });
576
});

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