@stigmer/runner 3.1.6 → 3.1.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.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/discover-mcp-server.js +24 -0
  4. package/dist/activities/discover-mcp-server.js.map +1 -1
  5. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -2
  6. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  7. package/dist/activities/execute-cursor/approval-state.d.ts +58 -10
  8. package/dist/activities/execute-cursor/approval-state.js +37 -8
  9. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  10. package/dist/activities/execute-cursor/env-resolver.js +7 -1
  11. package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
  12. package/dist/activities/execute-cursor/hook-script.d.ts +16 -3
  13. package/dist/activities/execute-cursor/hook-script.js +66 -21
  14. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  15. package/dist/activities/execute-cursor/index.js +59 -0
  16. package/dist/activities/execute-cursor/index.js.map +1 -1
  17. package/dist/activities/execute-cursor/message-translator.d.ts +52 -0
  18. package/dist/activities/execute-cursor/message-translator.js +107 -1
  19. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  20. package/dist/activities/execute-cursor/turn-boundary.d.ts +25 -3
  21. package/dist/activities/execute-cursor/turn-boundary.js +46 -8
  22. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
  23. package/dist/activities/execute-cursor/turn-stream.js +6 -2
  24. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  25. package/dist/activities/execute-cursor/workspace-setup.d.ts +16 -2
  26. package/dist/activities/execute-cursor/workspace-setup.js +22 -12
  27. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/environment.js +7 -1
  29. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/hitl.d.ts +23 -14
  31. package/dist/activities/execute-deep-agent/hitl.js +42 -37
  32. package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +35 -26
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/setup.d.ts +7 -0
  36. package/dist/activities/execute-deep-agent/setup.js +9 -2
  37. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  38. package/dist/activities/hydrate-workflow-execution.js +8 -1
  39. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  40. package/dist/client/stigmer-client.d.ts +69 -1
  41. package/dist/client/stigmer-client.js +108 -4
  42. package/dist/client/stigmer-client.js.map +1 -1
  43. package/dist/client/token-claims.d.ts +22 -0
  44. package/dist/client/token-claims.js +40 -0
  45. package/dist/client/token-claims.js.map +1 -0
  46. package/dist/config.d.ts +11 -1
  47. package/dist/config.js +5 -1
  48. package/dist/config.js.map +1 -1
  49. package/dist/middleware/approval-gate.js +9 -2
  50. package/dist/middleware/approval-gate.js.map +1 -1
  51. package/dist/runner-manager.d.ts +4 -2
  52. package/dist/runner-manager.js +20 -7
  53. package/dist/runner-manager.js.map +1 -1
  54. package/dist/runner.d.ts +2 -2
  55. package/dist/runner.js +1 -1
  56. package/dist/shared/checkpointer/factory.d.ts +11 -7
  57. package/dist/shared/checkpointer/factory.js +21 -8
  58. package/dist/shared/checkpointer/factory.js.map +1 -1
  59. package/dist/shared/checkpointer/sqlite-saver.d.ts +54 -0
  60. package/dist/shared/checkpointer/sqlite-saver.js +272 -0
  61. package/dist/shared/checkpointer/sqlite-saver.js.map +1 -0
  62. package/dist/shared/checkpointer/types.d.ts +8 -4
  63. package/dist/shared/mcp-oauth-detect.d.ts +53 -0
  64. package/dist/shared/mcp-oauth-detect.js +99 -0
  65. package/dist/shared/mcp-oauth-detect.js.map +1 -0
  66. package/dist/shared/tool-row.js +14 -1
  67. package/dist/shared/tool-row.js.map +1 -1
  68. package/dist/shared/workspace/platform-dir.d.ts +18 -0
  69. package/dist/shared/workspace/platform-dir.js +23 -0
  70. package/dist/shared/workspace/platform-dir.js.map +1 -1
  71. package/package.json +3 -3
  72. package/src/__test-utils__/mock-client.ts +3 -0
  73. package/src/activities/__tests__/hydrate-workflow-execution.test.ts +2 -0
  74. package/src/activities/discover-mcp-server.ts +26 -0
  75. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +5 -3
  76. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +272 -14
  77. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +72 -16
  78. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +65 -0
  79. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +128 -1
  80. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +95 -0
  81. package/src/activities/execute-cursor/approval-state.ts +75 -11
  82. package/src/activities/execute-cursor/env-resolver.ts +8 -1
  83. package/src/activities/execute-cursor/hook-script.ts +66 -21
  84. package/src/activities/execute-cursor/index.ts +61 -0
  85. package/src/activities/execute-cursor/message-translator.ts +124 -1
  86. package/src/activities/execute-cursor/turn-boundary.ts +79 -6
  87. package/src/activities/execute-cursor/turn-stream.ts +6 -2
  88. package/src/activities/execute-cursor/workspace-setup.ts +42 -14
  89. package/src/activities/execute-deep-agent/__tests__/environment.test.ts +5 -0
  90. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +314 -0
  91. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +64 -46
  92. package/src/activities/execute-deep-agent/environment.ts +8 -1
  93. package/src/activities/execute-deep-agent/hitl.ts +43 -45
  94. package/src/activities/execute-deep-agent/index.ts +36 -27
  95. package/src/activities/execute-deep-agent/setup.ts +15 -2
  96. package/src/activities/hydrate-workflow-execution.ts +9 -1
  97. package/src/client/__tests__/stigmer-client.test.ts +239 -2
  98. package/src/client/__tests__/token-claims.test.ts +45 -0
  99. package/src/client/stigmer-client.ts +152 -3
  100. package/src/client/token-claims.ts +42 -0
  101. package/src/config.ts +16 -3
  102. package/src/middleware/__tests__/approval-gate.test.ts +8 -1
  103. package/src/middleware/approval-gate.ts +9 -2
  104. package/src/runner-manager.ts +22 -8
  105. package/src/runner.ts +3 -3
  106. package/src/shared/__tests__/mcp-oauth-detect.test.ts +147 -0
  107. package/src/shared/__tests__/tool-row.test.ts +35 -0
  108. package/src/shared/checkpointer/__tests__/factory.test.ts +27 -1
  109. package/src/shared/checkpointer/__tests__/sqlite-saver.test.ts +212 -0
  110. package/src/shared/checkpointer/factory.ts +25 -8
  111. package/src/shared/checkpointer/sqlite-saver.ts +384 -0
  112. package/src/shared/checkpointer/types.ts +8 -4
  113. package/src/shared/mcp-oauth-detect.ts +112 -0
  114. package/src/shared/tool-row.ts +14 -1
  115. package/src/shared/workspace/platform-dir.ts +25 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * REJECT semantics across both checkpointers (issue #197).
3
+ *
4
+ * These tests exercise the full ExecuteDeepAgent activity on the post-decision
5
+ * re-invocation after a user REJECTS a gated tool call, under BOTH the durable
6
+ * (http, cloud) and ephemeral (memory, OSS/desktop) checkpointers.
7
+ *
8
+ * The contract (deny-tool-and-continue): a rejected tool is NOT executed, the
9
+ * user's objection is fed back to the model, and the execution CONTINUES to
10
+ * EXECUTION_COMPLETED — identically on both checkpointers. The rejected tool
11
+ * call is terminalized (never left stuck at WAITING_APPROVAL) with
12
+ * approval_action=REJECT recorded for audit.
13
+ *
14
+ * Modeled on hitl-resume-history.test.ts (http) and sequential-gate-resume.test.ts
15
+ * (memory): performSetup + the agent graph are mocked so the assertions isolate
16
+ * the activity's resume orchestration and status reconciliation.
17
+ */
18
+
19
+ import { describe, it, expect, vi, beforeEach } from "vitest";
20
+ import { makeInMemoryArtifactStorage } from "../../../__test-utils__/fake-artifact-storage.js";
21
+ import { create, clone } from "@bufbuild/protobuf";
22
+ import {
23
+ AgentExecutionStatusSchema,
24
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
25
+ import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
26
+ import {
27
+ AgentMessageSchema,
28
+ ToolCallSchema,
29
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
30
+ import {
31
+ ApprovalAction,
32
+ ExecutionPhase,
33
+ MessageType,
34
+ ToolCallStatus,
35
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
36
+ import type { V3ProtocolEvent } from "../v3-event-recorder.js";
37
+ import type { Config } from "../../../config.js";
38
+ import type { SetupResult } from "../setup.js";
39
+
40
+ vi.mock("@temporalio/activity", () => ({
41
+ Context: {
42
+ current: () => ({
43
+ cancellationSignal: new AbortController().signal,
44
+ heartbeat: vi.fn(),
45
+ }),
46
+ },
47
+ CancelledFailure: class CancelledFailure extends Error {},
48
+ }));
49
+
50
+ vi.mock("../../../idle-watchdog.js", () => ({
51
+ activityStarted: vi.fn(),
52
+ activityFinished: vi.fn(),
53
+ }));
54
+
55
+ const { persistedStatuses } = vi.hoisted(() => ({
56
+ persistedStatuses: [] as AgentExecutionStatus[],
57
+ }));
58
+
59
+ vi.mock("../../../client/stigmer-client.js", () => ({
60
+ StigmerClient: vi.fn().mockImplementation(() => ({
61
+ updateStatus: vi.fn(async (_id: string, status: AgentExecutionStatus) => {
62
+ persistedStatuses.push(clone(AgentExecutionStatusSchema, status));
63
+ return { signal: 0 };
64
+ }),
65
+ getExecution: vi.fn(),
66
+ })),
67
+ }));
68
+
69
+ vi.mock("../setup.js", () => ({ performSetup: vi.fn() }));
70
+
71
+ import { createDeepAgentActivities } from "../index.js";
72
+ import { performSetup } from "../setup.js";
73
+
74
+ const GATED_TOOL_CALL_ID = "toolu_reject_01";
75
+ const PRE_AI_TEXT = "I'll call the echo tool.";
76
+ const POST_AI_TEXT = "Understood — I'll proceed without that tool.";
77
+ const REJECT_COMMENT = "not this time";
78
+
79
+ function v3Event(seq: number, method: string, data: unknown, namespace: string[] = []): V3ProtocolEvent {
80
+ return { type: "event", seq, method, params: { namespace, timestamp: Date.now(), data } };
81
+ }
82
+
83
+ /**
84
+ * A rejected tool does NOT execute, so the resumed stream carries only the
85
+ * model's follow-up assistant turn reacting to the rejection — no
86
+ * tool-started / tool-finished for the gated call.
87
+ */
88
+ function postRejectAssistantTurn(): V3ProtocolEvent[] {
89
+ return [
90
+ v3Event(0, "messages", { event: "message-start", run_id: "r2" }),
91
+ v3Event(1, "messages", {
92
+ event: "content-block-delta",
93
+ index: 0,
94
+ delta: { type: "text-delta", text: POST_AI_TEXT },
95
+ run_id: "r2",
96
+ }),
97
+ v3Event(2, "messages", { event: "message-finish", run_id: "r2" }),
98
+ ];
99
+ }
100
+
101
+ function mockV3Run(events: V3ProtocolEvent[]) {
102
+ return {
103
+ [Symbol.asyncIterator]: async function* () {
104
+ for (const e of events) yield e;
105
+ },
106
+ output: Promise.resolve({ messages: [] }),
107
+ abort: vi.fn(),
108
+ signal: new AbortController().signal,
109
+ };
110
+ }
111
+
112
+ /** Run-1 transcript with the gated call carrying the user's REJECT decision. */
113
+ function persistedRejectedStatus(): AgentExecutionStatus {
114
+ return create(AgentExecutionStatusSchema, {
115
+ phase: ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
116
+ startedAt: "2026-06-15T00:00:00.000Z",
117
+ messages: [
118
+ create(AgentMessageSchema, {
119
+ type: MessageType.MESSAGE_AI,
120
+ content: PRE_AI_TEXT,
121
+ timestamp: "2026-06-15T00:00:01.000Z",
122
+ toolCalls: [
123
+ create(ToolCallSchema, {
124
+ id: GATED_TOOL_CALL_ID,
125
+ name: "echo",
126
+ status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
127
+ requiresApproval: true,
128
+ approvalMessage: "Execute echo tool",
129
+ approvalAction: ApprovalAction.REJECT,
130
+ mcpServerSlug: "test-mcp",
131
+ }),
132
+ ],
133
+ }),
134
+ ],
135
+ });
136
+ }
137
+
138
+ const approvalPolicies = new Map([
139
+ ["test-mcp/echo", {
140
+ toolName: "echo",
141
+ mcpServerSlug: "test-mcp",
142
+ requiresApproval: true,
143
+ approvalMessage: "Execute echo tool",
144
+ }],
145
+ ]);
146
+
147
+ /** Durable (http) resume: getState reports a pending interrupt, then resolves. */
148
+ function fakeHttpRejectSetup(): SetupResult {
149
+ let streamed = false;
150
+ const pendingInterruptState = {
151
+ values: { messages: [{ role: "assistant", content: PRE_AI_TEXT }] },
152
+ tasks: [{
153
+ id: "task-1",
154
+ interrupts: [{
155
+ value: {
156
+ tool_call_id: GATED_TOOL_CALL_ID,
157
+ tool_name: "echo",
158
+ mcp_server_slug: "test-mcp",
159
+ message: "Execute echo tool",
160
+ },
161
+ resumeValue: undefined,
162
+ }],
163
+ }],
164
+ };
165
+ const resolvedState = { values: { messages: [{ role: "assistant", content: PRE_AI_TEXT }] }, tasks: [] };
166
+
167
+ const agentGraph = {
168
+ getState: vi.fn(async () => (streamed ? resolvedState : pendingInterruptState)),
169
+ streamEvents: vi.fn(async () => {
170
+ streamed = true;
171
+ return mockV3Run(postRejectAssistantTurn());
172
+ }),
173
+ };
174
+
175
+ return {
176
+ agentGraph,
177
+ langgraphConfig: { configurable: { thread_id: "thread-reject-http" } },
178
+ langgraphInput: { messages: [{ role: "user", content: "Call echo" }] },
179
+ execution: {
180
+ spec: { message: "Call echo with 'hello'." },
181
+ status: persistedRejectedStatus(),
182
+ },
183
+ agent: {},
184
+ session: { spec: { workspaceEntries: [] } },
185
+ workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-reject-http" },
186
+ mcpConnection: null,
187
+ mergedEnvVars: {},
188
+ secretKeys: new Set<string>(),
189
+ modelName: "claude-sonnet",
190
+ gracefulStop: undefined,
191
+ artifactStorage: makeInMemoryArtifactStorage({ urlBase: "https://artifacts.local/" }).storage,
192
+ provisionResults: [],
193
+ approvalPolicies,
194
+ toolServerMap: new Map([["echo", "test-mcp"]]),
195
+ leasedCategories: new Set(),
196
+ globalBypass: false,
197
+ hasStructuredOutput: false,
198
+ streamVersion: "v3",
199
+ } as unknown as SetupResult;
200
+ }
201
+
202
+ /** Memory resume: empty live checkpoint (replay), stream completes with a turn. */
203
+ function fakeMemoryRejectSetup(): SetupResult {
204
+ const emptyState = { values: { messages: [] as unknown[] }, tasks: [] };
205
+ const agentGraph = {
206
+ getState: vi.fn(async () => emptyState),
207
+ streamEvents: vi.fn(async () => mockV3Run(postRejectAssistantTurn())),
208
+ };
209
+
210
+ return {
211
+ agentGraph,
212
+ langgraphConfig: { configurable: { thread_id: "thread-reject-mem" } },
213
+ langgraphInput: { messages: [{ role: "user", content: "Call echo" }] },
214
+ execution: {
215
+ spec: { message: "Call echo with 'hello'." },
216
+ status: persistedRejectedStatus(),
217
+ },
218
+ agent: {},
219
+ session: { spec: { workspaceEntries: [] } },
220
+ workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-reject-mem" },
221
+ mcpConnection: null,
222
+ mergedEnvVars: {},
223
+ secretKeys: new Set<string>(),
224
+ modelName: "claude-sonnet",
225
+ gracefulStop: undefined,
226
+ artifactStorage: makeInMemoryArtifactStorage({ urlBase: "https://artifacts.local/" }).storage,
227
+ provisionResults: [],
228
+ approvalPolicies,
229
+ toolServerMap: new Map([["echo", "test-mcp"]]),
230
+ leasedCategories: new Set(),
231
+ globalBypass: false,
232
+ hasStructuredOutput: false,
233
+ streamVersion: "v3",
234
+ } as unknown as SetupResult;
235
+ }
236
+
237
+ const httpConfig: Config = {
238
+ taskQueue: "test-queue",
239
+ temporalAddress: "localhost:7233",
240
+ temporalNamespace: "default",
241
+ stigmerBackendEndpoint: "http://localhost:7234",
242
+ stigmerToken: null,
243
+ cursorApiKey: "",
244
+ workspaceRootDir: "/tmp/test",
245
+ mode: "cloud",
246
+ proxyEndpoint: "http://localhost:7234",
247
+ maxConcurrentActivities: 5,
248
+ idleTimeoutSeconds: null,
249
+ cloudModeEnabled: true,
250
+ checkpointerType: "http",
251
+ checkpointerProxyEndpoint: "http://localhost:7234",
252
+ primaryModel: "claude-sonnet",
253
+ cursorStreamStallTimeoutMs: 180000,
254
+ workspaceLockTimeoutMs: 900000,
255
+ };
256
+
257
+ const memoryConfig: Config = {
258
+ ...httpConfig,
259
+ mode: "local",
260
+ proxyEndpoint: null,
261
+ cloudModeEnabled: false,
262
+ checkpointerType: "memory",
263
+ checkpointerProxyEndpoint: null,
264
+ };
265
+
266
+ function gatedToolCall(status: AgentExecutionStatus) {
267
+ return status.messages.flatMap(m => m.toolCalls).find(tc => tc.id === GATED_TOOL_CALL_ID);
268
+ }
269
+
270
+ describe("ExecuteDeepAgent — REJECT continues the execution (issue #197)", () => {
271
+ beforeEach(() => {
272
+ persistedStatuses.length = 0;
273
+ });
274
+
275
+ it("http checkpointer: reject denies the tool and the execution COMPLETES", async () => {
276
+ vi.mocked(performSetup).mockResolvedValue(fakeHttpRejectSetup());
277
+ const activities = createDeepAgentActivities(httpConfig);
278
+ await activities.ExecuteDeepAgent("exec-reject-http", "thread-reject-http");
279
+
280
+ const final = persistedStatuses.at(-1)!;
281
+ expect(final.phase, "rejected execution continues to COMPLETED").toBe(
282
+ ExecutionPhase.EXECUTION_COMPLETED,
283
+ );
284
+
285
+ const tc = gatedToolCall(final);
286
+ expect(tc, "the gated tool call is present").toBeDefined();
287
+ expect(
288
+ tc!.status,
289
+ "rejected tool call is terminalized (SKIPPED), never stuck at WAITING_APPROVAL",
290
+ ).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
291
+ expect(tc!.approvalAction, "the REJECT decision is preserved for audit").toBe(
292
+ ApprovalAction.REJECT,
293
+ );
294
+ });
295
+
296
+ it("memory checkpointer: reject denies the tool and the execution COMPLETES", async () => {
297
+ vi.mocked(performSetup).mockResolvedValue(fakeMemoryRejectSetup());
298
+ const activities = createDeepAgentActivities(memoryConfig);
299
+ await activities.ExecuteDeepAgent("exec-reject-mem", "thread-reject-mem");
300
+
301
+ const final = persistedStatuses.at(-1)!;
302
+ expect(final.phase, "rejected execution continues to COMPLETED on memory too").toBe(
303
+ ExecutionPhase.EXECUTION_COMPLETED,
304
+ );
305
+
306
+ const tc = gatedToolCall(final);
307
+ expect(tc, "the gated tool call is present").toBeDefined();
308
+ expect(
309
+ tc!.status,
310
+ "rejected tool call is terminalized identically on memory",
311
+ ).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
312
+ expect(tc!.approvalAction).toBe(ApprovalAction.REJECT);
313
+ });
314
+ });
@@ -1,13 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { Command } from "@langchain/langgraph";
3
- import { resolveResumeInput, reconcileToolCallStatuses } from "../hitl.js";
3
+ import { resolveResumeInput, reconcileNonExecutingDecisions } from "../hitl.js";
4
4
  import { ApprovalAction, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
5
5
  import { create } from "@bufbuild/protobuf";
6
6
  import {
7
7
  AgentMessageSchema,
8
8
  ToolCallSchema,
9
9
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
10
- import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
11
10
  import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
12
11
  import { MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
13
12
  import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
@@ -78,7 +77,6 @@ describe("resolveResumeInput", () => {
78
77
  const result = resolveResumeInput(execution, state, "hello");
79
78
 
80
79
  expect(result.isResumeFromApproval).toBe(false);
81
- expect(result.hasRejection).toBe(false);
82
80
  expect(result.graphInput).toEqual({
83
81
  messages: [{ role: "user", content: "hello" }],
84
82
  });
@@ -105,11 +103,10 @@ describe("resolveResumeInput", () => {
105
103
  const result = resolveResumeInput(execution, state, "hello");
106
104
 
107
105
  expect(result.isResumeFromApproval).toBe(true);
108
- expect(result.hasRejection).toBe(false);
109
106
  expect(result.graphInput).toBeInstanceOf(Command);
110
107
  });
111
108
 
112
- it("detects rejection when a decision is REJECT", () => {
109
+ it("builds a resume Command for a REJECT decision (denies the tool, does not fail the run)", () => {
113
110
  const execution = makeExecution([{
114
111
  id: "call-1",
115
112
  status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
@@ -120,9 +117,12 @@ describe("resolveResumeInput", () => {
120
117
 
121
118
  const result = resolveResumeInput(execution, state, "hello");
122
119
 
120
+ // REJECT resumes the gate like any other decision — the gate returns a
121
+ // denial ToolMessage and the run continues. There is no execution-level
122
+ // "rejection" flag any more; the terminal tool status is set by
123
+ // reconcileNonExecutingDecisions.
123
124
  expect(result.isResumeFromApproval).toBe(true);
124
- expect(result.hasRejection).toBe(true);
125
- expect(result.rejectionReason).toContain("Rejected by user");
125
+ expect(result.graphInput).toBeInstanceOf(Command);
126
126
  });
127
127
 
128
128
  it("handles multiple interrupts with mixed decisions", () => {
@@ -147,7 +147,6 @@ describe("resolveResumeInput", () => {
147
147
  const result = resolveResumeInput(execution, state, "hello");
148
148
 
149
149
  expect(result.isResumeFromApproval).toBe(true);
150
- expect(result.hasRejection).toBe(false);
151
150
  });
152
151
 
153
152
  it("skips already-resumed interrupts", () => {
@@ -167,56 +166,75 @@ describe("resolveResumeInput", () => {
167
166
  });
168
167
  });
169
168
 
170
- describe("reconcileToolCallStatuses", () => {
171
- it("updates APPROVE to RUNNING", () => {
172
- const tc = create(ToolCallSchema, {
173
- id: "call-1",
174
- status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
169
+ describe("reconcileNonExecutingDecisions", () => {
170
+ function statusWith(
171
+ toolCalls: Array<{ id: string; status: ToolCallStatus; approvalAction: ApprovalAction }>,
172
+ ) {
173
+ return create(AgentExecutionStatusSchema, {
174
+ messages: [
175
+ create(AgentMessageSchema, {
176
+ toolCalls: toolCalls.map(tc =>
177
+ create(ToolCallSchema, {
178
+ id: tc.id,
179
+ name: "test_tool",
180
+ status: tc.status,
181
+ approvalAction: tc.approvalAction,
182
+ }),
183
+ ),
184
+ }),
185
+ ],
175
186
  });
176
- const toolCalls = new Map<string, ToolCall>([["call-1", tc]]);
177
- const decisions = new Map([
178
- ["call-1", { action: ApprovalAction.APPROVE, comment: "" }],
179
- ]);
180
-
181
- reconcileToolCallStatuses(toolCalls, decisions);
182
- expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_RUNNING);
183
- });
187
+ }
184
188
 
185
- it("updates SKIP to SKIPPED", () => {
186
- const tc = create(ToolCallSchema, {
187
- id: "call-2",
189
+ it("terminalizes a REJECT decision to SKIPPED with a reason (does not FAIL)", () => {
190
+ const status = statusWith([{
191
+ id: "call-reject",
188
192
  status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
189
- });
190
- const toolCalls = new Map<string, ToolCall>([["call-2", tc]]);
191
- const decisions = new Map([
192
- ["call-2", { action: ApprovalAction.SKIP, comment: "" }],
193
- ]);
193
+ approvalAction: ApprovalAction.REJECT,
194
+ }]);
195
+
196
+ reconcileNonExecutingDecisions(status);
194
197
 
195
- reconcileToolCallStatuses(toolCalls, decisions);
198
+ const tc = status.messages[0].toolCalls[0];
196
199
  expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
200
+ expect(tc.error).toContain("Rejected by user");
201
+ // The REJECT decision is preserved so the audit trail stays honest.
202
+ expect(tc.approvalAction).toBe(ApprovalAction.REJECT);
197
203
  });
198
204
 
199
- it("updates REJECT to FAILED with error message", () => {
200
- const tc = create(ToolCallSchema, {
201
- id: "call-3",
205
+ it("terminalizes a SKIP decision to SKIPPED (fixes the stuck-WAITING for skip too)", () => {
206
+ const status = statusWith([{
207
+ id: "call-skip",
202
208
  status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
203
- });
204
- const toolCalls = new Map<string, ToolCall>([["call-3", tc]]);
205
- const decisions = new Map([
206
- ["call-3", { action: ApprovalAction.REJECT, comment: "too risky" }],
207
- ]);
209
+ approvalAction: ApprovalAction.SKIP,
210
+ }]);
208
211
 
209
- reconcileToolCallStatuses(toolCalls, decisions);
210
- expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
211
- expect(tc.error).toContain("too risky");
212
+ reconcileNonExecutingDecisions(status);
213
+
214
+ expect(status.messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
212
215
  });
213
216
 
214
- it("ignores tool calls not in the map", () => {
215
- const toolCalls = new Map<string, ToolCall>();
216
- const decisions = new Map([
217
- ["call-missing", { action: ApprovalAction.APPROVE, comment: "" }],
217
+ it("leaves APPROVE / APPROVE_ALL untouched (the tool executes)", () => {
218
+ const status = statusWith([
219
+ { id: "call-approve", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL, approvalAction: ApprovalAction.APPROVE },
220
+ { id: "call-approve-all", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL, approvalAction: ApprovalAction.APPROVE_ALL },
218
221
  ]);
219
222
 
220
- expect(() => reconcileToolCallStatuses(toolCalls, decisions)).not.toThrow();
223
+ reconcileNonExecutingDecisions(status);
224
+
225
+ // Still WAITING — real tool events (not this reconciler) terminalize them.
226
+ expect(status.messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
227
+ expect(status.messages[0].toolCalls[1].status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
228
+ });
229
+
230
+ it("is a no-op when there are no decided tool calls", () => {
231
+ const status = statusWith([{
232
+ id: "call-pending",
233
+ status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
234
+ approvalAction: ApprovalAction.UNSPECIFIED,
235
+ }]);
236
+
237
+ expect(() => reconcileNonExecutingDecisions(status)).not.toThrow();
238
+ expect(status.messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
221
239
  });
222
240
  });
@@ -28,9 +28,16 @@ export async function resolveEnvironment(
28
28
  client: StigmerClient,
29
29
  executionId: string,
30
30
  ): Promise<EnvironmentResult> {
31
+ // A desktop runner exchanges its bootstrap credential for a token scoped to
32
+ // this execution's session, so cloud's decrypt gate binds the read (#156).
33
+ // No-op for cloud sandbox and OSS runners.
34
+ const scopedToken = await client.acquireScopedRunnerToken({
35
+ agentExecutionId: executionId,
36
+ });
37
+
31
38
  let execCtx;
32
39
  try {
33
- execCtx = await client.getExecutionContextByExecutionId(executionId);
40
+ execCtx = await client.getExecutionContextByExecutionId(executionId, scopedToken);
34
41
  } catch (err: unknown) {
35
42
  // ConnectError uses numeric Code.NotFound (5); match the pattern
36
43
  // from execute-cursor/env-resolver.ts.
@@ -14,18 +14,20 @@
14
14
  */
15
15
 
16
16
  import { Command } from "@langchain/langgraph";
17
- import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
17
+ import type { AgentExecution, AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
18
18
  import {
19
19
  ApprovalAction,
20
20
  ToolCallStatus,
21
21
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
22
- import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
22
+ import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
23
23
 
24
24
  // APPROVE_ALL resumes the interrupted tool exactly like APPROVE. Its
25
25
  // "auto-approve the rest of the run" effect is realized in setup.ts (the
26
26
  // approval gate is disabled for the whole execution once any APPROVE_ALL
27
27
  // decision exists), not here — this map only resolves the currently
28
- // interrupted tool calls.
28
+ // interrupted tool calls. REJECT resumes the gate too (the gate returns a
29
+ // denial ToolMessage that the model reads); it denies a single tool, it does
30
+ // NOT fail the run — see reconcileNonExecutingDecisions for the terminal status.
29
31
  const ACTION_MAP: ReadonlyMap<ApprovalAction, string> = new Map([
30
32
  [ApprovalAction.APPROVE, "approve"],
31
33
  [ApprovalAction.APPROVE_ALL, "approve"],
@@ -36,8 +38,6 @@ const ACTION_MAP: ReadonlyMap<ApprovalAction, string> = new Map([
36
38
  export interface ResumeResult {
37
39
  readonly graphInput: Command | Record<string, unknown>;
38
40
  readonly isResumeFromApproval: boolean;
39
- readonly hasRejection: boolean;
40
- readonly rejectionReason: string;
41
41
  }
42
42
 
43
43
  export interface GraphStateSnapshot {
@@ -87,8 +87,6 @@ export function resolveResumeInput(
87
87
  return {
88
88
  graphInput: { messages: [{ role: "user", content: userMessage }] },
89
89
  isResumeFromApproval: false,
90
- hasRejection: false,
91
- rejectionReason: "",
92
90
  };
93
91
  }
94
92
 
@@ -97,14 +95,10 @@ export function resolveResumeInput(
97
95
  return {
98
96
  graphInput: { messages: [{ role: "user", content: userMessage }] },
99
97
  isResumeFromApproval: false,
100
- hasRejection: false,
101
- rejectionReason: "",
102
98
  };
103
99
  }
104
100
 
105
101
  const resumeDict: Record<string, { action: string; comment?: string }> = {};
106
- let hasRejection = false;
107
- let rejectionReason = "";
108
102
 
109
103
  for (const intr of pendingInterrupts) {
110
104
  const toolCallId = intr.toolCallId;
@@ -118,32 +112,22 @@ export function resolveResumeInput(
118
112
  action: actionStr,
119
113
  ...(decision.comment ? { comment: decision.comment } : {}),
120
114
  };
121
-
122
- if (decision.action === ApprovalAction.REJECT) {
123
- hasRejection = true;
124
- rejectionReason = decision.comment || "Rejected by user";
125
- }
126
115
  }
127
116
 
128
117
  if (Object.keys(resumeDict).length === 0) {
129
118
  return {
130
119
  graphInput: { messages: [{ role: "user", content: userMessage }] },
131
120
  isResumeFromApproval: false,
132
- hasRejection: false,
133
- rejectionReason: "",
134
121
  };
135
122
  }
136
123
 
137
124
  console.log(
138
- `[hitl] Building resume for ${Object.keys(resumeDict).length} interrupt(s), ` +
139
- `rejection=${hasRejection}`,
125
+ `[hitl] Building resume for ${Object.keys(resumeDict).length} interrupt(s)`,
140
126
  );
141
127
 
142
128
  return {
143
129
  graphInput: new Command({ resume: resumeDict }),
144
130
  isResumeFromApproval: true,
145
- hasRejection,
146
- rejectionReason,
147
131
  };
148
132
  }
149
133
 
@@ -207,31 +191,45 @@ function extractApprovalDecisions(
207
191
  }
208
192
 
209
193
  /**
210
- * Reconcile tool call statuses after resume decisions are applied.
194
+ * Terminalize tool calls whose approval decision is non-executing — SKIP or
195
+ * REJECT — so a denied or skipped call is never left stuck at WAITING_APPROVAL.
196
+ *
197
+ * This is the single, authoritative, checkpointer-independent reconciliation of
198
+ * the two decisions that never run the tool: their outcome is fully determined
199
+ * by the recorded decision (ToolCall.approval_action), not by any graph event.
200
+ * APPROVE / APPROVE_ALL are intentionally NOT handled here — the tool actually
201
+ * executes, and real tool events (v3 tool_started → tool_finished) terminalize
202
+ * it in place.
203
+ *
204
+ * Why a decision-derived reconciler rather than the resumed stream: on the
205
+ * durable path (sqlite local / http cloud) the gate returns a denial/skip
206
+ * ToolMessage WITHOUT an on_tool_start/on_tool_end pair, and on the memory path
207
+ * the graph replays without ever re-driving the gate — so in both cases the
208
+ * seeded WAITING_APPROVAL row is never flipped by the stream and would persist
209
+ * on a COMPLETED execution. Folding the recorded decision into a terminal status
210
+ * makes every checkpointer backend behave identically by construction.
211
211
  *
212
- * Updates the StatusBuilder's tool call entries to reflect the approval
213
- * outcomes: APPROVE RUNNING, SKIP SKIPPED, REJECT FAILED.
212
+ * REJECT and SKIP share TOOL_CALL_SKIPPED as the terminal status (the tool did
213
+ * not run); they stay distinguishable by ToolCall.approval_action and by the
214
+ * append-only approval-event stream (REJECTED vs SKIPPED). Idempotent: a row
215
+ * already resolved carries the same decision and re-resolves identically.
214
216
  */
215
- export function reconcileToolCallStatuses(
216
- toolCalls: ReadonlyMap<string, ToolCall>,
217
- decisions: ReadonlyMap<string, ApprovalDecisionEntry>,
218
- ): void {
219
- for (const [toolCallId, decision] of decisions) {
220
- const tc = toolCalls.get(toolCallId);
221
- if (!tc) continue;
222
-
223
- switch (decision.action) {
224
- case ApprovalAction.APPROVE:
225
- case ApprovalAction.APPROVE_ALL:
226
- tc.status = ToolCallStatus.TOOL_CALL_RUNNING;
227
- break;
228
- case ApprovalAction.SKIP:
229
- tc.status = ToolCallStatus.TOOL_CALL_SKIPPED;
230
- break;
231
- case ApprovalAction.REJECT:
232
- tc.status = ToolCallStatus.TOOL_CALL_FAILED;
233
- tc.error = `Rejected by user: ${decision.comment || "no reason given"}`;
234
- break;
217
+ export function reconcileNonExecutingDecisions(status: AgentExecutionStatus): void {
218
+ const apply = (messages: readonly AgentMessage[]): void => {
219
+ for (const msg of messages) {
220
+ for (const tc of msg.toolCalls) {
221
+ if (tc.approvalAction === ApprovalAction.SKIP) {
222
+ tc.status = ToolCallStatus.TOOL_CALL_SKIPPED;
223
+ } else if (tc.approvalAction === ApprovalAction.REJECT) {
224
+ tc.status = ToolCallStatus.TOOL_CALL_SKIPPED;
225
+ if (!tc.error) tc.error = "Rejected by user";
226
+ }
227
+ }
235
228
  }
229
+ };
230
+
231
+ apply(status.messages);
232
+ for (const subAgent of status.subAgentExecutions) {
233
+ apply(subAgent.messages);
236
234
  }
237
235
  }