@stigmer/runner 3.1.2 → 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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.d.ts +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +29 -0
- package/dist/activities/execute-cursor/capture-flow.js +59 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/command-provenance.d.ts +11 -25
- package/dist/activities/execute-cursor/command-provenance.js +25 -115
- package/dist/activities/execute-cursor/command-provenance.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +358 -557
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
- package/dist/activities/execute-cursor/todo-tracker.js +15 -43
- package/dist/activities/execute-cursor/todo-tracker.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
- package/dist/activities/execute-cursor/turn-boundary.js +163 -0
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
- package/dist/activities/execute-cursor/turn-stream.d.ts +141 -0
- package/dist/activities/execute-cursor/turn-stream.js +249 -0
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
- package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
- package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
- package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
- package/dist/activities/execute-deep-agent/index.js +73 -4
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +8 -1
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
- package/dist/activities/execute-deep-agent/streaming.js +5 -1
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +12 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/otel.js +10 -0
- package/dist/otel.js.map +1 -1
- package/dist/shared/filereview/capture.d.ts +18 -1
- package/dist/shared/filereview/capture.js +105 -28
- package/dist/shared/filereview/capture.js.map +1 -1
- package/dist/shared/filereview/cas-progress.d.ts +63 -0
- package/dist/shared/filereview/cas-progress.js +128 -0
- package/dist/shared/filereview/cas-progress.js.map +1 -0
- package/dist/shared/filereview/cas-substrate.d.ts +38 -0
- package/dist/shared/filereview/cas-substrate.js +48 -15
- package/dist/shared/filereview/cas-substrate.js.map +1 -1
- package/dist/shared/filereview/command-provenance.d.ts +93 -0
- package/dist/shared/filereview/command-provenance.js +132 -0
- package/dist/shared/filereview/command-provenance.js.map +1 -0
- package/dist/shared/filereview/events.d.ts +16 -0
- package/dist/shared/filereview/events.js +37 -0
- package/dist/shared/filereview/events.js.map +1 -1
- package/dist/shared/filereview/git-substrate.d.ts +48 -0
- package/dist/shared/filereview/git-substrate.js +78 -0
- package/dist/shared/filereview/git-substrate.js.map +1 -1
- package/dist/shared/filereview/index.d.ts +5 -2
- package/dist/shared/filereview/index.js +5 -2
- package/dist/shared/filereview/index.js.map +1 -1
- package/dist/shared/filereview/line-counts.d.ts +48 -0
- package/dist/shared/filereview/line-counts.js +72 -0
- package/dist/shared/filereview/line-counts.js.map +1 -0
- package/dist/shared/filereview/progress.d.ts +167 -0
- package/dist/shared/filereview/progress.js +196 -0
- package/dist/shared/filereview/progress.js.map +1 -0
- package/dist/shared/implement-plan-prompt.d.ts +6 -4
- package/dist/shared/implement-plan-prompt.js +38 -6
- package/dist/shared/implement-plan-prompt.js.map +1 -1
- package/dist/shared/plan-artifact.d.ts +59 -11
- package/dist/shared/plan-artifact.js +144 -16
- package/dist/shared/plan-artifact.js.map +1 -1
- package/dist/shared/plan-mode-prompt.d.ts +15 -4
- package/dist/shared/plan-mode-prompt.js +28 -5
- package/dist/shared/plan-mode-prompt.js.map +1 -1
- package/dist/shared/todos.d.ts +56 -0
- package/dist/shared/todos.js +98 -0
- package/dist/shared/todos.js.map +1 -0
- package/dist/shared/tool-row.d.ts +16 -0
- package/dist/shared/tool-row.js +31 -0
- package/dist/shared/tool-row.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/otel-turn-span.test.ts +61 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +7 -0
- package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
- package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
- package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
- package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
- package/src/activities/execute-cursor/capture-flow.ts +78 -0
- package/src/activities/execute-cursor/command-provenance.ts +25 -120
- package/src/activities/execute-cursor/index.ts +419 -602
- package/src/activities/execute-cursor/todo-tracker.ts +17 -59
- package/src/activities/execute-cursor/turn-boundary.ts +274 -0
- package/src/activities/execute-cursor/turn-stream.ts +418 -0
- package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +11 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
- package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
- package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
- package/src/activities/execute-deep-agent/index.ts +91 -4
- package/src/activities/execute-deep-agent/status-builder.ts +9 -0
- package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
- package/src/activities/execute-deep-agent/streaming.ts +12 -1
- package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
- package/src/otel.ts +8 -0
- package/src/shared/__tests__/implement-plan-prompt.test.ts +40 -2
- package/src/shared/__tests__/plan-artifact.test.ts +142 -9
- package/src/shared/__tests__/todos.test.ts +216 -0
- package/src/shared/filereview/__tests__/capture.test.ts +182 -1
- package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +100 -0
- package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
- package/src/shared/filereview/__tests__/events.test.ts +105 -0
- package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
- package/src/shared/filereview/__tests__/progress.test.ts +338 -0
- package/src/shared/filereview/capture.ts +134 -30
- package/src/shared/filereview/cas-progress.ts +170 -0
- package/src/shared/filereview/cas-substrate.ts +79 -18
- package/src/shared/filereview/command-provenance.ts +180 -0
- package/src/shared/filereview/events.ts +50 -0
- package/src/shared/filereview/git-substrate.ts +114 -0
- package/src/shared/filereview/index.ts +36 -1
- package/src/shared/filereview/line-counts.ts +83 -0
- package/src/shared/filereview/progress.ts +310 -0
- package/src/shared/implement-plan-prompt.ts +39 -8
- package/src/shared/plan-artifact.ts +152 -16
- package/src/shared/plan-mode-prompt.ts +28 -5
- package/src/shared/todos.ts +126 -0
- package/src/shared/tool-row.ts +34 -0
|
@@ -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
|
+
});
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
casBlobKey,
|
|
25
25
|
casBlobReader,
|
|
26
26
|
casManifestKey,
|
|
27
|
+
classifyCasChange,
|
|
27
28
|
loadCasManifest,
|
|
28
29
|
restoreCasToBaseline,
|
|
29
30
|
snapshotCasChangeSet,
|
|
@@ -97,6 +98,40 @@ describe("snapshotCasChangeSet — classification", () => {
|
|
|
97
98
|
expect(del.before?.sha256).toBe(sha256Bytes(bytes("BYE=1")));
|
|
98
99
|
});
|
|
99
100
|
|
|
101
|
+
it("counts display line changes from the in-memory bytes at capture", async () => {
|
|
102
|
+
const { storage } = makeFakeStorage();
|
|
103
|
+
const { manifest } = await snapshotCasChangeSet({
|
|
104
|
+
storage,
|
|
105
|
+
executionId: EXEC,
|
|
106
|
+
changeSetId: CHANGE_SET,
|
|
107
|
+
captures: [
|
|
108
|
+
{ path: "created.env", before: null, after: bytes("A=1\nB=2\n"), captureClass: IGNORED },
|
|
109
|
+
{ path: "changed.env", before: bytes("OLD=1\n"), after: bytes("OLD=2\n"), captureClass: IGNORED },
|
|
110
|
+
// Binary side: no text diff exists, so no counts may claim one does.
|
|
111
|
+
{ path: "blob.bin", before: null, after: new Uint8Array([0, 1, 2]), captureClass: IGNORED },
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
const byPath = new Map(manifest.files.map((f) => [f.pathAfter || f.pathBefore, f]));
|
|
115
|
+
|
|
116
|
+
expect(byPath.get("created.env")!.lineCounts).toEqual({ linesAdded: 2, linesRemoved: 0 });
|
|
117
|
+
expect(byPath.get("changed.env")!.lineCounts).toEqual({ linesAdded: 1, linesRemoved: 1 });
|
|
118
|
+
expect(byPath.get("blob.bin")!.lineCounts).toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("keeps line counts out of the persisted manifest (display data, not an enforcement record)", async () => {
|
|
122
|
+
const { storage, readBlob } = makeFakeStorage();
|
|
123
|
+
const { ref } = await snapshotCasChangeSet({
|
|
124
|
+
storage,
|
|
125
|
+
executionId: EXEC,
|
|
126
|
+
changeSetId: CHANGE_SET,
|
|
127
|
+
captures: [
|
|
128
|
+
{ path: "changed.env", before: bytes("OLD=1\n"), after: bytes("OLD=2\n"), captureClass: IGNORED },
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
const roundTripped = await loadCasManifest({ readBlob, ref });
|
|
132
|
+
expect(roundTripped.files[0].lineCounts).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
100
135
|
it("drops a touched-but-unchanged path (before == after)", async () => {
|
|
101
136
|
const { storage } = makeFakeStorage();
|
|
102
137
|
const { manifest } = await snapshotCasChangeSet({
|
|
@@ -402,3 +437,68 @@ describe("digest independence from capture class", () => {
|
|
|
402
437
|
expect(files.map((f) => f.pathAfter)).toEqual(["a", "m", "z"]);
|
|
403
438
|
});
|
|
404
439
|
});
|
|
440
|
+
|
|
441
|
+
// The single classification authority for a CAS before/after pair. Both the
|
|
442
|
+
// turn-boundary capture (buildCasCapturedFile, exercised via snapshotCasChangeSet)
|
|
443
|
+
// and the mid-run progress producer (cas-progress) route through it, so these
|
|
444
|
+
// cases lock the shape the strip and the reviewed set share.
|
|
445
|
+
describe("classifyCasChange (pure)", () => {
|
|
446
|
+
const buf = (s: string): Buffer => Buffer.from(s, "utf8");
|
|
447
|
+
|
|
448
|
+
it("classifies ADD from a null before and blanks the before path", () => {
|
|
449
|
+
const c = classifyCasChange("new.ts", null, buf("a\nb\nc\n"))!;
|
|
450
|
+
expect(c.kind).toBe(FileChangeKind.ADD);
|
|
451
|
+
expect(c.pathBefore).toBe("");
|
|
452
|
+
expect(c.pathAfter).toBe("new.ts");
|
|
453
|
+
expect(c.isBinary).toBe(false);
|
|
454
|
+
expect(c.lineCounts).toEqual({ linesAdded: 3, linesRemoved: 0 });
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("classifies DELETE from a null after and blanks the after path", () => {
|
|
458
|
+
const c = classifyCasChange("gone.ts", buf("x\ny\n"), null)!;
|
|
459
|
+
expect(c.kind).toBe(FileChangeKind.DELETE);
|
|
460
|
+
expect(c.pathBefore).toBe("gone.ts");
|
|
461
|
+
expect(c.pathAfter).toBe("");
|
|
462
|
+
expect(c.lineCounts).toEqual({ linesAdded: 0, linesRemoved: 2 });
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("classifies MODIFY from two present sides", () => {
|
|
466
|
+
const c = classifyCasChange("m.ts", buf("a\n"), buf("a\nb\n"))!;
|
|
467
|
+
expect(c.kind).toBe(FileChangeKind.MODIFY);
|
|
468
|
+
expect(c.pathBefore).toBe("m.ts");
|
|
469
|
+
expect(c.pathAfter).toBe("m.ts");
|
|
470
|
+
expect(c.lineCounts).toEqual({ linesAdded: 1, linesRemoved: 0 });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("returns undefined for both-null and for a no-op touch (unchanged bytes)", () => {
|
|
474
|
+
expect(classifyCasChange("p", null, null)).toBeUndefined();
|
|
475
|
+
expect(classifyCasChange("p", buf("same\n"), buf("same\n"))).toBeUndefined();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("marks a binary side and withholds line counts", () => {
|
|
479
|
+
const bin = Buffer.from([0, 1, 2, 0, 255, 0]);
|
|
480
|
+
const c = classifyCasChange("blob.bin", null, bin)!;
|
|
481
|
+
expect(c.kind).toBe(FileChangeKind.ADD);
|
|
482
|
+
expect(c.isBinary).toBe(true);
|
|
483
|
+
expect(c.lineCounts).toBeUndefined();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("is the authority snapshotCasChangeSet uses (parity guard)", async () => {
|
|
487
|
+
const { storage } = makeFakeStorage();
|
|
488
|
+
const before = bytes("one\ntwo\n");
|
|
489
|
+
const after = bytes("one\ntwo\nthree\n");
|
|
490
|
+
const { manifest } = await snapshotCasChangeSet({
|
|
491
|
+
storage, executionId: EXEC, changeSetId: CHANGE_SET,
|
|
492
|
+
captures: [{ path: "m.ts", before, after, captureClass: IGNORED }],
|
|
493
|
+
});
|
|
494
|
+
const file = manifest.files[0];
|
|
495
|
+
const direct = classifyCasChange("m.ts", Buffer.from(before), Buffer.from(after))!;
|
|
496
|
+
// The captured file's display classification is exactly what the shared
|
|
497
|
+
// classifier produces — no second authority that could drift.
|
|
498
|
+
expect(file.kind).toBe(direct.kind);
|
|
499
|
+
expect(file.pathBefore).toBe(direct.pathBefore);
|
|
500
|
+
expect(file.pathAfter).toBe(direct.pathAfter);
|
|
501
|
+
expect(file.diffComplete).toBe(!direct.isBinary);
|
|
502
|
+
expect(file.lineCounts).toEqual(direct.lineCounts);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the shared DD-28 qualification rule
|
|
3
|
+
* ({@link qualifyTurnCommandProvenance}) — the harness-agnostic core both the
|
|
4
|
+
* Cursor and deep-agent adapters delegate to.
|
|
5
|
+
*
|
|
6
|
+
* These exercise the rule in isolation over synthetic `turnToolCalls` (the
|
|
7
|
+
* harness supplies the scoping + consent resolvers); each harness's own mapping
|
|
8
|
+
* is covered by its adapter suite. The rule: a turn qualifies only when every
|
|
9
|
+
* mutation-capable call it executed was a shell command with a provable consent
|
|
10
|
+
* source (direct / lease / auto_approve_all), and every uncertainty fails closed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from "vitest";
|
|
14
|
+
import { create, type MessageInitShape } from "@bufbuild/protobuf";
|
|
15
|
+
import {
|
|
16
|
+
AgentMessageSchema,
|
|
17
|
+
ToolCallSchema,
|
|
18
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
19
|
+
import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
20
|
+
import {
|
|
21
|
+
ApprovalAction,
|
|
22
|
+
MessageType,
|
|
23
|
+
ToolCallStatus,
|
|
24
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
25
|
+
|
|
26
|
+
import { qualifyTurnCommandProvenance, findLeaseConsentId } from "../command-provenance.js";
|
|
27
|
+
|
|
28
|
+
function toolCall(overrides: MessageInitShape<typeof ToolCallSchema>): ToolCall {
|
|
29
|
+
return create(ToolCallSchema, {
|
|
30
|
+
id: "call-1",
|
|
31
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
32
|
+
...overrides,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function aiMessageWith(toolCalls: ToolCall[]): AgentMessage {
|
|
37
|
+
return create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Default resolvers for the common "no direct consent, every listed call ran"
|
|
41
|
+
// case — individual tests override as needed.
|
|
42
|
+
const executedAlways = (): boolean => true;
|
|
43
|
+
const noDirectConsent = (): undefined => undefined;
|
|
44
|
+
|
|
45
|
+
function shell(id: string, cmd = "seq 1 5 > out.txt"): ToolCall {
|
|
46
|
+
return toolCall({ id, name: "shell", args: { command: cmd } });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("qualifyTurnCommandProvenance", () => {
|
|
50
|
+
it("cites the direct consent id the resolver returns", () => {
|
|
51
|
+
const tc = shell("shell-1");
|
|
52
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
53
|
+
turnToolCalls: [tc],
|
|
54
|
+
messages: [aiMessageWith([tc])],
|
|
55
|
+
isExecutedCommand: executedAlways,
|
|
56
|
+
resolveDirectConsent: (t) => (t.id === "shell-1" ? "consent-row" : undefined),
|
|
57
|
+
globalBypass: false,
|
|
58
|
+
});
|
|
59
|
+
expect(provenance).toBeDefined();
|
|
60
|
+
expect(provenance!.consentToolCallIds).toEqual(["consent-row"]);
|
|
61
|
+
expect(provenance!.authorizedByAutoApproveAll).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("falls back to the APPROVE_ALL lease row when there is no direct consent", () => {
|
|
65
|
+
const leaseRow = toolCall({
|
|
66
|
+
id: "lease-row",
|
|
67
|
+
name: "shell",
|
|
68
|
+
status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
|
|
69
|
+
approvalAction: ApprovalAction.APPROVE_ALL,
|
|
70
|
+
});
|
|
71
|
+
const leased = shell("shell-leased");
|
|
72
|
+
const messages = [aiMessageWith([leaseRow]), aiMessageWith([leased])];
|
|
73
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
74
|
+
turnToolCalls: [leased],
|
|
75
|
+
messages,
|
|
76
|
+
isExecutedCommand: executedAlways,
|
|
77
|
+
resolveDirectConsent: noDirectConsent,
|
|
78
|
+
globalBypass: false,
|
|
79
|
+
});
|
|
80
|
+
expect(provenance).toBeDefined();
|
|
81
|
+
expect(provenance!.consentToolCallIds).toEqual(["lease-row"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("flags auto_approve_all when only the global bypass authorized the command", () => {
|
|
85
|
+
const tc = shell("shell-1");
|
|
86
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
87
|
+
turnToolCalls: [tc],
|
|
88
|
+
messages: [aiMessageWith([tc])],
|
|
89
|
+
isExecutedCommand: executedAlways,
|
|
90
|
+
resolveDirectConsent: noDirectConsent,
|
|
91
|
+
globalBypass: true,
|
|
92
|
+
});
|
|
93
|
+
expect(provenance).toBeDefined();
|
|
94
|
+
expect(provenance!.consentToolCallIds).toEqual([]);
|
|
95
|
+
expect(provenance!.authorizedByAutoApproveAll).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("prefers direct consent over auto_approve_all when both are available", () => {
|
|
99
|
+
const tc = shell("shell-1");
|
|
100
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
101
|
+
turnToolCalls: [tc],
|
|
102
|
+
messages: [aiMessageWith([tc])],
|
|
103
|
+
isExecutedCommand: executedAlways,
|
|
104
|
+
resolveDirectConsent: () => "consent-row",
|
|
105
|
+
globalBypass: true,
|
|
106
|
+
});
|
|
107
|
+
expect(provenance!.consentToolCallIds).toEqual(["consent-row"]);
|
|
108
|
+
expect(provenance!.authorizedByAutoApproveAll).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("fails closed on a file-tool call (mixed turn)", () => {
|
|
112
|
+
const tc = shell("shell-1");
|
|
113
|
+
const edit = toolCall({ id: "edit-1", name: "edit", args: { path: "a.txt" } });
|
|
114
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
115
|
+
turnToolCalls: [tc, edit],
|
|
116
|
+
messages: [aiMessageWith([tc, edit])],
|
|
117
|
+
isExecutedCommand: executedAlways,
|
|
118
|
+
resolveDirectConsent: () => "consent-row",
|
|
119
|
+
globalBypass: false,
|
|
120
|
+
});
|
|
121
|
+
expect(provenance).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("fails closed on an MCP tool", () => {
|
|
125
|
+
const tc = shell("shell-1");
|
|
126
|
+
const mcp = toolCall({ id: "mcp-1", name: "apply_resource", mcpServerSlug: "planton" });
|
|
127
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
128
|
+
turnToolCalls: [tc, mcp],
|
|
129
|
+
messages: [aiMessageWith([tc, mcp])],
|
|
130
|
+
isExecutedCommand: executedAlways,
|
|
131
|
+
resolveDirectConsent: () => "consent-row",
|
|
132
|
+
globalBypass: false,
|
|
133
|
+
});
|
|
134
|
+
expect(provenance).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("fails closed on a sub-agent delegation (task -> SUBAGENT kind)", () => {
|
|
138
|
+
const tc = shell("shell-1");
|
|
139
|
+
const task = toolCall({ id: "task-1", name: "task", args: { prompt: "go" } });
|
|
140
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
141
|
+
turnToolCalls: [tc, task],
|
|
142
|
+
messages: [aiMessageWith([tc, task])],
|
|
143
|
+
isExecutedCommand: executedAlways,
|
|
144
|
+
resolveDirectConsent: () => "consent-row",
|
|
145
|
+
globalBypass: false,
|
|
146
|
+
});
|
|
147
|
+
expect(provenance).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("fails closed on an unrecognized tool name", () => {
|
|
151
|
+
const tc = shell("shell-1");
|
|
152
|
+
const unknown = toolCall({ id: "u-1", name: "mystery_tool" });
|
|
153
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
154
|
+
turnToolCalls: [tc, unknown],
|
|
155
|
+
messages: [aiMessageWith([tc, unknown])],
|
|
156
|
+
isExecutedCommand: executedAlways,
|
|
157
|
+
resolveDirectConsent: () => "consent-row",
|
|
158
|
+
globalBypass: false,
|
|
159
|
+
});
|
|
160
|
+
expect(provenance).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("fails closed on an executed command with no provable consent source", () => {
|
|
164
|
+
const tc = shell("shell-1");
|
|
165
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
166
|
+
turnToolCalls: [tc],
|
|
167
|
+
messages: [aiMessageWith([tc])],
|
|
168
|
+
isExecutedCommand: executedAlways,
|
|
169
|
+
resolveDirectConsent: noDirectConsent,
|
|
170
|
+
globalBypass: false,
|
|
171
|
+
});
|
|
172
|
+
expect(provenance).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("returns undefined when no command executed (only a pending shell)", () => {
|
|
176
|
+
const pending = shell("shell-pending");
|
|
177
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
178
|
+
turnToolCalls: [pending],
|
|
179
|
+
messages: [aiMessageWith([pending])],
|
|
180
|
+
isExecutedCommand: () => false, // pending / denied — never executed
|
|
181
|
+
resolveDirectConsent: () => "consent-row",
|
|
182
|
+
globalBypass: false,
|
|
183
|
+
});
|
|
184
|
+
expect(provenance).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("skips read-only tools and hidden collapsed rows, then qualifies", () => {
|
|
188
|
+
const read = toolCall({ id: "read-1", name: "read", args: { path: "a.txt" } });
|
|
189
|
+
const hidden = toolCall({
|
|
190
|
+
id: "hidden-1",
|
|
191
|
+
name: "shell",
|
|
192
|
+
status: ToolCallStatus.TOOL_CALL_SKIPPED,
|
|
193
|
+
args: undefined,
|
|
194
|
+
});
|
|
195
|
+
const executed = shell("shell-executed");
|
|
196
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
197
|
+
turnToolCalls: [read, hidden, executed],
|
|
198
|
+
messages: [aiMessageWith([read, hidden, executed])],
|
|
199
|
+
isExecutedCommand: executedAlways,
|
|
200
|
+
resolveDirectConsent: (t) => (t.id === "shell-executed" ? "consent-row" : undefined),
|
|
201
|
+
globalBypass: false,
|
|
202
|
+
});
|
|
203
|
+
expect(provenance).toBeDefined();
|
|
204
|
+
expect(provenance!.consentToolCallIds).toEqual(["consent-row"]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("dedupes consent ids across multiple commands citing the same lease", () => {
|
|
208
|
+
const leaseRow = toolCall({
|
|
209
|
+
id: "lease-row",
|
|
210
|
+
name: "shell",
|
|
211
|
+
status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
|
|
212
|
+
approvalAction: ApprovalAction.APPROVE_ALL,
|
|
213
|
+
});
|
|
214
|
+
const a = shell("shell-a");
|
|
215
|
+
const b = shell("shell-b");
|
|
216
|
+
const messages = [aiMessageWith([leaseRow]), aiMessageWith([a, b])];
|
|
217
|
+
const provenance = qualifyTurnCommandProvenance({
|
|
218
|
+
turnToolCalls: [a, b],
|
|
219
|
+
messages,
|
|
220
|
+
isExecutedCommand: executedAlways,
|
|
221
|
+
resolveDirectConsent: noDirectConsent,
|
|
222
|
+
globalBypass: false,
|
|
223
|
+
});
|
|
224
|
+
expect(provenance).toBeDefined();
|
|
225
|
+
expect(provenance!.consentToolCallIds).toEqual(["lease-row"]);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("findLeaseConsentId", () => {
|
|
230
|
+
it("returns the earliest APPROVE_ALL row of the tool's category", () => {
|
|
231
|
+
const first = toolCall({ id: "lease-1", name: "shell", approvalAction: ApprovalAction.APPROVE_ALL });
|
|
232
|
+
const second = toolCall({ id: "lease-2", name: "bash", approvalAction: ApprovalAction.APPROVE_ALL });
|
|
233
|
+
const messages = [aiMessageWith([first]), aiMessageWith([second])];
|
|
234
|
+
expect(findLeaseConsentId(messages, "execute")).toBe("lease-1");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns undefined when no APPROVE_ALL lease exists for the category", () => {
|
|
238
|
+
const writeApproveAll = toolCall({
|
|
239
|
+
id: "w-1",
|
|
240
|
+
name: "write",
|
|
241
|
+
approvalAction: ApprovalAction.APPROVE_ALL,
|
|
242
|
+
});
|
|
243
|
+
const messages = [aiMessageWith([writeApproveAll])];
|
|
244
|
+
// A write-category lease does not authorize a shell command.
|
|
245
|
+
expect(findLeaseConsentId(messages, "shell")).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("returns undefined for a non-gated (read-only) tool name", () => {
|
|
249
|
+
const messages = [aiMessageWith([shell("s")])];
|
|
250
|
+
expect(findLeaseConsentId(messages, "read")).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
});
|