@stigmer/runner 3.1.3 → 3.1.4

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 (83) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/capture-flow.d.ts +26 -15
  3. package/dist/activities/execute-cursor/capture-flow.js +56 -18
  4. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  5. package/dist/activities/execute-cursor/command-provenance.d.ts +11 -25
  6. package/dist/activities/execute-cursor/command-provenance.js +25 -115
  7. package/dist/activities/execute-cursor/command-provenance.js.map +1 -1
  8. package/dist/activities/execute-cursor/index.js +296 -477
  9. package/dist/activities/execute-cursor/index.js.map +1 -1
  10. package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
  11. package/dist/activities/execute-cursor/todo-tracker.js +15 -43
  12. package/dist/activities/execute-cursor/todo-tracker.js.map +1 -1
  13. package/dist/activities/execute-cursor/turn-stream.d.ts +141 -0
  14. package/dist/activities/execute-cursor/turn-stream.js +249 -0
  15. package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
  16. package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
  17. package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
  18. package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
  19. package/dist/activities/execute-deep-agent/index.js +60 -18
  20. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/status-builder.js +8 -1
  22. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/v3-status-builder.js +12 -1
  24. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  25. package/dist/otel.js +10 -0
  26. package/dist/otel.js.map +1 -1
  27. package/dist/shared/filereview/cas-progress.d.ts +63 -0
  28. package/dist/shared/filereview/cas-progress.js +128 -0
  29. package/dist/shared/filereview/cas-progress.js.map +1 -0
  30. package/dist/shared/filereview/cas-substrate.d.ts +29 -0
  31. package/dist/shared/filereview/cas-substrate.js +46 -21
  32. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  33. package/dist/shared/filereview/command-provenance.d.ts +93 -0
  34. package/dist/shared/filereview/command-provenance.js +132 -0
  35. package/dist/shared/filereview/command-provenance.js.map +1 -0
  36. package/dist/shared/filereview/git-substrate.d.ts +9 -3
  37. package/dist/shared/filereview/git-substrate.js.map +1 -1
  38. package/dist/shared/filereview/index.d.ts +4 -3
  39. package/dist/shared/filereview/index.js +3 -2
  40. package/dist/shared/filereview/index.js.map +1 -1
  41. package/dist/shared/filereview/progress.d.ts +105 -34
  42. package/dist/shared/filereview/progress.js +96 -34
  43. package/dist/shared/filereview/progress.js.map +1 -1
  44. package/dist/shared/plan-mode-prompt.d.ts +9 -0
  45. package/dist/shared/plan-mode-prompt.js +18 -0
  46. package/dist/shared/plan-mode-prompt.js.map +1 -1
  47. package/dist/shared/todos.d.ts +56 -0
  48. package/dist/shared/todos.js +98 -0
  49. package/dist/shared/todos.js.map +1 -0
  50. package/dist/shared/tool-row.d.ts +16 -0
  51. package/dist/shared/tool-row.js +31 -0
  52. package/dist/shared/tool-row.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/__tests__/otel-turn-span.test.ts +61 -0
  55. package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
  56. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
  57. package/src/activities/execute-cursor/capture-flow.ts +71 -25
  58. package/src/activities/execute-cursor/command-provenance.ts +25 -120
  59. package/src/activities/execute-cursor/index.ts +344 -504
  60. package/src/activities/execute-cursor/todo-tracker.ts +17 -59
  61. package/src/activities/execute-cursor/turn-stream.ts +418 -0
  62. package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
  63. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
  64. package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
  65. package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
  66. package/src/activities/execute-deep-agent/index.ts +74 -18
  67. package/src/activities/execute-deep-agent/status-builder.ts +9 -0
  68. package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
  69. package/src/otel.ts +8 -0
  70. package/src/shared/__tests__/todos.test.ts +216 -0
  71. package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
  72. package/src/shared/filereview/__tests__/cas-substrate.test.ts +66 -0
  73. package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
  74. package/src/shared/filereview/__tests__/progress.test.ts +112 -10
  75. package/src/shared/filereview/cas-progress.ts +170 -0
  76. package/src/shared/filereview/cas-substrate.ts +69 -24
  77. package/src/shared/filereview/command-provenance.ts +180 -0
  78. package/src/shared/filereview/git-substrate.ts +9 -3
  79. package/src/shared/filereview/index.ts +15 -1
  80. package/src/shared/filereview/progress.ts +171 -47
  81. package/src/shared/plan-mode-prompt.ts +18 -0
  82. package/src/shared/todos.ts +126 -0
  83. package/src/shared/tool-row.ts +34 -0
@@ -52,14 +52,26 @@ import {
52
52
  } from "../../shared/filereview/capture.js";
53
53
  import {
54
54
  captureFileChangeProgress,
55
+ createGitProgressSubstrate,
56
+ createHybridProgressSubstrate,
55
57
  newProgressCaptureState,
58
+ type ProgressSubstrate,
56
59
  } from "../../shared/filereview/progress.js";
60
+ import {
61
+ createCasProgressSubstrate,
62
+ type CasTouchedSnapshot,
63
+ } from "../../shared/filereview/cas-progress.js";
57
64
  import { hasCandidateCaptured } from "../../shared/filereview/events.js";
58
65
  import { casBlobReader, type CasPathCapture } from "../../shared/filereview/cas-substrate.js";
59
66
  import { partitionIgnoredPathsBySecret } from "../../shared/filereview/secret-paths.js";
60
67
  import type { CasCaptureObserver } from "./cas-capture-observer.js";
61
- import { collectSubAgentToolCallIds, withholdSecretContentFromMessages } from "../../shared/tool-row.js";
68
+ import {
69
+ collectSettledToolCallIds,
70
+ collectSubAgentToolCallIds,
71
+ withholdSecretContentFromMessages,
72
+ } from "../../shared/tool-row.js";
62
73
  import { stampFlowedFileEditRows, stampFlowedSubAgentFileEditRows } from "./stamp-flowed-rows.js";
74
+ import { deriveTurnCommandProvenance } from "./command-provenance.js";
63
75
 
64
76
  /** The harness id stamped on the deep-agent's file-review ledger events. */
65
77
  const DEEP_AGENT_HARNESS_ID = "deep-agent";
@@ -312,6 +324,12 @@ export function createDeepAgentActivities(config: Config) {
312
324
  // seeds prior sub-agents — but computed, not assumed, so it stays correct
313
325
  // if that ever changes; see collectSubAgentToolCallIds).
314
326
  const priorSubAgentToolCallIds = collectSubAgentToolCallIds(initialStatus.subAgentExecutions);
327
+ // Snapshot the top-level tool-call ids already SETTLED before this turn's
328
+ // stream, so the approved-command provenance (DD-28) scopes itself to THIS
329
+ // turn's executed commands by identity. The deep-agent's approved shell
330
+ // executes in place at its seeded position, so the Cursor positional scope
331
+ // would miss it — see execute-deep-agent/command-provenance.ts.
332
+ const priorSettledToolCallIds = collectSettledToolCallIds(initialStatus.messages);
315
333
  if (setup.captureMode) {
316
334
  captureBaselineTree = await captureBaselineToLedger({
317
335
  status: initialStatus,
@@ -323,14 +341,34 @@ export function createDeepAgentActivities(config: Config) {
323
341
  });
324
342
  }
325
343
 
326
- // Per-turn state for mid-run live capture (DD-32): the last progress tree
327
- // sha (short-circuit) + last capture time (floor), threaded across the
328
- // streaming loop's persists via the beforePersist hook below. The capture
329
- // flags are hoisted so the deferred hook does not depend on `setup`
330
- // (non-null here, but not narrowable inside a later-invoked closure).
344
+ // Per-turn state + substrate for mid-run live capture (DD-32 / DD-33). The
345
+ // floor lives in progressState; the substrate is chosen for this turn's
346
+ // workspace shape and owns its own short-circuit cache. An atomic snapshot
347
+ // of the shared observer feeds the CAS/HYBRID substrates copied
348
+ // synchronously so a concurrent sub-agent write cannot mutate it mid-read.
331
349
  const progressState = newProgressCaptureState();
332
- const progressCaptureMode = setup.captureMode;
333
- const progressGitWorkspace = setup.gitWorkspace;
350
+ const casObserver = setup.casObserver;
351
+ const readObserverTouched = (): CasTouchedSnapshot => ({
352
+ before: new Map(casObserver.before),
353
+ blockedSecretPaths: new Set(casObserver.blockedSecretPaths),
354
+ });
355
+ // Git tree -> hybrid (numstat for tracked + observer for gitignored);
356
+ // non-git -> cas over the observer (no baseline tree needed). Undefined
357
+ // outside capture mode (writes are deny-gated, nothing is captured).
358
+ const progressSubstrate: ProgressSubstrate | undefined = !setup.captureMode
359
+ ? undefined
360
+ : setup.gitWorkspace
361
+ ? captureBaselineTree
362
+ ? createHybridProgressSubstrate(
363
+ createGitProgressSubstrate({
364
+ workspaceRoot: gitRoot,
365
+ executionId,
366
+ baselineTree: captureBaselineTree,
367
+ }),
368
+ createCasProgressSubstrate({ workspaceRoot: gitRoot, read: readObserverTouched }),
369
+ )
370
+ : undefined
371
+ : createCasProgressSubstrate({ workspaceRoot: gitRoot, read: readObserverTouched });
334
372
 
335
373
  const cancellationSignal = Context.current().cancellationSignal;
336
374
 
@@ -357,20 +395,18 @@ export function createDeepAgentActivities(config: Config) {
357
395
  globalBypass: setup.globalBypass,
358
396
  },
359
397
  streamVersion: setup.streamVersion,
360
- // Mid-run live capture (DD-32): attach file_change_progress before each
361
- // scheduled persist. Git capture mode only (a pinned baseline exists);
362
- // deep-agent writes no runner-owned gate files into the tree, so no
363
- // excludePaths matching its turn-boundary candidate capture.
398
+ // Mid-run live capture (DD-32 / DD-33): attach file_change_progress
399
+ // before each scheduled persist, throttled by the floor inside
400
+ // captureFileChangeProgress. The substrate (git / non-git CAS / hybrid)
401
+ // was chosen for this turn above; deep-agent writes no runner-owned gate
402
+ // files into the tree, so the git slice needs no excludePaths — matching
403
+ // its turn-boundary candidate capture.
364
404
  beforePersist: async (status) => {
365
- if (!progressCaptureMode || !progressGitWorkspace || !captureBaselineTree) {
366
- return;
367
- }
405
+ if (!progressSubstrate) return;
368
406
  await captureFileChangeProgress({
369
407
  status,
370
- gitRoot,
371
- executionId,
372
408
  changeSetId,
373
- baselineTree: captureBaselineTree,
409
+ substrate: progressSubstrate,
374
410
  state: progressState,
375
411
  });
376
412
  },
@@ -429,6 +465,25 @@ export function createDeepAgentActivities(config: Config) {
429
465
  gitRoot,
430
466
  casCaptureClass,
431
467
  );
468
+ // Approved-command turn facts (DD-28): when every mutation-capable call
469
+ // this turn was a consented shell command, attach the provenance so the
470
+ // backend can verify the cited consent rows and auto-keep the set
471
+ // instead of arming a second review gate. Fail-closed: any non-qualifying
472
+ // turn attaches nothing and reviews manually exactly as before. Attached
473
+ // only when captureCandidateToLedger actually authors a CANDIDATE.
474
+ const commandProvenance = deriveTurnCommandProvenance({
475
+ status: initialStatus,
476
+ priorSettledToolCallIds,
477
+ priorSubAgentToolCallIds,
478
+ globalBypass: setup.globalBypass,
479
+ });
480
+ if (commandProvenance) {
481
+ console.log(
482
+ `[ExecuteDeepAgent] capture: turn qualifies for approved-command auto-keep ` +
483
+ `(consent rows: ${commandProvenance.consentToolCallIds.join(",") || "(auto_approve_all)"}); ` +
484
+ `attaching provenance to candidate (execution=${executionId})`,
485
+ );
486
+ }
432
487
  await captureCandidateToLedger({
433
488
  status: initialStatus,
434
489
  gitRoot,
@@ -441,6 +496,7 @@ export function createDeepAgentActivities(config: Config) {
441
496
  unreviewablePaths,
442
497
  unreviewableCaptureClass: casCaptureClass,
443
498
  gitWorkspace: setup.gitWorkspace,
499
+ commandProvenance,
444
500
  });
445
501
  // Review is pending iff a CANDIDATE was actually authored (the seam
446
502
  // drops no-op captures), so a turn that only touched-then-unchanged an
@@ -24,9 +24,11 @@ import {
24
24
  ExecutionPhase,
25
25
  MessageType,
26
26
  ToolCallStatus,
27
+ ToolKind,
27
28
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
28
29
  import { resolveApprovalMessage as resolveApprovalMsg } from "../../shared/approval-policy.js";
29
30
  import { classifyTool } from "../../shared/tool-kind.js";
31
+ import { applyTodoUpdate } from "../../shared/todos.js";
30
32
  import { ExecutionState } from "./execution-state.js";
31
33
  import { utcTimestamp } from "../../shared/status.js";
32
34
  import type { ExecutionStatusWriter } from "./execution-status-writer.js";
@@ -348,6 +350,13 @@ export class StatusBuilder {
348
350
  // (see v3 builder note). Truncating here would corrupt image base64.
349
351
  tc.status = ToolCallStatus.TOOL_CALL_COMPLETED;
350
352
  tc.result = extractToolResult(event.data);
353
+
354
+ // Project a completed to-do write into status.todos — success branch only,
355
+ // since a failed write_todos never ran its state-mutating Command. Shares
356
+ // the mapper and rationale documented in V3StatusBuilder.handleToolFinished.
357
+ if (tc.toolKind === ToolKind.TODO) {
358
+ applyTodoUpdate(this.state.proto.todos, tc.args?.todos, { merge: false });
359
+ }
351
360
  }
352
361
 
353
362
  tc.completedAt = utcTimestamp();
@@ -23,9 +23,11 @@ import {
23
23
  ExecutionPhase,
24
24
  MessageType,
25
25
  ToolCallStatus,
26
+ ToolKind,
26
27
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
27
28
  import { resolveApprovalMessage as resolveApprovalMsg } from "../../shared/approval-policy.js";
28
29
  import { classifyTool } from "../../shared/tool-kind.js";
30
+ import { applyTodoUpdate } from "../../shared/todos.js";
29
31
  import { ExecutionState } from "./execution-state.js";
30
32
  import { utcTimestamp } from "../../shared/status.js";
31
33
  import type { ExecutionStatusWriter } from "./execution-status-writer.js";
@@ -340,6 +342,17 @@ export class V3StatusBuilder implements ExecutionStatusWriter {
340
342
  this.state.toolStartTimes.delete(callId);
341
343
  this.toolArgBuffers.delete(callId);
342
344
 
345
+ // Project a completed to-do write into status.todos. deepagents' write_todos
346
+ // runs its state-mutating Command when the tool node COMPLETES, so we mirror
347
+ // it here (not at tool-start) — a call cancelled before finishing correctly
348
+ // projects nothing. Keyed on the harness-agnostic ToolKind.TODO (stamped at
349
+ // tool-start) and fed by the same shared mapper the Cursor tracker uses;
350
+ // deepagents always full-replaces (no merge field). The tool call itself
351
+ // stays in messages (the client filters ToolKind.TODO from the thread).
352
+ if (tc.toolKind === ToolKind.TODO) {
353
+ applyTodoUpdate(this.state.proto.todos, tc.args?.todos, { merge: false });
354
+ }
355
+
343
356
  this._forceNextUpdate = true;
344
357
  }
345
358
 
package/src/otel.ts CHANGED
@@ -125,12 +125,20 @@ export async function startCursorTurnSpan(attrs: {
125
125
  [ATTR_SESSION_ID]: attrs.sessionId,
126
126
  },
127
127
  });
128
+ // Guard against a double end / a late setTokens: the turn span is ended from
129
+ // the activity's finally (finishTurnTelemetry), and OTel warns when a span is
130
+ // ended twice or mutated after end. Making this a no-op keeps the caller free
131
+ // to end idempotently on every exit path.
132
+ let ended = false;
128
133
  return {
129
134
  setTokens(input: number, output: number) {
135
+ if (ended) return;
130
136
  if (input) span.setAttribute(ATTR_LLM_INPUT_TOKENS, input);
131
137
  if (output) span.setAttribute(ATTR_LLM_OUTPUT_TOKENS, output);
132
138
  },
133
139
  end() {
140
+ if (ended) return;
141
+ ended = true;
134
142
  span.end();
135
143
  },
136
144
  };
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Unit tests for applyTodoUpdate — the harness-agnostic to-do map projection
3
+ * shared by the Cursor TodoTracker and the native v2/v3 status builders.
4
+ *
5
+ * These pin the exact mapping contract (status coercion, id synthesis,
6
+ * created_at preservation, merge vs. full-replace, clear-on-empty, defensive
7
+ * input handling, dirty return) so no two writers of status.todos can drift.
8
+ */
9
+
10
+ import { describe, it, expect } from "vitest";
11
+ import { TodoStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
12
+ import type { TodoItem } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/todo_pb";
13
+ import { applyTodoUpdate } from "../todos.js";
14
+
15
+ function emptyMap(): { [key: string]: TodoItem } {
16
+ return {};
17
+ }
18
+
19
+ describe("applyTodoUpdate", () => {
20
+ describe("status mapping", () => {
21
+ it("maps pending", () => {
22
+ const m = emptyMap();
23
+ applyTodoUpdate(m, [{ content: "A", status: "pending" }], { merge: false });
24
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_PENDING);
25
+ });
26
+
27
+ it("maps in_progress (snake_case)", () => {
28
+ const m = emptyMap();
29
+ applyTodoUpdate(m, [{ content: "A", status: "in_progress" }], { merge: false });
30
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_IN_PROGRESS);
31
+ });
32
+
33
+ it("maps inProgress (camelCase, via toLowerCase)", () => {
34
+ const m = emptyMap();
35
+ applyTodoUpdate(m, [{ content: "A", status: "inProgress" }], { merge: false });
36
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_IN_PROGRESS);
37
+ });
38
+
39
+ it("maps completed", () => {
40
+ const m = emptyMap();
41
+ applyTodoUpdate(m, [{ content: "A", status: "completed" }], { merge: false });
42
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_COMPLETED);
43
+ });
44
+
45
+ it("maps cancelled (Cursor superset)", () => {
46
+ const m = emptyMap();
47
+ applyTodoUpdate(m, [{ content: "A", status: "cancelled" }], { merge: false });
48
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_CANCELLED);
49
+ });
50
+
51
+ it("defaults unknown status to pending", () => {
52
+ const m = emptyMap();
53
+ applyTodoUpdate(m, [{ content: "A", status: "bogus" }], { merge: false });
54
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_PENDING);
55
+ });
56
+
57
+ it("defaults missing status to pending", () => {
58
+ const m = emptyMap();
59
+ applyTodoUpdate(m, [{ content: "A" }], { merge: false });
60
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_PENDING);
61
+ });
62
+ });
63
+
64
+ describe("id synthesis", () => {
65
+ it("uses a provided id", () => {
66
+ const m = emptyMap();
67
+ applyTodoUpdate(m, [{ id: "t1", content: "A", status: "pending" }], { merge: false });
68
+ expect(Object.keys(m)).toEqual(["t1"]);
69
+ expect(m["t1"].id).toBe("t1");
70
+ });
71
+
72
+ it("falls back to todo-<index> when id is absent", () => {
73
+ const m = emptyMap();
74
+ applyTodoUpdate(
75
+ m,
76
+ [
77
+ { content: "A", status: "pending" },
78
+ { content: "B", status: "completed" },
79
+ ],
80
+ { merge: false },
81
+ );
82
+ expect(Object.keys(m)).toEqual(["todo-0", "todo-1"]);
83
+ });
84
+
85
+ it("falls back to todo-<index> when id is an empty string", () => {
86
+ const m = emptyMap();
87
+ applyTodoUpdate(m, [{ id: "", content: "A", status: "pending" }], { merge: false });
88
+ expect(Object.keys(m)).toEqual(["todo-0"]);
89
+ });
90
+ });
91
+
92
+ describe("timestamps", () => {
93
+ it("sets created_at from raw.created_at on full-replace", () => {
94
+ const m = emptyMap();
95
+ applyTodoUpdate(
96
+ m,
97
+ [{ id: "t1", content: "A", status: "pending", created_at: "2026-01-01T00:00:00Z" }],
98
+ { merge: false, now: "2026-06-06T06:06:06Z" },
99
+ );
100
+ expect(m["t1"].createdAt).toBe("2026-01-01T00:00:00Z");
101
+ });
102
+
103
+ it("uses now for created_at when raw.created_at is absent (full-replace)", () => {
104
+ const m = emptyMap();
105
+ applyTodoUpdate(m, [{ content: "A", status: "pending" }], {
106
+ merge: false,
107
+ now: "2026-06-06T06:06:06Z",
108
+ });
109
+ expect(m["todo-0"].createdAt).toBe("2026-06-06T06:06:06Z");
110
+ });
111
+
112
+ it("always bumps updated_at to now", () => {
113
+ const m = emptyMap();
114
+ applyTodoUpdate(m, [{ content: "A", status: "pending" }], {
115
+ merge: false,
116
+ now: "2026-06-06T06:06:06Z",
117
+ });
118
+ expect(m["todo-0"].updatedAt).toBe("2026-06-06T06:06:06Z");
119
+ });
120
+
121
+ it("preserves created_at from the prior entry ONLY on merge", () => {
122
+ const m = emptyMap();
123
+ applyTodoUpdate(m, [{ id: "t1", content: "A", status: "pending" }], {
124
+ merge: false,
125
+ now: "2026-01-01T00:00:00Z",
126
+ });
127
+ applyTodoUpdate(m, [{ id: "t1", content: "A", status: "completed" }], {
128
+ merge: true,
129
+ now: "2026-02-02T00:00:00Z",
130
+ });
131
+ expect(m["t1"].createdAt).toBe("2026-01-01T00:00:00Z");
132
+ expect(m["t1"].updatedAt).toBe("2026-02-02T00:00:00Z");
133
+ expect(m["t1"].status).toBe(TodoStatus.TODO_COMPLETED);
134
+ });
135
+
136
+ it("does NOT preserve created_at on full-replace of the same id", () => {
137
+ const m = emptyMap();
138
+ applyTodoUpdate(m, [{ id: "t1", content: "A", status: "pending" }], {
139
+ merge: false,
140
+ now: "2026-01-01T00:00:00Z",
141
+ });
142
+ applyTodoUpdate(m, [{ id: "t1", content: "A", status: "pending" }], {
143
+ merge: false,
144
+ now: "2026-02-02T00:00:00Z",
145
+ });
146
+ expect(m["t1"].createdAt).toBe("2026-02-02T00:00:00Z");
147
+ });
148
+ });
149
+
150
+ describe("merge vs. full-replace", () => {
151
+ it("full-replace clears prior entries", () => {
152
+ const m = emptyMap();
153
+ applyTodoUpdate(m, [{ id: "a", content: "A", status: "pending" }], { merge: false });
154
+ applyTodoUpdate(m, [{ id: "b", content: "B", status: "pending" }], { merge: false });
155
+ expect(Object.keys(m)).toEqual(["b"]);
156
+ });
157
+
158
+ it("merge preserves other existing entries", () => {
159
+ const m = emptyMap();
160
+ applyTodoUpdate(m, [{ id: "a", content: "A", status: "pending" }], { merge: false });
161
+ applyTodoUpdate(m, [{ id: "b", content: "B", status: "pending" }], { merge: true });
162
+ expect(Object.keys(m).sort()).toEqual(["a", "b"]);
163
+ });
164
+
165
+ it("merge updates an existing entry by id", () => {
166
+ const m = emptyMap();
167
+ applyTodoUpdate(m, [{ id: "a", content: "A", status: "pending" }], { merge: false });
168
+ applyTodoUpdate(m, [{ id: "a", content: "A2", status: "completed" }], { merge: true });
169
+ expect(m["a"].content).toBe("A2");
170
+ expect(m["a"].status).toBe(TodoStatus.TODO_COMPLETED);
171
+ });
172
+ });
173
+
174
+ describe("clear-on-empty", () => {
175
+ it("clears the map on empty array + full-replace and reports a change", () => {
176
+ const m = emptyMap();
177
+ applyTodoUpdate(m, [{ id: "a", content: "A", status: "pending" }], { merge: false });
178
+ const changed = applyTodoUpdate(m, [], { merge: false });
179
+ expect(changed).toBe(true);
180
+ expect(Object.keys(m)).toHaveLength(0);
181
+ });
182
+
183
+ it("does NOT clear and reports no change on empty array + merge", () => {
184
+ const m = emptyMap();
185
+ applyTodoUpdate(m, [{ id: "a", content: "A", status: "pending" }], { merge: false });
186
+ const changed = applyTodoUpdate(m, [], { merge: true });
187
+ expect(changed).toBe(false);
188
+ expect(Object.keys(m)).toEqual(["a"]);
189
+ });
190
+
191
+ it("treats a non-array payload like empty (clears on full-replace)", () => {
192
+ const m = emptyMap();
193
+ applyTodoUpdate(m, [{ id: "a", content: "A", status: "pending" }], { merge: false });
194
+ const changed = applyTodoUpdate(m, undefined, { merge: false });
195
+ expect(changed).toBe(true);
196
+ expect(Object.keys(m)).toHaveLength(0);
197
+ });
198
+ });
199
+
200
+ describe("defensive input handling", () => {
201
+ it("coerces non-object array entries to empty todos", () => {
202
+ const m = emptyMap();
203
+ const changed = applyTodoUpdate(m, ["oops", null, 42], { merge: false });
204
+ expect(changed).toBe(true);
205
+ expect(Object.keys(m)).toEqual(["todo-0", "todo-1", "todo-2"]);
206
+ expect(m["todo-0"].content).toBe("");
207
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_PENDING);
208
+ });
209
+ });
210
+
211
+ describe("dirty return value", () => {
212
+ it("returns true when items are written", () => {
213
+ expect(applyTodoUpdate(emptyMap(), [{ content: "A" }], { merge: false })).toBe(true);
214
+ });
215
+ });
216
+ });
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Unit tests for the CAS mid-run progress substrate (DD-33) — the non-git /
3
+ * gitignored half of the "N files changed so far" strip.
4
+ *
5
+ * The substrate reads a {@link CasTouchedSnapshot} (the same before-map the
6
+ * turn-boundary CAS capture reads) and the after-bytes off a real temp workspace,
7
+ * classifies each via the shared {@link classifyCasChange}, and reports a bounded
8
+ * read-prefix plus an honest `totalFilesChanged`. These cases prove: kind
9
+ * derivation, no-op/created-then-deleted exclusion, binary + oversize zeroing,
10
+ * secret exclusion, the read budget + tail total, revert-to-empty, and the
11
+ * size+mtime short-circuit.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
15
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
+ import { tmpdir } from "node:os";
18
+ import { FileChangeKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
19
+ import { createCasProgressSubstrate, type CasTouchedSnapshot } from "../cas-progress.js";
20
+ import { LINE_COUNT_MAX_BYTES } from "../line-counts.js";
21
+ import type { ProgressEntry } from "../progress.js";
22
+
23
+ let workspace: string;
24
+
25
+ beforeEach(() => {
26
+ workspace = mkdtempSync(join(tmpdir(), "cas-progress-"));
27
+ });
28
+
29
+ afterEach(() => {
30
+ rmSync(workspace, { recursive: true, force: true });
31
+ });
32
+
33
+ function bytes(s: string): Uint8Array {
34
+ return new TextEncoder().encode(s);
35
+ }
36
+
37
+ /** Write after-bytes to disk (the observed net result of the turn). */
38
+ function put(rel: string, content: string | Uint8Array): void {
39
+ const abs = join(workspace, rel);
40
+ mkdirSync(dirname(abs), { recursive: true });
41
+ writeFileSync(abs, typeof content === "string" ? content : Buffer.from(content));
42
+ }
43
+
44
+ /** A static reader over an explicit before-map + blocked set. */
45
+ function reader(
46
+ before: Map<string, Uint8Array | null>,
47
+ blockedSecretPaths: ReadonlySet<string> = new Set(),
48
+ ): () => CasTouchedSnapshot {
49
+ return () => ({ before, blockedSecretPaths });
50
+ }
51
+
52
+ function entryFor(entries: readonly ProgressEntry[], path: string): ProgressEntry | undefined {
53
+ return entries.find((e) => e.pathAfter === path || e.pathBefore === path);
54
+ }
55
+
56
+ describe("createCasProgressSubstrate", () => {
57
+ it("classifies ADD / MODIFY / DELETE with line counts from the observer + disk", async () => {
58
+ // before-map: new.ts is new (null), main.ts + gone.ts existed.
59
+ const before = new Map<string, Uint8Array | null>([
60
+ ["src/new.ts", null],
61
+ ["src/main.ts", bytes("export const x = 1;\n")],
62
+ ["gone.ts", bytes("l1\nl2\nl3\n")],
63
+ ]);
64
+ put("src/new.ts", "a\nb\nc\n"); // ADD, +3
65
+ put("src/main.ts", "export const x = 2;\nexport const y = 3;\n"); // MODIFY
66
+ // gone.ts is left absent on disk -> DELETE
67
+
68
+ const sub = createCasProgressSubstrate({ workspaceRoot: workspace, read: reader(before) });
69
+ const { delta, changed } = await sub.capture();
70
+ expect(changed).toBe(true);
71
+ expect(delta.totalFilesChanged).toBe(3);
72
+ expect(delta.entries).toHaveLength(3);
73
+
74
+ const added = entryFor(delta.entries, "src/new.ts")!;
75
+ expect(added.kind).toBe(FileChangeKind.ADD);
76
+ expect(added.pathBefore).toBe("");
77
+ expect(added.linesAdded).toBe(3);
78
+ expect(added.linesRemoved).toBe(0);
79
+
80
+ const modified = entryFor(delta.entries, "src/main.ts")!;
81
+ expect(modified.kind).toBe(FileChangeKind.MODIFY);
82
+ expect(modified.linesAdded).toBeGreaterThan(0);
83
+
84
+ const deleted = entryFor(delta.entries, "gone.ts")!;
85
+ expect(deleted.kind).toBe(FileChangeKind.DELETE);
86
+ expect(deleted.pathAfter).toBe("");
87
+ expect(deleted.linesRemoved).toBe(3);
88
+ });
89
+
90
+ it("excludes a no-op touch (bytes unchanged) from entries and the total", async () => {
91
+ const before = new Map<string, Uint8Array | null>([
92
+ ["noop.ts", bytes("same\n")],
93
+ ["real.ts", bytes("a\n")],
94
+ ]);
95
+ put("noop.ts", "same\n"); // unchanged
96
+ put("real.ts", "a\nb\n"); // changed
97
+
98
+ const sub = createCasProgressSubstrate({ workspaceRoot: workspace, read: reader(before) });
99
+ const { delta } = await sub.capture();
100
+ expect(entryFor(delta.entries, "noop.ts")).toBeUndefined();
101
+ expect(entryFor(delta.entries, "real.ts")).toBeDefined();
102
+ expect(delta.totalFilesChanged).toBe(1);
103
+ });
104
+
105
+ it("excludes a created-then-deleted path (both sides absent)", async () => {
106
+ const before = new Map<string, Uint8Array | null>([["ephemeral.ts", null]]);
107
+ // never written to disk -> after is null, before is null -> no change
108
+
109
+ const sub = createCasProgressSubstrate({ workspaceRoot: workspace, read: reader(before) });
110
+ const { delta } = await sub.capture();
111
+ expect(delta.entries).toHaveLength(0);
112
+ expect(delta.totalFilesChanged).toBe(0);
113
+ });
114
+
115
+ it("zeroes counts for a binary file", async () => {
116
+ const before = new Map<string, Uint8Array | null>([["blob.bin", null]]);
117
+ put("blob.bin", new Uint8Array([0, 1, 2, 0, 255, 0]));
118
+
119
+ const sub = createCasProgressSubstrate({ workspaceRoot: workspace, read: reader(before) });
120
+ const { delta } = await sub.capture();
121
+ const bin = entryFor(delta.entries, "blob.bin")!;
122
+ expect(bin.kind).toBe(FileChangeKind.ADD);
123
+ expect(bin.linesAdded).toBe(0);
124
+ expect(bin.linesRemoved).toBe(0);
125
+ });
126
+
127
+ it("derives kind for an oversize after WITHOUT reading it (zeroed counts)", async () => {
128
+ const before = new Map<string, Uint8Array | null>([["huge.ts", null]]);
129
+ // One byte over the count ceiling: the substrate stats it, sees it is too
130
+ // large, and emits an ADD with zero counts rather than reading MBs.
131
+ put("huge.ts", "x".repeat(LINE_COUNT_MAX_BYTES + 1));
132
+
133
+ const sub = createCasProgressSubstrate({ workspaceRoot: workspace, read: reader(before) });
134
+ const { delta } = await sub.capture();
135
+ const huge = entryFor(delta.entries, "huge.ts")!;
136
+ expect(huge.kind).toBe(FileChangeKind.ADD);
137
+ expect(huge.linesAdded).toBe(0);
138
+ expect(huge.linesRemoved).toBe(0);
139
+ expect(delta.totalFilesChanged).toBe(1);
140
+ });
141
+
142
+ it("excludes gate-blocked secret paths up front", async () => {
143
+ const before = new Map<string, Uint8Array | null>([
144
+ [".env", null],
145
+ ["app.ts", null],
146
+ ]);
147
+ put(".env", "TOKEN=abc\n");
148
+ put("app.ts", "code\n");
149
+
150
+ const sub = createCasProgressSubstrate({
151
+ workspaceRoot: workspace,
152
+ read: reader(before, new Set([".env"])),
153
+ });
154
+ const { delta } = await sub.capture();
155
+ expect(entryFor(delta.entries, ".env")).toBeUndefined();
156
+ expect(entryFor(delta.entries, "app.ts")).toBeDefined();
157
+ expect(delta.totalFilesChanged).toBe(1);
158
+ });
159
+
160
+ it("also excludes a secret-like path even when not gate-blocked (backstop)", async () => {
161
+ // No gate-block set, but the path LOOKS secret-like: partitionIgnoredPathsBySecret
162
+ // withholds it (the global-bypass backstop).
163
+ const before = new Map<string, Uint8Array | null>([["config/id_rsa", null]]);
164
+ put("config/id_rsa", "-----BEGIN KEY-----\n");
165
+
166
+ const sub = createCasProgressSubstrate({ workspaceRoot: workspace, read: reader(before) });
167
+ const { delta } = await sub.capture();
168
+ expect(delta.entries).toHaveLength(0);
169
+ expect(delta.totalFilesChanged).toBe(0);
170
+ });
171
+
172
+ it("bounds reads to maxEntries but keeps totalFilesChanged honest over the tail", async () => {
173
+ const before = new Map<string, Uint8Array | null>();
174
+ for (let i = 0; i < 5; i++) {
175
+ const p = `f${String(i).padStart(2, "0")}.ts`;
176
+ before.set(p, null);
177
+ put(p, `content ${i}\n`);
178
+ }
179
+ const sub = createCasProgressSubstrate({
180
+ workspaceRoot: workspace,
181
+ read: reader(before),
182
+ maxEntries: 2,
183
+ });
184
+ const { delta } = await sub.capture();
185
+ // Only the sorted prefix is read into entries…
186
+ expect(delta.entries).toHaveLength(2);
187
+ expect(delta.entries.map((e) => e.pathAfter)).toEqual(["f00.ts", "f01.ts"]);
188
+ // …but the honest total covers every capturable file.
189
+ expect(delta.totalFilesChanged).toBe(5);
190
+ });
191
+
192
+ it("returns a zero-file delta after the agent reverts its own edits", async () => {
193
+ const before = new Map<string, Uint8Array | null>([["r.ts", null]]);
194
+ // r.ts observed but never left on disk (reverted) -> after null, before null.
195
+ const sub = createCasProgressSubstrate({ workspaceRoot: workspace, read: reader(before) });
196
+ const { delta } = await sub.capture();
197
+ expect(delta.totalFilesChanged).toBe(0);
198
+ expect(delta.entries).toHaveLength(0);
199
+ });
200
+
201
+ it("short-circuits (changed:false) when the touched set + prefix stats are unchanged", async () => {
202
+ const before = new Map<string, Uint8Array | null>([["a.ts", null]]);
203
+ put("a.ts", "hello\n");
204
+ const sub = createCasProgressSubstrate({ workspaceRoot: workspace, read: reader(before) });
205
+
206
+ const first = await sub.capture();
207
+ expect(first.changed).toBe(true);
208
+
209
+ const second = await sub.capture();
210
+ expect(second.changed).toBe(false);
211
+ // The cached full delta is still returned so the hybrid can merge it.
212
+ expect(second.delta.totalFilesChanged).toBe(1);
213
+ });
214
+
215
+ it("re-reports changed:true when a NEW path is touched (e.g. a sub-agent write)", async () => {
216
+ const before = new Map<string, Uint8Array | null>([["a.ts", null]]);
217
+ put("a.ts", "hello\n");
218
+ const sub = createCasProgressSubstrate({ workspaceRoot: workspace, read: reader(before) });
219
+ await sub.capture();
220
+
221
+ // A concurrent write adds a new observed path.
222
+ before.set("b.ts", null);
223
+ put("b.ts", "world\n");
224
+ const next = await sub.capture();
225
+ expect(next.changed).toBe(true);
226
+ expect(next.delta.totalFilesChanged).toBe(2);
227
+ });
228
+ });