@stigmer/runner 3.31.0 → 3.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +13 -6
  3. package/dist/activities/execute-deep-agent/cas-capture-backend.js +21 -5
  4. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  5. package/dist/activities/execute-deep-agent/deepagents-profiles.d.ts +21 -7
  6. package/dist/activities/execute-deep-agent/deepagents-profiles.js +24 -9
  7. package/dist/activities/execute-deep-agent/deepagents-profiles.js.map +1 -1
  8. package/dist/activities/execute-deep-agent/platform-route.d.ts +75 -0
  9. package/dist/activities/execute-deep-agent/platform-route.js +107 -0
  10. package/dist/activities/execute-deep-agent/platform-route.js.map +1 -0
  11. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +2 -0
  12. package/dist/activities/execute-deep-agent/subagent-transformer.js +29 -5
  13. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  14. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -7
  15. package/dist/activities/execute-deep-agent/subagent-wiring.js +5 -7
  16. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  17. package/dist/activities/execute-deep-agent/translator.d.ts +6 -2
  18. package/dist/activities/execute-deep-agent/translator.js +6 -2
  19. package/dist/activities/execute-deep-agent/translator.js.map +1 -1
  20. package/dist/activities/execute-deep-agent/turn-setup.js +14 -3
  21. package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
  22. package/dist/middleware/index.d.ts +7 -3
  23. package/dist/middleware/index.js +7 -3
  24. package/dist/middleware/index.js.map +1 -1
  25. package/dist/shared/workspace/stigmer-link.d.ts +15 -3
  26. package/dist/shared/workspace/stigmer-link.js +22 -3
  27. package/dist/shared/workspace/stigmer-link.js.map +1 -1
  28. package/dist/shared/workspace/types.d.ts +4 -3
  29. package/package.json +15 -9
  30. package/src/activities/execute-deep-agent/__tests__/builtin-tool-surface.test.ts +107 -0
  31. package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +14 -0
  32. package/src/activities/execute-deep-agent/__tests__/hermetic/fixtures/paused-session.deepagents-1.10.2.json +592 -0
  33. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/sub-agent-delegation.status.json +2 -2
  34. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/tool-call.status.json +1 -1
  35. package/src/activities/execute-deep-agent/__tests__/hermetic/plain-turn.test.ts +10 -13
  36. package/src/activities/execute-deep-agent/__tests__/hermetic/resume-across-engine-upgrade.test.ts +374 -0
  37. package/src/activities/execute-deep-agent/__tests__/hermetic/sub-agent-delegation.test.ts +7 -0
  38. package/src/activities/execute-deep-agent/__tests__/hermetic/tool-call.test.ts +6 -0
  39. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +16 -8
  40. package/src/activities/execute-deep-agent/__tests__/platform-route.test.ts +148 -0
  41. package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +24 -0
  42. package/src/activities/execute-deep-agent/__tests__/summarization-verification.test.ts +16 -9
  43. package/src/activities/execute-deep-agent/cas-capture-backend.ts +25 -6
  44. package/src/activities/execute-deep-agent/deepagents-profiles.ts +25 -9
  45. package/src/activities/execute-deep-agent/platform-route.ts +134 -0
  46. package/src/activities/execute-deep-agent/subagent-transformer.ts +40 -6
  47. package/src/activities/execute-deep-agent/subagent-wiring.ts +5 -7
  48. package/src/activities/execute-deep-agent/translator.ts +6 -2
  49. package/src/activities/execute-deep-agent/turn-setup.ts +17 -3
  50. package/src/middleware/index.ts +7 -3
  51. package/src/shared/workspace/__tests__/stigmer-link.test.ts +41 -18
  52. package/src/shared/workspace/stigmer-link.ts +22 -3
  53. package/src/shared/workspace/types.ts +4 -3
@@ -12,7 +12,7 @@
12
12
  "description": "Look up the fixture value.",
13
13
  "subagent_type": "helper"
14
14
  },
15
- "result": "[{\"type\":\"text\",\"text\":\"The fixture value is forty-two.\"}]",
15
+ "result": "The fixture value is forty-two.",
16
16
  "status": "TOOL_CALL_COMPLETED",
17
17
  "startedAt": "2026-01-01T00:00:01.000Z",
18
18
  "completedAt": "2026-01-01T00:00:02.000Z",
@@ -33,7 +33,7 @@
33
33
  "id": "call-hermetic-task-0001",
34
34
  "name": "helper",
35
35
  "input": "Look up the fixture value.",
36
- "output": "[{\"type\":\"text\",\"text\":\"The fixture value is forty-two.\"}]",
36
+ "output": "The fixture value is forty-two.",
37
37
  "status": "SUB_AGENT_COMPLETED",
38
38
  "startedAt": "2026-01-01T00:00:01.000Z",
39
39
  "completedAt": "2026-01-01T00:00:02.000Z",
@@ -11,7 +11,7 @@
11
11
  "args": {
12
12
  "file_path": "/README.md"
13
13
  },
14
- "result": "[{\"type\":\"text\",\"text\":\" 1\\t# Hermetic\\n 2\\t\\n 3\\tA fixture readme.\"}]",
14
+ "result": "[{\"type\":\"text\",\"text\":\"@@ lines 1-3 of 3 @@\\n# Hermetic\\n\\nA fixture readme.\"}]",
15
15
  "status": "TOOL_CALL_COMPLETED",
16
16
  "startedAt": "2026-01-01T00:00:01.000Z",
17
17
  "completedAt": "2026-01-01T00:00:01.000Z",
@@ -15,12 +15,16 @@
15
15
  * model streams, so the events under this golden are the ones a provider
16
16
  * produces (`scripted-model.ts` header).
17
17
  *
18
- * Two shapes of the activity's wire contract ride on the same golden, and
19
- * matching the SAME file is the assertion that they are invisible to the
20
- * status: the legacy positional `(executionId, threadId)` form the control
21
- * planes still may send (`shared/activity-input.ts`), and an empty
22
- * `thread_id` (carried from `index.test.ts`: "handles empty threadId
23
- * gracefully").
18
+ * The legacy positional `(executionId, threadId)` form the control planes
19
+ * still may send (`shared/activity-input.ts`) rides on the same golden, and
20
+ * matching the SAME file is the assertion that it is invisible to the status.
21
+ * An empty `thread_id` rode here too (carried from `index.test.ts`: "handles
22
+ * empty threadId gracefully") until @langchain/langgraph-checkpoint 1.1's
23
+ * memory saver started refusing an empty key. That arm was only ever true of
24
+ * the test-only memory saver: the native thread id is runtime-minted
25
+ * (`EnsureThread` sends `thread-{sessionId}` on every invocation), and the
26
+ * sqlite saver the runner ships has always refused an empty one
27
+ * (`shared/checkpointer/sqlite-saver.ts`), so no saver left to prove it on.
24
28
  *
25
29
  * Since #1096 the activity is the turn runtime over the native adapter, and
26
30
  * `streamingUsage` carries the runtime accountant's fields — `model`,
@@ -132,11 +136,4 @@ describe("ExecuteDeepAgent hermetic — plain turn", () => {
132
136
  const json = JSON.stringify(toJson(AgentExecutionStatusSchema, record.lastFullStatus!), null, 2) + "\n";
133
137
  await expect(json).toMatchFileSnapshot(GOLDEN);
134
138
  });
135
-
136
- it("produces the same status with an empty thread_id", async () => {
137
- const { record, invocation } = await runPlainTurn({ threadId: "" });
138
- expect(invocation.outcome.kind).toBe("returned");
139
- const json = JSON.stringify(toJson(AgentExecutionStatusSchema, record.lastFullStatus!), null, 2) + "\n";
140
- await expect(json).toMatchFileSnapshot(GOLDEN);
141
- });
142
139
  });
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Hermetic net: a session paused for approval on an OLDER engine resumes on
3
+ * this one. Every stored checkpoint crosses an engine upgrade (deepagents,
4
+ * langchain, `@langchain/langgraph` and its checkpoint library move
5
+ * together), and cloud sessions live in the server's database, so "a session
6
+ * paused before the upgrade resumes after it" is a property the runner owes on
7
+ * both savers, not an accident of the serializer.
8
+ *
9
+ * The fixtures in `./fixtures/paused-session.deepagents-<version>.json` are
10
+ * the checkpoint rows an OLDER engine wrote, never regenerated after the fact:
11
+ * each is recorded on the engine it names, before the upgrade that leaves it
12
+ * behind, and committed with that upgrade. The scenario it froze:
13
+ *
14
+ * - round 0: the model writes its to-do list (`write_todos`, ungated);
15
+ * - round 1: the model proposes a gated `execute`; the gate's `interrupt()`
16
+ * pauses the graph and the activity persists WAITING_FOR_APPROVAL.
17
+ *
18
+ * A fixture carries the sqlite rows (`checkpoints`, `writes`) with each blob
19
+ * as UTF-8 text when it is JSON (the serializer's `json` tag) and base64
20
+ * otherwise, and the status the server held when the turn returned.
21
+ *
22
+ * What each fixture proves on the CURRENT engine:
23
+ * - sqlite, end to end: the rows are loaded into the session's checkpoint
24
+ * file, the server-held status is seeded, APPROVE is decided as
25
+ * `SubmitApproval` would, and the reinvocation resumes INSIDE the gate — the
26
+ * command runs exactly once, the run closes COMPLETED, and the to-do list
27
+ * the old engine wrote survives.
28
+ * - http: the same stored bytes served through the proxy wire
29
+ * (`HttpCheckpointSaver`) load into the same checkpoint tuple the sqlite
30
+ * saver loads — the graph resumes from the tuple, so the sqlite resume above
31
+ * carries over to cloud sessions.
32
+ *
33
+ * Recording a fixture (only on the engine that is about to be left behind,
34
+ * before its upgrade lands):
35
+ * RECORD_ENGINE_UPGRADE_FIXTURE=1 npx vitest run src/activities/execute-deep-agent/__tests__/hermetic/resume-across-engine-upgrade.test.ts
36
+ * The recording arm names the file after the installed deepagents version and
37
+ * refuses to overwrite one that exists.
38
+ *
39
+ * Resume is one-way: `@langchain/langgraph` 1.4.9 and later write Topic
40
+ * channels (`__pregel_tasks` among them) as a flat list that 1.3.x reads as
41
+ * `[seen, values]`, so a checkpoint written by this engine does not resume on
42
+ * the 1.3 line. Nothing here pretends otherwise.
43
+ */
44
+
45
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
46
+ import { createRequire } from "node:module";
47
+ import { dirname, join } from "node:path";
48
+ import { fileURLToPath } from "node:url";
49
+ import { DatabaseSync } from "node:sqlite";
50
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
51
+ import { fromJson, toJson, type JsonValue } from "@bufbuild/protobuf";
52
+ import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
53
+ import {
54
+ ApprovalAction,
55
+ ExecutionPhase,
56
+ TodoStatus,
57
+ ToolCallStatus,
58
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
59
+
60
+ vi.mock("../../../../shared/model-client.js", async () =>
61
+ (await import("../../__test-utils__/scripted-model-module.js")).scriptedModelClientModule(),
62
+ );
63
+ vi.mock("../../../../client/stigmer-client.js", async () =>
64
+ (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
65
+ );
66
+
67
+ import {
68
+ ScriptedClock,
69
+ createHermeticEnvironment,
70
+ type HermeticEnvironment,
71
+ } from "../../../../__test-utils__/hermetic-activity.js";
72
+ import { stubRegistryFetch } from "../../../../__test-utils__/model-registry-fixture.js";
73
+ import { getCheckpointDbPath } from "../../../../shared/workspace/platform-dir.js";
74
+ import { SqliteCheckpointSaver } from "../../../../shared/checkpointer/sqlite-saver.js";
75
+ import { HttpCheckpointSaver } from "../../../../shared/checkpointer/http-saver.js";
76
+ import {
77
+ FIXTURE,
78
+ beginDeepAgentScenario,
79
+ deepAgentExecutionRecord,
80
+ runDeepAgentTurn,
81
+ } from "../../__test-utils__/hermetic-deep-agent.js";
82
+ import type { ScriptedTurn } from "../../__test-utils__/scripted-model.js";
83
+ import { CLOSING_TURN, EXECUTE_CALL_A, GATED_OPENING_TURN } from "../../__test-utils__/hitl-script.js";
84
+
85
+ const USER_MESSAGE = "Plan it, then run the command for me.";
86
+ const DECIDED_AT = "2026-01-01T00:00:30.000Z";
87
+ const RESUME_AFTER_MS = 60_000;
88
+ const FIXTURE_DIR = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
89
+ const FIXTURE_PREFIX = "paused-session.deepagents-";
90
+ const RECORDING = process.env.RECORD_ENGINE_UPGRADE_FIXTURE === "1";
91
+
92
+ const TODOS = [
93
+ { content: "Run the command", status: "in_progress" },
94
+ { content: "Report the result", status: "pending" },
95
+ ];
96
+
97
+ /** Round 0: the to-do list, so the fixture carries the `todos` channel an engine writes. */
98
+ const TODO_TURN: ScriptedTurn = {
99
+ text: "Planning the two steps.",
100
+ toolCalls: [{ id: "call-hermetic-todo-0001", name: "write_todos", args: { todos: TODOS } }],
101
+ usage: { inputTokens: 1_200, outputTokens: 40 },
102
+ };
103
+
104
+ const SCRIPT = () => ({ turns: [TODO_TURN, GATED_OPENING_TURN, CLOSING_TURN] });
105
+
106
+ /** A blob as the fixture stores it: readable when the serializer wrote JSON. */
107
+ interface StoredBlob {
108
+ readonly encoding: "utf8" | "base64";
109
+ readonly data: string;
110
+ }
111
+
112
+ interface CheckpointRow {
113
+ readonly thread_id: string;
114
+ readonly checkpoint_ns: string;
115
+ readonly checkpoint_id: string;
116
+ readonly parent_checkpoint_id: string | null;
117
+ readonly type: string | null;
118
+ readonly checkpoint: StoredBlob;
119
+ readonly metadata: StoredBlob;
120
+ }
121
+
122
+ interface WriteRow {
123
+ readonly thread_id: string;
124
+ readonly checkpoint_ns: string;
125
+ readonly checkpoint_id: string;
126
+ readonly task_id: string;
127
+ readonly idx: number;
128
+ readonly channel: string;
129
+ readonly type: string | null;
130
+ readonly value: StoredBlob;
131
+ }
132
+
133
+ interface PausedSessionFixture {
134
+ /** The engine that wrote the rows. */
135
+ readonly recordedWith: Readonly<Record<string, string>>;
136
+ readonly threadId: string;
137
+ readonly checkpoints: readonly CheckpointRow[];
138
+ readonly writes: readonly WriteRow[];
139
+ /** The status the server held when the paused turn returned (`toJson` form). */
140
+ readonly status: JsonValue;
141
+ }
142
+
143
+ function installedVersion(pkg: string): string {
144
+ const require = createRequire(import.meta.url);
145
+ let dir = dirname(require.resolve(pkg));
146
+ while (!existsSync(join(dir, "package.json")) || readPackageName(dir) !== pkg) {
147
+ const parent = dirname(dir);
148
+ if (parent === dir) throw new Error(`no package.json found for ${pkg}`);
149
+ dir = parent;
150
+ }
151
+ return (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { version: string }).version;
152
+ }
153
+
154
+ function readPackageName(dir: string): string | undefined {
155
+ return (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { name?: string }).name;
156
+ }
157
+
158
+ function storeBlob(type: string | null, bytes: Uint8Array): StoredBlob {
159
+ return type === "json"
160
+ ? { encoding: "utf8", data: Buffer.from(bytes).toString("utf8") }
161
+ : { encoding: "base64", data: Buffer.from(bytes).toString("base64") };
162
+ }
163
+
164
+ function loadBlob(blob: StoredBlob): Uint8Array {
165
+ return new Uint8Array(Buffer.from(blob.data, blob.encoding));
166
+ }
167
+
168
+ function readRows(dbPath: string): Pick<PausedSessionFixture, "checkpoints" | "writes"> {
169
+ const db = new DatabaseSync(dbPath, { readOnly: true });
170
+ try {
171
+ const checkpoints = (
172
+ db
173
+ .prepare(
174
+ `SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata
175
+ FROM checkpoints ORDER BY checkpoint_ns, checkpoint_id`,
176
+ )
177
+ .all() as unknown as Array<Omit<CheckpointRow, "checkpoint" | "metadata"> & { checkpoint: Uint8Array; metadata: Uint8Array }>
178
+ ).map((r) => ({ ...r, checkpoint: storeBlob(r.type, r.checkpoint), metadata: storeBlob(r.type, r.metadata) }));
179
+ const writes = (
180
+ db
181
+ .prepare(
182
+ `SELECT thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value
183
+ FROM writes ORDER BY checkpoint_ns, checkpoint_id, task_id, idx`,
184
+ )
185
+ .all() as unknown as Array<Omit<WriteRow, "value"> & { value: Uint8Array }>
186
+ ).map((r) => ({ ...r, value: storeBlob(r.type, r.value) }));
187
+ return { checkpoints, writes };
188
+ } finally {
189
+ db.close();
190
+ }
191
+ }
192
+
193
+ /** Write a fixture's rows into a checkpoint file whose schema the saver itself created. */
194
+ async function loadRows(dbPath: string, fixture: PausedSessionFixture): Promise<void> {
195
+ mkdirSync(dirname(dbPath), { recursive: true });
196
+ const saver = new SqliteCheckpointSaver(dbPath);
197
+ expect(await saver.getTuple({ configurable: { thread_id: fixture.threadId } }), "a fresh file").toBeUndefined();
198
+ const db = new DatabaseSync(dbPath);
199
+ try {
200
+ const cp = db.prepare(
201
+ `INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata)
202
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
203
+ );
204
+ for (const r of fixture.checkpoints) {
205
+ cp.run(r.thread_id, r.checkpoint_ns, r.checkpoint_id, r.parent_checkpoint_id, r.type, loadBlob(r.checkpoint), loadBlob(r.metadata));
206
+ }
207
+ const w = db.prepare(
208
+ `INSERT INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value)
209
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
210
+ );
211
+ for (const r of fixture.writes) {
212
+ w.run(r.thread_id, r.checkpoint_ns, r.checkpoint_id, r.task_id, r.idx, r.channel, r.type, loadBlob(r.value));
213
+ }
214
+ } finally {
215
+ db.close();
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The proxy's checkpoint API over a fixture's rows, as `fetch` sees it: the
221
+ * documents the server stores and returns (`$binary` Extended JSON), keyed as
222
+ * the saver queries them.
223
+ */
224
+ function proxyFetch(fixture: PausedSessionFixture): typeof fetch {
225
+ const binary = (blob: StoredBlob) => ({
226
+ $binary: { base64: Buffer.from(loadBlob(blob)).toString("base64"), subType: "00" },
227
+ });
228
+ const checkpointDoc = (r: CheckpointRow) => ({
229
+ thread_id: r.thread_id,
230
+ checkpoint_ns: r.checkpoint_ns,
231
+ checkpoint_id: r.checkpoint_id,
232
+ parent_checkpoint_id: r.parent_checkpoint_id,
233
+ type: r.type,
234
+ checkpoint: binary(r.checkpoint),
235
+ metadata_type: r.type,
236
+ metadata: binary(r.metadata),
237
+ });
238
+ const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
239
+ return (async (input: string | URL | Request) => {
240
+ const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
241
+ const ns = url.searchParams.get("checkpoint_ns") ?? "";
242
+ const rows = fixture.checkpoints.filter((r) => r.thread_id === url.searchParams.get("thread_id") && r.checkpoint_ns === ns);
243
+ if (url.pathname.endsWith("/checkpoint")) {
244
+ const id = url.searchParams.get("checkpoint_id");
245
+ const row = id ? rows.find((r) => r.checkpoint_id === id) : [...rows].sort((a, b) => b.checkpoint_id.localeCompare(a.checkpoint_id))[0];
246
+ return row ? json(checkpointDoc(row)) : new Response("", { status: 404 });
247
+ }
248
+ if (url.pathname.endsWith("/writes")) {
249
+ const id = url.searchParams.get("checkpoint_id");
250
+ const writes = fixture.writes
251
+ .filter((w) => w.thread_id === url.searchParams.get("thread_id") && w.checkpoint_ns === ns && w.checkpoint_id === id)
252
+ .map((w) => ({ ...w, value: binary(w.value) }));
253
+ return json({ writes });
254
+ }
255
+ throw new Error(`the fixture proxy serves no ${url.pathname}`);
256
+ }) as typeof fetch;
257
+ }
258
+
259
+ function committedFixtures(): string[] {
260
+ if (!existsSync(FIXTURE_DIR)) return [];
261
+ return readdirSync(FIXTURE_DIR)
262
+ .filter((f) => f.startsWith(FIXTURE_PREFIX) && f.endsWith(".json"))
263
+ .sort();
264
+ }
265
+
266
+ describe("ExecuteDeepAgent hermetic — a paused session resumes across an engine upgrade", () => {
267
+ let env: HermeticEnvironment;
268
+ let registry: ReturnType<typeof stubRegistryFetch>;
269
+ const clock = new ScriptedClock();
270
+
271
+ beforeAll(() => {
272
+ env = createHermeticEnvironment();
273
+ registry = stubRegistryFetch();
274
+ clock.install();
275
+ });
276
+
277
+ afterAll(() => {
278
+ clock.uninstall();
279
+ registry.restore();
280
+ env.dispose();
281
+ });
282
+
283
+ it.runIf(RECORDING)("records the paused session on the installed engine", async () => {
284
+ const version = installedVersion("deepagents");
285
+ const target = join(FIXTURE_DIR, `${FIXTURE_PREFIX}${version}.json`);
286
+ expect(existsSync(target), `${target} is frozen; a fixture is recorded once, on the engine it names`).toBe(false);
287
+
288
+ clock.reset();
289
+ const record = deepAgentExecutionRecord({ message: USER_MESSAGE });
290
+ const scenario = beginDeepAgentScenario({ env, clock, record, checkpointer: "sqlite", script: SCRIPT });
291
+ const turn = await runDeepAgentTurn(scenario, { turnSeq: 0 });
292
+ expect(turn.outcome.kind).toBe("returned");
293
+ expect(record.lastFullStatus?.phase).toBe(ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL);
294
+ expect(record.lastFullStatus?.todos).not.toEqual({});
295
+
296
+ const fixture: PausedSessionFixture = {
297
+ recordedWith: {
298
+ deepagents: version,
299
+ "@langchain/langgraph": installedVersion("@langchain/langgraph"),
300
+ "@langchain/langgraph-checkpoint": installedVersion("@langchain/langgraph-checkpoint"),
301
+ "@langchain/core": installedVersion("@langchain/core"),
302
+ },
303
+ threadId: FIXTURE.threadId,
304
+ ...readRows(getCheckpointDbPath(FIXTURE.sessionId)),
305
+ status: toJson(AgentExecutionStatusSchema, record.lastFullStatus!),
306
+ };
307
+ mkdirSync(FIXTURE_DIR, { recursive: true });
308
+ writeFileSync(target, JSON.stringify(fixture, null, 2) + "\n");
309
+ });
310
+
311
+ describe.skipIf(RECORDING).each(committedFixtures())("%s", (file) => {
312
+ const fixture = JSON.parse(readFileSync(join(FIXTURE_DIR, file), "utf8")) as PausedSessionFixture;
313
+
314
+ it("resumes inside the gate on sqlite: the command runs once, the old to-do list survives", async () => {
315
+ // ── Arrange: the old engine's rows and the status the server held ─────
316
+ clock.reset();
317
+ const record = deepAgentExecutionRecord({ message: USER_MESSAGE });
318
+ const scenario = beginDeepAgentScenario({ env, clock, record, checkpointer: "sqlite", script: SCRIPT });
319
+ await loadRows(getCheckpointDbPath(FIXTURE.sessionId), fixture);
320
+ record.applyStatusUpdate(fromJson(AgentExecutionStatusSchema, fixture.status));
321
+ // The approval arrives after the pause, as it does live. Checkpoint ids
322
+ // are time-ordered, and the recorded turn ran on this same scripted
323
+ // clock from its epoch, so the resumed turn starts a minute on: its new
324
+ // checkpoints must sort after the paused one they continue.
325
+ clock.tick(RESUME_AFTER_MS);
326
+ expect(record.waitingToolCalls().map((tc) => tc.id)).toEqual([EXECUTE_CALL_A.id]);
327
+
328
+ // ── Between turns: what SubmitApproval does to the row ─────────────────
329
+ expect(record.decideWaitingToolCalls(ApprovalAction.APPROVE, DECIDED_AT)).toBe(1);
330
+
331
+ // ── Act: the reinvocation on THIS engine ───────────────────────────────
332
+ const turn = await runDeepAgentTurn(scenario, { turnSeq: 1 });
333
+
334
+ // ── Assert ─────────────────────────────────────────────────────────────
335
+ expect(turn.outcome.kind).toBe("returned");
336
+ expect((turn.outcome as { value: Record<string, unknown> }).value.phase).toBe("EXECUTION_COMPLETED");
337
+ const final = record.lastFullStatus!;
338
+ const rows = final.messages.flatMap((m) => m.toolCalls).filter((tc) => tc.id === EXECUTE_CALL_A.id);
339
+ expect(rows, "exactly one copy of the gated call — a resume, not a replay").toHaveLength(1);
340
+ expect(rows[0].status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
341
+ expect(rows[0].result, "the command ran on this engine").toContain("hermetic-a");
342
+ expect(
343
+ Object.values(final.todos).map((t) => [t.content, t.status]),
344
+ "the to-do list the old engine wrote",
345
+ ).toEqual([
346
+ ["Run the command", TodoStatus.TODO_IN_PROGRESS],
347
+ ["Report the result", TodoStatus.TODO_PENDING],
348
+ ]);
349
+ expect(final.messages.at(-1)?.content).toBe(CLOSING_TURN.text);
350
+ expect(registry.urls.every((u) => u.includes("/model-registry"))).toBe(true);
351
+ });
352
+
353
+ it("loads the same stored bytes into the same tuple through the proxy wire", async () => {
354
+ const dbPath = join(env.home, "resume-across-engine-upgrade", file.replace(/\.json$/, ".db"));
355
+ await loadRows(dbPath, fixture);
356
+ const config = { configurable: { thread_id: fixture.threadId, checkpoint_ns: "" } };
357
+ const fromSqlite = await new SqliteCheckpointSaver(dbPath).getTuple(config);
358
+
359
+ const realFetch = globalThis.fetch;
360
+ globalThis.fetch = proxyFetch(fixture);
361
+ try {
362
+ const fromProxy = await new HttpCheckpointSaver("http://proxy.invalid", { current: "t" }, { maxRetries: 0 }).getTuple(config);
363
+ expect(fromSqlite, "the sqlite saver loads the old rows").toBeDefined();
364
+ expect(fromProxy?.checkpoint).toEqual(fromSqlite?.checkpoint);
365
+ expect(fromProxy?.metadata).toEqual(fromSqlite?.metadata);
366
+ expect(fromProxy?.parentConfig).toEqual(fromSqlite?.parentConfig);
367
+ expect(fromProxy?.pendingWrites).toEqual(fromSqlite?.pendingWrites);
368
+ expect(fromSqlite?.pendingWrites?.length, "the paused turn left its interrupt as a pending write").toBeGreaterThan(0);
369
+ } finally {
370
+ globalThis.fetch = realFetch;
371
+ }
372
+ });
373
+ });
374
+ });
@@ -47,6 +47,13 @@
47
47
  * the blocks array the image offload reads — where they were the whole
48
48
  * serialized Command. Those two lines are the whole of that hunk.
49
49
  *
50
+ * Since deepagents 1.14 (2026-09-28): the `task` tool returns the
51
+ * sub-agent's last non-empty text as a plain string, so the same two lines
52
+ * read `"The fixture value is forty-two."` instead of the blocks array. The
53
+ * status offload is size-driven and treats either form the same; an image
54
+ * could only ever have ridden a `task` result inside the sub-agent's final
55
+ * AI message. Those two lines are the whole of that hunk.
56
+ *
50
57
  * Also since #1097: the `task` row sits on the AI message whose text
51
58
  * delegated ("Delegating to the helper."), not on an empty AI message of its
52
59
  * own after it — two root AI messages (the delegating text with the row, the
@@ -33,6 +33,12 @@
33
33
  * thinking mode (one writer of the summary, the runtime). Nothing
34
34
  * else in this golden moved with the flip.
35
35
  *
36
+ * Since deepagents 1.12 (landed with 1.14, 2026-09-28): `read_file`
37
+ * returns the file's lines unnumbered under a status header,
38
+ * `@@ lines 1-3 of 3 @@`, where it returned `cat -n` numbered lines. The
39
+ * row's `result` is that one line of the golden; the clients' read view
40
+ * drops the header (`sdk/typescript/src/execution/tool-view.ts`).
41
+ *
36
42
  * Regenerate ONLY after a deliberate behavior change:
37
43
  * npx vitest run src/activities/execute-deep-agent/__tests__/hermetic -u
38
44
  */
@@ -11,8 +11,10 @@
11
11
  * relative paths to VIRTUAL-absolute at our seam, before enforcement sees
12
12
  * them. The read boundary #528 built out of rules is structural since #754:
13
13
  * the backend is virtual-rooted, so every expressible path resolves inside
14
- * the workspace — an out-of-root name is simply nonexistent, and the
15
- * `.stigmer` symlink keeps platform-dir reads addressable in-root.
14
+ * the workspace — an out-of-root name is simply nonexistent. Platform-dir
15
+ * reads stay addressable in-root at `.stigmer/…`: the virtual-rooted backend
16
+ * refuses to follow the workspace link out of the root, so the platform dir is
17
+ * mounted read-only at that prefix (`platform-route.ts`).
16
18
  *
17
19
  * The graph here is composed exactly the way turn-setup.ts composes the parent:
18
20
  * the PRODUCTION buildMiddlewareStack (pathNormalization present on every
@@ -36,30 +38,36 @@ import { createDeepAgent } from "deepagents";
36
38
  import { buildPlanModePermissions } from "../../../shared/plan-mode-permissions.js";
37
39
  import { buildMiddlewareStack } from "../../../middleware/index.js";
38
40
  import { createCasCaptureBackend } from "../cas-capture-backend.js";
41
+ import { mountPlatformRoute } from "../platform-route.js";
39
42
  import { CasCaptureObserver } from "../cas-capture-observer.js";
40
43
  import { ScriptedModel, type ScriptSelector } from "../__test-utils__/scripted-model.js";
41
44
 
42
45
  const SEEDED_CONTENT = "PLAN_MODE_README_TOKEN: hello from the seeded file";
43
46
  const OUTSIDE_CONTENT = "OUT_OF_ROOT_SECRET_TOKEN: must never cross the boundary";
44
- const PLATFORM_CONTENT = "PLATFORM_SKILL_TOKEN: reached through the .stigmer symlink";
47
+ const PLATFORM_CONTENT = "PLATFORM_SKILL_TOKEN: reached through the .stigmer route";
45
48
 
46
49
  /**
47
50
  * Build a plan-mode parent graph the way turn-setup.ts does: the production
48
51
  * middleware stack with pathNormalization set (derived, like the graph's
49
52
  * permissions, from the plan-mode rules), a filesystem-only CAS capture
50
- * backend (no shellEnv — plan mode clears it), and the production
51
- * buildPlanModePermissions rules on the graph.
53
+ * backend (no shellEnv — plan mode clears it) with the read-only platform
54
+ * route mounted when the workspace link points at `platformDir`, and the
55
+ * production buildPlanModePermissions rules on the graph.
52
56
  */
53
57
  async function buildPlanModeParent(
54
58
  root: string,
55
59
  observer: CasCaptureObserver,
56
60
  script: ScriptSelector,
61
+ platformDir?: string,
57
62
  ) {
58
63
  const { middleware } = buildMiddlewareStack({
59
64
  approvalGate: null,
60
65
  pathNormalization: { rootDir: root },
61
66
  });
62
- const backend = await createCasCaptureBackend({ rootDir: root, observer });
67
+ const backend = await mountPlatformRoute(await createCasCaptureBackend({ rootDir: root, observer }), {
68
+ workspaceDir: root,
69
+ platformDir,
70
+ });
63
71
  return createDeepAgent({
64
72
  model: new ScriptedModel(script),
65
73
  checkpointer: new MemorySaver() as never,
@@ -215,7 +223,7 @@ describe("plan-mode workspace read boundary (issue #528)", () => {
215
223
  });
216
224
 
217
225
  async function invokeOnce(script: ScriptSelector, threadId: string) {
218
- const agent = await buildPlanModeParent(root, observer, script);
226
+ const agent = await buildPlanModeParent(root, observer, script, platform);
219
227
  return (await agent.invoke(
220
228
  { messages: [new HumanMessage({ content: "go" })] },
221
229
  { configurable: { thread_id: threadId }, recursionLimit: 50 },
@@ -256,7 +264,7 @@ describe("plan-mode workspace read boundary (issue #528)", () => {
256
264
  expect(lsResult).not.toMatch(/permission denied/i);
257
265
  });
258
266
 
259
- it("reads platform-dir material through the .stigmer symlink — in-root strings, out-of-root bytes", async () => {
267
+ it("reads platform-dir material at .stigmer — in-root strings, out-of-root bytes", async () => {
260
268
  const result = await invokeOnce(
261
269
  () => ({
262
270
  toolCalls: [