@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,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Cursor mid-run progress substrate selection (DD-33) —
|
|
3
|
+
* `buildCursorProgressSubstrate`. This is the Cursor adapter's responsibility:
|
|
4
|
+
* pick git / non-git CAS / hybrid for the turn's workspace shape (the CAS + git
|
|
5
|
+
* capture behaviors themselves are covered in shared/filereview/__tests__).
|
|
6
|
+
*
|
|
7
|
+
* The chosen substrate is driven against a real temp workspace to prove WHICH
|
|
8
|
+
* kind was built: a git-only substrate surfaces the tracked change; a hybrid adds
|
|
9
|
+
* the (empty here) CAS slice; a non-git substrate reads the (empty) hook sidecar
|
|
10
|
+
* without touching git.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
19
|
+
import { buildCursorProgressSubstrate } from "../capture-flow.js";
|
|
20
|
+
import { snapshotBaseline, dropCaptureRefs } from "../../../shared/filereview/git-substrate.js";
|
|
21
|
+
import { makeInMemoryArtifactStorage } from "../../../__test-utils__/fake-artifact-storage.js";
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
24
|
+
const EXEC_ID = "exec-cursor-progress";
|
|
25
|
+
|
|
26
|
+
let repo: string;
|
|
27
|
+
let hitlDir: string;
|
|
28
|
+
|
|
29
|
+
async function git(args: string[]): Promise<void> {
|
|
30
|
+
await execFileAsync("git", args, { cwd: repo });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function write(rel: string, content: string): Promise<void> {
|
|
34
|
+
await mkdir(join(repo, rel, ".."), { recursive: true });
|
|
35
|
+
await writeFile(join(repo, rel), content, "utf-8");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
repo = await mkdtemp(join(tmpdir(), "cursor-progress-"));
|
|
40
|
+
hitlDir = await mkdtemp(join(tmpdir(), "cursor-progress-hitl-"));
|
|
41
|
+
await git(["init", "-q"]);
|
|
42
|
+
await git(["config", "user.email", "t@t.local"]);
|
|
43
|
+
await git(["config", "user.name", "t"]);
|
|
44
|
+
await write("src/main.ts", "export const x = 1;\n");
|
|
45
|
+
await git(["add", "-A"]);
|
|
46
|
+
await git(["commit", "-q", "-m", "initial"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
await dropCaptureRefs(repo, EXEC_ID).catch(() => {});
|
|
51
|
+
await rm(repo, { recursive: true, force: true });
|
|
52
|
+
await rm(hitlDir, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("buildCursorProgressSubstrate — selection", () => {
|
|
56
|
+
it("returns undefined outside capture mode", () => {
|
|
57
|
+
const sub = buildCursorProgressSubstrate({
|
|
58
|
+
captureMode: false,
|
|
59
|
+
gitWorkspace: true,
|
|
60
|
+
workspaceRoot: repo,
|
|
61
|
+
baselineTree: "sha",
|
|
62
|
+
executionId: EXEC_ID,
|
|
63
|
+
hitlDir,
|
|
64
|
+
storage: undefined,
|
|
65
|
+
});
|
|
66
|
+
expect(sub).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns undefined with no workspace root", () => {
|
|
70
|
+
const sub = buildCursorProgressSubstrate({
|
|
71
|
+
captureMode: true,
|
|
72
|
+
gitWorkspace: true,
|
|
73
|
+
workspaceRoot: undefined,
|
|
74
|
+
baselineTree: "sha",
|
|
75
|
+
executionId: EXEC_ID,
|
|
76
|
+
hitlDir,
|
|
77
|
+
storage: undefined,
|
|
78
|
+
});
|
|
79
|
+
expect(sub).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns undefined for a git tree with no baseline tree yet", () => {
|
|
83
|
+
const sub = buildCursorProgressSubstrate({
|
|
84
|
+
captureMode: true,
|
|
85
|
+
gitWorkspace: true,
|
|
86
|
+
workspaceRoot: repo,
|
|
87
|
+
baselineTree: undefined,
|
|
88
|
+
executionId: EXEC_ID,
|
|
89
|
+
hitlDir,
|
|
90
|
+
storage: undefined,
|
|
91
|
+
});
|
|
92
|
+
expect(sub).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns undefined for a non-git workspace with no storage/sidecar", () => {
|
|
96
|
+
const sub = buildCursorProgressSubstrate({
|
|
97
|
+
captureMode: true,
|
|
98
|
+
gitWorkspace: false,
|
|
99
|
+
workspaceRoot: repo,
|
|
100
|
+
baselineTree: undefined,
|
|
101
|
+
executionId: EXEC_ID,
|
|
102
|
+
hitlDir: undefined,
|
|
103
|
+
storage: undefined,
|
|
104
|
+
});
|
|
105
|
+
expect(sub).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("builds a git-only substrate (no storage) that surfaces the tracked change", async () => {
|
|
109
|
+
const baselineTree = await snapshotBaseline(repo, EXEC_ID);
|
|
110
|
+
const sub = buildCursorProgressSubstrate({
|
|
111
|
+
captureMode: true,
|
|
112
|
+
gitWorkspace: true,
|
|
113
|
+
workspaceRoot: repo,
|
|
114
|
+
baselineTree,
|
|
115
|
+
executionId: EXEC_ID,
|
|
116
|
+
hitlDir,
|
|
117
|
+
storage: undefined,
|
|
118
|
+
});
|
|
119
|
+
expect(sub).toBeDefined();
|
|
120
|
+
|
|
121
|
+
await write("src/main.ts", "export const x = 2;\nexport const y = 3;\n");
|
|
122
|
+
const { delta, changed } = await sub!.capture();
|
|
123
|
+
expect(changed).toBe(true);
|
|
124
|
+
expect(delta.entries.some((e) => e.pathAfter === "src/main.ts")).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("builds a hybrid substrate (git + storage) surfacing the git slice + empty CAS", async () => {
|
|
128
|
+
const baselineTree = await snapshotBaseline(repo, EXEC_ID);
|
|
129
|
+
const { storage } = makeInMemoryArtifactStorage();
|
|
130
|
+
const sub = buildCursorProgressSubstrate({
|
|
131
|
+
captureMode: true,
|
|
132
|
+
gitWorkspace: true,
|
|
133
|
+
workspaceRoot: repo,
|
|
134
|
+
baselineTree,
|
|
135
|
+
executionId: EXEC_ID,
|
|
136
|
+
hitlDir,
|
|
137
|
+
storage,
|
|
138
|
+
});
|
|
139
|
+
expect(sub).toBeDefined();
|
|
140
|
+
|
|
141
|
+
await write("src/main.ts", "export const x = 9;\n");
|
|
142
|
+
const { delta } = await sub!.capture();
|
|
143
|
+
// The git slice contributes the tracked change; the CAS sidecar is empty, so
|
|
144
|
+
// it adds nothing — total equals the git entries.
|
|
145
|
+
expect(delta.entries.some((e) => e.pathAfter === "src/main.ts")).toBe(true);
|
|
146
|
+
expect(delta.totalFilesChanged).toBe(delta.entries.length);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("builds a non-git CAS substrate that reads the (empty) sidecar without git", async () => {
|
|
150
|
+
const { storage } = makeInMemoryArtifactStorage();
|
|
151
|
+
const sub = buildCursorProgressSubstrate({
|
|
152
|
+
captureMode: true,
|
|
153
|
+
gitWorkspace: false,
|
|
154
|
+
workspaceRoot: repo,
|
|
155
|
+
baselineTree: undefined,
|
|
156
|
+
executionId: EXEC_ID,
|
|
157
|
+
hitlDir,
|
|
158
|
+
storage,
|
|
159
|
+
});
|
|
160
|
+
expect(sub).toBeDefined();
|
|
161
|
+
|
|
162
|
+
// Even with a git change on disk, the non-git substrate ignores git entirely;
|
|
163
|
+
// the sidecar is empty, so it reports zero changed files.
|
|
164
|
+
await write("src/main.ts", "export const x = 42;\n");
|
|
165
|
+
const { delta } = await sub!.capture();
|
|
166
|
+
expect(delta.totalFilesChanged).toBe(0);
|
|
167
|
+
expect(delta.entries).toHaveLength(0);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the extracted turn boundary (turn-boundary.ts) — the post-run
|
|
3
|
+
* pipeline shared by the primary path and the recovery retries.
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing scenario is the recovery-retry sequence that motivated the
|
|
6
|
+
* extraction (production case aex_01kws27q1e2esvkqjpvectttxf): the primary
|
|
7
|
+
* boundary runs against an untouched tree (the resumed agent errored before
|
|
8
|
+
* doing anything), the retry agent then edits files, and the boundary is
|
|
9
|
+
* re-entered — the candidate must be authored exactly once and the turn must
|
|
10
|
+
* pause for review. Runs against a REAL temp git repo with in-memory
|
|
11
|
+
* transcript + status protos, mirroring capture-flow.test.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { promisify } from "node:util";
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
20
|
+
import { create } from "@bufbuild/protobuf";
|
|
21
|
+
import {
|
|
22
|
+
AgentMessageSchema,
|
|
23
|
+
ToolCallSchema,
|
|
24
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
25
|
+
import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
26
|
+
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
27
|
+
import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
28
|
+
import {
|
|
29
|
+
FileReviewEventType,
|
|
30
|
+
MessageType,
|
|
31
|
+
ToolCallStatus,
|
|
32
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
33
|
+
import { captureBaselineToLedger } from "../capture-flow.js";
|
|
34
|
+
import { denialLedgerPath } from "../approval-state.js";
|
|
35
|
+
import { toolCallIdentityToken } from "../message-translator.js";
|
|
36
|
+
import { runTurnBoundary, type TurnBoundaryOptions } from "../turn-boundary.js";
|
|
37
|
+
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
39
|
+
const EXEC_ID = "exec-boundary-1";
|
|
40
|
+
const CHANGE_SET_ID = `${EXEC_ID}:0`;
|
|
41
|
+
|
|
42
|
+
let repo: string;
|
|
43
|
+
let hitlDir: string;
|
|
44
|
+
|
|
45
|
+
async function git(args: string[]): Promise<void> {
|
|
46
|
+
await execFileAsync("git", args, { cwd: repo });
|
|
47
|
+
}
|
|
48
|
+
async function write(rel: string, content: string): Promise<void> {
|
|
49
|
+
await writeFile(join(repo, rel), content, "utf-8");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function newStatus(): AgentExecutionStatus {
|
|
53
|
+
return create(AgentExecutionStatusSchema, {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A streamed (COMPLETED) file-edit tool call, as the SDK would have recorded. */
|
|
57
|
+
function streamedEdit(id: string, path: string, content: string): AgentMessage {
|
|
58
|
+
return create(AgentMessageSchema, {
|
|
59
|
+
type: MessageType.MESSAGE_AI,
|
|
60
|
+
toolCalls: [
|
|
61
|
+
create(ToolCallSchema, {
|
|
62
|
+
id,
|
|
63
|
+
name: "edit",
|
|
64
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
65
|
+
args: { path, content },
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Boundary options for this repo's turn; overrides layer the per-test shape. */
|
|
72
|
+
function boundaryOpts(
|
|
73
|
+
status: AgentExecutionStatus,
|
|
74
|
+
baselineTree: string,
|
|
75
|
+
overrides?: Partial<TurnBoundaryOptions>,
|
|
76
|
+
): TurnBoundaryOptions {
|
|
77
|
+
return {
|
|
78
|
+
status,
|
|
79
|
+
executionId: EXEC_ID,
|
|
80
|
+
changeSetId: CHANGE_SET_ID,
|
|
81
|
+
hitlDir,
|
|
82
|
+
captureMode: true,
|
|
83
|
+
baselineTree,
|
|
84
|
+
primaryWorkspaceDir: repo,
|
|
85
|
+
gitWorkspace: true,
|
|
86
|
+
turnStartMessageIndex: 0,
|
|
87
|
+
approvalGrants: undefined,
|
|
88
|
+
globalBypass: false,
|
|
89
|
+
seededSubAgents: [],
|
|
90
|
+
artifactStorage: undefined,
|
|
91
|
+
mergedPolicies: new Map(),
|
|
92
|
+
...overrides,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function candidateEvents(status: AgentExecutionStatus) {
|
|
97
|
+
return (status.fileReviewEventStream?.events ?? []).filter(
|
|
98
|
+
(e) => e.eventType === FileReviewEventType.CANDIDATE_CAPTURED,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beforeEach(async () => {
|
|
103
|
+
repo = await mkdtemp(join(tmpdir(), "stigmer-boundary-repo-"));
|
|
104
|
+
hitlDir = await mkdtemp(join(tmpdir(), "stigmer-boundary-hitl-"));
|
|
105
|
+
await git(["init", "-q"]);
|
|
106
|
+
await git(["config", "user.email", "t@t.local"]);
|
|
107
|
+
await git(["config", "user.name", "t"]);
|
|
108
|
+
await write("notes.md", "original notes\n");
|
|
109
|
+
await git(["add", "-A"]);
|
|
110
|
+
await git(["commit", "-q", "-m", "initial"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(async () => {
|
|
114
|
+
await rm(repo, { recursive: true, force: true });
|
|
115
|
+
await rm(hitlDir, { recursive: true, force: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("runTurnBoundary", () => {
|
|
119
|
+
it("authors a candidate, stamps the row, and pauses when the turn edited files", async () => {
|
|
120
|
+
const status = newStatus();
|
|
121
|
+
const baseline = await captureBaselineToLedger({
|
|
122
|
+
status,
|
|
123
|
+
gitRoot: repo,
|
|
124
|
+
executionId: EXEC_ID,
|
|
125
|
+
changeSetId: CHANGE_SET_ID,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await write("notes.md", "original notes\n\n## TODO\n- ship\n");
|
|
129
|
+
status.messages.push(
|
|
130
|
+
streamedEdit("tc-1", "notes.md", "original notes\n\n## TODO\n- ship\n"),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const result = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
134
|
+
|
|
135
|
+
expect(result.waiting).toBe(true);
|
|
136
|
+
expect(result.capturedChangeCount).toBe(1);
|
|
137
|
+
expect(result.deniedToolCallCount).toBe(0);
|
|
138
|
+
expect(candidateEvents(status)).toHaveLength(1);
|
|
139
|
+
// The streamed row is stamped with the change set id (observational record
|
|
140
|
+
// anchoring the decision surface).
|
|
141
|
+
expect(status.messages[0].toolCalls[0].fileChangeSetId).toBe(CHANGE_SET_ID);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("reports a clean turn when nothing changed (no candidate, no pause)", async () => {
|
|
145
|
+
const status = newStatus();
|
|
146
|
+
const baseline = await captureBaselineToLedger({
|
|
147
|
+
status,
|
|
148
|
+
gitRoot: repo,
|
|
149
|
+
executionId: EXEC_ID,
|
|
150
|
+
changeSetId: CHANGE_SET_ID,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
154
|
+
|
|
155
|
+
expect(result.waiting).toBe(false);
|
|
156
|
+
expect(result.capturedChangeCount).toBe(0);
|
|
157
|
+
expect(result.deniedToolCallCount).toBe(0);
|
|
158
|
+
expect(candidateEvents(status)).toHaveLength(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("skips capture when no baseline was authored this turn", async () => {
|
|
162
|
+
const status = newStatus();
|
|
163
|
+
await write("notes.md", "edited without a baseline\n");
|
|
164
|
+
|
|
165
|
+
const result = await runTurnBoundary(
|
|
166
|
+
boundaryOpts(status, "", { baselineTree: undefined }),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(result.waiting).toBe(false);
|
|
170
|
+
expect(result.capturedChangeCount).toBe(0);
|
|
171
|
+
expect(candidateEvents(status)).toHaveLength(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("re-entered after a no-op primary call, authors the candidate exactly once (recovery-retry sequence)", async () => {
|
|
175
|
+
const status = newStatus();
|
|
176
|
+
const baseline = await captureBaselineToLedger({
|
|
177
|
+
status,
|
|
178
|
+
gitRoot: repo,
|
|
179
|
+
executionId: EXEC_ID,
|
|
180
|
+
changeSetId: CHANGE_SET_ID,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Primary boundary: the resumed agent errored before touching anything —
|
|
184
|
+
// the tree is at baseline, no candidate is authored, the turn looks clean.
|
|
185
|
+
const primary = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
186
|
+
expect(primary.waiting).toBe(false);
|
|
187
|
+
expect(candidateEvents(status)).toHaveLength(0);
|
|
188
|
+
|
|
189
|
+
// Recovery retry: a fresh agent re-ran the prompt and created the file.
|
|
190
|
+
await write("notes.md", "original notes\n\nretry made this edit\n");
|
|
191
|
+
status.messages.push(
|
|
192
|
+
streamedEdit("tc-retry", "notes.md", "original notes\n\nretry made this edit\n"),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Boundary re-entry: the retry's edit reaches the ledger and arms the gate.
|
|
196
|
+
const retry = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
197
|
+
expect(retry.waiting).toBe(true);
|
|
198
|
+
expect(retry.capturedChangeCount).toBe(1);
|
|
199
|
+
expect(candidateEvents(status)).toHaveLength(1);
|
|
200
|
+
expect(status.messages[0].toolCalls[0].fileChangeSetId).toBe(CHANGE_SET_ID);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("surfaces a hook denial as a WAITING_APPROVAL gate and pauses", async () => {
|
|
204
|
+
const status = newStatus();
|
|
205
|
+
const baseline = await captureBaselineToLedger({
|
|
206
|
+
status,
|
|
207
|
+
gitRoot: repo,
|
|
208
|
+
executionId: EXEC_ID,
|
|
209
|
+
changeSetId: CHANGE_SET_ID,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// The hook gated a shell command mid-turn; the streamed call is still
|
|
213
|
+
// RUNNING (Cursor reported the deny to the model, not a completion).
|
|
214
|
+
const shellCall = create(ToolCallSchema, {
|
|
215
|
+
id: "tc-shell",
|
|
216
|
+
name: "shell",
|
|
217
|
+
status: ToolCallStatus.TOOL_CALL_RUNNING,
|
|
218
|
+
args: { command: "rm -rf build" },
|
|
219
|
+
});
|
|
220
|
+
status.messages.push(
|
|
221
|
+
create(AgentMessageSchema, {
|
|
222
|
+
type: MessageType.MESSAGE_AI,
|
|
223
|
+
content: "cleaning the build dir",
|
|
224
|
+
toolCalls: [shellCall],
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
await writeFile(
|
|
228
|
+
denialLedgerPath(hitlDir),
|
|
229
|
+
JSON.stringify({ toolName: "shell", token: toolCallIdentityToken(shellCall) }) + "\n",
|
|
230
|
+
"utf-8",
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const result = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
234
|
+
|
|
235
|
+
expect(result.waiting).toBe(true);
|
|
236
|
+
expect(result.deniedToolCallCount).toBe(1);
|
|
237
|
+
expect(result.capturedChangeCount).toBe(0);
|
|
238
|
+
expect(shellCall.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("waits for the denial-stop cancel to settle before reading the ledger", async () => {
|
|
242
|
+
const status = newStatus();
|
|
243
|
+
const baseline = await captureBaselineToLedger({
|
|
244
|
+
status,
|
|
245
|
+
gitRoot: repo,
|
|
246
|
+
executionId: EXEC_ID,
|
|
247
|
+
changeSetId: CHANGE_SET_ID,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const shellCall = create(ToolCallSchema, {
|
|
251
|
+
id: "tc-late",
|
|
252
|
+
name: "shell",
|
|
253
|
+
status: ToolCallStatus.TOOL_CALL_RUNNING,
|
|
254
|
+
args: { command: "curl example.com" },
|
|
255
|
+
});
|
|
256
|
+
status.messages.push(
|
|
257
|
+
create(AgentMessageSchema, {
|
|
258
|
+
type: MessageType.MESSAGE_AI,
|
|
259
|
+
toolCalls: [shellCall],
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// The denial lands only while the cancel is settling — a premature ledger
|
|
264
|
+
// read would miss it and leave the row RUNNING forever.
|
|
265
|
+
const denialCancelSettled = (async () => {
|
|
266
|
+
await writeFile(
|
|
267
|
+
denialLedgerPath(hitlDir),
|
|
268
|
+
JSON.stringify({ toolName: "shell", token: toolCallIdentityToken(shellCall) }) + "\n",
|
|
269
|
+
"utf-8",
|
|
270
|
+
);
|
|
271
|
+
})();
|
|
272
|
+
|
|
273
|
+
const result = await runTurnBoundary(
|
|
274
|
+
boundaryOpts(status, baseline, { denialCancelSettled }),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(result.deniedToolCallCount).toBe(1);
|
|
278
|
+
expect(shellCall.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
279
|
+
});
|
|
280
|
+
});
|