Read a conversation back through the capability loop

d773b309f5cd · AtlantisPleb · · parent f4e276a62324

Read a conversation back through the capability loop

The read_conversation guest plugin (#41): locate one Claude Code or
Codex session through the scanner's library and return its conversation
as ordered turns — newest session, or by id, or by working-directory
filter — with thinking and tool activity counted rather than replayed,
ceilings on turns and characters, and honest truncation totals. The
host gains openagents.read_file_range, a bounded range read with the
same confinement and no whole-file refusal, so an oversized session is
read from its tail rather than refused. The scanner's packet entry is
feature-gated so a guest can depend on its scan logic as a library.
Discovered by the capability tool like every installed plugin: asking
the coder to read a convo finds it, loads it under the approval ladder,
and runs it in the sandbox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1mRkPGYmTVvMKqAzmQvy5
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/coder-plugin-engine.ts
  • modified packages/openagents-cli/src/coder-plugins.ts
  • added packages/openagents-cli/test/coder-plugin-read-conversation.test.ts
  • modified plugins/Cargo.lock
  • modified plugins/Cargo.toml
  • modified plugins/README.md
  • modified plugins/foreign-sessions/Cargo.toml
  • modified plugins/foreign-sessions/foreign_sessions.wasm
  • modified plugins/foreign-sessions/manifest.json
  • modified plugins/foreign-sessions/src/lib.rs
  • modified plugins/pdk/src/lib.rs
  • added plugins/read-conversation/Cargo.toml
  • added plugins/read-conversation/manifest.json
  • added plugins/read-conversation/read_conversation.wasm
  • added plugins/read-conversation/src/lib.rs
  • added plugins/read-conversation/src/tests.rs

Diff

18 files changed, +1037 -40

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": 2484,
7
    "filesScanned": 2485,
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:ce37c3926fa88f843846d795ee0ff46b2ab151b93d2e3a99ccaade74dc749f43",
4
  "sourceDigest": "sha256:88c82b2d87bcc50ede92cc36ec6d4b69b275bb598a0645b6f611e4ab69b72bab",
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 (75 tracked test files)"
1879
          "ref": "packages/openagents-cli (76 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/coder-plugin-engine.ts modified +44 -5

@@ -126,7 +126,7 @@ export interface PluginEngine {

126 126
 */
127 127
const INVOKE_WORKER = `
128 128
const { parentPort, workerData } = require("node:worker_threads");
129
const { lstatSync, readdirSync, readFileSync, realpathSync } = require("node:fs");
129
const { lstatSync, readdirSync, readFileSync, realpathSync , openSync, readSync, closeSync } = require("node:fs");
130 130
const { isAbsolute, join, resolve, sep } = require("node:path");
131 131
(async () => {
132 132
  const { wasm, input, entry, alloc, mounts, mountFileLimit, mountDirEntryLimit } = workerData;

@@ -147,7 +147,7 @@ const { isAbsolute, join, resolve, sep } = require("node:path");

147 147
      return packet;
148 148
    };
149 149
150
    const readMounted = (path) => {
150
    const readMounted = (path, range) => {
151 151
      if (isAbsolute(path)) {
152 152
        return refusalPacket("mount_denied", "absolute paths are refused; mounted paths are relative to a declared mount root");
153 153
      }

@@ -181,11 +181,35 @@ const { isAbsolute, join, resolve, sep } = require("node:path");

181 181
        if (real !== root && !real.startsWith(root + sep)) {
182 182
          return refusalPacket("mount_denied", "the path resolves outside the mount root");
183 183
        }
184
        if (stat.size > mountFileLimit) {
185
          return refusalPacket("file_too_large", "the file is " + String(stat.size) + " bytes; the per-file bound is " + String(mountFileLimit));
184
        if (range === undefined) {
185
          if (stat.size > mountFileLimit) {
186
            return refusalPacket("file_too_large", "the file is " + String(stat.size) + " bytes; the per-file bound is " + String(mountFileLimit));
187
          }
188
          try {
189
            return okPacket(readFileSync(candidate));
190
          } catch (cause) {
191
            return refusalPacket("file_unreadable", String((cause && cause.message) || cause));
192
          }
186 193
        }
194
        // A range read has no whole-file refusal: the answer is bounded by
195
        // construction. The length is clamped to the same per-read bound,
196
        // and a range past the end answers with what remains, empty included.
197
        const offset = Math.min(Math.max(0, range.offset), stat.size);
198
        const length = Math.min(Math.max(0, range.maxBytes), mountFileLimit, stat.size - offset);
187 199
        try {
188
          return okPacket(readFileSync(candidate));
200
          const buffer = Buffer.alloc(length);
201
          const fd = openSync(candidate, "r");
202
          try {
203
            let filled = 0;
204
            while (filled < length) {
205
              const got = readSync(fd, buffer, filled, length - filled, offset + filled);
206
              if (got <= 0) break;
207
              filled += got;
208
            }
209
            return okPacket(buffer.subarray(0, filled));
210
          } finally {
211
            closeSync(fd);
212
          }
189 213
        } catch (cause) {
190 214
          return refusalPacket("file_unreadable", String((cause && cause.message) || cause));
191 215
        }

@@ -291,6 +315,21 @@ const { isAbsolute, join, resolve, sep } = require("node:path");

291 315
                }
292 316
                return answerGuest(packet);
293 317
              },
318
              // The bounded range read: same confinement, no whole-file
319
              // refusal. The offset crosses the boundary as a wasm i64, so
320
              // it arrives here as a BigInt.
321
              read_file_range: (pathPtr, pathLen, offset, maxBytes) => {
322
                let packet;
323
                try {
324
                  packet = readMounted(guestPath(pathPtr, pathLen), {
325
                    offset: Number(offset),
326
                    maxBytes: maxBytes,
327
                  });
328
                } catch (cause) {
329
                  packet = refusalPacket("file_unreadable", String((cause && cause.message) || cause));
330
                }
331
                return answerGuest(packet);
332
              },
294 333
              list_dir: (mountIndex, pathPtr, pathLen) => {
295 334
                let packet;
296 335
                try {
packages/openagents-cli/src/coder-plugins.ts modified +6 -2

@@ -205,12 +205,16 @@ export function loadPluginFromManifest(

205 205
206 206
  // Every import must be granted by a declared capability. Mounts grant
207 207
  // exactly two: the read_file and list_dir capability imports.
208
  const granted = new Set(mounts.length > 0 ? ["openagents.read_file", "openagents.list_dir"] : []);
208
  const granted = new Set(
209
    mounts.length > 0
210
      ? ["openagents.read_file", "openagents.read_file_range", "openagents.list_dir"]
211
      : [],
212
  );
209 213
  const undeclared = shape.imports.filter((name) => !granted.has(name));
210 214
  if (undeclared.length > 0) {
211 215
    const grantHint =
212 216
      mounts.length > 0
213
        ? "the declared mounts grant only `openagents.read_file` and `openagents.list_dir`"
217
        ? "the declared mounts grant only `openagents.read_file`, `openagents.read_file_range`, and `openagents.list_dir`"
214 218
        : "the manifest declares no capabilities, so the module may import nothing";
215 219
    return refuse(
216 220
      "imports_undeclared",
packages/openagents-cli/test/coder-plugin-read-conversation.test.ts added +138

@@ -0,0 +1,138 @@

1
/**
2
 * The conversation reader through the real boundary: the checked-in
3
 * `read_conversation` plugin against staged fixture trees shaped like
4
 * `~/.claude` and `~/.codex`. The reader's logic is unit-tested against a
5
 * fake host in `plugins/read-conversation/src/tests.rs`; this file proves
6
 * the same behavior holds through the WASM sandbox — including the
7
 * bounded `read_file_range` import this plugin is the first to use, on a
8
 * session file past the whole-read bound.
9
 */
10
11
import { mkdirSync, mkdtempSync, copyFileSync, readFileSync, utimesSync, writeFileSync } from "node:fs";
12
import { tmpdir } from "node:os";
13
import { join } from "node:path";
14
import { fileURLToPath } from "node:url";
15
import { describe, expect, it } from "vitest";
16
17
import {
18
  MOUNT_FILE_LIMIT,
19
  invokePlugin,
20
  isRefusal,
21
  loadPluginFromManifest,
22
  type LoadedPlugin,
23
} from "../src/coder-plugins.js";
24
25
const MANIFEST = fileURLToPath(
26
  new URL("../../../plugins/read-conversation/manifest.json", import.meta.url),
27
);
28
const WASM = fileURLToPath(
29
  new URL("../../../plugins/read-conversation/read_conversation.wasm", import.meta.url),
30
);
31
32
const NOW_MS = Date.now();
33
const DAY_MS = 86_400_000;
34
35
const touch = (path: string, mtimeMs: number): void => {
36
  utimesSync(path, new Date(mtimeMs), new Date(mtimeMs));
37
};
38
39
const claudeLine = (cwd: string, id: string, role: string, text: string): string =>
40
  JSON.stringify({
41
    type: role,
42
    cwd,
43
    sessionId: id,
44
    message: { role, content: text },
45
  }) + "\n";
46
47
const stage = (options?: { oversized?: boolean }): { plugin: LoadedPlugin } => {
48
  const dir = mkdtempSync(join(tmpdir(), "read-conversation-"));
49
  const claudeRoot = join(dir, "dot-claude");
50
  const codexRoot = join(dir, "dot-codex");
51
52
  const project = join(claudeRoot, "projects", "-Users-ada-work-alpha");
53
  mkdirSync(project, { recursive: true });
54
  const conversation =
55
    claudeLine("/Users/ada/work/alpha", "good", "user", "what broke?") +
56
    claudeLine("/Users/ada/work/alpha", "good", "assistant", "the test; fixing it");
57
  const body = options?.oversized
58
    ? JSON.stringify({ type: "padding", filler: "p".repeat(MOUNT_FILE_LIMIT) }) +
59
      "\n" +
60
      conversation
61
    : conversation;
62
  writeFileSync(join(project, "good.jsonl"), body);
63
  touch(join(project, "good.jsonl"), NOW_MS - DAY_MS);
64
65
  mkdirSync(join(codexRoot, "sessions"), { recursive: true });
66
67
  const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")) as {
68
    capabilities: { mounts: Array<{ path: string; readonly: true }> };
69
  };
70
  manifest.capabilities.mounts = [
71
    { path: claudeRoot, readonly: true },
72
    { path: codexRoot, readonly: true },
73
  ];
74
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
75
  copyFileSync(WASM, join(dir, "read_conversation.wasm"));
76
77
  const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
78
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
79
  return { plugin: outcome };
80
};
81
82
type Output = {
83
  source: string;
84
  session_id: string;
85
  tail_only?: boolean;
86
  turns: Array<{ role: string; text: string }>;
87
  dropped_leading_turns: number;
88
};
89
90
const packet = (input: Record<string, unknown>): Uint8Array =>
91
  new TextEncoder().encode(JSON.stringify({ ...input, now_ms: NOW_MS }));
92
93
/** The guest envelope: `ok` on success, `refusal` as a value otherwise. */
94
type Envelope = { ok?: Output; refusal?: { code: string; reason: string } };
95
96
const invoke = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<Envelope> => {
97
  const outcome = await invokePlugin(plugin, packet(input));
98
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
99
  return JSON.parse(new TextDecoder().decode(outcome)) as Envelope;
100
};
101
102
const call = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<Output> => {
103
  const envelope = await invoke(plugin, input);
104
  if (envelope.ok === undefined) throw new Error(JSON.stringify(envelope.refusal));
105
  return envelope.ok;
106
};
107
108
describe("the read_conversation plugin through the sandbox", () => {
109
  it("reads the newest conversation back as ordered turns", async () => {
110
    const { plugin } = stage();
111
    const out = await call(plugin, {});
112
113
    expect(out.source).toBe("claude");
114
    expect(out.session_id).toBe("good");
115
    expect(out.turns.map((turn) => [turn.role, turn.text])).toEqual([
116
      ["user", "what broke?"],
117
      ["assistant", "the test; fixing it"],
118
    ]);
119
    expect(out.tail_only ?? false).toBe(false);
120
  });
121
122
  it("reads an oversized session from its tail through the range import", async () => {
123
    const { plugin } = stage({ oversized: true });
124
    const out = await call(plugin, {});
125
126
    expect(out.tail_only).toBe(true);
127
    expect(out.turns.map((turn) => turn.text)).toEqual(["what broke?", "the test; fixing it"]);
128
  });
129
130
  it("refuses an unknown session id with a pointer at the scanner", async () => {
131
    const { plugin } = stage();
132
    const envelope = await invoke(plugin, { session_id: "nope" });
133
134
    expect(envelope.ok).toBeUndefined();
135
    expect(envelope.refusal?.reason).toContain("nope");
136
    expect(envelope.refusal?.reason).toContain("foreign_sessions");
137
  });
138
});
plugins/Cargo.lock modified +10

@@ -90,6 +90,16 @@ dependencies = [

90 90
 "proc-macro2",
91 91
]
92 92
93
[[package]]
94
name = "read-conversation"
95
version = "0.1.0"
96
dependencies = [
97
 "foreign-sessions",
98
 "openagents-pdk",
99
 "serde",
100
 "serde_json",
101
]
102
93 103
[[package]]
94 104
name = "serde"
95 105
version = "1.0.229"
plugins/Cargo.toml modified +1 -1

@@ -13,7 +13,7 @@

13 13
14 14
[workspace]
15 15
resolver = "2"
16
members = ["pdk", "word-stats", "file-stats", "dir-stats", "foreign-sessions", "git-lost-work"]
16
members = ["pdk", "word-stats", "file-stats", "dir-stats", "foreign-sessions", "git-lost-work", "read-conversation"]
17 17
18 18
[workspace.package]
19 19
edition = "2021"
plugins/README.md modified +8

@@ -25,6 +25,14 @@ OpenAgentsInc/openagents#26 and

25 25
  discovers recent Claude Code and Codex CLI sessions from `~/.claude` and
26 26
  `~/.codex` mounted read-only, metadata only. The scanner half of
27 27
  OpenAgentsInc/openagents.com#198.
28
- `read-conversation/` — the content half (OpenAgentsInc/openagents#41):
29
  locates one session through the scanner's library and reads its
30
  conversation back as ordered turns, bounded by turn and character
31
  ceilings. The first plugin over `openagents.read_file_range`, the
32
  bounded range import that reads the tail of a session file past the
33
  whole-file bound. `foreign-sessions` gates its packet entry behind the
34
  default `entry` feature so this crate can depend on the scan logic
35
  without a duplicate-export link error.
28 36
29 37
Each plugin's built `.wasm` artifact and its `sha256:` digest pin are
30 38
checked in beside the source, so the CLI runs them without a Rust
plugins/foreign-sessions/Cargo.toml modified +5

@@ -15,3 +15,8 @@ crate-type = ["cdylib", "rlib"]

15 15
openagents-pdk = { workspace = true }
16 16
serde = { workspace = true }
17 17
serde_json = { workspace = true }
18
19
[features]
20
default = ["entry"]
21
# The packet-v0 exports. Off for library consumers, on for the artifact.
22
entry = []
plugins/foreign-sessions/foreign_sessions.wasm modified

Binary file. Nothing to show as text.

plugins/foreign-sessions/manifest.json modified +84 -26

@@ -3,10 +3,10 @@

3 3
  "name": "foreign_sessions",
4 4
  "version": "0.1.0",
5 5
  "author": "OpenAgents",
6
  "description": "Discover recent Claude Code and Codex CLI sessions from their local state directories, mounted read-only: for each session its source, id, working directory, modification time, size, and record count. Metadata only — it never reads whole conversations back, never writes, and cannot resume anything. Use it when asked what foreign coding-agent sessions exist on this machine, optionally filtered to a working directory.",
6
  "description": "Discover recent Claude Code and Codex CLI sessions from their local state directories, mounted read-only: for each session its source, id, working directory, modification time, size, and record count. Metadata only \u2014 it never reads whole conversations back, never writes, and cannot resume anything. Use it when asked what foreign coding-agent sessions exist on this machine, optionally filtered to a working directory.",
7 7
  "artifact": {
8 8
    "path": "foreign_sessions.wasm",
9
    "digest": "sha256:164eed5637ccadd11c1df698e22436cdeeb963194b7a765dd845a745fcf3854d"
9
    "digest": "sha256:5ba9c4265b7a18dbb1c1c175e10a0ffcff5b7d124c766536b582c6e33bde84bb"
10 10
  },
11 11
  "abi": {
12 12
    "kind": "packet-v0",

@@ -19,7 +19,13 @@

19 19
      "properties": {
20 20
        "sources": {
21 21
          "type": "array",
22
          "items": { "type": "string", "enum": ["claude", "codex"] },
22
          "items": {
23
            "type": "string",
24
            "enum": [
25
              "claude",
26
              "codex"
27
            ]
28
          },
23 29
          "description": "Which stores to scan. Both when omitted."
24 30
        },
25 31
        "cwd_filter": {

@@ -53,49 +59,101 @@

53 59
              "items": {
54 60
                "type": "object",
55 61
                "properties": {
56
                  "source": { "type": "string" },
57
                  "session_id": { "type": "string" },
58
                  "path": { "type": "string" },
59
                  "cwd": { "type": "string" },
60
                  "project_dir": { "type": "string" },
61
                  "mtime_ms": { "type": "integer" },
62
                  "size_bytes": { "type": "integer" },
63
                  "record_count": { "type": "integer" },
64
                  "metadata_truncated": { "type": "boolean" }
62
                  "source": {
63
                    "type": "string"
64
                  },
65
                  "session_id": {
66
                    "type": "string"
67
                  },
68
                  "path": {
69
                    "type": "string"
70
                  },
71
                  "cwd": {
72
                    "type": "string"
73
                  },
74
                  "project_dir": {
75
                    "type": "string"
76
                  },
77
                  "mtime_ms": {
78
                    "type": "integer"
79
                  },
80
                  "size_bytes": {
81
                    "type": "integer"
82
                  },
83
                  "record_count": {
84
                    "type": "integer"
85
                  },
86
                  "metadata_truncated": {
87
                    "type": "boolean"
88
                  }
65 89
                }
66 90
              }
67 91
            },
68
            "scanned_dirs": { "type": "integer" },
69
            "scanned_files": { "type": "integer" },
92
            "scanned_dirs": {
93
              "type": "integer"
94
            },
95
            "scanned_files": {
96
              "type": "integer"
97
            },
70 98
            "skipped": {
71 99
              "type": "object",
72 100
              "properties": {
73
                "malformed": { "type": "integer" },
74
                "unreadable": { "type": "integer" },
75
                "symlinked": { "type": "integer" }
101
                "malformed": {
102
                  "type": "integer"
103
                },
104
                "unreadable": {
105
                  "type": "integer"
106
                },
107
                "symlinked": {
108
                  "type": "integer"
109
                }
76 110
              }
77 111
            },
78
            "oversized": { "type": "integer" },
79
            "missing_sources": { "type": "array", "items": { "type": "string" } },
80
            "scan_truncated": { "type": "boolean" },
81
            "read_budget_exhausted": { "type": "boolean" }
112
            "oversized": {
113
              "type": "integer"
114
            },
115
            "missing_sources": {
116
              "type": "array",
117
              "items": {
118
                "type": "string"
119
              }
120
            },
121
            "scan_truncated": {
122
              "type": "boolean"
123
            },
124
            "read_budget_exhausted": {
125
              "type": "boolean"
126
            }
82 127
          }
83 128
        },
84 129
        "refusal": {
85 130
          "type": "object",
86 131
          "properties": {
87
            "code": { "type": "string" },
88
            "reason": { "type": "string" }
132
            "code": {
133
              "type": "string"
134
            },
135
            "reason": {
136
              "type": "string"
137
            }
89 138
          },
90
          "required": ["code", "reason"]
139
          "required": [
140
            "code",
141
            "reason"
142
          ]
91 143
        }
92 144
      }
93 145
    }
94 146
  },
95 147
  "capabilities": {
96 148
    "mounts": [
97
      { "path": "~/.claude", "readonly": true },
98
      { "path": "~/.codex", "readonly": true }
149
      {
150
        "path": "~/.claude",
151
        "readonly": true
152
      },
153
      {
154
        "path": "~/.codex",
155
        "readonly": true
156
      }
99 157
    ],
100 158
    "hosts": [],
101 159
    "timeout_ms": 10000,
plugins/foreign-sessions/src/lib.rs modified +12 -3

@@ -18,9 +18,9 @@

18 18
//! runs against `now_ms` when the caller provides it, and otherwise
19 19
//! against the newest mtime the scan observed.
20 20
21
use openagents_pdk::{
22
    list_mounted_dir, plugin_entry, read_mounted_file, MountDirListing, Refusal, RefusalCode,
23
};
21
#[cfg(feature = "entry")]
22
use openagents_pdk::plugin_entry;
23
use openagents_pdk::{list_mounted_dir, read_mounted_file, MountDirListing, Refusal, RefusalCode};
24 24
use serde::{Deserialize, Serialize};
25 25
26 26
/// Mount indices, fixed by the order `manifest.json` declares the mounts.

@@ -121,8 +121,10 @@ pub trait Host {

121 121
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal>;
122 122
}
123 123
124
#[cfg(feature = "entry")]
124 125
struct RealHost;
125 126
127
#[cfg(feature = "entry")]
126 128
impl Host for RealHost {
127 129
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
128 130
        list_mounted_dir(mount_index, path)

@@ -479,10 +481,17 @@ fn dirs_of<'l>(listing: &'l MountDirListing, skipped: &mut Skipped) -> Vec<&'l s

479 481
    dirs
480 482
}
481 483
484
// The packet entry is feature-gated so another guest crate can depend on
485
// the scan logic as a library: `plugin_entry!` emits the `handle_packet`
486
// and `packet_alloc` exports, and two copies of those cannot link into one
487
// module. The artifact build keeps the default feature; a library consumer
488
// turns it off.
489
#[cfg(feature = "entry")]
482 490
fn handle(input: Input) -> Result<Output, Refusal> {
483 491
    scan(&RealHost, &input)
484 492
}
485 493
494
#[cfg(feature = "entry")]
486 495
plugin_entry!(handle);
487 496
488 497
#[cfg(test)]
plugins/pdk/src/lib.rs modified +39

@@ -190,6 +190,22 @@ pub fn read_mounted_file(path: &str) -> Result<Vec<u8>, Refusal> {

190 190
    imp::read_mounted_file(path)
191 191
}
192 192
193
/// Read a bounded range of a mounted file, for files past the whole-file
194
/// bound.
195
///
196
/// Same confinement as [`read_mounted_file`]; the difference is the answer.
197
/// A whole-file read of an oversized file is refused, while a range read
198
/// answers with up to `max_bytes` bytes from `offset` — the host clamps
199
/// `max_bytes` to its per-read bound, and a range past the end answers with
200
/// what remains, empty included.
201
pub fn read_mounted_file_range(
202
    path: &str,
203
    offset: u64,
204
    max_bytes: u32,
205
) -> Result<Vec<u8>, Refusal> {
206
    imp::read_mounted_file_range(path, offset, max_bytes)
207
}
208
193 209
/// One entry of a mounted directory listing, as the host reports it.
194 210
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
195 211
pub struct MountDirEntry {

@@ -268,6 +284,10 @@ mod imp {

268 284
        /// payload is the JSON encoding of a listing. Present only when the
269 285
        /// manifest declares mounts.
270 286
        fn list_dir(mount_index: u32, path_ptr: *const u8, path_len: u32) -> u64;
287
        /// Host capability import: read up to `max_bytes` bytes of a mounted
288
        /// file starting at `offset`. Same answer-packet shape and the same
289
        /// confinement as `read_file`, without the whole-file size refusal.
290
        fn read_file_range(path_ptr: *const u8, path_len: u32, offset: u64, max_bytes: u32) -> u64;
271 291
    }
272 292
273 293
    /// Unpack a host answer word into the packet slice it points at.

@@ -285,6 +305,16 @@ mod imp {

285 305
        let packet = unsafe { host_packet(packed) }?;
286 306
        parse_host_packet(packet)
287 307
    }
308
    pub fn read_mounted_file_range(
309
        path: &str,
310
        offset: u64,
311
        max_bytes: u32,
312
    ) -> Result<Vec<u8>, Refusal> {
313
        let packed =
314
            unsafe { read_file_range(path.as_ptr(), path.len() as u32, offset, max_bytes) };
315
        let packet = unsafe { host_packet(packed) }?;
316
        parse_host_packet(packet)
317
    }
288 318
289 319
    pub fn list_mounted_dir(mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
290 320
        let packed = unsafe { list_dir(mount_index, path.as_ptr(), path.len() as u32) };

@@ -297,6 +327,15 @@ mod imp {

297 327
mod imp {
298 328
    use super::{MountDirListing, Refusal};
299 329
330
    pub fn read_mounted_file_range(
331
        _path: &str,
332
        _offset: u64,
333
        _max_bytes: u32,
334
    ) -> Result<Vec<u8>, Refusal> {
335
        Err(Refusal::unsupported(
336
            "mounted reads exist only inside the WASM sandbox",
337
        ))
338
    }
300 339
    pub fn read_mounted_file(_path: &str) -> Result<Vec<u8>, Refusal> {
301 340
        Err(Refusal::unsupported(
302 341
            "read_mounted_file is a host capability import; it exists only inside the WASM host",
plugins/read-conversation/Cargo.toml added +19

@@ -0,0 +1,19 @@

1
# The conversation reader: the content half the metadata-only scanner
2
# deliberately left out (OpenAgentsInc/openagents#41). Locates one foreign
3
# session through the scanner and reads its conversation back, bounded.
4
5
[package]
6
name = "read-conversation"
7
version = "0.1.0"
8
edition.workspace = true
9
license.workspace = true
10
description = "Guest plugin that reads one Claude Code or Codex conversation back as ordered turns, through read-only mounts, bounded by turn and character ceilings."
11
12
[lib]
13
crate-type = ["cdylib", "rlib"]
14
15
[dependencies]
16
openagents-pdk = { workspace = true }
17
foreign-sessions = { path = "../foreign-sessions", default-features = false }
18
serde = { workspace = true }
19
serde_json = { workspace = true }
plugins/read-conversation/manifest.json added +94

@@ -0,0 +1,94 @@

1
{
2
  "manifest_version": 1,
3
  "name": "read_conversation",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "Read a conversation back from this machine: the transcript of a Claude Code or Codex CLI session, returned as ordered turns of user and assistant text. Use it when asked to read a convo, conversation, chat, or session transcript — the latest one here, or one named by session id, or the newest under a working directory. Read-only and bounded: thinking and tool activity are counted rather than replayed, long conversations return their most recent turns and say how many were dropped, and oversized session files are read from the tail. It never resumes, continues, or writes anything.",
7
  "artifact": {
8
    "path": "read_conversation.wasm",
9
    "digest": "sha256:11f71cbb25e098a29f4fb2dc591f6ba83fb5c8229560eb0305ba7ff0cc940e8c"
10
  },
11
  "abi": {
12
    "kind": "packet-v0",
13
    "entry": "handle_packet",
14
    "alloc": "packet_alloc"
15
  },
16
  "interface": {
17
    "input": {
18
      "type": "object",
19
      "properties": {
20
        "source": {
21
          "type": "string",
22
          "enum": ["claude", "codex"],
23
          "description": "Which store holds the session. Both are searched when omitted."
24
        },
25
        "session_id": {
26
          "type": "string",
27
          "description": "The session to read, by id or id prefix. The newest session wins when omitted."
28
        },
29
        "cwd_filter": {
30
          "type": "string",
31
          "description": "Only consider sessions whose working directory contains this substring."
32
        },
33
        "max_turns": {
34
          "type": "integer",
35
          "description": "Most turns to return, from the end of the conversation. Default 60, capped at 200."
36
        },
37
        "max_chars": {
38
          "type": "integer",
39
          "description": "Character ceiling per turn; longer turns keep their head and tail. Default 2000, capped at 8000."
40
        },
41
        "now_ms": {
42
          "type": "integer",
43
          "description": "Current time in milliseconds since the Unix epoch, for the recency window. The sandbox has no clock; when omitted, the newest session's own timestamp stands in."
44
        }
45
      }
46
    },
47
    "output": {
48
      "type": "object",
49
      "properties": {
50
        "source": { "type": "string" },
51
        "session_id": { "type": "string" },
52
        "path": { "type": "string" },
53
        "cwd": { "type": "string" },
54
        "file_bytes": { "type": "integer" },
55
        "bytes_read": { "type": "integer" },
56
        "tail_only": {
57
          "type": "boolean",
58
          "description": "True when the file exceeded the whole-read bound, so only its tail was read."
59
        },
60
        "records_seen": { "type": "integer" },
61
        "turns_total": { "type": "integer" },
62
        "dropped_leading_turns": { "type": "integer" },
63
        "turns": {
64
          "type": "array",
65
          "items": {
66
            "type": "object",
67
            "properties": {
68
              "role": { "type": "string" },
69
              "text": { "type": "string" },
70
              "truncated": { "type": "boolean" }
71
            }
72
          }
73
        },
74
        "skipped": {
75
          "type": "object",
76
          "properties": {
77
            "thinking": { "type": "integer" },
78
            "tool_activity": { "type": "integer" },
79
            "other": { "type": "integer" }
80
          }
81
        }
82
      }
83
    }
84
  },
85
  "capabilities": {
86
    "mounts": [
87
      { "path": "~/.claude", "readonly": true },
88
      { "path": "~/.codex", "readonly": true }
89
    ],
90
    "hosts": [],
91
    "timeout_ms": 10000,
92
    "memory_max_mib": 128
93
  }
94
}
plugins/read-conversation/read_conversation.wasm added

Binary file. Nothing to show as text.

plugins/read-conversation/src/lib.rs added +356

@@ -0,0 +1,356 @@

1
//! Read one foreign conversation back, as a `packet-v0` guest plugin.
2
//!
3
//! The content half of what `foreign-sessions` deliberately left out
4
//! (OpenAgentsInc/openagents#41): given a source and a session id — or just
5
//! "the newest session here" — locate the session through the scanner and
6
//! return its conversation as ordered turns, role and text, oldest first.
7
//!
8
//! The posture is the scanner's: read-only through the host's confined
9
//! capability imports, bounded everywhere, fail-soft on malformed records.
10
//! A file past the host's whole-file bound is read from its tail through
11
//! the bounded range import, and the output says so (`tail_only`), says how
12
//! many turns the ceilings dropped, and counts what it skipped (thinking,
13
//! tool activity, other records) — a truncated read that names what it left
14
//! out rather than pretending to be whole.
15
//!
16
//! Reading is the whole capability. Nothing here resumes, continues, or
17
//! writes anything.
18
19
use foreign_sessions::{scan, Host, Input as ScanInput, Session};
20
use openagents_pdk::{
21
    list_mounted_dir, plugin_entry, read_mounted_file, read_mounted_file_range, MountDirListing,
22
    Refusal,
23
};
24
use serde::{Deserialize, Serialize};
25
26
/// Mirror of the host's per-read bound; a whole-file read past it refuses.
27
const WHOLE_READ_BOUND: u64 = 1_048_576;
28
/// How much of an oversized file's tail one read asks for.
29
const TAIL_BYTES: u32 = 1_048_576;
30
const DEFAULT_MAX_TURNS: usize = 60;
31
const TURN_CAP: usize = 200;
32
const DEFAULT_MAX_CHARS: usize = 2_000;
33
const CHAR_CAP: usize = 8_000;
34
35
#[derive(Deserialize)]
36
pub struct Input {
37
    /// `claude` or `codex`; both are searched when absent.
38
    #[serde(default)]
39
    pub source: Option<String>,
40
    /// The session to read, by id or id prefix. Absent, the newest wins.
41
    #[serde(default)]
42
    pub session_id: Option<String>,
43
    /// Substring the session's working directory must contain.
44
    #[serde(default)]
45
    pub cwd_filter: Option<String>,
46
    /// Most turns to return, from the end of the conversation. Default 60.
47
    #[serde(default)]
48
    pub max_turns: Option<usize>,
49
    /// Character ceiling per turn; longer turns keep head and tail. Default 2000.
50
    #[serde(default)]
51
    pub max_chars: Option<usize>,
52
    /// Milliseconds since the Unix epoch, handed through to the scanner.
53
    #[serde(default)]
54
    pub now_ms: Option<i64>,
55
}
56
57
#[derive(Debug, Serialize, PartialEq)]
58
pub struct Turn {
59
    pub role: String,
60
    pub text: String,
61
    /// True when the character ceiling elided the middle of this turn.
62
    #[serde(skip_serializing_if = "std::ops::Not::not")]
63
    pub truncated: bool,
64
}
65
66
#[derive(Debug, Default, Serialize, PartialEq, Eq)]
67
pub struct Skipped {
68
    /// Reasoning blocks, which are thoughts rather than the conversation.
69
    pub thinking: usize,
70
    /// Tool calls and tool results, counted rather than replayed.
71
    pub tool_activity: usize,
72
    /// Records of any other kind, malformed lines included.
73
    pub other: usize,
74
}
75
76
#[derive(Debug, Serialize)]
77
pub struct Output {
78
    pub source: String,
79
    pub session_id: String,
80
    /// Path relative to the source's mount root.
81
    pub path: String,
82
    #[serde(skip_serializing_if = "Option::is_none")]
83
    pub cwd: Option<String>,
84
    pub file_bytes: u64,
85
    pub bytes_read: usize,
86
    /// True when the file exceeded the whole-read bound, so only its tail
87
    /// was read and earlier turns are not merely dropped but unseen.
88
    #[serde(skip_serializing_if = "std::ops::Not::not")]
89
    pub tail_only: bool,
90
    /// JSONL records inspected in what was read.
91
    pub records_seen: usize,
92
    /// Conversation turns found in what was read, before the turn ceiling.
93
    pub turns_total: usize,
94
    /// Turns the ceiling dropped from the front of what was read.
95
    pub dropped_leading_turns: usize,
96
    pub turns: Vec<Turn>,
97
    pub skipped: Skipped,
98
}
99
100
/// The scanner's host plus the bounded range read this plugin adds.
101
pub trait RangeHost: Host {
102
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal>;
103
}
104
105
struct RealHost;
106
107
impl Host for RealHost {
108
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
109
        list_mounted_dir(mount_index, path)
110
    }
111
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
112
        read_mounted_file(path)
113
    }
114
}
115
116
impl RangeHost for RealHost {
117
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal> {
118
        read_mounted_file_range(path, offset, max_bytes)
119
    }
120
}
121
122
/// The whole read, over any [`RangeHost`].
123
pub fn read_conversation(host: &dyn RangeHost, input: &Input) -> Result<Output, Refusal> {
124
    let session = choose(host, input)?;
125
    let (bytes, tail_only) = load(host, &session)?;
126
    let bytes_read = bytes.len();
127
128
    let (raw_turns, records_seen, skipped) = match session.source {
129
        "claude" => claude_turns(&bytes),
130
        _ => codex_turns(&bytes),
131
    };
132
133
    let max_turns = input.max_turns.unwrap_or(DEFAULT_MAX_TURNS).clamp(1, TURN_CAP);
134
    let max_chars = input.max_chars.unwrap_or(DEFAULT_MAX_CHARS).clamp(200, CHAR_CAP);
135
    let turns_total = raw_turns.len();
136
    let dropped = turns_total.saturating_sub(max_turns);
137
    let turns = raw_turns
138
        .into_iter()
139
        .skip(dropped)
140
        .map(|(role, text)| bounded_turn(role, &text, max_chars))
141
        .collect();
142
143
    Ok(Output {
144
        source: session.source.to_string(),
145
        session_id: session.session_id,
146
        path: session.path,
147
        cwd: session.cwd,
148
        file_bytes: session.size_bytes,
149
        bytes_read,
150
        tail_only,
151
        records_seen,
152
        turns_total,
153
        dropped_leading_turns: dropped,
154
        turns,
155
        skipped,
156
    })
157
}
158
159
/// The session to read: the id match when one is asked for, the newest
160
/// session the scanner reports otherwise.
161
fn choose(host: &dyn RangeHost, input: &Input) -> Result<Session, Refusal> {
162
    let scan_input = ScanInput {
163
        sources: input.source.as_ref().map(|s| vec![s.clone()]),
164
        cwd_filter: input.cwd_filter.clone(),
165
        max_age_days: Some(365.0),
166
        limit: Some(50),
167
        now_ms: input.now_ms,
168
    };
169
    // `RangeHost: Host`, and dyn upcasting is stable, so the scanner
170
    // takes the same host value.
171
    let scanned = scan(host as &dyn Host, &scan_input)?;
172
173
    match &input.session_id {
174
        Some(want) => scanned
175
            .sessions
176
            .into_iter()
177
            .find(|s| s.session_id.starts_with(want.as_str()) || s.path.contains(want.as_str()))
178
            .ok_or_else(|| {
179
                Refusal::unsupported(format!(
180
                    "no recent session matches `{want}`; ask foreign_sessions what exists"
181
                ))
182
            }),
183
        None => scanned.sessions.into_iter().next().ok_or_else(|| {
184
            Refusal::unsupported(
185
                "no recent foreign sessions were found; nothing to read".to_string(),
186
            )
187
        }),
188
    }
189
}
190
191
/// The session's bytes: whole when the file fits the host's bound, the tail
192
/// (aligned to the first whole line) when it does not.
193
fn load(host: &dyn RangeHost, session: &Session) -> Result<(Vec<u8>, bool), Refusal> {
194
    if session.size_bytes <= WHOLE_READ_BOUND {
195
        return Ok((host.read(&session.path)?, false));
196
    }
197
    let offset = session.size_bytes - u64::from(TAIL_BYTES);
198
    let bytes = host.read_range(&session.path, offset, TAIL_BYTES)?;
199
    let aligned = match bytes.iter().position(|b| *b == b'\n') {
200
        Some(at) => bytes[at + 1..].to_vec(),
201
        None => Vec::new(),
202
    };
203
    Ok((aligned, true))
204
}
205
206
/// One turn, elided in the middle when it is past the ceiling.
207
fn bounded_turn(role: String, text: &str, max_chars: usize) -> Turn {
208
    let count = text.chars().count();
209
    if count <= max_chars {
210
        return Turn { role, text: text.to_string(), truncated: false };
211
    }
212
    let half = max_chars / 2;
213
    let head: String = text.chars().take(half).collect();
214
    let tail: String = text
215
        .chars()
216
        .skip(count - half)
217
        .collect();
218
    Turn {
219
        role,
220
        text: format!("{head}\n…[{} characters elided]…\n{tail}", count - half * 2),
221
        truncated: true,
222
    }
223
}
224
225
/// Claude Code records: `user` / `assistant` with `message.content` as a
226
/// string or a block list. Text blocks are the conversation; thinking and
227
/// tool blocks are counted, not replayed.
228
pub fn claude_turns(bytes: &[u8]) -> (Vec<(String, String)>, usize, Skipped) {
229
    let text = String::from_utf8_lossy(bytes);
230
    let mut turns = Vec::new();
231
    let mut skipped = Skipped::default();
232
    let mut records = 0usize;
233
234
    for line in text.lines() {
235
        if line.trim().is_empty() {
236
            continue;
237
        }
238
        records += 1;
239
        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
240
            skipped.other += 1;
241
            continue;
242
        };
243
        let kind = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
244
        if kind != "user" && kind != "assistant" {
245
            skipped.other += 1;
246
            continue;
247
        }
248
        let message = value.get("message").unwrap_or(&serde_json::Value::Null);
249
        let role = message
250
            .get("role")
251
            .and_then(|v| v.as_str())
252
            .unwrap_or(kind)
253
            .to_string();
254
        let mut parts: Vec<String> = Vec::new();
255
        match message.get("content") {
256
            Some(serde_json::Value::String(content)) => parts.push(content.clone()),
257
            Some(serde_json::Value::Array(blocks)) => {
258
                for block in blocks {
259
                    match block.get("type").and_then(|v| v.as_str()) {
260
                        Some("text") => {
261
                            if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
262
                                parts.push(t.to_string());
263
                            }
264
                        }
265
                        Some("thinking") => skipped.thinking += 1,
266
                        Some("tool_use") | Some("tool_result") => skipped.tool_activity += 1,
267
                        _ => skipped.other += 1,
268
                    }
269
                }
270
            }
271
            _ => skipped.other += 1,
272
        }
273
        let joined = parts.join("\n").trim().to_string();
274
        if !joined.is_empty() {
275
            turns.push((role, joined));
276
        }
277
    }
278
    (turns, records, skipped)
279
}
280
281
/// Codex rollout records: `response_item` messages carry the conversation;
282
/// reasoning and tool items are counted, everything else is other.
283
pub fn codex_turns(bytes: &[u8]) -> (Vec<(String, String)>, usize, Skipped) {
284
    let text = String::from_utf8_lossy(bytes);
285
    let mut turns = Vec::new();
286
    let mut skipped = Skipped::default();
287
    let mut records = 0usize;
288
289
    for line in text.lines() {
290
        if line.trim().is_empty() {
291
            continue;
292
        }
293
        records += 1;
294
        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
295
            skipped.other += 1;
296
            continue;
297
        };
298
        if value.get("type").and_then(|v| v.as_str()) != Some("response_item") {
299
            skipped.other += 1;
300
            continue;
301
        }
302
        let Some(payload) = value.get("payload") else {
303
            skipped.other += 1;
304
            continue;
305
        };
306
        match payload.get("type").and_then(|v| v.as_str()) {
307
            Some("message") => {
308
                let role = payload.get("role").and_then(|v| v.as_str()).unwrap_or("");
309
                if role != "user" && role != "assistant" {
310
                    skipped.other += 1;
311
                    continue;
312
                }
313
                let joined = block_text(payload.get("content"));
314
                if !joined.is_empty() {
315
                    turns.push((role.to_string(), joined));
316
                }
317
            }
318
            Some("agent_message") => {
319
                let joined = block_text(payload.get("content"));
320
                if !joined.is_empty() {
321
                    turns.push(("assistant".to_string(), joined));
322
                }
323
            }
324
            Some("reasoning") => skipped.thinking += 1,
325
            // `function_call`, `custom_tool_call`, and their `_output` twins.
326
            Some(kind) if kind.contains("call") => skipped.tool_activity += 1,
327
            _ => skipped.other += 1,
328
        }
329
    }
330
    (turns, records, skipped)
331
}
332
333
/// The text of a Codex content value: a plain string, or the joined text of
334
/// its `input_text` / `output_text` blocks.
335
fn block_text(content: Option<&serde_json::Value>) -> String {
336
    match content {
337
        Some(serde_json::Value::String(text)) => text.trim().to_string(),
338
        Some(serde_json::Value::Array(blocks)) => blocks
339
            .iter()
340
            .filter_map(|block| block.get("text").and_then(|v| v.as_str()))
341
            .collect::<Vec<_>>()
342
            .join("\n")
343
            .trim()
344
            .to_string(),
345
        _ => String::new(),
346
    }
347
}
348
349
fn handle(input: Input) -> Result<Output, Refusal> {
350
    read_conversation(&RealHost, &input)
351
}
352
353
plugin_entry!(handle);
354
355
#[cfg(test)]
356
mod tests;
plugins/read-conversation/src/tests.rs added +218

@@ -0,0 +1,218 @@

1
//! The reader against a fake host: a seeded Claude tree and Codex tree,
2
//! whole-file and tail reads, ceilings, and honest refusals — all without a
3
//! WASM runtime, the scanner's own testing pattern.
4
5
use super::*;
6
use openagents_pdk::{MountDirEntry, RefusalCode};
7
use std::collections::BTreeMap;
8
9
const CLAUDE_MOUNT: u32 = 0;
10
const CODEX_MOUNT: u32 = 1;
11
const DAY: i64 = 86_400_000;
12
const NOW: i64 = 1_756_000_000_000;
13
14
#[derive(Default)]
15
struct FakeHost {
16
    dirs: BTreeMap<(u32, String), MountDirListing>,
17
    files: BTreeMap<String, Vec<u8>>,
18
}
19
20
impl FakeHost {
21
    fn dir(&mut self, mount: u32, path: &str, entries: Vec<MountDirEntry>) {
22
        self.dirs
23
            .insert((mount, path.to_string()), MountDirListing { entries, truncated: false });
24
    }
25
    fn file(&mut self, path: &str, bytes: &str) {
26
        self.files.insert(path.to_string(), bytes.as_bytes().to_vec());
27
    }
28
}
29
30
impl Host for FakeHost {
31
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
32
        self.dirs.get(&(mount_index, path.to_string())).cloned().ok_or_else(|| {
33
            Refusal::new(RefusalCode::FileUnreadable, "the mount has no such directory")
34
        })
35
    }
36
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
37
        let bytes = self
38
            .files
39
            .get(path)
40
            .ok_or_else(|| Refusal::new(RefusalCode::MountDenied, "no mount holds the path"))?;
41
        if bytes.len() as u64 > WHOLE_READ_BOUND {
42
            return Err(Refusal::new(RefusalCode::FileTooLarge, "over the bound"));
43
        }
44
        Ok(bytes.clone())
45
    }
46
}
47
48
impl RangeHost for FakeHost {
49
    fn read_range(&self, path: &str, offset: u64, max_bytes: u32) -> Result<Vec<u8>, Refusal> {
50
        let bytes = self
51
            .files
52
            .get(path)
53
            .ok_or_else(|| Refusal::new(RefusalCode::MountDenied, "no mount holds the path"))?;
54
        let start = (offset as usize).min(bytes.len());
55
        let end = (start + max_bytes as usize).min(bytes.len());
56
        Ok(bytes[start..end].to_vec())
57
    }
58
}
59
60
fn entry(name: &str, kind: &str, size: u64, mtime_ms: i64) -> MountDirEntry {
61
    MountDirEntry { name: name.to_string(), kind: kind.to_string(), size, mtime_ms }
62
}
63
64
fn claude_record(role: &str, content: &str) -> String {
65
    format!(
66
        r#"{{"type":"{role}","cwd":"/Users/ada/work/proj","sessionId":"aaa","message":{{"role":"{role}","content":"{content}"}}}}"#
67
    )
68
}
69
70
fn seeded(session_body: &str) -> FakeHost {
71
    let mut host = FakeHost::default();
72
    host.dir(CLAUDE_MOUNT, "projects", vec![entry("-Users-ada-work-proj", "dir", 0, NOW)]);
73
    host.dir(
74
        CLAUDE_MOUNT,
75
        "projects/-Users-ada-work-proj",
76
        vec![entry("aaa.jsonl", "file", session_body.len() as u64, NOW - DAY)],
77
    );
78
    host.file("projects/-Users-ada-work-proj/aaa.jsonl", session_body);
79
    host.dir(CODEX_MOUNT, "sessions", vec![entry("2026", "dir", 0, NOW)]);
80
    host.dir(CODEX_MOUNT, "sessions/2026", vec![entry("08", "dir", 0, NOW)]);
81
    host.dir(CODEX_MOUNT, "sessions/2026/08", vec![entry("20", "dir", 0, NOW)]);
82
    host.dir(
83
        CODEX_MOUNT,
84
        "sessions/2026/08/20",
85
        vec![entry("rollout-2026-08-20T10-00-00-bbb.jsonl", "file", 400, NOW - 2 * DAY)],
86
    );
87
    host.file(
88
        "sessions/2026/08/20/rollout-2026-08-20T10-00-00-bbb.jsonl",
89
        concat!(
90
            r#"{"type":"session_meta","payload":{"id":"bbb","cwd":"/Users/ada/work/other"}}"#,
91
            "\n",
92
            r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"fix the test"}]}}"#,
93
            "\n",
94
            r#"{"type":"response_item","payload":{"type":"reasoning","summary":[]}}"#,
95
            "\n",
96
            r#"{"type":"response_item","payload":{"type":"function_call","name":"shell"}}"#,
97
            "\n",
98
            r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done, it passes"}]}}"#,
99
            "\n",
100
        ),
101
    );
102
    host
103
}
104
105
fn input() -> Input {
106
    Input {
107
        source: None,
108
        session_id: None,
109
        cwd_filter: None,
110
        max_turns: None,
111
        max_chars: None,
112
        now_ms: Some(NOW),
113
    }
114
}
115
116
fn claude_session() -> String {
117
    format!("{}\n{}\n", claude_record("user", "hello there"), claude_record("assistant", "hi!"))
118
}
119
120
#[test]
121
fn reads_the_newest_session_when_none_is_named() {
122
    let host = seeded(&claude_session());
123
    let out = read_conversation(&host, &input()).unwrap();
124
    assert_eq!(out.source, "claude");
125
    assert_eq!(out.session_id, "aaa");
126
    assert_eq!(
127
        out.turns.iter().map(|t| (t.role.as_str(), t.text.as_str())).collect::<Vec<_>>(),
128
        vec![("user", "hello there"), ("assistant", "hi!")],
129
    );
130
    assert!(!out.tail_only);
131
    assert_eq!(out.dropped_leading_turns, 0);
132
}
133
134
#[test]
135
fn reads_a_codex_session_by_id_and_counts_what_it_skips() {
136
    let host = seeded(&claude_session());
137
    let out = read_conversation(
138
        &host,
139
        &Input { source: Some("codex".into()), session_id: Some("bbb".into()), ..input() },
140
    )
141
    .unwrap();
142
    assert_eq!(out.source, "codex");
143
    assert_eq!(
144
        out.turns.iter().map(|t| (t.role.as_str(), t.text.as_str())).collect::<Vec<_>>(),
145
        vec![("user", "fix the test"), ("assistant", "done, it passes")],
146
    );
147
    assert_eq!(out.skipped.thinking, 1);
148
    assert_eq!(out.skipped.tool_activity, 1);
149
    // session_meta is a record, not a turn.
150
    assert!(out.skipped.other >= 1);
151
}
152
153
#[test]
154
fn an_unknown_session_id_is_a_refusal_naming_the_scanner() {
155
    let host = seeded(&claude_session());
156
    let refusal = read_conversation(
157
        &host,
158
        &Input { session_id: Some("zzz".into()), ..input() },
159
    )
160
    .unwrap_err();
161
    assert!(refusal.reason.contains("zzz"));
162
    assert!(refusal.reason.contains("foreign_sessions"));
163
}
164
165
#[test]
166
fn the_turn_ceiling_keeps_the_end_and_says_what_it_dropped() {
167
    let mut body = String::new();
168
    for at in 0..10 {
169
        body.push_str(&claude_record("user", &format!("question {at}")));
170
        body.push('\n');
171
    }
172
    let host = seeded(&body);
173
    let out = read_conversation(&host, &Input { max_turns: Some(3), ..input() }).unwrap();
174
    assert_eq!(out.turns_total, 10);
175
    assert_eq!(out.dropped_leading_turns, 7);
176
    assert_eq!(out.turns.last().unwrap().text, "question 9");
177
}
178
179
#[test]
180
fn a_long_turn_is_elided_in_the_middle_and_marked() {
181
    let long = "x".repeat(5_000);
182
    let host = seeded(&format!("{}\n", claude_record("user", &long)));
183
    let out = read_conversation(&host, &Input { max_chars: Some(400), ..input() }).unwrap();
184
    let turn = &out.turns[0];
185
    assert!(turn.truncated);
186
    assert!(turn.text.contains("characters elided"));
187
    assert!(turn.text.chars().count() < 500);
188
}
189
190
#[test]
191
fn an_oversized_file_is_read_from_its_tail_and_says_so() {
192
    // One line of padding pushes the file over the whole-read bound; the
193
    // conversation sits at the end, where a tail read finds it.
194
    let padding = format!(
195
        r#"{{"type":"padding","filler":"{}"}}"#,
196
        "p".repeat(WHOLE_READ_BOUND as usize)
197
    );
198
    let body = format!("{padding}\n{}", claude_session());
199
    let host = seeded(&body);
200
    let out = read_conversation(&host, &input()).unwrap();
201
    assert!(out.tail_only);
202
    assert_eq!(out.turns.len(), 2);
203
    assert_eq!(out.turns[1].text, "hi!");
204
    assert!(out.bytes_read <= TAIL_BYTES as usize);
205
}
206
207
#[test]
208
fn thinking_blocks_are_counted_not_replayed() {
209
    let body = format!(
210
        "{}\n",
211
        r#"{"type":"assistant","cwd":"/Users/ada/work/proj","sessionId":"aaa","message":{"role":"assistant","content":[{"type":"thinking","thinking":"secret"},{"type":"text","text":"the answer"}]}}"#
212
    );
213
    let host = seeded(&body);
214
    let out = read_conversation(&host, &input()).unwrap();
215
    assert_eq!(out.turns[0].text, "the answer");
216
    assert_eq!(out.skipped.thinking, 1);
217
    assert!(!out.turns[0].text.contains("secret"));
218
}

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