@stigmer/runner 3.1.1 → 3.1.3
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 +49 -8
- package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
- package/dist/activities/execute-cursor/capture-flow.js +21 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
- package/dist/activities/execute-cursor/cas-observations.js +39 -5
- package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +19 -1
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +5 -0
- package/dist/activities/execute-cursor/index.js +222 -160
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.js +10 -1
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
- package/dist/activities/execute-cursor/prompt-builder.js +38 -20
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
- package/dist/activities/execute-cursor/skill-resolver.js +3 -55
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
- package/dist/activities/execute-cursor/stigmer-link.js +73 -0
- package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
- 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/workspace-provision.d.ts +3 -2
- package/dist/activities/execute-cursor/workspace-provision.js +5 -3
- package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +89 -5
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +22 -16
- package/dist/activities/execute-deep-agent/setup.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/config.d.ts +7 -0
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/middleware/approval-gate.js +36 -6
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/runner-manager.d.ts +2 -0
- package/dist/runner-manager.js +2 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +2 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/artifact-storage.d.ts +32 -0
- package/dist/shared/artifact-storage.js +74 -1
- package/dist/shared/artifact-storage.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-substrate.d.ts +9 -0
- package/dist/shared/filereview/cas-substrate.js +9 -1
- package/dist/shared/filereview/cas-substrate.js.map +1 -1
- 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 +42 -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 +3 -1
- package/dist/shared/filereview/index.js +3 -1
- 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 +96 -0
- package/dist/shared/filereview/progress.js +134 -0
- package/dist/shared/filereview/progress.js.map +1 -0
- package/dist/shared/implement-plan-prompt.d.ts +44 -0
- package/dist/shared/implement-plan-prompt.js +98 -0
- package/dist/shared/implement-plan-prompt.js.map +1 -0
- 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 +32 -0
- package/dist/shared/plan-mode-prompt.js +56 -0
- package/dist/shared/plan-mode-prompt.js.map +1 -0
- package/dist/shared/status.d.ts +11 -0
- package/dist/shared/status.js +12 -2
- package/dist/shared/status.js.map +1 -1
- package/dist/shared/tool-row.d.ts +30 -1
- package/dist/shared/tool-row.js +53 -4
- package/dist/shared/tool-row.js.map +1 -1
- package/dist/shared/workspace/session-root.d.ts +35 -0
- package/dist/shared/workspace/session-root.js +53 -0
- package/dist/shared/workspace/session-root.js.map +1 -0
- package/dist/shared/workspace/workspace-lock.d.ts +86 -0
- package/dist/shared/workspace/workspace-lock.js +187 -0
- package/dist/shared/workspace/workspace-lock.js.map +1 -0
- package/package.json +8 -5
- package/src/__tests__/config.test.ts +8 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +132 -2
- package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
- package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
- package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
- package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
- package/src/activities/execute-cursor/capture-flow.ts +32 -0
- package/src/activities/execute-cursor/cas-observations.ts +41 -5
- package/src/activities/execute-cursor/hook-script.ts +19 -1
- package/src/activities/execute-cursor/index.ts +243 -178
- package/src/activities/execute-cursor/message-translator.ts +9 -1
- package/src/activities/execute-cursor/prompt-builder.ts +60 -22
- package/src/activities/execute-cursor/skill-resolver.ts +3 -59
- package/src/activities/execute-cursor/stigmer-link.ts +78 -0
- package/src/activities/execute-cursor/turn-boundary.ts +274 -0
- package/src/activities/execute-cursor/workspace-provision.ts +5 -3
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +99 -0
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
- package/src/activities/execute-deep-agent/index.ts +104 -5
- package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
- package/src/activities/execute-deep-agent/setup.ts +25 -17
- package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
- package/src/activities/execute-deep-agent/streaming.ts +12 -1
- package/src/config.ts +17 -0
- package/src/middleware/__tests__/approval-gate.test.ts +77 -12
- package/src/middleware/approval-gate.ts +39 -7
- package/src/runner-manager.ts +6 -1
- package/src/runner.ts +6 -1
- package/src/shared/__tests__/artifact-storage.test.ts +111 -1
- package/src/shared/__tests__/implement-plan-prompt.test.ts +86 -0
- package/src/shared/__tests__/plan-artifact.test.ts +142 -9
- package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
- package/src/shared/__tests__/status.test.ts +7 -2
- package/src/shared/__tests__/tool-row.test.ts +136 -1
- package/src/shared/artifact-storage.ts +83 -1
- package/src/shared/filereview/__tests__/capture.test.ts +182 -1
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -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 +236 -0
- package/src/shared/filereview/capture.ts +134 -30
- package/src/shared/filereview/cas-substrate.ts +17 -1
- package/src/shared/filereview/events.ts +50 -0
- package/src/shared/filereview/git-substrate.ts +108 -0
- package/src/shared/filereview/index.ts +22 -1
- package/src/shared/filereview/line-counts.ts +83 -0
- package/src/shared/filereview/progress.ts +186 -0
- package/src/shared/implement-plan-prompt.ts +104 -0
- package/src/shared/plan-artifact.ts +152 -16
- package/src/shared/plan-mode-prompt.ts +56 -0
- package/src/shared/status.ts +11 -1
- package/src/shared/tool-row.ts +58 -5
- package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
- package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
- package/src/shared/workspace/session-root.ts +60 -0
- package/src/shared/workspace/workspace-lock.ts +241 -0
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
|
|
10
10
|
import { describe, it, expect } from "vitest";
|
|
11
11
|
import { create } from "@bufbuild/protobuf";
|
|
12
|
-
import { ApprovalAction } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
12
|
+
import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
13
13
|
import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
|
|
14
14
|
|
|
15
15
|
import { buildPrompt } from "../index.js";
|
|
16
16
|
import type { BuildPromptInput } from "../index.js";
|
|
17
|
-
import { buildReinvocationPrompt, formatToolApprovalProtocol, buildToolApprovalRuleFile } from "../prompt-builder.js";
|
|
17
|
+
import { buildReinvocationPrompt, formatInteractionModePrefix, formatImplementPlanSection, formatToolApprovalProtocol, buildToolApprovalRuleFile } from "../prompt-builder.js";
|
|
18
|
+
import { PLAN_MODE_DIRECTIVE } from "../../../shared/plan-mode-prompt.js";
|
|
18
19
|
import type { AgentResolution, AgentResolutionReason } from "../session-lifecycle.js";
|
|
19
20
|
|
|
20
21
|
const USER_MESSAGE = "What was the secret token I told you?";
|
|
@@ -250,3 +251,132 @@ describe("buildReinvocationPrompt", () => {
|
|
|
250
251
|
expect(prompt).not.toContain("Carry them out now");
|
|
251
252
|
});
|
|
252
253
|
});
|
|
254
|
+
|
|
255
|
+
describe("formatInteractionModePrefix", () => {
|
|
256
|
+
it("wraps the shared plan-mode directive in the interaction_mode section", () => {
|
|
257
|
+
const prefix = formatInteractionModePrefix(InteractionMode.PLAN);
|
|
258
|
+
|
|
259
|
+
expect(prefix).toBeDefined();
|
|
260
|
+
expect(prefix!.startsWith("<interaction_mode>")).toBe(true);
|
|
261
|
+
expect(prefix!.endsWith("</interaction_mode>")).toBe(true);
|
|
262
|
+
expect(prefix).toContain(PLAN_MODE_DIRECTIVE);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it.each([
|
|
266
|
+
["AGENT", InteractionMode.AGENT],
|
|
267
|
+
["UNSPECIFIED", InteractionMode.UNSPECIFIED],
|
|
268
|
+
["undefined", undefined],
|
|
269
|
+
])("returns undefined for %s", (_label, mode) => {
|
|
270
|
+
expect(formatInteractionModePrefix(mode)).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("injects the directive into a Plan-mode first prompt", () => {
|
|
274
|
+
const prompt = buildPrompt(
|
|
275
|
+
input({
|
|
276
|
+
resolution: resolution("local", "created_first_execution"),
|
|
277
|
+
interactionMode: InteractionMode.PLAN,
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(prompt).toContain("<interaction_mode>");
|
|
282
|
+
expect(prompt).toContain("your FINAL message IS the plan");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("prefixes the directive on a resumed Plan-mode follow-up (mode is per-execution)", () => {
|
|
286
|
+
const prompt = buildPrompt(
|
|
287
|
+
input({
|
|
288
|
+
resolution: resolution("local", "resumed_successfully"),
|
|
289
|
+
interactionMode: InteractionMode.PLAN,
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expect(prompt.startsWith("<interaction_mode>")).toBe(true);
|
|
294
|
+
expect(prompt.endsWith(USER_MESSAGE)).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("keeps a resumed Agent-mode follow-up as the raw user message", () => {
|
|
298
|
+
const prompt = buildPrompt(
|
|
299
|
+
input({
|
|
300
|
+
resolution: resolution("local", "resumed_successfully"),
|
|
301
|
+
interactionMode: InteractionMode.AGENT,
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
expect(prompt).toBe(USER_MESSAGE);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("formatImplementPlanSection", () => {
|
|
310
|
+
const PLAN_PATH = ".stigmer/inputs/plan.md";
|
|
311
|
+
|
|
312
|
+
it("wraps the attached-plan directive when the plan is among the attachments", () => {
|
|
313
|
+
const section = formatImplementPlanSection(true, [PLAN_PATH, ".stigmer/inputs/data.csv"]);
|
|
314
|
+
|
|
315
|
+
expect(section).toBeDefined();
|
|
316
|
+
expect(section!.startsWith("<implement_plan>")).toBe(true);
|
|
317
|
+
expect(section!.endsWith("</implement_plan>")).toBe(true);
|
|
318
|
+
expect(section).toContain(`\`${PLAN_PATH}\``);
|
|
319
|
+
expect(section).toContain("APPROVED");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("falls back to the conversation-plan directive when no plan attachment resolved", () => {
|
|
323
|
+
const section = formatImplementPlanSection(true, [".stigmer/inputs/data.csv"]);
|
|
324
|
+
|
|
325
|
+
expect(section).toBeDefined();
|
|
326
|
+
expect(section).not.toContain("plan.md");
|
|
327
|
+
expect(section).toContain("conversation above");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("returns undefined for an ordinary (non-build) execution", () => {
|
|
331
|
+
expect(formatImplementPlanSection(false, [PLAN_PATH])).toBeUndefined();
|
|
332
|
+
expect(formatImplementPlanSection(undefined, [PLAN_PATH])).toBeUndefined();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("carries the plan-derived progress-tracking instruction (Tier 3)", () => {
|
|
336
|
+
const section = formatImplementPlanSection(true, [PLAN_PATH]);
|
|
337
|
+
|
|
338
|
+
expect(section).toContain("to-do list");
|
|
339
|
+
expect(section).toContain("break the plan into");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("injects the directive into a build-from-plan first prompt", () => {
|
|
343
|
+
const prompt = buildPrompt(
|
|
344
|
+
input({
|
|
345
|
+
resolution: resolution("local", "created_first_execution"),
|
|
346
|
+
buildFromPlan: true,
|
|
347
|
+
attachmentPaths: [PLAN_PATH],
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
expect(prompt).toContain("<implement_plan>");
|
|
352
|
+
expect(prompt).toContain(`\`${PLAN_PATH}\``);
|
|
353
|
+
expect(prompt).toContain(USER_MESSAGE);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("prefixes the directive on a resumed build turn (build_from_plan is per-execution)", () => {
|
|
357
|
+
// The common shape: the plan turn ran earlier in the session, so the
|
|
358
|
+
// build turn resumes the agent — the directive must still arrive.
|
|
359
|
+
const prompt = buildPrompt(
|
|
360
|
+
input({
|
|
361
|
+
resolution: resolution("local", "resumed_successfully"),
|
|
362
|
+
buildFromPlan: true,
|
|
363
|
+
attachmentPaths: [PLAN_PATH],
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
expect(prompt.startsWith("<implement_plan>")).toBe(true);
|
|
368
|
+
expect(prompt.endsWith(USER_MESSAGE)).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("keeps a resumed non-build follow-up as the raw user message", () => {
|
|
372
|
+
const prompt = buildPrompt(
|
|
373
|
+
input({
|
|
374
|
+
resolution: resolution("local", "resumed_successfully"),
|
|
375
|
+
buildFromPlan: false,
|
|
376
|
+
attachmentPaths: [PLAN_PATH],
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
expect(prompt).toBe(USER_MESSAGE);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
resetCasObservations,
|
|
27
27
|
casObservationsDir,
|
|
28
28
|
buildObservationStagingScript,
|
|
29
|
+
buildSecretClassifyScript,
|
|
29
30
|
} from "../cas-observations.js";
|
|
30
31
|
import { isSecretLikePath } from "../../../shared/filereview/secret-paths.js";
|
|
31
32
|
|
|
@@ -184,4 +185,58 @@ describe("cas-observations sidecar", () => {
|
|
|
184
185
|
}
|
|
185
186
|
});
|
|
186
187
|
});
|
|
188
|
+
|
|
189
|
+
// The deny-gate (no-capture-substrate) classify-only script. It shares the
|
|
190
|
+
// classifier fragment with the staging script, so its verdict must equal
|
|
191
|
+
// isSecretLikePath byte-for-byte — a secret must never fall through the hook's
|
|
192
|
+
// deny-gate secret hard-block (DD-26 #2).
|
|
193
|
+
describe("buildSecretClassifyScript (deny-gate classify-only)", () => {
|
|
194
|
+
function runClassify(salient: string): string {
|
|
195
|
+
return execFileSync(process.execPath, ["-e", buildSecretClassifyScript()], {
|
|
196
|
+
input: salient,
|
|
197
|
+
})
|
|
198
|
+
.toString()
|
|
199
|
+
.trim();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
it("classifies secrets byte-for-byte identically to isSecretLikePath (no staging)", () => {
|
|
203
|
+
const paths = [
|
|
204
|
+
".env",
|
|
205
|
+
".env.local",
|
|
206
|
+
"config/.env.production",
|
|
207
|
+
"id_rsa",
|
|
208
|
+
"server.pem",
|
|
209
|
+
"certs/key.pfx",
|
|
210
|
+
"terraform.tfstate",
|
|
211
|
+
".aws/credentials",
|
|
212
|
+
"home/.ssh/known_hosts",
|
|
213
|
+
".npmrc",
|
|
214
|
+
"secrets.yaml",
|
|
215
|
+
"/abs/path/.env",
|
|
216
|
+
"id_rsa.pub",
|
|
217
|
+
"app.log",
|
|
218
|
+
"src/index.ts",
|
|
219
|
+
"docs/notes.md",
|
|
220
|
+
"build/out.js",
|
|
221
|
+
"environment.ts",
|
|
222
|
+
];
|
|
223
|
+
for (const p of paths) {
|
|
224
|
+
const expected = isSecretLikePath(p) ? "secret" : "ok";
|
|
225
|
+
expect(runClassify(p), `classification for ${p}`).toBe(expected);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("fail-closes on empty input (classifies as secret), matching isSecretLikePath('')", () => {
|
|
230
|
+
expect(runClassify("")).toBe("secret");
|
|
231
|
+
expect(isSecretLikePath("")).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("does not stage anything (pure classifier)", async () => {
|
|
235
|
+
const ws = tmp("obs-classify-nostage-");
|
|
236
|
+
writeFileSync(join(ws, ".env"), "API_KEY=xyz", "utf-8");
|
|
237
|
+
expect(runClassify(".env")).toBe("secret");
|
|
238
|
+
// No sidecar dir is created or written by the classify-only script.
|
|
239
|
+
expect(await readCasObservations(ws)).toEqual({ captured: [], secretPaths: [] });
|
|
240
|
+
});
|
|
241
|
+
});
|
|
187
242
|
});
|
|
@@ -240,3 +240,71 @@ describe("DeltaEnricher finalize reconciliation", () => {
|
|
|
240
240
|
expect(messages[1].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
241
241
|
});
|
|
242
242
|
});
|
|
243
|
+
|
|
244
|
+
describe("DeltaEnricher recovery-retry stream epilogue", () => {
|
|
245
|
+
// Regression for the zombie RUNNING row of aex_01kws27q1e2esvkqjpvectttxf:
|
|
246
|
+
// the recovery retry's bare stream loop buffered the tool-call-completed
|
|
247
|
+
// delta but never flushed it, so the row lacked the completedAt evidence
|
|
248
|
+
// finalize's sweep requires and persisted as RUNNING forever. The epilogue
|
|
249
|
+
// must applyEnrichments (flush the buffered evidence) BEFORE finalize.
|
|
250
|
+
it("applyEnrichments + finalize promotes a retry's buffered-completion RUNNING row", () => {
|
|
251
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
252
|
+
|
|
253
|
+
const enricher = new DeltaEnricher();
|
|
254
|
+
const messages: AgentMessage[] = [
|
|
255
|
+
makeMessage([{ id: "tc-retry", name: "Shell", status: ToolCallStatus.TOOL_CALL_RUNNING }]),
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
// The retry's onDelta buffered the completion, but the bare retry loop
|
|
259
|
+
// never applied it to the transcript.
|
|
260
|
+
enricher.processDelta({
|
|
261
|
+
type: "tool-call-completed",
|
|
262
|
+
callId: "tc-retry",
|
|
263
|
+
toolCall: { type: "shell" },
|
|
264
|
+
} as unknown as InteractionUpdate);
|
|
265
|
+
|
|
266
|
+
// Without the flush, finalize alone cannot promote (no evidence on the row).
|
|
267
|
+
const unflushed = makeMessage([
|
|
268
|
+
{ id: "tc-retry", name: "Shell", status: ToolCallStatus.TOOL_CALL_RUNNING },
|
|
269
|
+
]);
|
|
270
|
+
new DeltaEnricher().finalize([unflushed]);
|
|
271
|
+
expect(unflushed.toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_RUNNING);
|
|
272
|
+
|
|
273
|
+
// The epilogue sequence: flush the buffered evidence, then finalize.
|
|
274
|
+
enricher.applyEnrichments(messages);
|
|
275
|
+
enricher.finalize(messages);
|
|
276
|
+
|
|
277
|
+
const tc = messages[0].toolCalls[0];
|
|
278
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
279
|
+
expect(tc.completedAt).toBeTruthy();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("re-running applyEnrichments and finalize is idempotent", () => {
|
|
283
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
284
|
+
|
|
285
|
+
const enricher = new DeltaEnricher();
|
|
286
|
+
const messages: AgentMessage[] = [
|
|
287
|
+
makeMessage([{ id: "tc-1", name: "Shell", status: ToolCallStatus.TOOL_CALL_RUNNING }]),
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
enricher.processDelta({
|
|
291
|
+
type: "tool-call-completed",
|
|
292
|
+
callId: "tc-1",
|
|
293
|
+
toolCall: { type: "shell" },
|
|
294
|
+
} as unknown as InteractionUpdate);
|
|
295
|
+
|
|
296
|
+
// Primary epilogue ...
|
|
297
|
+
enricher.applyEnrichments(messages);
|
|
298
|
+
enricher.finalize(messages);
|
|
299
|
+
const stamped = messages[0].toolCalls[0].completedAt;
|
|
300
|
+
|
|
301
|
+
// ... and the retry epilogue re-runs both. The row must keep its original
|
|
302
|
+
// completion evidence and terminal status.
|
|
303
|
+
enricher.applyEnrichments(messages);
|
|
304
|
+
enricher.finalize(messages);
|
|
305
|
+
|
|
306
|
+
const tc = messages[0].toolCalls[0];
|
|
307
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
308
|
+
expect(tc.completedAt).toBe(stamped);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -1025,6 +1025,34 @@ describe("reconcileDeniedToolCalls — authoritative hook input overlay", () =>
|
|
|
1025
1025
|
expect(tc.approvalContentDigest).not.toBe("");
|
|
1026
1026
|
});
|
|
1027
1027
|
|
|
1028
|
+
it("does NOT overlay a secret-like write's content (DD-26 #2 defensive guard)", async () => {
|
|
1029
|
+
// Normally unreachable — the hook hard-blocks a secret write and records no
|
|
1030
|
+
// ledger entry — but if a hook classify failure fell one through with content,
|
|
1031
|
+
// that content must still never reach args / args_preview / the digest.
|
|
1032
|
+
const tc = toolCall({
|
|
1033
|
+
id: "c1",
|
|
1034
|
+
name: "write",
|
|
1035
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
1036
|
+
argsPreview: JSON.stringify({ path: ".env" }),
|
|
1037
|
+
});
|
|
1038
|
+
const messages = [aiMessageWith([tc])];
|
|
1039
|
+
|
|
1040
|
+
await reconcileDeniedToolCalls(
|
|
1041
|
+
messages,
|
|
1042
|
+
[{
|
|
1043
|
+
toolName: "Write",
|
|
1044
|
+
token: grantToken("write", ".env"),
|
|
1045
|
+
input: { file_path: ".env", content: "API_KEY=super-secret" },
|
|
1046
|
+
}],
|
|
1047
|
+
undefined,
|
|
1048
|
+
rootBackend(),
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
expect(args(tc).content).toBeUndefined(); // content never overlaid
|
|
1052
|
+
expect(tc.argsPreview).not.toContain("super-secret");
|
|
1053
|
+
expect(tc.approvalContentDigest).toBe(""); // digest not stamped from secret content
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1028
1056
|
it("stamps the captured edit old/new strings and a digest", async () => {
|
|
1029
1057
|
const tc = toolCall({
|
|
1030
1058
|
id: "c1",
|
|
@@ -403,10 +403,11 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
403
403
|
});
|
|
404
404
|
|
|
405
405
|
it("keeps gating a write to a GITIGNORED path (the snapshot cannot revert it)", () => {
|
|
406
|
-
//
|
|
407
|
-
// mode must still gate
|
|
408
|
-
|
|
409
|
-
|
|
406
|
+
// A NON-secret gitignored path is invisible to the git snapshot, so capture
|
|
407
|
+
// mode must still gate it for explicit approval. (A secret-like gitignored
|
|
408
|
+
// write is hard-blocked instead — see the deny-gate secret cases below.)
|
|
409
|
+
const h = setup({ captureMode: true, gitignored: ["ignored.txt"] });
|
|
410
|
+
expect(h.decide(hookWrite("ignored.txt")).permission).toBe("deny");
|
|
410
411
|
expect(h.ledger().map((e) => e.toolName)).toContain("Write");
|
|
411
412
|
// A non-ignored sibling still flows.
|
|
412
413
|
expect(h.decide(hookWrite("normal.txt")).permission).toBe("allow");
|
|
@@ -418,6 +419,64 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
418
419
|
});
|
|
419
420
|
});
|
|
420
421
|
|
|
422
|
+
// Deny-gate secret hard-block (DD-26 #2): with no capture substrate for a write
|
|
423
|
+
// (capture off — the classic deny-gate — or captureIgnored off in a git-no-
|
|
424
|
+
// storage workspace) a secret-like WRITE must NOT surface its content for
|
|
425
|
+
// approval. The hook hard-blocks it with the security message and records NO
|
|
426
|
+
// ledger entry, so it never becomes an approvable WAITING row; a non-secret
|
|
427
|
+
// write still deny-gates, and a delete (content-less) stays gated.
|
|
428
|
+
describe("deny-gate secret hard-block (DD-26 #2)", () => {
|
|
429
|
+
it("hard-blocks a secret-like write and records NO ledger entry", () => {
|
|
430
|
+
const h = setup({}); // captureMode off — the classic deny-gate
|
|
431
|
+
const dec = h.decide(hookWrite(".env", "API_KEY=abc"));
|
|
432
|
+
expect(dec.permission).toBe("deny");
|
|
433
|
+
expect(dec.raw).toContain("blocked for security");
|
|
434
|
+
// SECRET_BLOCKED, not APPROVAL_REQUIRED: the model is told to move on.
|
|
435
|
+
expect(dec.raw.toLowerCase()).toContain("nothing was written");
|
|
436
|
+
expect(dec.raw).not.toContain("submitted to the user for approval");
|
|
437
|
+
expect(h.ledger()).toEqual([]); // never recorded → never approvable
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("hard-blocks a secret-like edit (path-fragment match)", () => {
|
|
441
|
+
const h = setup({});
|
|
442
|
+
const dec = h.decide(hookEdit(".ssh/id_rsa"));
|
|
443
|
+
expect(dec.permission).toBe("deny");
|
|
444
|
+
expect(dec.raw).toContain("blocked for security");
|
|
445
|
+
expect(h.ledger()).toEqual([]);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("still deny-gates a NON-secret write and records its content for approval", () => {
|
|
449
|
+
const h = setup({});
|
|
450
|
+
const dec = h.decide(hookWrite("notes.md", "hello"));
|
|
451
|
+
expect(dec.permission).toBe("deny");
|
|
452
|
+
expect(dec.raw).toContain("submitted to the user for approval"); // APPROVAL_REQUIRED
|
|
453
|
+
expect(h.ledger().map((e) => e.toolName)).toContain("Write");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("a write-category lease does NOT bypass the secret block", () => {
|
|
457
|
+
const h = setup({ leasedCategories: ["write"] });
|
|
458
|
+
expect(h.decide(hookWrite("notes.md", "x")).permission).toBe("allow"); // non-secret rides the lease
|
|
459
|
+
const dec = h.decide(hookWrite(".env", "SECRET")); // secret is still hard-blocked
|
|
460
|
+
expect(dec.permission).toBe("deny");
|
|
461
|
+
expect(dec.raw).toContain("blocked for security");
|
|
462
|
+
expect(h.ledger()).toEqual([]);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("hard-blocks a secret write in a git workspace with captureIgnored off (no storage)", () => {
|
|
466
|
+
const h = setup({ captureMode: true, captureIgnored: false, gitignored: [".env"] });
|
|
467
|
+
const dec = h.decide(hookWrite(".env", "API_KEY=abc"));
|
|
468
|
+
expect(dec.permission).toBe("deny");
|
|
469
|
+
expect(dec.raw).toContain("blocked for security");
|
|
470
|
+
expect(h.ledger()).toEqual([]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("does NOT hard-block a secret DELETE (no content; stays deny-gated)", () => {
|
|
474
|
+
const h = setup({});
|
|
475
|
+
expect(h.decide(hookDelete(".env")).permission).toBe("deny");
|
|
476
|
+
expect(h.ledger().map((e) => e.toolName)).toContain("Delete");
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
421
480
|
// CAS parity (DD-18): with captureIgnored on, a non-secret gitignored write no
|
|
422
481
|
// longer stays on the deny-gate — the hook stages its pre-turn bytes into the
|
|
423
482
|
// cas-observations sidecar and ALLOWS it (apply-then-review), while a secret-
|
|
@@ -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
|
+
});
|