@stigmer/runner 3.0.8-dev.20260613085218 → 3.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/hook-script.d.ts +23 -12
- package/dist/activities/execute-cursor/hook-script.js +85 -51
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +210 -79
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +35 -0
- package/dist/activities/execute-cursor/message-translator.js +114 -6
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +25 -0
- package/dist/activities/execute-cursor/prompt-builder.js +54 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +8 -2
- package/dist/activities/execute-cursor/workspace-setup.js +62 -30
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +14 -4
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +0 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.js +32 -8
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +4 -5
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js +3 -4
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +8 -0
- package/dist/activities/execute-deep-agent/streaming.js +3 -4
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-tracker.js +4 -5
- package/dist/activities/execute-deep-agent/subagent-tracker.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -5
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/in-flight.d.ts +35 -0
- package/dist/in-flight.js +61 -0
- package/dist/in-flight.js.map +1 -0
- package/dist/runner-manager.d.ts +2 -0
- package/dist/runner-manager.js +90 -29
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +2 -0
- package/dist/runner.js.map +1 -1
- package/dist/shared/grpc-retry.d.ts +9 -20
- package/dist/shared/grpc-retry.js +9 -52
- package/dist/shared/grpc-retry.js.map +1 -1
- package/dist/shared/stall-watchdog.d.ts +68 -0
- package/dist/shared/stall-watchdog.js +102 -0
- package/dist/shared/stall-watchdog.js.map +1 -0
- package/dist/shared/status-offload.d.ts +84 -0
- package/dist/shared/status-offload.js +292 -0
- package/dist/shared/status-offload.js.map +1 -0
- package/dist/shared/status.d.ts +34 -3
- package/dist/shared/status.js +102 -9
- package/dist/shared/status.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/config.test.ts +8 -0
- package/src/__tests__/in-flight.test.ts +84 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +74 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +90 -15
- package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +244 -0
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +53 -4
- package/src/activities/execute-cursor/hook-script.ts +85 -51
- package/src/activities/execute-cursor/index.ts +170 -35
- package/src/activities/execute-cursor/message-translator.ts +113 -6
- package/src/activities/execute-cursor/prompt-builder.ts +59 -0
- package/src/activities/execute-cursor/workspace-setup.ts +76 -44
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder-shared.test.ts +66 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +6 -3
- package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +70 -0
- package/src/activities/execute-deep-agent/index.ts +16 -4
- package/src/activities/execute-deep-agent/status-builder-shared.ts +27 -5
- package/src/activities/execute-deep-agent/status-builder.ts +3 -5
- package/src/activities/execute-deep-agent/streaming-v3.ts +4 -4
- package/src/activities/execute-deep-agent/streaming.ts +13 -4
- package/src/activities/execute-deep-agent/subagent-tracker.ts +4 -5
- package/src/activities/execute-deep-agent/v3-status-builder.ts +5 -5
- package/src/config.ts +27 -0
- package/src/in-flight.ts +71 -0
- package/src/runner-manager.ts +127 -33
- package/src/runner.ts +6 -0
- package/src/shared/__tests__/artifact-storage.test.ts +1 -0
- package/src/shared/__tests__/grpc-retry-extended.test.ts +6 -144
- package/src/shared/__tests__/grpc-retry.test.ts +5 -134
- package/src/shared/__tests__/stall-watchdog.test.ts +193 -0
- package/src/shared/__tests__/status-offload.test.ts +256 -0
- package/src/shared/__tests__/status.test.ts +199 -0
- package/src/shared/grpc-retry.ts +9 -72
- package/src/shared/stall-watchdog.ts +122 -0
- package/src/shared/status-offload.ts +342 -0
- package/src/shared/status.ts +142 -8
|
@@ -14,6 +14,7 @@ import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agente
|
|
|
14
14
|
|
|
15
15
|
import { buildPrompt } from "../index.js";
|
|
16
16
|
import type { BuildPromptInput } from "../index.js";
|
|
17
|
+
import { buildReinvocationPrompt, formatToolApprovalProtocol } from "../prompt-builder.js";
|
|
17
18
|
import type { AgentResolution, AgentResolutionReason } from "../session-lifecycle.js";
|
|
18
19
|
|
|
19
20
|
const USER_MESSAGE = "What was the secret token I told you?";
|
|
@@ -109,3 +110,76 @@ describe("buildPrompt", () => {
|
|
|
109
110
|
expect(prompt).not.toContain("tool-call-1");
|
|
110
111
|
});
|
|
111
112
|
});
|
|
113
|
+
|
|
114
|
+
describe("tool-approval protocol injection", () => {
|
|
115
|
+
it("includes the tool-approval protocol on the first execution", () => {
|
|
116
|
+
const prompt = buildPrompt(
|
|
117
|
+
input({ resolution: resolution("local", "created_first_execution") }),
|
|
118
|
+
);
|
|
119
|
+
expect(prompt).toContain("<tool_approval_protocol>");
|
|
120
|
+
// The decisive override against a server's "ask first" guidance.
|
|
121
|
+
expect(prompt).toContain("calling the appropriate tool directly");
|
|
122
|
+
expect(prompt).toContain("Invoke the tool and let the platform");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("places the protocol after the agent instructions, before the user request", () => {
|
|
126
|
+
const prompt = buildPrompt(
|
|
127
|
+
input({ resolution: resolution("local", "created_first_execution") }),
|
|
128
|
+
);
|
|
129
|
+
const protocolIdx = prompt.indexOf("<tool_approval_protocol>");
|
|
130
|
+
const requestIdx = prompt.indexOf("<user_request>");
|
|
131
|
+
expect(protocolIdx).toBeGreaterThan(prompt.indexOf("<agent_instructions>"));
|
|
132
|
+
expect(protocolIdx).toBeLessThan(requestIdx);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("also injects the protocol for a fresh agent after a resume failure", () => {
|
|
136
|
+
const prompt = buildPrompt(
|
|
137
|
+
input({ resolution: resolution("local", "created_after_resume_failure") }),
|
|
138
|
+
);
|
|
139
|
+
expect(prompt).toContain("<tool_approval_protocol>");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("formatToolApprovalProtocol", () => {
|
|
144
|
+
it("instructs the agent to invoke tools and never ask for permission in prose", () => {
|
|
145
|
+
const section = formatToolApprovalProtocol();
|
|
146
|
+
expect(section).toContain("<tool_approval_protocol>");
|
|
147
|
+
expect(section).toContain("</tool_approval_protocol>");
|
|
148
|
+
expect(section.toLowerCase()).toContain("never ask the");
|
|
149
|
+
// Explicit override of MCP-server "confirm before acting" guidance.
|
|
150
|
+
expect(section.toLowerCase()).toContain("even if a tool or mcp server");
|
|
151
|
+
expect(section).toContain("Invoke the tool and let the platform");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("contains no characters that would break the prompt assembly", () => {
|
|
155
|
+
// Sanity: the protocol is plain prose joined into the user-message prompt.
|
|
156
|
+
expect(formatToolApprovalProtocol()).not.toContain("undefined");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("buildReinvocationPrompt", () => {
|
|
161
|
+
function pending(toolCallId: string, message: string) {
|
|
162
|
+
return create(PendingApprovalSchema, { toolCallId, toolName: "Write", message });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
it("tells the agent to carry out approved actions and keep invoking tools", () => {
|
|
166
|
+
const decisions = new Map<string, ApprovalAction>([
|
|
167
|
+
["tc-1", ApprovalAction.APPROVE],
|
|
168
|
+
]);
|
|
169
|
+
const prompt = buildReinvocationPrompt([pending("tc-1", "Write file: a.txt")], decisions);
|
|
170
|
+
expect(prompt).toContain("APPROVED");
|
|
171
|
+
expect(prompt).toContain("Write file: a.txt");
|
|
172
|
+
// The continuation + override directive must be present.
|
|
173
|
+
expect(prompt).toContain("Continue the rest of the task");
|
|
174
|
+
expect(prompt.toLowerCase()).toContain("do not ask the user for permission in prose");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("still carries the continuation/override when every action was skipped", () => {
|
|
178
|
+
const decisions = new Map<string, ApprovalAction>([
|
|
179
|
+
["tc-1", ApprovalAction.SKIP],
|
|
180
|
+
]);
|
|
181
|
+
const prompt = buildReinvocationPrompt([pending("tc-1", "Write file: a.txt")], decisions);
|
|
182
|
+
expect(prompt).toContain("SKIPPED");
|
|
183
|
+
expect(prompt).toContain("Continue the rest of the task");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -89,13 +89,26 @@ function setup(opts: {
|
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
// Real hook-input shapes (PascalCase name, file_path/command in
|
|
92
|
+
// Real preToolUse hook-input shapes (PascalCase name, file_path/command in
|
|
93
|
+
// tool_input). These omit hook_event_name on purpose: a payload with no event
|
|
94
|
+
// must still take the built-in arm (the script only diverts to the MCP arm on an
|
|
95
|
+
// explicit beforeMCPExecution).
|
|
93
96
|
const hookWrite = (filePath: string) => ({ tool_name: "Write", tool_input: { file_path: filePath, content: "x" } });
|
|
94
97
|
const hookShell = (command: string) => ({ tool_name: "Shell", tool_input: { command, cwd: "/x", timeout: 30000 } });
|
|
95
98
|
const hookDelete = (filePath: string) => ({ tool_name: "Delete", tool_input: { file_path: filePath } });
|
|
96
99
|
const hookRead = (filePath: string) => ({ tool_name: "Read", tool_input: { file_path: filePath } });
|
|
97
100
|
|
|
98
|
-
|
|
101
|
+
// Real beforeMCPExecution shape (captured live): bare tool_name, tool_input as a
|
|
102
|
+
// JSON STRING, server identity, and the hook_event_name discriminator.
|
|
103
|
+
const hookMcp = (name: string, input: Record<string, unknown> = {}) => ({
|
|
104
|
+
tool_name: name,
|
|
105
|
+
tool_input: JSON.stringify(input),
|
|
106
|
+
mcp_server_name: "srv",
|
|
107
|
+
command: "npx -y srv mcp",
|
|
108
|
+
hook_event_name: "beforeMCPExecution",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
99
112
|
it("denies gated built-ins (Write/Shell/Delete) and records a category+salient token", () => {
|
|
100
113
|
const h = setup({});
|
|
101
114
|
|
|
@@ -134,24 +147,86 @@ d("generated preToolUse hook", () => {
|
|
|
134
147
|
expect(h.decide(hookWrite("/x/OTHER.txt")).permission).toBe("deny");
|
|
135
148
|
});
|
|
136
149
|
|
|
137
|
-
it("denies require-approval MCP tools and allows them once granted (name-only)", () => {
|
|
138
|
-
const mcpPolicies = { apply_x: { requiresApproval: true, message: "Apply X" } };
|
|
139
|
-
const denyH = setup({ mcpPolicies });
|
|
140
|
-
expect(denyH.decide({ tool_name: "apply_x", tool_input: {} }).permission).toBe("deny");
|
|
141
|
-
expect(denyH.ledger()[0].token).toBe(grantToken("apply_x", ""));
|
|
142
|
-
|
|
143
|
-
const grantH = setup({
|
|
144
|
-
mcpPolicies,
|
|
145
|
-
grants: [{ toolName: "apply_x", mcpServerSlug: "srv", key: "apply_x", salient: "" }],
|
|
146
|
-
});
|
|
147
|
-
expect(grantH.decide({ tool_name: "apply_x", tool_input: {} }).permission).toBe("allow");
|
|
148
|
-
});
|
|
149
|
-
|
|
150
150
|
it("fails closed (deny) when the state file is missing", () => {
|
|
151
151
|
const h = setup({ noStateFile: true });
|
|
152
152
|
expect(h.decide(hookWrite("/x/a.txt")).permission).toBe("deny");
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
// MCP gating runs ONLY on the beforeMCPExecution event (preToolUse does not
|
|
156
|
+
// enforce MCP), so a denial is recorded in exactly one place. The identity is
|
|
157
|
+
// name-only (base64("<tool>\n")) because the bare tool name is identical on the
|
|
158
|
+
// hook input and the runner's stream event.
|
|
159
|
+
describe("MCP tools (beforeMCPExecution event)", () => {
|
|
160
|
+
it("denies a require-approval MCP tool and surfaces its policy message", () => {
|
|
161
|
+
const h = setup({ mcpPolicies: { click: { requiresApproval: true, message: "Approve click?" } } });
|
|
162
|
+
const res = h.decide(hookMcp("click", { app: "Slack", element_index: "59" }));
|
|
163
|
+
expect(res.permission).toBe("deny");
|
|
164
|
+
expect(res.raw).toContain("Approve click?");
|
|
165
|
+
expect(h.ledger()).toHaveLength(1);
|
|
166
|
+
expect(h.ledger()[0].token).toBe(grantToken("click", ""));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("denial agent_message frames approval as automatic and never trains ask-in-prose", () => {
|
|
170
|
+
const h = setup({ mcpPolicies: { click: { requiresApproval: true, message: "Approve click?" } } });
|
|
171
|
+
const res = h.decide(hookMcp("click"));
|
|
172
|
+
// The agent_message must tell the model approval is handled automatically
|
|
173
|
+
// and that it should continue, NOT stop and wait or ask for permission.
|
|
174
|
+
expect(res.raw).toContain("submitted to the user for approval automatically");
|
|
175
|
+
expect(res.raw).toContain("continue with the rest of the task");
|
|
176
|
+
// The old propose-then-wait framing and internal sentinel must be gone.
|
|
177
|
+
expect(res.raw).not.toContain("STIGMER_APPROVAL_REQUIRED");
|
|
178
|
+
expect(res.raw).not.toContain("Stop and wait");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("allows a require-approval MCP tool once it has been granted (reinvocation)", () => {
|
|
182
|
+
const h = setup({
|
|
183
|
+
mcpPolicies: { click: { requiresApproval: true } },
|
|
184
|
+
grants: [{ toolName: "click", mcpServerSlug: "srv", key: "click", salient: "" }],
|
|
185
|
+
});
|
|
186
|
+
expect(h.decide(hookMcp("click")).permission).toBe("allow");
|
|
187
|
+
expect(h.ledger()).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("allows an auto-approved MCP tool (absent from mcpToolPolicies)", () => {
|
|
191
|
+
const h = setup({ mcpPolicies: { click: { requiresApproval: true } } });
|
|
192
|
+
expect(h.decide(hookMcp("list_apps")).permission).toBe("allow");
|
|
193
|
+
expect(h.ledger()).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("allows an MCP tool whose policy is explicitly requiresApproval:false", () => {
|
|
197
|
+
const h = setup({ mcpPolicies: { click: { requiresApproval: false } } });
|
|
198
|
+
expect(h.decide(hookMcp("click")).permission).toBe("allow");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("auto-approve-all allows a require-approval MCP tool", () => {
|
|
202
|
+
const h = setup({ autoApproveAll: true, mcpPolicies: { click: { requiresApproval: true } } });
|
|
203
|
+
expect(h.decide(hookMcp("click")).permission).toBe("allow");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("fails closed (deny) when the state file is missing", () => {
|
|
207
|
+
const h = setup({ noStateFile: true });
|
|
208
|
+
expect(h.decide(hookMcp("click")).permission).toBe("deny");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("does NOT gate the same MCP tool delivered on preToolUse (no double-gating)", () => {
|
|
212
|
+
const h = setup({ mcpPolicies: { click: { requiresApproval: true } } });
|
|
213
|
+
// preToolUse must fall through to allow for MCP — gating belongs to
|
|
214
|
+
// beforeMCPExecution alone, so the denial is never recorded twice.
|
|
215
|
+
const res = h.decide({ tool_name: "click", tool_input: "{}", hook_event_name: "preToolUse" });
|
|
216
|
+
expect(res.permission).toBe("allow");
|
|
217
|
+
expect(h.ledger()).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("scope guard: a foreign MCP invocation is allowed and never recorded", () => {
|
|
221
|
+
const h = setup({
|
|
222
|
+
mcpPolicies: { click: { requiresApproval: true } },
|
|
223
|
+
runnerPid: 2_147_483_600,
|
|
224
|
+
});
|
|
225
|
+
expect(h.decide(hookMcp("click")).permission).toBe("allow");
|
|
226
|
+
expect(h.ledger()).toEqual([]);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
155
230
|
// Regression: the original grep-based extraction truncated string values at
|
|
156
231
|
// the first JSON-escaped character, so a shell command containing double
|
|
157
232
|
// quotes (e.g. `printf '%s' 'x' > "file"`) produced a ledger token that never
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for Cursor MCP image-result normalization.
|
|
3
|
+
*
|
|
4
|
+
* The Cursor SDK wraps an MCP tool result as
|
|
5
|
+
* { status, value: { content: [ { text:{text} }, { image:{ data, mimeType } } ] } }
|
|
6
|
+
* where image `data` is a Node Buffer-JSON ({ type:"Buffer", data:number[] }).
|
|
7
|
+
* The translator must re-emit that as the canonical top-level content-block
|
|
8
|
+
* array the shared persist-time offload consumes, so a screenshot lands as a
|
|
9
|
+
* renderable image ToolCallOutputRef instead of text/plain. These tests pin
|
|
10
|
+
* that normalization and confirm it flows end-to-end through the offload.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi } from "vitest";
|
|
14
|
+
import { create } from "@bufbuild/protobuf";
|
|
15
|
+
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
16
|
+
import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
17
|
+
import type { SDKMessage } from "@cursor/sdk";
|
|
18
|
+
import type { ArtifactStorage } from "../../../shared/artifact-storage.js";
|
|
19
|
+
import {
|
|
20
|
+
offloadOversizedToolOutputs,
|
|
21
|
+
detectImagePayload,
|
|
22
|
+
} from "../../../shared/status-offload.js";
|
|
23
|
+
import {
|
|
24
|
+
toResultString,
|
|
25
|
+
canonicalizeImageResult,
|
|
26
|
+
buildToolCallProto,
|
|
27
|
+
MessageAccumulator,
|
|
28
|
+
} from "../message-translator.js";
|
|
29
|
+
import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
30
|
+
|
|
31
|
+
// PNG signature + a little payload, the way the Cursor SDK serializes bytes.
|
|
32
|
+
const PNG_BYTES = [137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82];
|
|
33
|
+
const PNG_BASE64 = Buffer.from(PNG_BYTES).toString("base64");
|
|
34
|
+
|
|
35
|
+
function cursorImageEnvelope(text = "App=com.example") {
|
|
36
|
+
return {
|
|
37
|
+
status: "success",
|
|
38
|
+
value: {
|
|
39
|
+
content: [
|
|
40
|
+
{ text: { text } },
|
|
41
|
+
{ image: { data: { type: "Buffer", data: PNG_BYTES }, mimeType: "image/png" } },
|
|
42
|
+
],
|
|
43
|
+
isError: false,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("canonicalizeImageResult", () => {
|
|
49
|
+
it("converts a Cursor image envelope (Buffer-JSON) to the canonical array", () => {
|
|
50
|
+
const out = canonicalizeImageResult(cursorImageEnvelope("App=Slack"));
|
|
51
|
+
expect(out).toBeDefined();
|
|
52
|
+
expect(JSON.parse(out!)).toEqual([
|
|
53
|
+
{ type: "text", text: "App=Slack" },
|
|
54
|
+
{ type: "image", data: PNG_BASE64, mimeType: "image/png" },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("handles a bare { content: [...] } envelope (no status/value wrapper)", () => {
|
|
59
|
+
const out = canonicalizeImageResult({
|
|
60
|
+
content: [{ image: { data: { type: "Buffer", data: PNG_BYTES }, mimeType: "image/png" } }],
|
|
61
|
+
});
|
|
62
|
+
expect(JSON.parse(out!)).toEqual([
|
|
63
|
+
{ type: "image", data: PNG_BASE64, mimeType: "image/png" },
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("accepts an already-base64 image data string", () => {
|
|
68
|
+
const out = canonicalizeImageResult({
|
|
69
|
+
content: [{ image: { data: PNG_BASE64, mimeType: "image/png" } }],
|
|
70
|
+
});
|
|
71
|
+
expect(JSON.parse(out!)).toEqual([
|
|
72
|
+
{ type: "image", data: PNG_BASE64, mimeType: "image/png" },
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("accepts a data: URL image data string", () => {
|
|
77
|
+
const out = canonicalizeImageResult({
|
|
78
|
+
content: [{ image: { data: `data:image/png;base64,${PNG_BASE64}`, mimeType: "image/png" } }],
|
|
79
|
+
});
|
|
80
|
+
expect(JSON.parse(out!)).toEqual([
|
|
81
|
+
{ type: "image", data: PNG_BASE64, mimeType: "image/png" },
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("defaults mimeType to image/png when absent", () => {
|
|
86
|
+
const out = canonicalizeImageResult({
|
|
87
|
+
content: [{ image: { data: { type: "Buffer", data: PNG_BYTES } } }],
|
|
88
|
+
});
|
|
89
|
+
expect(JSON.parse(out!)[0]).toEqual({ type: "image", data: PNG_BASE64, mimeType: "image/png" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns undefined for a text-only envelope (no transformation)", () => {
|
|
93
|
+
expect(canonicalizeImageResult({ status: "success", value: { content: [{ text: { text: "hi" } }] } }))
|
|
94
|
+
.toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns undefined when there is no content array", () => {
|
|
98
|
+
expect(canonicalizeImageResult({ status: "success", value: { stdout: "ok" } })).toBeUndefined();
|
|
99
|
+
expect(canonicalizeImageResult("plain string")).toBeUndefined();
|
|
100
|
+
expect(canonicalizeImageResult(null)).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("toResultString", () => {
|
|
105
|
+
it("passes a string through unchanged", () => {
|
|
106
|
+
expect(toResultString("just logs")).toBe("just logs");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns empty string for an absent result", () => {
|
|
110
|
+
expect(toResultString(null)).toBe("");
|
|
111
|
+
expect(toResultString(undefined)).toBe("");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("JSON.stringifies a non-image object unchanged (text-only envelope)", () => {
|
|
115
|
+
const env = { status: "success", value: { content: [{ text: { text: "hi" } }] } };
|
|
116
|
+
expect(toResultString(env)).toBe(JSON.stringify(env));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("normalizes an image envelope to the canonical array", () => {
|
|
120
|
+
const out = toResultString(cursorImageEnvelope("App=X"));
|
|
121
|
+
expect(JSON.parse(out)).toEqual([
|
|
122
|
+
{ type: "text", text: "App=X" },
|
|
123
|
+
{ type: "image", data: PNG_BASE64, mimeType: "image/png" },
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("buildToolCallProto image normalization", () => {
|
|
129
|
+
it("produces a result the shared offload detector recognizes as an image", () => {
|
|
130
|
+
const event = {
|
|
131
|
+
type: "tool_call",
|
|
132
|
+
agent_id: "a1",
|
|
133
|
+
run_id: "r1",
|
|
134
|
+
call_id: "tc-img",
|
|
135
|
+
name: "mcp",
|
|
136
|
+
status: "completed",
|
|
137
|
+
args: { providerIdentifier: "open-computer-use", toolName: "get_app_state", args: {} },
|
|
138
|
+
result: cursorImageEnvelope(),
|
|
139
|
+
} as unknown as Extract<SDKMessage, { type: "tool_call" }>;
|
|
140
|
+
|
|
141
|
+
const tc = buildToolCallProto(event);
|
|
142
|
+
expect(tc.name).toBe("get_app_state");
|
|
143
|
+
const img = detectImagePayload(tc.result);
|
|
144
|
+
expect(img).not.toBeNull();
|
|
145
|
+
expect(img?.mimeType).toBe("image/png");
|
|
146
|
+
expect(img?.base64).toBe(PNG_BASE64);
|
|
147
|
+
// The bloated Buffer-JSON must not survive into the persisted result.
|
|
148
|
+
expect(tc.result).not.toContain('"Buffer"');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("cursor image flows through the persist-time offload", () => {
|
|
153
|
+
it("offloads the screenshot as an image ref with no inline bytes", async () => {
|
|
154
|
+
const uploads: { key: string; contentType?: string }[] = [];
|
|
155
|
+
const storage: ArtifactStorage = {
|
|
156
|
+
upload: vi.fn(async (key: string, _content: Buffer, contentType?: string) => {
|
|
157
|
+
uploads.push({ key, contentType });
|
|
158
|
+
return key;
|
|
159
|
+
}),
|
|
160
|
+
getDownloadUrl: vi.fn(async (key: string) => `https://artifacts.local/${key}`),
|
|
161
|
+
exists: vi.fn(async () => true),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const event = {
|
|
165
|
+
type: "tool_call",
|
|
166
|
+
agent_id: "a1",
|
|
167
|
+
run_id: "r1",
|
|
168
|
+
call_id: "tc-img",
|
|
169
|
+
name: "mcp",
|
|
170
|
+
status: "completed",
|
|
171
|
+
args: { providerIdentifier: "open-computer-use", toolName: "get_app_state", args: {} },
|
|
172
|
+
result: cursorImageEnvelope(),
|
|
173
|
+
} as unknown as Extract<SDKMessage, { type: "tool_call" }>;
|
|
174
|
+
|
|
175
|
+
const tc = buildToolCallProto(event);
|
|
176
|
+
const status = create(AgentExecutionStatusSchema, {
|
|
177
|
+
messages: [create(AgentMessageSchema, { toolCalls: [tc] })],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await offloadOversizedToolOutputs(status, { artifactStorage: storage, executionId: "exec-1" });
|
|
181
|
+
|
|
182
|
+
const out = status.messages[0].toolCalls[0];
|
|
183
|
+
expect(out.outputRef).toBeDefined();
|
|
184
|
+
expect(out.outputRef!.isImage).toBe(true);
|
|
185
|
+
expect(out.outputRef!.mimeType).toBe("image/png");
|
|
186
|
+
expect(out.outputRef!.storageKey.endsWith(".png")).toBe(true);
|
|
187
|
+
expect(uploads[0]?.contentType).toBe("image/png");
|
|
188
|
+
// Inline result is collapsed; no base64/Buffer bytes remain in the status.
|
|
189
|
+
expect(out.result).not.toContain(PNG_BASE64);
|
|
190
|
+
expect(out.result).not.toContain('"Buffer"');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("sub-agent image normalization (extractConversationSteps)", () => {
|
|
195
|
+
it("normalizes a screenshot returned inside a sub-agent toolCall step", () => {
|
|
196
|
+
const messages: AgentMessage[] = [];
|
|
197
|
+
const acc = new MessageAccumulator(messages);
|
|
198
|
+
|
|
199
|
+
const running = {
|
|
200
|
+
type: "tool_call",
|
|
201
|
+
agent_id: "a1",
|
|
202
|
+
run_id: "r1",
|
|
203
|
+
call_id: "tc-sub-img",
|
|
204
|
+
name: "task",
|
|
205
|
+
status: "running",
|
|
206
|
+
args: { description: "screenshot", prompt: "capture" },
|
|
207
|
+
} as unknown as Extract<SDKMessage, { type: "tool_call" }>;
|
|
208
|
+
acc.processEvent(running);
|
|
209
|
+
acc.trackSubAgentExecution(running);
|
|
210
|
+
|
|
211
|
+
const completed = {
|
|
212
|
+
type: "tool_call",
|
|
213
|
+
agent_id: "a1",
|
|
214
|
+
run_id: "r1",
|
|
215
|
+
call_id: "tc-sub-img",
|
|
216
|
+
name: "task",
|
|
217
|
+
status: "completed",
|
|
218
|
+
args: { description: "screenshot", prompt: "capture" },
|
|
219
|
+
result: {
|
|
220
|
+
status: "success",
|
|
221
|
+
value: {
|
|
222
|
+
conversationSteps: [
|
|
223
|
+
{
|
|
224
|
+
type: "toolCall",
|
|
225
|
+
message: {
|
|
226
|
+
type: "get_app_state",
|
|
227
|
+
args: {},
|
|
228
|
+
result: cursorImageEnvelope("App=SubAgent"),
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
} as unknown as Extract<SDKMessage, { type: "tool_call" }>;
|
|
235
|
+
|
|
236
|
+
acc.processEvent(completed);
|
|
237
|
+
acc.trackSubAgentExecution(completed);
|
|
238
|
+
|
|
239
|
+
const sub = acc.subAgentExecutions[0];
|
|
240
|
+
const subToolResult = sub.messages[0].toolCalls[0].result;
|
|
241
|
+
const img = detectImagePayload(subToolResult);
|
|
242
|
+
expect(img?.base64).toBe(PNG_BASE64);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -43,9 +43,12 @@ function freshRoot(): string {
|
|
|
43
43
|
const stigmerScript = (root: string) =>
|
|
44
44
|
join(root, ".stigmer", "sessions", "ses-1", "hitl", "stigmer-approval.sh");
|
|
45
45
|
|
|
46
|
+
// Single preToolUse registration — the common shape in these tests.
|
|
47
|
+
const pre = (scriptPath: string) => [{ event: "preToolUse", scriptPath }];
|
|
48
|
+
|
|
46
49
|
describe("buildMergedConfig", () => {
|
|
47
50
|
it("writes a standalone config and restores by delete when no hooks.json exists", () => {
|
|
48
|
-
const { merged, restoreTo } = buildMergedConfig(null, "/abs/hitl/stigmer-approval.sh");
|
|
51
|
+
const { merged, restoreTo } = buildMergedConfig(null, pre("/abs/hitl/stigmer-approval.sh"));
|
|
49
52
|
const parsed = JSON.parse(merged);
|
|
50
53
|
expect(parsed.hooks.preToolUse).toHaveLength(1);
|
|
51
54
|
expect(parsed.hooks.preToolUse[0].command).toBe("/abs/hitl/stigmer-approval.sh");
|
|
@@ -54,6 +57,47 @@ describe("buildMergedConfig", () => {
|
|
|
54
57
|
expect(restoreTo).toBeNull();
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
it("registers multiple events (preToolUse + beforeMCPExecution) and restores by delete", () => {
|
|
61
|
+
const { merged, restoreTo } = buildMergedConfig(null, [
|
|
62
|
+
{ event: "preToolUse", scriptPath: "/abs/hitl/stigmer-approval.sh" },
|
|
63
|
+
{ event: "beforeMCPExecution", scriptPath: "/abs/hitl/stigmer-mcp-capture.sh" },
|
|
64
|
+
]);
|
|
65
|
+
const parsed = JSON.parse(merged);
|
|
66
|
+
expect(parsed.hooks.preToolUse[0].command).toBe("/abs/hitl/stigmer-approval.sh");
|
|
67
|
+
expect(parsed.hooks.beforeMCPExecution[0].command).toBe("/abs/hitl/stigmer-mcp-capture.sh");
|
|
68
|
+
expect(parsed.hooks.beforeMCPExecution[0].failClosed).toBe(true);
|
|
69
|
+
expect(restoreTo).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("merges into both event arrays and strips stale Stigmer entries from each on restore", () => {
|
|
73
|
+
const root = "/abs";
|
|
74
|
+
const stalePre = join(root, ".stigmer", "sessions", "ses-1", "hitl", "stigmer-approval.sh");
|
|
75
|
+
const staleMcp = join(root, ".stigmer", "sessions", "ses-1", "hitl", "stigmer-mcp-capture.sh");
|
|
76
|
+
const original = JSON.stringify({
|
|
77
|
+
version: 1,
|
|
78
|
+
hooks: {
|
|
79
|
+
preToolUse: [{ command: "./user.sh" }, { command: stalePre, failClosed: true }],
|
|
80
|
+
beforeMCPExecution: [{ command: staleMcp, failClosed: true }],
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const freshPre = join(root, ".stigmer", "sessions", "ses-2", "hitl", "stigmer-approval.sh");
|
|
84
|
+
const freshMcp = join(root, ".stigmer", "sessions", "ses-2", "hitl", "stigmer-mcp-capture.sh");
|
|
85
|
+
|
|
86
|
+
const { merged, restoreTo } = buildMergedConfig(original, [
|
|
87
|
+
{ event: "preToolUse", scriptPath: freshPre },
|
|
88
|
+
{ event: "beforeMCPExecution", scriptPath: freshMcp },
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
const m = JSON.parse(merged);
|
|
92
|
+
expect(m.hooks.preToolUse.map((e: any) => e.command)).toEqual(["./user.sh", freshPre]);
|
|
93
|
+
expect(m.hooks.beforeMCPExecution.map((e: any) => e.command)).toEqual([freshMcp]);
|
|
94
|
+
|
|
95
|
+
// Restore is self-healing: every stale Stigmer entry is removed from both.
|
|
96
|
+
const r = JSON.parse(restoreTo!);
|
|
97
|
+
expect(r.hooks.preToolUse).toEqual([{ command: "./user.sh" }]);
|
|
98
|
+
expect(r.hooks.beforeMCPExecution).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
57
101
|
it("merges with a user's hooks.json and restores the original bytes verbatim", () => {
|
|
58
102
|
const original = JSON.stringify(
|
|
59
103
|
{
|
|
@@ -67,7 +111,7 @@ describe("buildMergedConfig", () => {
|
|
|
67
111
|
2,
|
|
68
112
|
);
|
|
69
113
|
const script = "/abs/.stigmer/sessions/ses-1/hitl/stigmer-approval.sh";
|
|
70
|
-
const { merged, restoreTo } = buildMergedConfig(original, script);
|
|
114
|
+
const { merged, restoreTo } = buildMergedConfig(original, pre(script));
|
|
71
115
|
const parsed = JSON.parse(merged);
|
|
72
116
|
|
|
73
117
|
// Our entry is appended; the user's preToolUse hook is preserved...
|
|
@@ -93,7 +137,7 @@ describe("buildMergedConfig", () => {
|
|
|
93
137
|
},
|
|
94
138
|
});
|
|
95
139
|
const fresh = join(root, ".stigmer", "sessions", "ses-2", "hitl", "stigmer-approval.sh");
|
|
96
|
-
const { merged, restoreTo } = buildMergedConfig(original, fresh);
|
|
140
|
+
const { merged, restoreTo } = buildMergedConfig(original, pre(fresh));
|
|
97
141
|
|
|
98
142
|
const mergedParsed = JSON.parse(merged);
|
|
99
143
|
// No duplicate: user entry + exactly one fresh Stigmer entry.
|
|
@@ -109,7 +153,7 @@ describe("buildMergedConfig", () => {
|
|
|
109
153
|
|
|
110
154
|
it("replaces an unparseable hooks.json for the turn but restores its exact bytes", () => {
|
|
111
155
|
const garbage = "{ this is not json ";
|
|
112
|
-
const { merged, restoreTo } = buildMergedConfig(garbage, "/abs/hitl/stigmer-approval.sh");
|
|
156
|
+
const { merged, restoreTo } = buildMergedConfig(garbage, pre("/abs/hitl/stigmer-approval.sh"));
|
|
113
157
|
// We still install a working gate for the turn...
|
|
114
158
|
expect(JSON.parse(merged).hooks.preToolUse).toHaveLength(1);
|
|
115
159
|
// ...and never "fix" the user's file: restore their exact original bytes.
|
|
@@ -147,6 +191,11 @@ describe("installHitlGate / removeHitlGate", () => {
|
|
|
147
191
|
const command = hooksJson.hooks.preToolUse[0].command;
|
|
148
192
|
expect(command).toBe(join(hitlDir, "stigmer-approval.sh"));
|
|
149
193
|
expect(command.startsWith("/")).toBe(true);
|
|
194
|
+
// The SAME script gates MCP via beforeMCPExecution (preToolUse does not
|
|
195
|
+
// enforce MCP); the script branches internally on hook_event_name.
|
|
196
|
+
expect(hooksJson.hooks.beforeMCPExecution[0].command).toBe(
|
|
197
|
+
join(hitlDir, "stigmer-approval.sh"),
|
|
198
|
+
);
|
|
150
199
|
// The workspace holds no relocated artifacts.
|
|
151
200
|
expect(existsSync(join(workspaceRoot, ".cursor", "hooks"))).toBe(false);
|
|
152
201
|
});
|