@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +26 -15
- package/dist/activities/execute-cursor/capture-flow.js +56 -18
- 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 +296 -477
- 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-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 +60 -18
- 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/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/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 +29 -0
- package/dist/shared/filereview/cas-substrate.js +46 -21
- 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/git-substrate.d.ts +9 -3
- package/dist/shared/filereview/git-substrate.js.map +1 -1
- package/dist/shared/filereview/index.d.ts +4 -3
- package/dist/shared/filereview/index.js +3 -2
- package/dist/shared/filereview/index.js.map +1 -1
- package/dist/shared/filereview/progress.d.ts +105 -34
- package/dist/shared/filereview/progress.js +96 -34
- package/dist/shared/filereview/progress.js.map +1 -1
- package/dist/shared/plan-mode-prompt.d.ts +9 -0
- package/dist/shared/plan-mode-prompt.js +18 -0
- 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 +2 -2
- package/src/__tests__/otel-turn-span.test.ts +61 -0
- package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
- package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
- package/src/activities/execute-cursor/capture-flow.ts +71 -25
- package/src/activities/execute-cursor/command-provenance.ts +25 -120
- package/src/activities/execute-cursor/index.ts +344 -504
- package/src/activities/execute-cursor/todo-tracker.ts +17 -59
- 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__/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 +74 -18
- package/src/activities/execute-deep-agent/status-builder.ts +9 -0
- package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
- package/src/otel.ts +8 -0
- package/src/shared/__tests__/todos.test.ts +216 -0
- package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +66 -0
- package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
- package/src/shared/filereview/__tests__/progress.test.ts +112 -10
- package/src/shared/filereview/cas-progress.ts +170 -0
- package/src/shared/filereview/cas-substrate.ts +69 -24
- package/src/shared/filereview/command-provenance.ts +180 -0
- package/src/shared/filereview/git-substrate.ts +9 -3
- package/src/shared/filereview/index.ts +15 -1
- package/src/shared/filereview/progress.ts +171 -47
- package/src/shared/plan-mode-prompt.ts +18 -0
- package/src/shared/todos.ts +126 -0
- 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 {
|
|
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
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
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
|
|
333
|
-
const
|
|
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
|
|
361
|
-
// scheduled persist
|
|
362
|
-
//
|
|
363
|
-
//
|
|
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 (!
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
405
|
+
if (!progressSubstrate) return;
|
|
368
406
|
await captureFileChangeProgress({
|
|
369
407
|
status,
|
|
370
|
-
gitRoot,
|
|
371
|
-
executionId,
|
|
372
408
|
changeSetId,
|
|
373
|
-
|
|
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
|
+
});
|