@stigmer/runner 3.0.9-dev.20260616060535 → 3.1.0
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/__test-utils__/approval-contract/types.d.ts +174 -0
- package/dist/__test-utils__/approval-contract/types.js +24 -0
- package/dist/__test-utils__/approval-contract/types.js.map +1 -0
- package/dist/activities/call-agent-status.d.ts +19 -1
- package/dist/activities/call-agent-status.js +64 -5
- package/dist/activities/call-agent-status.js.map +1 -1
- package/dist/activities/call-llm.js +19 -53
- package/dist/activities/call-llm.js.map +1 -1
- package/dist/activities/classify-tool-approvals.d.ts +24 -0
- package/dist/activities/classify-tool-approvals.js +69 -17
- package/dist/activities/classify-tool-approvals.js.map +1 -1
- package/dist/activities/discover-mcp-server.d.ts +7 -0
- package/dist/activities/discover-mcp-server.js +11 -1
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +140 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +130 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -0
- package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.d.ts +18 -0
- package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js +123 -0
- package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js.map +1 -0
- package/dist/activities/execute-cursor/approval-policy.d.ts +17 -61
- package/dist/activities/execute-cursor/approval-policy.js +8 -120
- package/dist/activities/execute-cursor/approval-policy.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +219 -9
- package/dist/activities/execute-cursor/approval-state.js +233 -17
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +127 -0
- package/dist/activities/execute-cursor/capture-flow.js +234 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -0
- package/dist/activities/execute-cursor/cas-observations.d.ts +96 -0
- package/dist/activities/execute-cursor/cas-observations.js +184 -0
- package/dist/activities/execute-cursor/cas-observations.js.map +1 -0
- package/dist/activities/execute-cursor/command-provenance.d.ts +62 -0
- package/dist/activities/execute-cursor/command-provenance.js +128 -0
- package/dist/activities/execute-cursor/command-provenance.js.map +1 -0
- package/dist/activities/execute-cursor/exact-apply.d.ts +110 -0
- package/dist/activities/execute-cursor/exact-apply.js +204 -0
- package/dist/activities/execute-cursor/exact-apply.js.map +1 -0
- package/dist/activities/execute-cursor/hook-script.d.ts +53 -24
- package/dist/activities/execute-cursor/hook-script.js +310 -47
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +8 -1
- package/dist/activities/execute-cursor/index.js +500 -64
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +258 -11
- package/dist/activities/execute-cursor/message-translator.js +836 -90
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +20 -23
- package/dist/activities/execute-cursor/prompt-builder.js +74 -15
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +17 -0
- package/dist/activities/execute-cursor/workspace-setup.js +212 -33
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.d.ts +19 -0
- package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.js +143 -0
- package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.js.map +1 -0
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +88 -0
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +81 -0
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -0
- package/dist/activities/execute-deep-agent/approval-file-change.d.ts +47 -0
- package/dist/activities/execute-deep-agent/approval-file-change.js +68 -0
- package/dist/activities/execute-deep-agent/approval-file-change.js.map +1 -0
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +8 -1
- package/dist/activities/execute-deep-agent/attachment-injector.js +7 -7
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +42 -0
- package/dist/activities/execute-deep-agent/cas-capture-backend.js +47 -0
- package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -0
- package/dist/activities/execute-deep-agent/cas-capture-observer.d.ts +79 -0
- package/dist/activities/execute-deep-agent/cas-capture-observer.js +112 -0
- package/dist/activities/execute-deep-agent/cas-capture-observer.js.map +1 -0
- package/dist/activities/execute-deep-agent/hitl.d.ts +10 -0
- package/dist/activities/execute-deep-agent/hitl.js +5 -1
- package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.d.ts +2 -1
- package/dist/activities/execute-deep-agent/index.js +370 -56
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/inline-publisher.d.ts +7 -1
- package/dist/activities/execute-deep-agent/inline-publisher.js +23 -2
- package/dist/activities/execute-deep-agent/inline-publisher.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +53 -2
- package/dist/activities/execute-deep-agent/setup.js +149 -92
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/stamp-flowed-rows.d.ts +36 -0
- package/dist/activities/execute-deep-agent/stamp-flowed-rows.js +56 -0
- package/dist/activities/execute-deep-agent/stamp-flowed-rows.js.map +1 -0
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +34 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.js +26 -25
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.d.ts +11 -5
- package/dist/activities/execute-deep-agent/status-builder.js +6 -2
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-side-effects.js +2 -19
- package/dist/activities/execute-deep-agent/streaming-side-effects.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.js +3 -15
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +25 -7
- package/dist/activities/execute-deep-agent/subagent-transformer.js +23 -7
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +30 -3
- package/dist/activities/execute-deep-agent/subagent-wiring.js +29 -3
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -2
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/claimcheck/payload-codec.js +9 -5
- package/dist/claimcheck/payload-codec.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +2 -0
- package/dist/client/stigmer-client.js +2 -0
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +85 -4
- package/dist/middleware/approval-gate.js +165 -38
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/middleware/types.d.ts +2 -5
- package/dist/shared/activity-input.d.ts +43 -0
- package/dist/shared/activity-input.js +17 -0
- package/dist/shared/activity-input.js.map +1 -0
- package/dist/shared/approval-canonicalize.d.ts +19 -0
- package/dist/shared/approval-canonicalize.js +119 -0
- package/dist/shared/approval-canonicalize.js.map +1 -0
- package/dist/shared/approval-fingerprint.d.ts +106 -0
- package/dist/shared/approval-fingerprint.js +113 -0
- package/dist/shared/approval-fingerprint.js.map +1 -0
- package/dist/shared/approval-policy.d.ts +182 -12
- package/dist/shared/approval-policy.js +213 -27
- package/dist/shared/approval-policy.js.map +1 -1
- package/dist/shared/args-preview.d.ts +52 -0
- package/dist/shared/args-preview.js +93 -0
- package/dist/shared/args-preview.js.map +1 -0
- package/dist/shared/artifact-storage.d.ts +19 -1
- package/dist/shared/artifact-storage.js +48 -11
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/file-change.d.ts +44 -0
- package/dist/shared/file-change.js +57 -0
- package/dist/shared/file-change.js.map +1 -0
- package/dist/shared/file-tools.d.ts +107 -0
- package/dist/shared/file-tools.js +168 -0
- package/dist/shared/file-tools.js.map +1 -0
- package/dist/shared/filereview/capture.d.ts +202 -0
- package/dist/shared/filereview/capture.js +498 -0
- package/dist/shared/filereview/capture.js.map +1 -0
- package/dist/shared/filereview/cas-substrate.d.ts +190 -0
- package/dist/shared/filereview/cas-substrate.js +284 -0
- package/dist/shared/filereview/cas-substrate.js.map +1 -0
- package/dist/shared/filereview/digest.d.ts +40 -0
- package/dist/shared/filereview/digest.js +66 -0
- package/dist/shared/filereview/digest.js.map +1 -0
- package/dist/shared/filereview/events.d.ts +170 -0
- package/dist/shared/filereview/events.js +298 -0
- package/dist/shared/filereview/events.js.map +1 -0
- package/dist/shared/filereview/git-substrate.d.ts +175 -0
- package/dist/shared/filereview/git-substrate.js +439 -0
- package/dist/shared/filereview/git-substrate.js.map +1 -0
- package/dist/shared/filereview/index.d.ts +11 -0
- package/dist/shared/filereview/index.js +12 -0
- package/dist/shared/filereview/index.js.map +1 -0
- package/dist/shared/filereview/secret-paths.d.ts +63 -0
- package/dist/shared/filereview/secret-paths.js +105 -0
- package/dist/shared/filereview/secret-paths.js.map +1 -0
- package/dist/shared/fingerprint-secret.d.ts +26 -0
- package/dist/shared/fingerprint-secret.js +47 -0
- package/dist/shared/fingerprint-secret.js.map +1 -0
- package/dist/shared/model-client.d.ts +51 -0
- package/dist/shared/model-client.js +77 -0
- package/dist/shared/model-client.js.map +1 -0
- package/dist/shared/plan-artifact.js +0 -2
- package/dist/shared/plan-artifact.js.map +1 -1
- package/dist/shared/status-offload.d.ts +83 -9
- package/dist/shared/status-offload.js +399 -79
- package/dist/shared/status-offload.js.map +1 -1
- package/dist/shared/status.js +14 -1
- package/dist/shared/status.js.map +1 -1
- package/dist/shared/tool-kind.d.ts +19 -0
- package/dist/shared/tool-kind.js +13 -0
- package/dist/shared/tool-kind.js.map +1 -1
- package/dist/shared/tool-row.d.ts +88 -0
- package/dist/shared/tool-row.js +127 -0
- package/dist/shared/tool-row.js.map +1 -0
- package/dist/shared/workspace/platform-dir.d.ts +25 -0
- package/dist/shared/workspace/platform-dir.js +38 -2
- package/dist/shared/workspace/platform-dir.js.map +1 -1
- package/dist/workflows/call-agent-orchestrator.js +56 -7
- package/dist/workflows/call-agent-orchestrator.js.map +1 -1
- package/dist/workflows/connect-mcp-server.d.ts +50 -0
- package/dist/workflows/connect-mcp-server.js +136 -15
- package/dist/workflows/connect-mcp-server.js.map +1 -1
- package/dist/workflows/types.d.ts +8 -0
- package/package.json +2 -2
- package/src/__test-utils__/approval-contract/contract.ts +224 -0
- package/src/__test-utils__/approval-contract/types.ts +179 -0
- package/src/__test-utils__/fake-artifact-storage.ts +72 -0
- package/src/__tests__/approval-gateway-contract.test.ts +29 -0
- package/src/__tests__/claimcheck-codec.test.ts +16 -53
- package/src/__tests__/golden-e2e.test.ts +2 -0
- package/src/__tests__/runner-token-coordinator.test.ts +3 -3
- package/src/activities/__tests__/call-agent-status.test.ts +135 -0
- package/src/activities/__tests__/call-llm.test.ts +1 -1
- package/src/activities/__tests__/classify-tool-approvals.test.ts +208 -1
- package/src/activities/__tests__/discover-mcp-server.test.ts +30 -0
- package/src/activities/__tests__/workflow-event-activities.test.ts +2 -1
- package/src/activities/call-agent-status.ts +74 -4
- package/src/activities/call-llm.ts +18 -63
- package/src/activities/classify-tool-approvals.ts +101 -19
- package/src/activities/discover-mcp-server.ts +29 -1
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +216 -0
- package/src/activities/execute-cursor/__test-utils__/gateway-substrate.ts +148 -0
- package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +41 -9
- package/src/activities/execute-cursor/__tests__/approval-state.test.ts +292 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +68 -1
- package/src/activities/execute-cursor/__tests__/capture-flow.test.ts +1005 -0
- package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +187 -0
- package/src/activities/execute-cursor/__tests__/coarse-fingerprint.test.ts +97 -0
- package/src/activities/execute-cursor/__tests__/command-provenance.test.ts +240 -0
- package/src/activities/execute-cursor/__tests__/deny-gate-exact-apply.test.ts +203 -0
- package/src/activities/execute-cursor/__tests__/exact-apply.test.ts +375 -0
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +1294 -24
- package/src/activities/execute-cursor/__tests__/hitl-resume-history.test.ts +446 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +384 -110
- package/src/activities/execute-cursor/__tests__/message-translator.test.ts +171 -25
- package/src/activities/execute-cursor/__tests__/sequential-gate-resume.test.ts +189 -0
- package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +44 -23
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +190 -10
- package/src/activities/execute-cursor/approval-policy.ts +28 -159
- package/src/activities/execute-cursor/approval-state.ts +366 -18
- package/src/activities/execute-cursor/capture-flow.ts +323 -0
- package/src/activities/execute-cursor/cas-observations.ts +204 -0
- package/src/activities/execute-cursor/command-provenance.ts +168 -0
- package/src/activities/execute-cursor/exact-apply.ts +253 -0
- package/src/activities/execute-cursor/hook-script.ts +317 -51
- package/src/activities/execute-cursor/index.ts +575 -67
- package/src/activities/execute-cursor/message-translator.ts +963 -89
- package/src/activities/execute-cursor/prompt-builder.ts +80 -14
- package/src/activities/execute-cursor/workspace-setup.ts +257 -42
- package/src/activities/execute-deep-agent/__test-utils__/gateway-substrate.ts +180 -0
- package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +134 -0
- package/src/activities/execute-deep-agent/__tests__/approval-file-change.test.ts +84 -0
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +11 -24
- package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +64 -0
- package/src/activities/execute-deep-agent/__tests__/cas-capture-observer.test.ts +163 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-integration.test.ts +8 -5
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +342 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +2 -5
- package/src/activities/execute-deep-agent/__tests__/inline-publisher.test.ts +31 -13
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +349 -0
- package/src/activities/execute-deep-agent/__tests__/stamp-flowed-rows.test.ts +119 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +12 -11
- package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +9 -9
- package/src/activities/execute-deep-agent/__tests__/subagent-approval-propagation.test.ts +160 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-gitignored-capture.test.ts +213 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +3 -6
- package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +84 -1
- package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +4 -1
- package/src/activities/execute-deep-agent/approval-file-change.ts +80 -0
- package/src/activities/execute-deep-agent/attachment-injector.ts +20 -11
- package/src/activities/execute-deep-agent/cas-capture-backend.ts +66 -0
- package/src/activities/execute-deep-agent/cas-capture-observer.ts +125 -0
- package/src/activities/execute-deep-agent/hitl.ts +15 -1
- package/src/activities/execute-deep-agent/index.ts +434 -64
- package/src/activities/execute-deep-agent/inline-publisher.ts +27 -4
- package/src/activities/execute-deep-agent/setup.ts +223 -125
- package/src/activities/execute-deep-agent/stamp-flowed-rows.ts +64 -0
- package/src/activities/execute-deep-agent/status-builder-shared.ts +62 -23
- package/src/activities/execute-deep-agent/status-builder.ts +19 -7
- package/src/activities/execute-deep-agent/streaming-side-effects.ts +2 -16
- package/src/activities/execute-deep-agent/streaming.ts +3 -13
- package/src/activities/execute-deep-agent/subagent-transformer.ts +53 -13
- package/src/activities/execute-deep-agent/subagent-wiring.ts +50 -3
- package/src/activities/execute-deep-agent/v3-status-builder.ts +8 -2
- package/src/claimcheck/payload-codec.ts +8 -8
- package/src/client/stigmer-client.ts +9 -1
- package/src/middleware/__tests__/approval-gate.test.ts +488 -4
- package/src/middleware/approval-gate.ts +247 -38
- package/src/middleware/types.ts +5 -5
- package/src/shared/__tests__/activity-input.test.ts +78 -0
- package/src/shared/__tests__/approval-canonicalize.test.ts +106 -0
- package/src/shared/__tests__/approval-fingerprint.test.ts +115 -0
- package/src/shared/__tests__/approval-policy.test.ts +274 -40
- package/src/shared/__tests__/args-preview.test.ts +78 -0
- package/src/shared/__tests__/artifact-storage-extended.test.ts +62 -10
- package/src/shared/__tests__/artifact-storage.test.ts +123 -11
- package/src/shared/__tests__/file-change.test.ts +85 -0
- package/src/shared/__tests__/file-tools.test.ts +90 -0
- package/src/shared/__tests__/fingerprint-secret.test.ts +51 -0
- package/src/shared/__tests__/lease-scope-corpus.test.ts +56 -0
- package/src/shared/__tests__/model-client.test.ts +162 -0
- package/src/shared/__tests__/plan-artifact.test.ts +11 -26
- package/src/shared/__tests__/policy-source-corpus.test.ts +58 -0
- package/src/shared/__tests__/status-offload.test.ts +573 -16
- package/src/shared/__tests__/status.test.ts +4 -5
- package/src/shared/__tests__/tool-kind.test.ts +24 -1
- package/src/shared/__tests__/tool-row.test.ts +221 -0
- package/src/shared/activity-input.ts +57 -0
- package/src/shared/approval-canonicalize.ts +159 -0
- package/src/shared/approval-fingerprint.ts +148 -0
- package/src/shared/approval-policy.ts +303 -27
- package/src/shared/args-preview.ts +98 -0
- package/src/shared/artifact-storage.ts +62 -11
- package/src/shared/checkpointer/__tests__/http-saver.test.ts +1 -2
- package/src/shared/file-change.ts +64 -0
- package/src/shared/file-tools.ts +169 -0
- package/src/shared/filereview/__tests__/capture.test.ts +856 -0
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +404 -0
- package/src/shared/filereview/__tests__/digest.test.ts +100 -0
- package/src/shared/filereview/__tests__/events.test.ts +245 -0
- package/src/shared/filereview/__tests__/git-substrate.test.ts +362 -0
- package/src/shared/filereview/__tests__/proxy-reconcile.test.ts +286 -0
- package/src/shared/filereview/__tests__/secret-paths.test.ts +121 -0
- package/src/shared/filereview/capture.ts +727 -0
- package/src/shared/filereview/cas-substrate.ts +401 -0
- package/src/shared/filereview/digest.ts +83 -0
- package/src/shared/filereview/events.ts +449 -0
- package/src/shared/filereview/git-substrate.ts +555 -0
- package/src/shared/filereview/index.ts +60 -0
- package/src/shared/filereview/secret-paths.ts +121 -0
- package/src/shared/fingerprint-secret.ts +53 -0
- package/src/shared/model-client.ts +122 -0
- package/src/shared/plan-artifact.ts +0 -2
- package/src/shared/status-offload.ts +433 -77
- package/src/shared/status.ts +13 -0
- package/src/shared/tool-kind.ts +33 -0
- package/src/shared/tool-row.ts +135 -0
- package/src/shared/workspace/platform-dir.ts +41 -2
- package/src/workflow-engine/__tests__/golden-execution.test.ts +35 -18
- package/src/workflow-engine/__tests__/tasks/try.test.ts +1 -1
- package/src/workflows/__tests__/connect-mcp-server.test.ts +304 -29
- package/src/workflows/call-agent-orchestrator.ts +53 -6
- package/src/workflows/connect-mcp-server.ts +179 -24
- package/src/workflows/types.ts +8 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* @regression file-hitl-phase0 — pins file-edit HITL fixes #1, #2, #3, #5 (see _projects/2026-06/20260630.01.file-change-hitl-redesign/tasks/T01_3_regression-manifest.md)
|
|
3
|
+
*
|
|
2
4
|
* Unit tests for the Cursor-harness HITL denial-ledger flow.
|
|
3
5
|
*
|
|
4
6
|
* These cover the architecture the platform converges on: the preToolUse hook
|
|
@@ -11,14 +13,17 @@
|
|
|
11
13
|
* - token-correlated overlay of WAITING_APPROVAL onto streamed tool calls,
|
|
12
14
|
* INCLUDING the regression where a denied tool was reported as "completed"
|
|
13
15
|
* (the green-checkmark bug) — it must become WAITING_APPROVAL, not success
|
|
16
|
+
* - the gate's before-reading diff capture: a whole-file rewrite renders a true
|
|
17
|
+
* before/after, an edit renders a HUNK — via the shared gate-file-change builder
|
|
18
|
+
* - in-place collapse of a same-turn duplicate denial twin (one card, not two)
|
|
14
19
|
* - synthesis of a tool call when a denial produced no stream event
|
|
15
20
|
* - reconstruction of adjudicated approvals from tool calls on reinvocation
|
|
16
21
|
* (pending_approvals is empty by then because the backend cleared it)
|
|
17
22
|
* - the generated hook script wiring (records denials in both deny branches)
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
|
-
import { describe, it, expect, afterEach } from "vitest";
|
|
21
|
-
import { create } from "@bufbuild/protobuf";
|
|
25
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
26
|
+
import { create, type MessageInitShape } from "@bufbuild/protobuf";
|
|
22
27
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
23
28
|
import { writeFile } from "node:fs/promises";
|
|
24
29
|
import { tmpdir } from "node:os";
|
|
@@ -28,6 +33,7 @@ import {
|
|
|
28
33
|
AgentMessageSchema,
|
|
29
34
|
ToolCallSchema,
|
|
30
35
|
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
36
|
+
import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
|
|
31
37
|
import type {
|
|
32
38
|
AgentMessage,
|
|
33
39
|
ToolCall,
|
|
@@ -43,9 +49,13 @@ import {
|
|
|
43
49
|
readDenialLedger,
|
|
44
50
|
denialLedgerPath,
|
|
45
51
|
reconstructAdjudicatedApprovals,
|
|
52
|
+
buildApprovalGrants,
|
|
46
53
|
grantToken,
|
|
54
|
+
watchDenialLedger,
|
|
47
55
|
} from "../approval-state.js";
|
|
48
|
-
import { reconcileDeniedToolCalls } from "../message-translator.js";
|
|
56
|
+
import { reconcileDeniedToolCalls, clearProvisionalPostDenialNarration, collapseRedundantToolCallTwins, toolCallIdentityToken } from "../message-translator.js";
|
|
57
|
+
import { mockWorkspaceBackend } from "../../../__test-utils__/mock-workspace.js";
|
|
58
|
+
import type { WorkspaceBackend } from "../../../shared/workspace/types.js";
|
|
49
59
|
import { generateHookScript } from "../hook-script.js";
|
|
50
60
|
import type { MergedToolPolicy } from "../approval-policy.js";
|
|
51
61
|
|
|
@@ -63,11 +73,23 @@ function makeWorkspace(): string {
|
|
|
63
73
|
return dir;
|
|
64
74
|
}
|
|
65
75
|
|
|
76
|
+
// The gate now reads each denied file's pre-edit `before` from a WorkspaceBackend
|
|
77
|
+
// (the tool was denied, so disk still holds the old content). These fakes back
|
|
78
|
+
// the two reads the gate uses (exists/readFile) from an in-memory map so the
|
|
79
|
+
// before/after capture is deterministic with no real IO. Rooted at "/root" so the
|
|
80
|
+
// existing absolute-path assertions hold.
|
|
81
|
+
const ROOT = "/root";
|
|
82
|
+
|
|
83
|
+
/** A backend whose files are absent (rooted at ROOT for path assertions). */
|
|
84
|
+
function rootBackend(): WorkspaceBackend {
|
|
85
|
+
return mockWorkspaceBackend({ rootDir: ROOT });
|
|
86
|
+
}
|
|
87
|
+
|
|
66
88
|
// Stream tool calls use the lowercase SDK taxonomy (edit/shell/delete); the
|
|
67
89
|
// denial ledger uses the hook taxonomy (Write/Shell/Delete) + a canonical
|
|
68
90
|
// category+salient token. The two correlate via approvalCategory — that cross-
|
|
69
91
|
// taxonomy match is exactly what these tests pin.
|
|
70
|
-
function toolCall(overrides:
|
|
92
|
+
function toolCall(overrides: MessageInitShape<typeof ToolCallSchema>): ToolCall {
|
|
71
93
|
return create(ToolCallSchema, {
|
|
72
94
|
id: "call-1",
|
|
73
95
|
name: "edit",
|
|
@@ -118,10 +140,32 @@ describe("denial ledger reset/read", () => {
|
|
|
118
140
|
{ toolName: "Shell", token: shellToken },
|
|
119
141
|
]);
|
|
120
142
|
});
|
|
143
|
+
|
|
144
|
+
it("decodes the base64 tool_input the hook captures, tolerating absence and garbage", async () => {
|
|
145
|
+
const ws = makeWorkspace();
|
|
146
|
+
await resetDenialLedger(ws);
|
|
147
|
+
const token = grantToken("write", "notes.md");
|
|
148
|
+
const input = { path: "notes.md", contents: "# Notes\n" };
|
|
149
|
+
const inputB64 = Buffer.from(JSON.stringify(input), "utf-8").toString("base64");
|
|
150
|
+
await writeFile(
|
|
151
|
+
denialLedgerPath(ws),
|
|
152
|
+
// 1) full capture, 2) no input field (grep fallback), 3) garbage input.
|
|
153
|
+
`{"toolName":"Write","token":"${token}","input":"${inputB64}"}\n` +
|
|
154
|
+
`{"toolName":"Write","token":"${grantToken("write", "b.txt")}"}\n` +
|
|
155
|
+
`{"toolName":"Write","token":"${grantToken("write", "c.txt")}","input":"!!!not-base64!!!"}\n`,
|
|
156
|
+
"utf-8",
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const entries = await readDenialLedger(ws);
|
|
160
|
+
expect(entries).toHaveLength(3);
|
|
161
|
+
expect(entries[0].input).toEqual(input);
|
|
162
|
+
expect(entries[1].input).toBeUndefined();
|
|
163
|
+
expect(entries[2].input).toBeUndefined();
|
|
164
|
+
});
|
|
121
165
|
});
|
|
122
166
|
|
|
123
167
|
describe("reconcileDeniedToolCalls", () => {
|
|
124
|
-
it("overlays WAITING_APPROVAL onto the REAL denied tool reported as completed (the green-check bug)", () => {
|
|
168
|
+
it("overlays WAITING_APPROVAL onto the REAL denied tool reported as completed (the green-check bug)", async () => {
|
|
125
169
|
// Stream reports the file mutation as `edit` (RUNNING/COMPLETED); the hook
|
|
126
170
|
// denied it as `Write`. The category+salient token bridges the two so the
|
|
127
171
|
// overlay lands on this exact streamed tool call — no synthesized placeholder.
|
|
@@ -136,7 +180,7 @@ describe("reconcileDeniedToolCalls", () => {
|
|
|
136
180
|
});
|
|
137
181
|
const messages = [aiMessageWith([tc])];
|
|
138
182
|
|
|
139
|
-
const reconciled = reconcileDeniedToolCalls(messages, [
|
|
183
|
+
const reconciled = await reconcileDeniedToolCalls(messages, [
|
|
140
184
|
{ toolName: "Write", token: grantToken("write", "gated.txt") },
|
|
141
185
|
]);
|
|
142
186
|
|
|
@@ -156,7 +200,7 @@ describe("reconcileDeniedToolCalls", () => {
|
|
|
156
200
|
expect(tc.error).toBe("");
|
|
157
201
|
});
|
|
158
202
|
|
|
159
|
-
it("resolves the MCP policy message for a denied MCP tool", () => {
|
|
203
|
+
it("resolves the MCP policy message for a denied MCP tool", async () => {
|
|
160
204
|
const tc = toolCall({
|
|
161
205
|
id: "c1",
|
|
162
206
|
name: "apply_x",
|
|
@@ -170,11 +214,12 @@ describe("reconcileDeniedToolCalls", () => {
|
|
|
170
214
|
mcpServerSlug: "planton",
|
|
171
215
|
requiresApproval: true,
|
|
172
216
|
approvalMessage: "Apply infrastructure change",
|
|
217
|
+
source: "classifier_default",
|
|
173
218
|
}],
|
|
174
219
|
]);
|
|
175
220
|
|
|
176
221
|
// MCP tools are keyed name-only (their name is consistent across layers).
|
|
177
|
-
reconcileDeniedToolCalls(messages, [
|
|
222
|
+
await reconcileDeniedToolCalls(messages, [
|
|
178
223
|
{ toolName: "apply_x", token: grantToken("apply_x", "") },
|
|
179
224
|
], policies);
|
|
180
225
|
|
|
@@ -182,7 +227,7 @@ describe("reconcileDeniedToolCalls", () => {
|
|
|
182
227
|
expect(tc.approvalMessage).toBe("Apply infrastructure change");
|
|
183
228
|
});
|
|
184
229
|
|
|
185
|
-
it("leaves non-denied tool calls untouched while overlaying the denied one", () => {
|
|
230
|
+
it("leaves non-denied tool calls untouched while overlaying the denied one", async () => {
|
|
186
231
|
const denied = toolCall({
|
|
187
232
|
id: "c1",
|
|
188
233
|
name: "edit",
|
|
@@ -197,7 +242,7 @@ describe("reconcileDeniedToolCalls", () => {
|
|
|
197
242
|
});
|
|
198
243
|
const messages = [aiMessageWith([denied, allowed])];
|
|
199
244
|
|
|
200
|
-
const reconciled = reconcileDeniedToolCalls(messages, [
|
|
245
|
+
const reconciled = await reconcileDeniedToolCalls(messages, [
|
|
201
246
|
{ toolName: "Write", token: grantToken("write", "gated.txt") },
|
|
202
247
|
]);
|
|
203
248
|
|
|
@@ -209,25 +254,37 @@ describe("reconcileDeniedToolCalls", () => {
|
|
|
209
254
|
expect(messages[0].toolCalls).toHaveLength(2);
|
|
210
255
|
});
|
|
211
256
|
|
|
212
|
-
it("collapses repeated denials
|
|
257
|
+
it("collapses repeated same-resource denials to one gate and one hidden twin", async () => {
|
|
258
|
+
// Two completed-but-denied edits of the same file (the green-check duplicate).
|
|
259
|
+
// The first becomes the single gate; the second is a content-less twin that
|
|
260
|
+
// must be collapsed IN PLACE to a hidden SKIPPED row, not left as a second
|
|
261
|
+
// settled card. Neither carries file_changes, so the collapse is safe.
|
|
213
262
|
const first = toolCall({ id: "c1", name: "edit", args: { path: "gated.txt" } });
|
|
214
263
|
const second = toolCall({ id: "c2", name: "edit", args: { path: "gated.txt" } });
|
|
215
264
|
const messages = [aiMessageWith([first, second])];
|
|
216
265
|
|
|
217
|
-
const reconciled = reconcileDeniedToolCalls(messages, [
|
|
266
|
+
const reconciled = await reconcileDeniedToolCalls(messages, [
|
|
218
267
|
{ toolName: "Write", token: grantToken("write", "gated.txt") },
|
|
219
268
|
]);
|
|
220
269
|
|
|
221
270
|
// One approval anchor (so the backend gate resolves cleanly on one decision).
|
|
222
271
|
expect(reconciled).toHaveLength(1);
|
|
223
272
|
expect(first.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
224
|
-
|
|
273
|
+
// The twin is collapsed in place: SKIPPED, content-less, no approval — the SDK
|
|
274
|
+
// hides it (isCollapsedToolCall), so one resource renders one card. Its id is
|
|
275
|
+
// preserved, so the backend append-only guard accepts the finalize.
|
|
276
|
+
expect(second.id).toBe("c2");
|
|
277
|
+
expect(second.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
278
|
+
expect(second.requiresApproval).toBe(false);
|
|
279
|
+
expect(second.argsPreview).toBe("");
|
|
280
|
+
expect(second.error).toBe("");
|
|
281
|
+
expect(second.result).toBe("");
|
|
225
282
|
});
|
|
226
283
|
|
|
227
|
-
it("synthesizes a WAITING_APPROVAL tool call when a denial produced no stream event", () => {
|
|
284
|
+
it("synthesizes a WAITING_APPROVAL tool call when a denial produced no stream event", async () => {
|
|
228
285
|
const messages = [aiMessageWith([])];
|
|
229
286
|
|
|
230
|
-
const reconciled = reconcileDeniedToolCalls(messages, [
|
|
287
|
+
const reconciled = await reconcileDeniedToolCalls(messages, [
|
|
231
288
|
{ toolName: "Shell", token: grantToken("shell", "rm -rf build") },
|
|
232
289
|
]);
|
|
233
290
|
|
|
@@ -243,14 +300,960 @@ describe("reconcileDeniedToolCalls", () => {
|
|
|
243
300
|
expect(synthesized.argsPreview).toContain("rm -rf build");
|
|
244
301
|
});
|
|
245
302
|
|
|
246
|
-
it("is a no-op when the ledger is empty", () => {
|
|
303
|
+
it("is a no-op when the ledger is empty", async () => {
|
|
247
304
|
const tc = toolCall({ id: "c1", status: ToolCallStatus.TOOL_CALL_COMPLETED });
|
|
248
305
|
const messages = [aiMessageWith([tc])];
|
|
249
|
-
expect(reconcileDeniedToolCalls(messages, [])).toEqual([]);
|
|
306
|
+
expect(await reconcileDeniedToolCalls(messages, [])).toEqual([]);
|
|
250
307
|
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
251
308
|
});
|
|
252
309
|
});
|
|
253
310
|
|
|
311
|
+
// One resource emitted twice in a turn produced two cards: the gate plus a
|
|
312
|
+
// settled "No preview available" twin. The runner collapses that twin in place
|
|
313
|
+
// (it cannot drop the committed id) to a hidden SKIPPED row. These pin the
|
|
314
|
+
// collapse and its safety guard (a twin carrying its own change is never hidden).
|
|
315
|
+
describe("reconcileDeniedToolCalls — duplicate denial-twin collapse", () => {
|
|
316
|
+
it("collapses a same-resource FAILED twin beside the overlaid gate", async () => {
|
|
317
|
+
const gate = toolCall({
|
|
318
|
+
id: "stream-1",
|
|
319
|
+
name: "edit",
|
|
320
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
321
|
+
args: { path: "notes.md", old_string: "a", new_string: "b" },
|
|
322
|
+
argsPreview: JSON.stringify({ path: "notes.md" }),
|
|
323
|
+
});
|
|
324
|
+
const twin = toolCall({
|
|
325
|
+
id: "stream-2",
|
|
326
|
+
name: "edit",
|
|
327
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
328
|
+
error: "blocked by a hook",
|
|
329
|
+
args: { path: "notes.md", old_string: "a", new_string: "b" },
|
|
330
|
+
argsPreview: JSON.stringify({ path: "notes.md" }),
|
|
331
|
+
});
|
|
332
|
+
const messages = [aiMessageWith([gate, twin])];
|
|
333
|
+
|
|
334
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
335
|
+
messages,
|
|
336
|
+
[{ toolName: "Write", token: grantToken("write", "notes.md") }],
|
|
337
|
+
undefined,
|
|
338
|
+
rootBackend(),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// Exactly one gate; both committed ids are preserved (append-only).
|
|
342
|
+
expect(reconciled).toHaveLength(1);
|
|
343
|
+
expect(reconciled[0].id).toBe("stream-1");
|
|
344
|
+
expect(messages[0].toolCalls.map((t) => t.id)).toEqual(["stream-1", "stream-2"]);
|
|
345
|
+
|
|
346
|
+
expect(gate.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
347
|
+
// The twin is collapsed to a hidden SKIPPED row.
|
|
348
|
+
expect(twin.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
349
|
+
expect(twin.requiresApproval).toBe(false);
|
|
350
|
+
expect(twin.error).toBe("");
|
|
351
|
+
expect(twin.argsPreview).toBe("");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("collapses a same-command SHELL twin too (the collapse is tool-agnostic, not edit-only)", async () => {
|
|
355
|
+
// The duplicate is keyed on the identity token, so it folds for any gated
|
|
356
|
+
// tool family — shell here, exercising the same collapse a delete or MCP twin
|
|
357
|
+
// would receive. Both attempts at the same command were denied.
|
|
358
|
+
const gate = toolCall({
|
|
359
|
+
id: "sh-1",
|
|
360
|
+
name: "shell",
|
|
361
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
362
|
+
args: { command: "rm -rf build" },
|
|
363
|
+
argsPreview: JSON.stringify({ command: "rm -rf build" }),
|
|
364
|
+
});
|
|
365
|
+
const twin = toolCall({
|
|
366
|
+
id: "sh-2",
|
|
367
|
+
name: "shell",
|
|
368
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
369
|
+
error: "blocked by a hook",
|
|
370
|
+
args: { command: "rm -rf build" },
|
|
371
|
+
argsPreview: JSON.stringify({ command: "rm -rf build" }),
|
|
372
|
+
});
|
|
373
|
+
const messages = [aiMessageWith([gate, twin])];
|
|
374
|
+
|
|
375
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
376
|
+
messages,
|
|
377
|
+
[{ toolName: "Shell", token: grantToken("shell", "rm -rf build") }],
|
|
378
|
+
undefined,
|
|
379
|
+
rootBackend(),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
expect(reconciled).toHaveLength(1);
|
|
383
|
+
expect(reconciled[0].id).toBe("sh-1");
|
|
384
|
+
expect(gate.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
385
|
+
expect(twin.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
386
|
+
expect(twin.requiresApproval).toBe(false);
|
|
387
|
+
expect(twin.argsPreview).toBe("");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("one gate per turn: surfaces the anchor (first denied) and defers a DISTINCT co-pending sibling", async () => {
|
|
391
|
+
// Two edits to DIFFERENT files denied in one turn. Under the Cursor deny-only
|
|
392
|
+
// one-gate-per-turn contract the FIRST denial (ledger[0] = a.md) is the single
|
|
393
|
+
// surfaced gate; the distinct sibling b.md is blanked to a hidden SKIPPED row
|
|
394
|
+
// and re-attempted (and re-gated) on the next turn — sequential gating, not a
|
|
395
|
+
// lost intent. (The native harness keeps full in-turn co-pending; this rule is
|
|
396
|
+
// cursor-only.)
|
|
397
|
+
const gateA = toolCall({
|
|
398
|
+
id: "edit-a",
|
|
399
|
+
name: "edit",
|
|
400
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
401
|
+
args: { path: "a.md", old_string: "1", new_string: "2" },
|
|
402
|
+
argsPreview: JSON.stringify({ path: "a.md" }),
|
|
403
|
+
});
|
|
404
|
+
const gateB = toolCall({
|
|
405
|
+
id: "edit-b",
|
|
406
|
+
name: "edit",
|
|
407
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
408
|
+
args: { path: "b.md", old_string: "3", new_string: "4" },
|
|
409
|
+
argsPreview: JSON.stringify({ path: "b.md" }),
|
|
410
|
+
});
|
|
411
|
+
const messages = [aiMessageWith([gateA, gateB])];
|
|
412
|
+
|
|
413
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
414
|
+
messages,
|
|
415
|
+
[
|
|
416
|
+
// The hook records the content-exact token for an edit (its primary token).
|
|
417
|
+
{ toolName: "Write", token: toolCallIdentityToken(gateA) },
|
|
418
|
+
{ toolName: "Write", token: toolCallIdentityToken(gateB) },
|
|
419
|
+
],
|
|
420
|
+
undefined,
|
|
421
|
+
rootBackend(),
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// Exactly one gate (the anchor); the sibling is hidden, not a second card.
|
|
425
|
+
expect(reconciled).toHaveLength(1);
|
|
426
|
+
expect(reconciled[0].id).toBe("edit-a");
|
|
427
|
+
expect(gateA.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
428
|
+
expect(gateB.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
429
|
+
expect(gateB.requiresApproval).toBe(false);
|
|
430
|
+
expect(gateB.argsPreview).toBe("");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("collapses a same-path denied write twin onto the single gate (file rows carry no diff)", async () => {
|
|
434
|
+
// Under apply-then-review a file row never carries an authoritative change (it
|
|
435
|
+
// lives in the ledger), so a same-path denied write twin — even a distinct
|
|
436
|
+
// edit — collapses onto the one gate (one gate per turn per resource).
|
|
437
|
+
const gate = toolCall({
|
|
438
|
+
id: "stream-1",
|
|
439
|
+
name: "edit",
|
|
440
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
441
|
+
args: { path: "notes.md", old_string: "a", new_string: "b" },
|
|
442
|
+
});
|
|
443
|
+
const twin = toolCall({
|
|
444
|
+
id: "stream-2",
|
|
445
|
+
name: "edit",
|
|
446
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
447
|
+
args: { path: "notes.md", old_string: "c", new_string: "d" },
|
|
448
|
+
});
|
|
449
|
+
const messages = [aiMessageWith([gate, twin])];
|
|
450
|
+
|
|
451
|
+
await reconcileDeniedToolCalls(
|
|
452
|
+
messages,
|
|
453
|
+
[{ toolName: "Write", token: grantToken("write", "notes.md") }],
|
|
454
|
+
undefined,
|
|
455
|
+
rootBackend(),
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
expect(gate.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
459
|
+
expect(twin.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// One gate per turn (the H-F deny-only clean pause). When a denied edit is
|
|
464
|
+
// followed by a DIFFERENT-identity workaround (the classic `shell: cat > file`
|
|
465
|
+
// bypass) the first-denial stop races the SDK's auto-execution, so both denials
|
|
466
|
+
// can land in the ledger. The reconcile anchors on the FIRST denial and blanks
|
|
467
|
+
// every other denied identity to a hidden SKIPPED row — one card for one intent,
|
|
468
|
+
// regardless of whether the two calls share an assistant message. These pin the
|
|
469
|
+
// production shape (exec aex_01kw4p0cqgk0j8vvxbs5t8gv59: edit + shell in ONE
|
|
470
|
+
// message, no narration between them) that no positional or same-identity rule
|
|
471
|
+
// could fix.
|
|
472
|
+
describe("reconcileDeniedToolCalls — one gate per turn (deny-only workaround)", () => {
|
|
473
|
+
it("collapses the edit+shell workaround in the SAME message to one gate (the production bug)", async () => {
|
|
474
|
+
// edit notes.md (denied) then shell `cat > notes.md` (denied, reported as a
|
|
475
|
+
// success by Cursor) — both attached to ONE AgentMessage with no narration
|
|
476
|
+
// between, exactly as captured in production. The edit is the anchor; the
|
|
477
|
+
// shell is a post-denial reaction and must be hidden, not a second card.
|
|
478
|
+
const edit = toolCall({
|
|
479
|
+
id: "edit-1",
|
|
480
|
+
name: "edit",
|
|
481
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
482
|
+
args: { path: "notes.md", old_string: "", new_string: "hi" },
|
|
483
|
+
argsPreview: JSON.stringify({ path: "notes.md" }),
|
|
484
|
+
});
|
|
485
|
+
const shell = toolCall({
|
|
486
|
+
id: "shell-1",
|
|
487
|
+
name: "shell",
|
|
488
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
489
|
+
result: '""', // denied-reported-as-success degenerate result
|
|
490
|
+
args: { command: "cat > notes.md" },
|
|
491
|
+
argsPreview: JSON.stringify({ command: "cat > notes.md" }),
|
|
492
|
+
});
|
|
493
|
+
const messages = [aiMessageWith([edit, shell])];
|
|
494
|
+
|
|
495
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
496
|
+
messages,
|
|
497
|
+
[
|
|
498
|
+
{ toolName: "Write", token: grantToken("write", "notes.md") },
|
|
499
|
+
{ toolName: "Shell", token: grantToken("shell", "cat > notes.md") },
|
|
500
|
+
],
|
|
501
|
+
undefined,
|
|
502
|
+
rootBackend(),
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
expect(reconciled).toHaveLength(1);
|
|
506
|
+
expect(reconciled[0].id).toBe("edit-1");
|
|
507
|
+
expect(edit.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
508
|
+
// The workaround is hidden (SKIPPED), never surfaced, never COMPLETED.
|
|
509
|
+
expect(shell.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
510
|
+
expect(shell.requiresApproval).toBe(false);
|
|
511
|
+
expect(shell.result).toBe("");
|
|
512
|
+
expect(shell.argsPreview).toBe("");
|
|
513
|
+
// Both ids are preserved in place (append-only finalize).
|
|
514
|
+
expect(messages[0].toolCalls.map((t) => t.id)).toEqual(["edit-1", "shell-1"]);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("collapses the workaround even when the shell lands in a LATER message (narration case)", async () => {
|
|
518
|
+
// The narration variant: a new AgentMessage starts between the two tools.
|
|
519
|
+
// The rule is identity/ledger-based, not positional, so the outcome matches
|
|
520
|
+
// the same-message case above — proving it is robust to message segmentation.
|
|
521
|
+
const edit = toolCall({
|
|
522
|
+
id: "edit-1",
|
|
523
|
+
name: "edit",
|
|
524
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
525
|
+
args: { path: "notes.md", old_string: "", new_string: "hi" },
|
|
526
|
+
argsPreview: JSON.stringify({ path: "notes.md" }),
|
|
527
|
+
});
|
|
528
|
+
const shell = toolCall({
|
|
529
|
+
id: "shell-1",
|
|
530
|
+
name: "shell",
|
|
531
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
532
|
+
error: "blocked by a hook",
|
|
533
|
+
args: { command: "cat > notes.md" },
|
|
534
|
+
argsPreview: JSON.stringify({ command: "cat > notes.md" }),
|
|
535
|
+
});
|
|
536
|
+
const messages = [aiMessageWith([edit]), aiMessageWith([shell])];
|
|
537
|
+
|
|
538
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
539
|
+
messages,
|
|
540
|
+
[
|
|
541
|
+
{ toolName: "Write", token: grantToken("write", "notes.md") },
|
|
542
|
+
{ toolName: "Shell", token: grantToken("shell", "cat > notes.md") },
|
|
543
|
+
],
|
|
544
|
+
undefined,
|
|
545
|
+
rootBackend(),
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
expect(reconciled).toHaveLength(1);
|
|
549
|
+
expect(reconciled[0].id).toBe("edit-1");
|
|
550
|
+
expect(edit.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
551
|
+
expect(shell.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("anchors on the FIRST denial by ledger order (shell first -> shell is the gate)", async () => {
|
|
555
|
+
// The anchor is the first denial of the turn, whatever its family. Here the
|
|
556
|
+
// shell was denied first, so it is the surfaced gate and the later edit is
|
|
557
|
+
// the deferred sibling.
|
|
558
|
+
const shell = toolCall({
|
|
559
|
+
id: "shell-1",
|
|
560
|
+
name: "shell",
|
|
561
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
562
|
+
args: { command: "echo hi > notes.md" },
|
|
563
|
+
argsPreview: JSON.stringify({ command: "echo hi > notes.md" }),
|
|
564
|
+
});
|
|
565
|
+
const edit = toolCall({
|
|
566
|
+
id: "edit-1",
|
|
567
|
+
name: "edit",
|
|
568
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
569
|
+
args: { path: "notes.md", old_string: "", new_string: "hi" },
|
|
570
|
+
argsPreview: JSON.stringify({ path: "notes.md" }),
|
|
571
|
+
});
|
|
572
|
+
const messages = [aiMessageWith([shell, edit])];
|
|
573
|
+
|
|
574
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
575
|
+
messages,
|
|
576
|
+
[
|
|
577
|
+
{ toolName: "Shell", token: toolCallIdentityToken(shell) },
|
|
578
|
+
{ toolName: "Write", token: toolCallIdentityToken(edit) },
|
|
579
|
+
],
|
|
580
|
+
undefined,
|
|
581
|
+
rootBackend(),
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
expect(reconciled).toHaveLength(1);
|
|
585
|
+
expect(reconciled[0].id).toBe("shell-1");
|
|
586
|
+
expect(shell.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
587
|
+
expect(edit.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("anchors an MCP gate and collapses a built-in reaction", async () => {
|
|
591
|
+
// The anchor can be an MCP tool; a built-in workaround denied in the same
|
|
592
|
+
// turn is still the deferred sibling.
|
|
593
|
+
const mcp = toolCall({
|
|
594
|
+
id: "mcp-1",
|
|
595
|
+
name: "fetch",
|
|
596
|
+
mcpServerSlug: "web",
|
|
597
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
598
|
+
argsPreview: JSON.stringify({ url: "https://x" }),
|
|
599
|
+
});
|
|
600
|
+
const edit = toolCall({
|
|
601
|
+
id: "edit-1",
|
|
602
|
+
name: "edit",
|
|
603
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
604
|
+
args: { path: "notes.md", old_string: "", new_string: "hi" },
|
|
605
|
+
argsPreview: JSON.stringify({ path: "notes.md" }),
|
|
606
|
+
});
|
|
607
|
+
const messages = [aiMessageWith([mcp, edit])];
|
|
608
|
+
|
|
609
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
610
|
+
messages,
|
|
611
|
+
[
|
|
612
|
+
{ toolName: "fetch", token: toolCallIdentityToken(mcp) },
|
|
613
|
+
{ toolName: "Write", token: toolCallIdentityToken(edit) },
|
|
614
|
+
],
|
|
615
|
+
undefined,
|
|
616
|
+
rootBackend(),
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
expect(reconciled).toHaveLength(1);
|
|
620
|
+
expect(reconciled[0].id).toBe("mcp-1");
|
|
621
|
+
expect(mcp.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
622
|
+
expect(edit.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("regression lock: a lone denial is unchanged (one gate, nothing collapsed)", async () => {
|
|
626
|
+
const edit = toolCall({
|
|
627
|
+
id: "edit-1",
|
|
628
|
+
name: "edit",
|
|
629
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
630
|
+
args: { path: "notes.md", old_string: "", new_string: "hi" },
|
|
631
|
+
argsPreview: JSON.stringify({ path: "notes.md" }),
|
|
632
|
+
});
|
|
633
|
+
const messages = [aiMessageWith([edit])];
|
|
634
|
+
|
|
635
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
636
|
+
messages,
|
|
637
|
+
[{ toolName: "Write", token: grantToken("write", "notes.md") }],
|
|
638
|
+
undefined,
|
|
639
|
+
rootBackend(),
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
expect(reconciled).toHaveLength(1);
|
|
643
|
+
expect(edit.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("round-trip: approving the anchor never grants the collapsed workaround", async () => {
|
|
647
|
+
// After the user approves the surfaced gate, the grant set keys only on the
|
|
648
|
+
// original write — the shell, hidden as SKIPPED, never becomes a pending
|
|
649
|
+
// approval and so can never be granted (approving the write != approving the
|
|
650
|
+
// shell). This is the safety property the duplicate cards used to violate.
|
|
651
|
+
const edit = toolCall({
|
|
652
|
+
id: "edit-1",
|
|
653
|
+
name: "edit",
|
|
654
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
655
|
+
args: { path: "notes.md", old_string: "", new_string: "hi" },
|
|
656
|
+
argsPreview: JSON.stringify({ path: "notes.md" }),
|
|
657
|
+
});
|
|
658
|
+
const shell = toolCall({
|
|
659
|
+
id: "shell-1",
|
|
660
|
+
name: "shell",
|
|
661
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
662
|
+
result: '""',
|
|
663
|
+
args: { command: "cat > notes.md" },
|
|
664
|
+
argsPreview: JSON.stringify({ command: "cat > notes.md" }),
|
|
665
|
+
});
|
|
666
|
+
const messages = [aiMessageWith([edit, shell])];
|
|
667
|
+
|
|
668
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
669
|
+
messages,
|
|
670
|
+
[
|
|
671
|
+
{ toolName: "Write", token: grantToken("write", "notes.md") },
|
|
672
|
+
{ toolName: "Shell", token: grantToken("shell", "cat > notes.md") },
|
|
673
|
+
],
|
|
674
|
+
undefined,
|
|
675
|
+
rootBackend(),
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
// The backend projects pending approvals from WAITING_APPROVAL tool calls —
|
|
679
|
+
// only the anchor qualifies. Simulate the user approving every surfaced gate.
|
|
680
|
+
const pendingApprovals = reconciled.map((tc) =>
|
|
681
|
+
create(PendingApprovalSchema, {
|
|
682
|
+
toolCallId: tc.id,
|
|
683
|
+
toolName: tc.name,
|
|
684
|
+
mcpServerSlug: tc.mcpServerSlug,
|
|
685
|
+
argsPreview: tc.argsPreview,
|
|
686
|
+
}),
|
|
687
|
+
);
|
|
688
|
+
const decisions = new Map(
|
|
689
|
+
pendingApprovals.map((pa) => [pa.toolCallId, ApprovalAction.APPROVE]),
|
|
690
|
+
);
|
|
691
|
+
const grants = buildApprovalGrants(pendingApprovals, decisions);
|
|
692
|
+
const grantTokens = grants.map((g) => grantToken(g.key, g.salient));
|
|
693
|
+
|
|
694
|
+
expect(grantTokens).toContain(grantToken("write", "notes.md"));
|
|
695
|
+
expect(grantTokens).not.toContain(grantToken("shell", "cat > notes.md"));
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// The terminal/resume path has no denial ledger (the tool is already granted), so
|
|
700
|
+
// reconcileDeniedToolCalls never runs. The shared routine runs directly at the
|
|
701
|
+
// terminal finalize and must collapse the duplicate-edit shapes observed in
|
|
702
|
+
// production data while preserving genuine distinct work and every non-file tool.
|
|
703
|
+
describe("reconcileDeniedToolCalls — interrupted non-terminal rows (raced workaround)", () => {
|
|
704
|
+
function aiText(content: string): AgentMessage {
|
|
705
|
+
return create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, content });
|
|
706
|
+
}
|
|
707
|
+
function thinking(content: string): AgentMessage {
|
|
708
|
+
return create(AgentMessageSchema, { type: MessageType.MESSAGE_THINKING, content });
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
it("collapses a workaround stuck RUNNING whose denial never reached the ledger, and the redaction then blanks the whole reaction block (production shape aex_01kwj07f7g23c3wp9sn8496z5g)", async () => {
|
|
712
|
+
// The forensic transcript: the anchor shell was denied (ledger entry), the
|
|
713
|
+
// model reacted with thinking + narration + a python-write shell workaround,
|
|
714
|
+
// and the run.cancel() landed before the workaround's own hook denial
|
|
715
|
+
// reached the ledger read — so the token-scoped collapse could not see it.
|
|
716
|
+
// It persisted as RUNNING + requiresApproval forever, and its tool-bearing
|
|
717
|
+
// message stopped the narration redaction from blanking anything.
|
|
718
|
+
const seqCommand = "for i in $(seq 1 5000); do echo line $i; done > big.txt";
|
|
719
|
+
const anchor = toolCall({
|
|
720
|
+
id: "shell-anchor",
|
|
721
|
+
name: "shell",
|
|
722
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
723
|
+
args: { command: seqCommand },
|
|
724
|
+
argsPreview: JSON.stringify({ command: seqCommand }),
|
|
725
|
+
requiresApproval: true,
|
|
726
|
+
});
|
|
727
|
+
const workaround = toolCall({
|
|
728
|
+
id: "shell-workaround",
|
|
729
|
+
name: "shell",
|
|
730
|
+
status: ToolCallStatus.TOOL_CALL_RUNNING,
|
|
731
|
+
args: { command: "python3 -c \"open('big.txt','w').write('...')\"" },
|
|
732
|
+
argsPreview: JSON.stringify({ command: "python3 -c ..." }),
|
|
733
|
+
requiresApproval: true,
|
|
734
|
+
});
|
|
735
|
+
const narration = aiText("Generating the file with a script since the shell command needs approval.");
|
|
736
|
+
narration.toolCalls = [workaround];
|
|
737
|
+
const messages: AgentMessage[] = [
|
|
738
|
+
aiMessageWith([anchor]),
|
|
739
|
+
thinking("Shell commands were blocked. The Write tool will be used instead."),
|
|
740
|
+
narration,
|
|
741
|
+
];
|
|
742
|
+
|
|
743
|
+
// Only the anchor's denial is in the ledger — the workaround's raced it.
|
|
744
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
745
|
+
messages,
|
|
746
|
+
[{ toolName: "Shell", token: toolCallIdentityToken(anchor) }],
|
|
747
|
+
undefined,
|
|
748
|
+
rootBackend(),
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
expect(reconciled).toHaveLength(1);
|
|
752
|
+
expect(anchor.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
753
|
+
// The interrupted workaround is finalized to the hidden SKIPPED shape —
|
|
754
|
+
// never an eternal spinner, never a second approval card.
|
|
755
|
+
expect(workaround.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
756
|
+
expect(workaround.requiresApproval).toBe(false);
|
|
757
|
+
expect(workaround.args).toBeUndefined();
|
|
758
|
+
|
|
759
|
+
// With the workaround hidden, its message is trailing narration again and
|
|
760
|
+
// the whole reaction block blanks — thinking included.
|
|
761
|
+
const redacted = clearProvisionalPostDenialNarration(messages, reconciled);
|
|
762
|
+
expect(redacted).toHaveLength(2);
|
|
763
|
+
expect(messages[1].content).toBe("");
|
|
764
|
+
expect(messages[2].content).toBe("");
|
|
765
|
+
// Ids preserved in place (append-only finalize).
|
|
766
|
+
expect(messages[2].toolCalls.map((t) => t.id)).toEqual(["shell-workaround"]);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("leaves terminal rows untouched: a completed tool after the gate is real activity and still stops the redaction walk", async () => {
|
|
770
|
+
const anchor = toolCall({
|
|
771
|
+
id: "shell-anchor",
|
|
772
|
+
name: "shell",
|
|
773
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
774
|
+
args: { command: "rm -rf build" },
|
|
775
|
+
argsPreview: JSON.stringify({ command: "rm -rf build" }),
|
|
776
|
+
});
|
|
777
|
+
const completedRead = toolCall({
|
|
778
|
+
id: "read-1",
|
|
779
|
+
name: "read",
|
|
780
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
781
|
+
result: "file contents",
|
|
782
|
+
args: { path: "a.txt" },
|
|
783
|
+
});
|
|
784
|
+
const readMsg = aiMessageWith([completedRead]);
|
|
785
|
+
const messages: AgentMessage[] = [
|
|
786
|
+
aiMessageWith([anchor]),
|
|
787
|
+
readMsg,
|
|
788
|
+
create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, content: "trailing reaction" }),
|
|
789
|
+
];
|
|
790
|
+
|
|
791
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
792
|
+
messages,
|
|
793
|
+
[{ toolName: "Shell", token: toolCallIdentityToken(anchor) }],
|
|
794
|
+
undefined,
|
|
795
|
+
rootBackend(),
|
|
796
|
+
);
|
|
797
|
+
|
|
798
|
+
// The completed read is terminal — the interrupted-row sweep never touches it.
|
|
799
|
+
expect(completedRead.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
800
|
+
expect(completedRead.result).toBe("file contents");
|
|
801
|
+
|
|
802
|
+
// And as a VISIBLE tool-bearing message it bounds the redaction: only the
|
|
803
|
+
// text after it blanks.
|
|
804
|
+
const redacted = clearProvisionalPostDenialNarration(messages, reconciled);
|
|
805
|
+
expect(redacted).toHaveLength(1);
|
|
806
|
+
expect(messages[2].content).toBe("");
|
|
807
|
+
expect(readMsg.toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
describe("collapseRedundantToolCallTwins — terminal-path twin collapse", () => {
|
|
812
|
+
function completedEdit(id: string, path: string): ToolCall {
|
|
813
|
+
return toolCall({
|
|
814
|
+
id,
|
|
815
|
+
name: "edit",
|
|
816
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
817
|
+
args: { path },
|
|
818
|
+
argsPreview: JSON.stringify({ path }),
|
|
819
|
+
result: "success",
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function bareEdit(id: string, path: string, overrides: MessageInitShape<typeof ToolCallSchema> = {}): ToolCall {
|
|
824
|
+
return toolCall({
|
|
825
|
+
id,
|
|
826
|
+
name: "edit",
|
|
827
|
+
args: { path },
|
|
828
|
+
argsPreview: JSON.stringify({ path }),
|
|
829
|
+
...overrides,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
it("collapses a stuck RUNNING twin beside the settled COMPLETED edit (the screenshot)", () => {
|
|
834
|
+
const settled = completedEdit("tool-approved", "notes.md");
|
|
835
|
+
const zombie = bareEdit("tool-zombie", "notes.md", {
|
|
836
|
+
status: ToolCallStatus.TOOL_CALL_RUNNING,
|
|
837
|
+
});
|
|
838
|
+
const messages = [aiMessageWith([settled, zombie])];
|
|
839
|
+
|
|
840
|
+
const collapsed = collapseRedundantToolCallTwins(messages);
|
|
841
|
+
|
|
842
|
+
expect(collapsed).toBe(1);
|
|
843
|
+
// The settled attempt is the survivor (terminal preferred over the zombie).
|
|
844
|
+
expect(settled.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
845
|
+
expect(zombie.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
846
|
+
expect(zombie.requiresApproval).toBe(false);
|
|
847
|
+
expect(zombie.argsPreview).toBe("");
|
|
848
|
+
// Both committed ids are preserved (append-only by construction).
|
|
849
|
+
expect(messages[0].toolCalls.map((t) => t.id)).toEqual(["tool-approved", "tool-zombie"]);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it("keeps exactly one representative when every attempt produced no change", () => {
|
|
853
|
+
// Two COMPLETED edits to one path, neither carrying a diff (both the empty-
|
|
854
|
+
// result green-check). No diff-carrier and no gate — keep one card, hide the
|
|
855
|
+
// rest, so the resource never renders as a duplicate.
|
|
856
|
+
const first = bareEdit("tool-a", "notes.md", {
|
|
857
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
858
|
+
result: '""',
|
|
859
|
+
});
|
|
860
|
+
const second = bareEdit("tool-b", "notes.md", {
|
|
861
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
862
|
+
result: '""',
|
|
863
|
+
});
|
|
864
|
+
const messages = [aiMessageWith([first, second])];
|
|
865
|
+
|
|
866
|
+
const collapsed = collapseRedundantToolCallTwins(messages);
|
|
867
|
+
|
|
868
|
+
expect(collapsed).toBe(1);
|
|
869
|
+
const visible = messages[0].toolCalls.filter(
|
|
870
|
+
(t) => t.status !== ToolCallStatus.TOOL_CALL_SKIPPED,
|
|
871
|
+
);
|
|
872
|
+
expect(visible).toHaveLength(1);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
it("prefers a settled attempt over a stuck RUNNING zombie as the survivor", () => {
|
|
876
|
+
const zombie = bareEdit("tool-running", "notes.md", {
|
|
877
|
+
status: ToolCallStatus.TOOL_CALL_RUNNING,
|
|
878
|
+
});
|
|
879
|
+
const done = bareEdit("tool-done", "notes.md", {
|
|
880
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
881
|
+
result: '""',
|
|
882
|
+
});
|
|
883
|
+
const messages = [aiMessageWith([zombie, done])];
|
|
884
|
+
|
|
885
|
+
collapseRedundantToolCallTwins(messages);
|
|
886
|
+
|
|
887
|
+
expect(done.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
888
|
+
expect(zombie.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it("hides a RUNNING twin beside a WAITING_APPROVAL gate, leaving the gate intact", () => {
|
|
892
|
+
const gate = bareEdit("gate", "notes.md", {
|
|
893
|
+
status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
|
|
894
|
+
requiresApproval: true,
|
|
895
|
+
});
|
|
896
|
+
const zombie = bareEdit("zombie", "notes.md", {
|
|
897
|
+
status: ToolCallStatus.TOOL_CALL_RUNNING,
|
|
898
|
+
});
|
|
899
|
+
const messages = [aiMessageWith([gate, zombie])];
|
|
900
|
+
|
|
901
|
+
const collapsed = collapseRedundantToolCallTwins(messages);
|
|
902
|
+
|
|
903
|
+
expect(collapsed).toBe(1);
|
|
904
|
+
expect(gate.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
905
|
+
expect(zombie.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("makes a WAITING_APPROVAL gate the SOLE keeper: collapses a same-path write sibling", () => {
|
|
909
|
+
// Two whole-file writes to one path in one turn: the gate (call A) is the
|
|
910
|
+
// single review surface; the sibling (call B) is redundant (a file row carries
|
|
911
|
+
// no authoritative change — the ledger does), so it must collapse — one gate.
|
|
912
|
+
const gate = bareEdit("gate", "notes.md", {
|
|
913
|
+
status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
|
|
914
|
+
requiresApproval: true,
|
|
915
|
+
});
|
|
916
|
+
const staleSibling = toolCall({
|
|
917
|
+
id: "stale-sibling",
|
|
918
|
+
name: "write",
|
|
919
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
920
|
+
args: { path: "notes.md", content: "stale\n" },
|
|
921
|
+
argsPreview: JSON.stringify({ path: "notes.md" }),
|
|
922
|
+
});
|
|
923
|
+
const messages = [aiMessageWith([gate, staleSibling])];
|
|
924
|
+
|
|
925
|
+
const collapsed = collapseRedundantToolCallTwins(messages);
|
|
926
|
+
|
|
927
|
+
expect(collapsed).toBe(1);
|
|
928
|
+
expect(gate.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
929
|
+
expect(staleSibling.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
930
|
+
// Both committed ids are preserved (append-only by construction).
|
|
931
|
+
expect(messages[0].toolCalls.map((t) => t.id)).toEqual(["gate", "stale-sibling"]);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("keeps BOTH identical shell runs that each produced output (output is their change)", () => {
|
|
935
|
+
const runA = toolCall({
|
|
936
|
+
id: "sh-a",
|
|
937
|
+
name: "shell",
|
|
938
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
939
|
+
args: { command: "ls" },
|
|
940
|
+
argsPreview: JSON.stringify({ command: "ls" }),
|
|
941
|
+
result: "file.txt",
|
|
942
|
+
});
|
|
943
|
+
const runB = toolCall({
|
|
944
|
+
id: "sh-b",
|
|
945
|
+
name: "shell",
|
|
946
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
947
|
+
args: { command: "ls" },
|
|
948
|
+
argsPreview: JSON.stringify({ command: "ls" }),
|
|
949
|
+
result: "file.txt",
|
|
950
|
+
});
|
|
951
|
+
const messages = [aiMessageWith([runA, runB])];
|
|
952
|
+
|
|
953
|
+
const collapsed = collapseRedundantToolCallTwins(messages);
|
|
954
|
+
|
|
955
|
+
expect(collapsed).toBe(0);
|
|
956
|
+
expect(runA.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
957
|
+
expect(runB.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it("leaves an ungated read-only duplicate untouched (out of scope)", () => {
|
|
961
|
+
const readA = toolCall({
|
|
962
|
+
id: "r-a",
|
|
963
|
+
name: "read",
|
|
964
|
+
status: ToolCallStatus.TOOL_CALL_RUNNING,
|
|
965
|
+
args: { path: "notes.md" },
|
|
966
|
+
argsPreview: JSON.stringify({ path: "notes.md" }),
|
|
967
|
+
});
|
|
968
|
+
const readB = toolCall({
|
|
969
|
+
id: "r-b",
|
|
970
|
+
name: "read",
|
|
971
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
972
|
+
args: { path: "notes.md" },
|
|
973
|
+
argsPreview: JSON.stringify({ path: "notes.md" }),
|
|
974
|
+
result: "contents",
|
|
975
|
+
});
|
|
976
|
+
const messages = [aiMessageWith([readA, readB])];
|
|
977
|
+
|
|
978
|
+
const collapsed = collapseRedundantToolCallTwins(messages);
|
|
979
|
+
|
|
980
|
+
expect(collapsed).toBe(0);
|
|
981
|
+
expect(readA.status).toBe(ToolCallStatus.TOOL_CALL_RUNNING);
|
|
982
|
+
expect(readB.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
// The hook captures the COMPLETE proposed args (tool_input) at gate time — the
|
|
987
|
+
// authoritative source the stream may not have carried before the first-denial
|
|
988
|
+
// cancel. These pin that the runner overlays that input onto the gated call so
|
|
989
|
+
// the approval card renders the proposed change from `args`, for every tool kind,
|
|
990
|
+
// with a compact-but-valid args_preview that preserves the resume-grant salient,
|
|
991
|
+
// and stamps the content digest that binds the resume grant + re-gates a sibling.
|
|
992
|
+
describe("reconcileDeniedToolCalls — authoritative hook input overlay", () => {
|
|
993
|
+
function parsePreview(tc: ToolCall): Record<string, unknown> {
|
|
994
|
+
return JSON.parse(tc.argsPreview) as Record<string, unknown>;
|
|
995
|
+
}
|
|
996
|
+
function args(tc: ToolCall): Record<string, unknown> {
|
|
997
|
+
return (tc.args ?? {}) as Record<string, unknown>;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
it("stamps the authoritative write content + a salient-preserving preview + a digest", async () => {
|
|
1001
|
+
const tc = toolCall({
|
|
1002
|
+
id: "c1",
|
|
1003
|
+
name: "write",
|
|
1004
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
1005
|
+
argsPreview: JSON.stringify({ path: "src/new.ts" }),
|
|
1006
|
+
});
|
|
1007
|
+
const messages = [aiMessageWith([tc])];
|
|
1008
|
+
|
|
1009
|
+
await reconcileDeniedToolCalls(
|
|
1010
|
+
messages,
|
|
1011
|
+
[{
|
|
1012
|
+
toolName: "Write",
|
|
1013
|
+
token: grantToken("write", "src/new.ts"),
|
|
1014
|
+
input: { file_path: "src/new.ts", content: "export const x = 1;\n" },
|
|
1015
|
+
}],
|
|
1016
|
+
undefined,
|
|
1017
|
+
rootBackend(),
|
|
1018
|
+
);
|
|
1019
|
+
|
|
1020
|
+
// The full proposed content lives on args (the single source exact-apply reads).
|
|
1021
|
+
expect(args(tc).content).toBe("export const x = 1;\n");
|
|
1022
|
+
// args_preview reflects the authoritative input and stays parseable.
|
|
1023
|
+
expect(parsePreview(tc).file_path).toBe("src/new.ts");
|
|
1024
|
+
// The digest binds the resume grant / re-gates a diverging sibling.
|
|
1025
|
+
expect(tc.approvalContentDigest).not.toBe("");
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("stamps the captured edit old/new strings and a digest", async () => {
|
|
1029
|
+
const tc = toolCall({
|
|
1030
|
+
id: "c1",
|
|
1031
|
+
name: "edit",
|
|
1032
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
1033
|
+
argsPreview: JSON.stringify({ path: "src/app.ts" }),
|
|
1034
|
+
});
|
|
1035
|
+
const messages = [aiMessageWith([tc])];
|
|
1036
|
+
|
|
1037
|
+
await reconcileDeniedToolCalls(
|
|
1038
|
+
messages,
|
|
1039
|
+
[{
|
|
1040
|
+
toolName: "StrReplace",
|
|
1041
|
+
token: grantToken("write", "src/app.ts"),
|
|
1042
|
+
input: { file_path: "src/app.ts", old_string: "alpha", new_string: "beta" },
|
|
1043
|
+
}],
|
|
1044
|
+
undefined,
|
|
1045
|
+
rootBackend(),
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
expect(args(tc).old_string).toBe("alpha");
|
|
1049
|
+
expect(args(tc).new_string).toBe("beta");
|
|
1050
|
+
expect(tc.approvalContentDigest).not.toBe("");
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("preserves a notebook edit's target_notebook salient in the preview", async () => {
|
|
1054
|
+
const tc = toolCall({
|
|
1055
|
+
id: "c1",
|
|
1056
|
+
name: "EditNotebook",
|
|
1057
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
1058
|
+
argsPreview: JSON.stringify({ target_notebook: "nb.ipynb" }),
|
|
1059
|
+
});
|
|
1060
|
+
const messages = [aiMessageWith([tc])];
|
|
1061
|
+
|
|
1062
|
+
await reconcileDeniedToolCalls(
|
|
1063
|
+
messages,
|
|
1064
|
+
[{
|
|
1065
|
+
toolName: "EditNotebook",
|
|
1066
|
+
token: grantToken("write", "nb.ipynb"),
|
|
1067
|
+
input: { target_notebook: "nb.ipynb", old_string: "x = 1", new_string: "x = 2" },
|
|
1068
|
+
}],
|
|
1069
|
+
undefined,
|
|
1070
|
+
rootBackend(),
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
expect(parsePreview(tc).target_notebook).toBe("nb.ipynb");
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it("carries shell args so the gate shows the command", async () => {
|
|
1077
|
+
const tc = toolCall({
|
|
1078
|
+
id: "c1",
|
|
1079
|
+
name: "shell",
|
|
1080
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
1081
|
+
argsPreview: JSON.stringify({ command: "rm -rf build" }),
|
|
1082
|
+
});
|
|
1083
|
+
const messages = [aiMessageWith([tc])];
|
|
1084
|
+
|
|
1085
|
+
await reconcileDeniedToolCalls(
|
|
1086
|
+
messages,
|
|
1087
|
+
[{
|
|
1088
|
+
toolName: "Shell",
|
|
1089
|
+
token: grantToken("shell", "rm -rf build"),
|
|
1090
|
+
input: { command: "rm -rf build", cwd: "/root" },
|
|
1091
|
+
}],
|
|
1092
|
+
undefined,
|
|
1093
|
+
rootBackend(),
|
|
1094
|
+
);
|
|
1095
|
+
|
|
1096
|
+
expect(parsePreview(tc).command).toBe("rm -rf build");
|
|
1097
|
+
expect(parsePreview(tc).cwd).toBe("/root");
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it("upgrades a synthesized placeholder from path-only to the full captured args", async () => {
|
|
1101
|
+
// No streamed call matches (rare); the placeholder must still carry the real
|
|
1102
|
+
// proposed content from the captured input, not a bare {path}.
|
|
1103
|
+
const messages = [aiMessageWith([])];
|
|
1104
|
+
|
|
1105
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
1106
|
+
messages,
|
|
1107
|
+
[{
|
|
1108
|
+
toolName: "Write",
|
|
1109
|
+
token: grantToken("write", "ghost.md"),
|
|
1110
|
+
input: { file_path: "ghost.md", content: "# Ghost\n" },
|
|
1111
|
+
}],
|
|
1112
|
+
undefined,
|
|
1113
|
+
rootBackend(),
|
|
1114
|
+
);
|
|
1115
|
+
|
|
1116
|
+
expect(reconciled).toHaveLength(1);
|
|
1117
|
+
const tc = reconciled[0];
|
|
1118
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
1119
|
+
expect(args(tc).content).toBe("# Ghost\n");
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("keeps a large write's args_preview small, valid, and salient-preserving", async () => {
|
|
1123
|
+
const content = "x".repeat(50_000);
|
|
1124
|
+
const tc = toolCall({
|
|
1125
|
+
id: "c1",
|
|
1126
|
+
name: "write",
|
|
1127
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
1128
|
+
argsPreview: JSON.stringify({ path: "big.ts" }),
|
|
1129
|
+
});
|
|
1130
|
+
const messages = [aiMessageWith([tc])];
|
|
1131
|
+
|
|
1132
|
+
await reconcileDeniedToolCalls(
|
|
1133
|
+
messages,
|
|
1134
|
+
[{
|
|
1135
|
+
toolName: "Write",
|
|
1136
|
+
token: grantToken("write", "big.ts"),
|
|
1137
|
+
input: { file_path: "big.ts", content },
|
|
1138
|
+
}],
|
|
1139
|
+
undefined,
|
|
1140
|
+
rootBackend(),
|
|
1141
|
+
);
|
|
1142
|
+
|
|
1143
|
+
// The preview is bounded and parseable (resume reads it) with the salient
|
|
1144
|
+
// preserved; the full content lives on args (which exact-apply reads).
|
|
1145
|
+
expect(tc.argsPreview.length).toBeLessThan(1_000);
|
|
1146
|
+
expect(parsePreview(tc).file_path).toBe("big.ts");
|
|
1147
|
+
expect((args(tc).content as string).length).toBe(content.length);
|
|
1148
|
+
});
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
// The hook computes its denial identity token from the RAW path Cursor hands it
|
|
1152
|
+
// (it is a bash script and cannot normalize against the workspace root), while
|
|
1153
|
+
// the stream event may carry the same file under a different path FORM (the
|
|
1154
|
+
// classic case: an ABSOLUTE file_path in the hook input vs. a RELATIVE path in
|
|
1155
|
+
// the stream). Their raw tokens then differ, exact correlation misses, and the
|
|
1156
|
+
// reconcile would synthesize a content-less WAITING_APPROVAL placeholder BESIDE
|
|
1157
|
+
// the real streamed call — two cards for one edit, the gate showing "No preview
|
|
1158
|
+
// available" because the synthesized placeholder carries no file_changes. These
|
|
1159
|
+
// pin the runner-side normalized-path fallback that overlays the REAL streamed
|
|
1160
|
+
// call instead (guard-safe: it reuses the already-committed id, never drops one,
|
|
1161
|
+
// and the captured new-file content survives onto the gate).
|
|
1162
|
+
describe("reconcileDeniedToolCalls — normalized-path fallback (abs/rel drift)", () => {
|
|
1163
|
+
it("overlays the real streamed write when the hook salient is absolute but the stream path is relative", async () => {
|
|
1164
|
+
// A denied write streamed (and was committed) as TOOL_CALL_FAILED; the hook
|
|
1165
|
+
// recorded the denial under the absolute path, so the raw tokens do not match.
|
|
1166
|
+
const streamed = toolCall({
|
|
1167
|
+
id: "stream-create",
|
|
1168
|
+
name: "write",
|
|
1169
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
1170
|
+
args: { path: "notes.md", contents: "# Notes\n" },
|
|
1171
|
+
});
|
|
1172
|
+
const messages = [aiMessageWith([streamed])];
|
|
1173
|
+
|
|
1174
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
1175
|
+
messages,
|
|
1176
|
+
[{ toolName: "Write", token: grantToken("write", "/root/notes.md") }],
|
|
1177
|
+
undefined,
|
|
1178
|
+
rootBackend(),
|
|
1179
|
+
);
|
|
1180
|
+
|
|
1181
|
+
// No synthesized placeholder was appended; the only tool call is the real
|
|
1182
|
+
// streamed one (its committed id preserved → backend append-only guard-safe).
|
|
1183
|
+
const ids = messages.flatMap((m) => m.toolCalls.map((t) => t.id));
|
|
1184
|
+
expect(ids).toEqual(["stream-create"]);
|
|
1185
|
+
expect(ids.some((id) => id.startsWith("approval:"))).toBe(false);
|
|
1186
|
+
|
|
1187
|
+
// The streamed call was overlaid in place as the single gate.
|
|
1188
|
+
expect(streamed.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
1189
|
+
expect(streamed.requiresApproval).toBe(true);
|
|
1190
|
+
expect(reconciled).toHaveLength(1);
|
|
1191
|
+
expect(reconciled[0].id).toBe("stream-create");
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
it("overlays a denied EDIT under abs/rel drift via the shared overlay path", async () => {
|
|
1195
|
+
const streamed = toolCall({
|
|
1196
|
+
id: "stream-edit",
|
|
1197
|
+
name: "edit",
|
|
1198
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
1199
|
+
args: { path: "src/app.ts", old_string: "alpha", new_string: "beta" },
|
|
1200
|
+
});
|
|
1201
|
+
const messages = [aiMessageWith([streamed])];
|
|
1202
|
+
|
|
1203
|
+
await reconcileDeniedToolCalls(
|
|
1204
|
+
messages,
|
|
1205
|
+
[{ toolName: "Write", token: grantToken("write", "/root/src/app.ts") }],
|
|
1206
|
+
undefined,
|
|
1207
|
+
rootBackend(),
|
|
1208
|
+
);
|
|
1209
|
+
|
|
1210
|
+
const ids = messages.flatMap((m) => m.toolCalls.map((t) => t.id));
|
|
1211
|
+
expect(ids).toEqual(["stream-edit"]);
|
|
1212
|
+
expect(streamed.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
it("still synthesizes a placeholder when NO streamed call matches even after normalization", async () => {
|
|
1216
|
+
// The genuine no-stream-event denial (rare) must still surface a gate.
|
|
1217
|
+
const messages = [aiMessageWith([])];
|
|
1218
|
+
|
|
1219
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
1220
|
+
messages,
|
|
1221
|
+
[{ toolName: "Write", token: grantToken("write", "/root/ghost.md") }],
|
|
1222
|
+
undefined,
|
|
1223
|
+
rootBackend(),
|
|
1224
|
+
);
|
|
1225
|
+
|
|
1226
|
+
expect(reconciled).toHaveLength(1);
|
|
1227
|
+
expect(reconciled[0].status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
1228
|
+
expect(reconciled[0].id.startsWith("approval:")).toBe(true);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
it("does not overlay a DIFFERENT file that happens to be denied (no false normalized match)", async () => {
|
|
1232
|
+
// A streamed create for one file must not absorb a denial for another file
|
|
1233
|
+
// just because the fallback ran — normalization is per-path.
|
|
1234
|
+
const streamed = toolCall({
|
|
1235
|
+
id: "stream-other",
|
|
1236
|
+
name: "write",
|
|
1237
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
1238
|
+
args: { path: "other.md", contents: "x" },
|
|
1239
|
+
});
|
|
1240
|
+
const messages = [aiMessageWith([streamed])];
|
|
1241
|
+
|
|
1242
|
+
const reconciled = await reconcileDeniedToolCalls(
|
|
1243
|
+
messages,
|
|
1244
|
+
[{ toolName: "Write", token: grantToken("write", "/root/notes.md") }],
|
|
1245
|
+
undefined,
|
|
1246
|
+
rootBackend(),
|
|
1247
|
+
);
|
|
1248
|
+
|
|
1249
|
+
// The unrelated streamed call is untouched; the denial is satisfied by a
|
|
1250
|
+
// synthesized placeholder for the actually-denied file.
|
|
1251
|
+
expect(streamed.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
|
|
1252
|
+
expect(reconciled).toHaveLength(1);
|
|
1253
|
+
expect(reconciled[0].id).toBe("approval:" + grantToken("write", "/root/notes.md"));
|
|
1254
|
+
});
|
|
1255
|
+
});
|
|
1256
|
+
|
|
254
1257
|
describe("reconstructAdjudicatedApprovals", () => {
|
|
255
1258
|
it("reads decisions and rebuilds pending approvals from adjudicated tool calls", () => {
|
|
256
1259
|
const approved = toolCall({
|
|
@@ -296,15 +1299,282 @@ describe("reconstructAdjudicatedApprovals", () => {
|
|
|
296
1299
|
});
|
|
297
1300
|
});
|
|
298
1301
|
|
|
1302
|
+
// When a Cursor turn pauses for approval, the model frequently reacts to the
|
|
1303
|
+
// deny ("blocked by a hook; enable it in your Cursor settings") and that
|
|
1304
|
+
// provisional verdict must NOT render next to the approval card. These pin the
|
|
1305
|
+
// deterministic positional rule that BLANKS exactly the trailing reaction block
|
|
1306
|
+
// (preserving the message count so the finalize stays append-only) and touches
|
|
1307
|
+
// nothing load-bearing.
|
|
1308
|
+
describe("clearProvisionalPostDenialNarration", () => {
|
|
1309
|
+
function aiText(content: string): AgentMessage {
|
|
1310
|
+
return create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, content });
|
|
1311
|
+
}
|
|
1312
|
+
function thinking(content: string): AgentMessage {
|
|
1313
|
+
return create(AgentMessageSchema, { type: MessageType.MESSAGE_THINKING, content });
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
it("blanks the trailing assistant/thinking block after the last gated tool call without removing it", () => {
|
|
1317
|
+
const gated = toolCall({ id: "c1", name: "edit", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL });
|
|
1318
|
+
const messages: AgentMessage[] = [
|
|
1319
|
+
aiText("Let me create the file."),
|
|
1320
|
+
aiMessageWith([gated]),
|
|
1321
|
+
thinking("The hook blocked me; the environment must be misconfigured."),
|
|
1322
|
+
aiText("I couldn't do this — please enable the hook in your Cursor settings."),
|
|
1323
|
+
];
|
|
1324
|
+
|
|
1325
|
+
const redacted = clearProvisionalPostDenialNarration(messages, [gated]);
|
|
1326
|
+
|
|
1327
|
+
expect(redacted).toHaveLength(2);
|
|
1328
|
+
// Append-only by construction: count is preserved, only content is blanked.
|
|
1329
|
+
expect(messages).toHaveLength(4);
|
|
1330
|
+
expect(messages[0].content).toBe("Let me create the file."); // pre-tool text untouched
|
|
1331
|
+
expect(messages[1].toolCalls[0]).toBe(gated); // gated call untouched
|
|
1332
|
+
expect(messages[2].content).toBe(""); // provisional thinking blanked
|
|
1333
|
+
expect(messages[2].isStreaming).toBe(false);
|
|
1334
|
+
expect(messages[3].content).toBe(""); // provisional verdict blanked
|
|
1335
|
+
expect(messages[3].isStreaming).toBe(false);
|
|
1336
|
+
// The redacted handles are the very messages still in the transcript.
|
|
1337
|
+
expect(redacted[0]).toBe(messages[2]);
|
|
1338
|
+
expect(redacted[1]).toBe(messages[3]);
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
it("preserves pre-tool narration and the gated call itself", () => {
|
|
1342
|
+
const gated = toolCall({ id: "c1", name: "edit", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL });
|
|
1343
|
+
const messages: AgentMessage[] = [aiText("Working on it."), aiMessageWith([gated])];
|
|
1344
|
+
|
|
1345
|
+
const redacted = clearProvisionalPostDenialNarration(messages, [gated]);
|
|
1346
|
+
|
|
1347
|
+
expect(redacted).toHaveLength(0);
|
|
1348
|
+
expect(messages).toHaveLength(2);
|
|
1349
|
+
expect(messages[0].content).toBe("Working on it.");
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
it("stops at the first tool-bearing message so real post-gate activity is kept", () => {
|
|
1353
|
+
const gated = toolCall({ id: "c1", name: "edit", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL });
|
|
1354
|
+
const readAfter = toolCall({ id: "c2", name: "read", status: ToolCallStatus.TOOL_CALL_COMPLETED });
|
|
1355
|
+
const messages: AgentMessage[] = [
|
|
1356
|
+
aiMessageWith([gated]),
|
|
1357
|
+
aiText("checking something else"),
|
|
1358
|
+
aiMessageWith([readAfter]), // real activity — not trailing narration
|
|
1359
|
+
];
|
|
1360
|
+
|
|
1361
|
+
const redacted = clearProvisionalPostDenialNarration(messages, [gated]);
|
|
1362
|
+
|
|
1363
|
+
// The read message is the last gated-or-activity boundary; iteration stops
|
|
1364
|
+
// there and the intermediate text is preserved (it is not "trailing").
|
|
1365
|
+
expect(redacted).toHaveLength(0);
|
|
1366
|
+
expect(messages).toHaveLength(3);
|
|
1367
|
+
expect(messages[1].content).toBe("checking something else");
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
it("is a no-op when there are no denied tool calls", () => {
|
|
1371
|
+
const messages: AgentMessage[] = [aiText("all good")];
|
|
1372
|
+
expect(clearProvisionalPostDenialNarration(messages, [])).toEqual([]);
|
|
1373
|
+
expect(messages).toHaveLength(1);
|
|
1374
|
+
expect(messages[0].content).toBe("all good");
|
|
1375
|
+
});
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
// First-denial stop contract (index.ts stream loop). When the preToolUse hook
|
|
1379
|
+
// records its first denial, the runner ends the turn immediately — before the
|
|
1380
|
+
// model can react to Cursor's tool-failure surface with inter-tool narration or
|
|
1381
|
+
// a second gated tool. These pin the COMPOSED outcome of that stop using the
|
|
1382
|
+
// real ledger + reconcile + trim helpers; the live SDK orchestration is covered
|
|
1383
|
+
// by the end-to-end integration test (cursor_hitl_test.go).
|
|
1384
|
+
describe("first-denial stop contract", () => {
|
|
1385
|
+
type SimEvent =
|
|
1386
|
+
| { kind: "text"; content: string }
|
|
1387
|
+
| {
|
|
1388
|
+
kind: "tool";
|
|
1389
|
+
tool: ToolCall;
|
|
1390
|
+
denyToken?: { name: string; token: string };
|
|
1391
|
+
// When the hook's ledger append lands relative to the loop's read for
|
|
1392
|
+
// THIS event. "before-read" is the common case (the hook adjudicates as
|
|
1393
|
+
// the tool_call event surfaces); "after-read" reproduces the production
|
|
1394
|
+
// race where the append lands just after — the watcher's dirty flag
|
|
1395
|
+
// must then stop the turn on the NEXT event of any type.
|
|
1396
|
+
denyTiming?: "before-read" | "after-read";
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
// Faithfully mirrors the index.ts loop rule: process each event, then read
|
|
1400
|
+
// the denial ledger when the fs watcher flagged it dirty OR the event is a
|
|
1401
|
+
// tool_call (the backstop) — the instant the ledger is non-empty, cancel the
|
|
1402
|
+
// run and stop consuming the stream. The watcher is simulated by flipping the
|
|
1403
|
+
// dirty flag as the append happens (the real fs.watch notification).
|
|
1404
|
+
async function runTurnWithFirstDenialStop(
|
|
1405
|
+
hitlDir: string,
|
|
1406
|
+
events: SimEvent[],
|
|
1407
|
+
): Promise<{ messages: AgentMessage[]; cancelled: boolean; consumed: number }> {
|
|
1408
|
+
await resetDenialLedger(hitlDir);
|
|
1409
|
+
const messages: AgentMessage[] = [];
|
|
1410
|
+
let cancelled = false;
|
|
1411
|
+
let consumed = 0;
|
|
1412
|
+
let ledgerDirty = false;
|
|
1413
|
+
let pendingAppend: { name: string; token: string } | undefined;
|
|
1414
|
+
|
|
1415
|
+
const appendDenial = async (deny: { name: string; token: string }) => {
|
|
1416
|
+
await writeFile(
|
|
1417
|
+
denialLedgerPath(hitlDir),
|
|
1418
|
+
`{"toolName":"${deny.name}","token":"${deny.token}"}\n`,
|
|
1419
|
+
{ flag: "a" },
|
|
1420
|
+
);
|
|
1421
|
+
ledgerDirty = true;
|
|
1422
|
+
};
|
|
1423
|
+
|
|
1424
|
+
for (const ev of events) {
|
|
1425
|
+
consumed++;
|
|
1426
|
+
// A raced append from the PREVIOUS event lands as this event arrives.
|
|
1427
|
+
if (pendingAppend) {
|
|
1428
|
+
await appendDenial(pendingAppend);
|
|
1429
|
+
pendingAppend = undefined;
|
|
1430
|
+
}
|
|
1431
|
+
if (ev.kind === "text") {
|
|
1432
|
+
messages.push(create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, content: ev.content }));
|
|
1433
|
+
} else {
|
|
1434
|
+
messages.push(aiMessageWith([ev.tool]));
|
|
1435
|
+
if (ev.denyToken) {
|
|
1436
|
+
if ((ev.denyTiming ?? "before-read") === "before-read") {
|
|
1437
|
+
await appendDenial(ev.denyToken);
|
|
1438
|
+
} else {
|
|
1439
|
+
pendingAppend = ev.denyToken;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (ledgerDirty || ev.kind === "tool") {
|
|
1444
|
+
ledgerDirty = false;
|
|
1445
|
+
const denials = await readDenialLedger(hitlDir);
|
|
1446
|
+
if (denials.length > 0) {
|
|
1447
|
+
cancelled = true;
|
|
1448
|
+
break;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
return { messages, cancelled, consumed };
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
it("stops at the first gated tool, never consuming the inter-tool narration or a second gated tool", async () => {
|
|
1457
|
+
const ws = makeWorkspace();
|
|
1458
|
+
const edit = toolCall({ id: "c1", name: "edit", status: ToolCallStatus.TOOL_CALL_COMPLETED, args: { path: "gated.txt" } });
|
|
1459
|
+
const shell = toolCall({ id: "c2", name: "shell", status: ToolCallStatus.TOOL_CALL_COMPLETED, args: { command: "echo hi > gated.txt" } });
|
|
1460
|
+
|
|
1461
|
+
// The full turn the model WOULD produce if left running: pre-tool text, the
|
|
1462
|
+
// gated edit (denied), a defeatist reaction, then a shell workaround (also
|
|
1463
|
+
// gated). The stop must cut the turn after the gated edit.
|
|
1464
|
+
const { messages, cancelled, consumed } = await runTurnWithFirstDenialStop(ws, [
|
|
1465
|
+
{ kind: "text", content: "Let me create the file." },
|
|
1466
|
+
{ kind: "tool", tool: edit, denyToken: { name: "Write", token: grantToken("write", "gated.txt") } },
|
|
1467
|
+
{ kind: "text", content: "I'm blocked by a hook — I'll try the shell instead." },
|
|
1468
|
+
{ kind: "tool", tool: shell, denyToken: { name: "Shell", token: grantToken("shell", "echo hi > gated.txt") } },
|
|
1469
|
+
]);
|
|
1470
|
+
|
|
1471
|
+
expect(cancelled).toBe(true);
|
|
1472
|
+
expect(consumed).toBe(2); // pre-tool text + gated edit only
|
|
1473
|
+
// The second gated tool and the inter-tool narration were never consumed.
|
|
1474
|
+
expect(messages).toHaveLength(2);
|
|
1475
|
+
expect(messages.some((m) => m.content.includes("try the shell"))).toBe(false);
|
|
1476
|
+
|
|
1477
|
+
// Phase 12 reconcile + redact on the stopped transcript yields the clean shape.
|
|
1478
|
+
const denied = await reconcileDeniedToolCalls(messages, await readDenialLedger(ws));
|
|
1479
|
+
expect(denied).toHaveLength(1);
|
|
1480
|
+
expect(edit.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
1481
|
+
const redacted = clearProvisionalPostDenialNarration(messages, denied);
|
|
1482
|
+
expect(redacted).toHaveLength(0); // nothing trailing to redact — the stop already did it
|
|
1483
|
+
expect(messages).toHaveLength(2);
|
|
1484
|
+
expect(messages[0].content).toBe("Let me create the file.");
|
|
1485
|
+
expect(messages[1].toolCalls[0]).toBe(edit);
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
it("does not stop a turn with no denials (auto-approved / read-only tools run to completion)", async () => {
|
|
1489
|
+
const ws = makeWorkspace();
|
|
1490
|
+
const read = toolCall({ id: "c1", name: "read", status: ToolCallStatus.TOOL_CALL_COMPLETED, args: { path: "a.txt" } });
|
|
1491
|
+
|
|
1492
|
+
const { cancelled, consumed } = await runTurnWithFirstDenialStop(ws, [
|
|
1493
|
+
{ kind: "text", content: "Reading the file." },
|
|
1494
|
+
{ kind: "tool", tool: read },
|
|
1495
|
+
{ kind: "text", content: "Here is the content." },
|
|
1496
|
+
]);
|
|
1497
|
+
|
|
1498
|
+
expect(cancelled).toBe(false);
|
|
1499
|
+
expect(consumed).toBe(3); // the whole turn is consumed
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
it("stops on the NEXT event of any type when the denial lands after the tool_call read (the watcher rule)", async () => {
|
|
1503
|
+
// The production race (aex_01kwj07f7g23c3wp9sn8496z5g): the hook's ledger
|
|
1504
|
+
// append landed just after the gated tool's own tool_call-event read, so the
|
|
1505
|
+
// old tool_call-only rule consumed the model's ENTIRE reaction (thinking,
|
|
1506
|
+
// narration, a workaround shell) before the next tool_call finally saw the
|
|
1507
|
+
// ledger. The watcher flags the append the moment it lands; the very next
|
|
1508
|
+
// event — a mere text delta — must end the turn.
|
|
1509
|
+
const ws = makeWorkspace();
|
|
1510
|
+
const shell = toolCall({ id: "c1", name: "shell", status: ToolCallStatus.TOOL_CALL_RUNNING, args: { command: "seq 1 5000 > big.txt" } });
|
|
1511
|
+
const workaround = toolCall({ id: "c2", name: "shell", status: ToolCallStatus.TOOL_CALL_RUNNING, args: { command: "python3 -c ..." } });
|
|
1512
|
+
|
|
1513
|
+
const { messages, cancelled, consumed } = await runTurnWithFirstDenialStop(ws, [
|
|
1514
|
+
{ kind: "text", content: "Let me create the file." },
|
|
1515
|
+
{ kind: "tool", tool: shell, denyToken: { name: "Shell", token: grantToken("shell", "seq 1 5000 > big.txt") }, denyTiming: "after-read" },
|
|
1516
|
+
{ kind: "text", content: "Shell was blocked — generating with a script instead." },
|
|
1517
|
+
{ kind: "tool", tool: workaround, denyToken: { name: "Shell", token: grantToken("shell", "python3 -c ...") } },
|
|
1518
|
+
]);
|
|
1519
|
+
|
|
1520
|
+
expect(cancelled).toBe(true);
|
|
1521
|
+
// The reaction text event triggers the dirty-flag read and is the LAST
|
|
1522
|
+
// event consumed; the workaround tool is never consumed at all.
|
|
1523
|
+
expect(consumed).toBe(3);
|
|
1524
|
+
expect(messages).toHaveLength(3);
|
|
1525
|
+
expect(messages.some((m) => m.toolCalls.includes(workaround))).toBe(false);
|
|
1526
|
+
|
|
1527
|
+
// Phase 12 on the stopped transcript: the anchor gates, and the one raced
|
|
1528
|
+
// reaction message is blanked by the trim backstop.
|
|
1529
|
+
const denied = await reconcileDeniedToolCalls(messages, await readDenialLedger(ws), undefined, rootBackend());
|
|
1530
|
+
expect(denied).toHaveLength(1);
|
|
1531
|
+
expect(shell.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
1532
|
+
const redacted = clearProvisionalPostDenialNarration(messages, denied);
|
|
1533
|
+
expect(redacted).toHaveLength(1);
|
|
1534
|
+
expect(messages[2].content).toBe("");
|
|
1535
|
+
});
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
describe("watchDenialLedger", () => {
|
|
1539
|
+
it("flags dirty when the hook appends a denial to the ledger", async () => {
|
|
1540
|
+
const ws = makeWorkspace();
|
|
1541
|
+
await resetDenialLedger(ws);
|
|
1542
|
+
let dirty = false;
|
|
1543
|
+
const stop = watchDenialLedger(ws, () => {
|
|
1544
|
+
dirty = true;
|
|
1545
|
+
});
|
|
1546
|
+
try {
|
|
1547
|
+
// Give the watcher a beat to arm, then simulate the hook's append.
|
|
1548
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1549
|
+
await writeFile(
|
|
1550
|
+
denialLedgerPath(ws),
|
|
1551
|
+
`{"toolName":"Shell","token":"${grantToken("shell", "echo hi")}"}\n`,
|
|
1552
|
+
{ flag: "a" },
|
|
1553
|
+
);
|
|
1554
|
+
await vi.waitFor(() => expect(dirty).toBe(true), { timeout: 3_000 });
|
|
1555
|
+
} finally {
|
|
1556
|
+
stop();
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
it("close is idempotent and safe after the directory is gone", async () => {
|
|
1561
|
+
const ws = makeWorkspace();
|
|
1562
|
+
await resetDenialLedger(ws);
|
|
1563
|
+
const stop = watchDenialLedger(ws, () => {});
|
|
1564
|
+
stop();
|
|
1565
|
+
stop(); // second close must not throw
|
|
1566
|
+
});
|
|
1567
|
+
});
|
|
1568
|
+
|
|
299
1569
|
describe("generateHookScript ledger wiring", () => {
|
|
300
|
-
it("
|
|
301
|
-
const script = generateHookScript(
|
|
302
|
-
"/hitl/approval-state.json",
|
|
303
|
-
"/hitl/denials.jsonl",
|
|
304
|
-
process.pid,
|
|
305
|
-
);
|
|
1570
|
+
it("bakes the active-turn pointer, derives the ledger from it, and records denials in both deny branches", () => {
|
|
1571
|
+
const script = generateHookScript("/gate/active.json");
|
|
306
1572
|
|
|
307
|
-
|
|
1573
|
+
// Stable script: it bakes the pointer path (not per-turn state/ledger), and
|
|
1574
|
+
// derives LEDGER_FILE from the pointer it reads each invocation.
|
|
1575
|
+
expect(script).toContain('ACTIVE_FILE="/gate/active.json"');
|
|
1576
|
+
expect(script).not.toContain('LEDGER_FILE="/');
|
|
1577
|
+
expect(script).toContain('>> "$LEDGER_FILE"');
|
|
308
1578
|
expect(script).toContain("record_denial()");
|
|
309
1579
|
// One definition + a call in the gated-built-in branch + a call in the MCP
|
|
310
1580
|
// branch = 3 occurrences.
|