@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.
- package/README.md +1 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/discover-mcp-server.js +24 -0
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -2
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +58 -10
- package/dist/activities/execute-cursor/approval-state.js +37 -8
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/env-resolver.js +7 -1
- package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.d.ts +16 -3
- package/dist/activities/execute-cursor/hook-script.js +66 -21
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +59 -0
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +52 -0
- package/dist/activities/execute-cursor/message-translator.js +107 -1
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.d.ts +25 -3
- package/dist/activities/execute-cursor/turn-boundary.js +46 -8
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
- package/dist/activities/execute-cursor/turn-stream.js +6 -2
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +16 -2
- package/dist/activities/execute-cursor/workspace-setup.js +22 -12
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/environment.js +7 -1
- package/dist/activities/execute-deep-agent/environment.js.map +1 -1
- package/dist/activities/execute-deep-agent/hitl.d.ts +23 -14
- package/dist/activities/execute-deep-agent/hitl.js +42 -37
- package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +35 -26
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +7 -0
- package/dist/activities/execute-deep-agent/setup.js +9 -2
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/hydrate-workflow-execution.js +8 -1
- package/dist/activities/hydrate-workflow-execution.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +69 -1
- package/dist/client/stigmer-client.js +108 -4
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/client/token-claims.d.ts +22 -0
- package/dist/client/token-claims.js +40 -0
- package/dist/client/token-claims.js.map +1 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +5 -1
- package/dist/config.js.map +1 -1
- package/dist/middleware/approval-gate.js +9 -2
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/runner-manager.d.ts +4 -2
- package/dist/runner-manager.js +20 -7
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -2
- package/dist/runner.js +1 -1
- package/dist/shared/checkpointer/factory.d.ts +11 -7
- package/dist/shared/checkpointer/factory.js +21 -8
- package/dist/shared/checkpointer/factory.js.map +1 -1
- package/dist/shared/checkpointer/sqlite-saver.d.ts +54 -0
- package/dist/shared/checkpointer/sqlite-saver.js +272 -0
- package/dist/shared/checkpointer/sqlite-saver.js.map +1 -0
- package/dist/shared/checkpointer/types.d.ts +8 -4
- package/dist/shared/mcp-oauth-detect.d.ts +53 -0
- package/dist/shared/mcp-oauth-detect.js +99 -0
- package/dist/shared/mcp-oauth-detect.js.map +1 -0
- package/dist/shared/tool-row.js +14 -1
- package/dist/shared/tool-row.js.map +1 -1
- package/dist/shared/workspace/platform-dir.d.ts +18 -0
- package/dist/shared/workspace/platform-dir.js +23 -0
- package/dist/shared/workspace/platform-dir.js.map +1 -1
- package/package.json +3 -3
- package/src/__test-utils__/mock-client.ts +3 -0
- package/src/activities/__tests__/hydrate-workflow-execution.test.ts +2 -0
- package/src/activities/discover-mcp-server.ts +26 -0
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +5 -3
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +272 -14
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +72 -16
- package/src/activities/execute-cursor/__tests__/message-translator.test.ts +65 -0
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +128 -1
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +95 -0
- package/src/activities/execute-cursor/approval-state.ts +75 -11
- package/src/activities/execute-cursor/env-resolver.ts +8 -1
- package/src/activities/execute-cursor/hook-script.ts +66 -21
- package/src/activities/execute-cursor/index.ts +61 -0
- package/src/activities/execute-cursor/message-translator.ts +124 -1
- package/src/activities/execute-cursor/turn-boundary.ts +79 -6
- package/src/activities/execute-cursor/turn-stream.ts +6 -2
- package/src/activities/execute-cursor/workspace-setup.ts +42 -14
- package/src/activities/execute-deep-agent/__tests__/environment.test.ts +5 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +314 -0
- package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +64 -46
- package/src/activities/execute-deep-agent/environment.ts +8 -1
- package/src/activities/execute-deep-agent/hitl.ts +43 -45
- package/src/activities/execute-deep-agent/index.ts +36 -27
- package/src/activities/execute-deep-agent/setup.ts +15 -2
- package/src/activities/hydrate-workflow-execution.ts +9 -1
- package/src/client/__tests__/stigmer-client.test.ts +239 -2
- package/src/client/__tests__/token-claims.test.ts +45 -0
- package/src/client/stigmer-client.ts +152 -3
- package/src/client/token-claims.ts +42 -0
- package/src/config.ts +16 -3
- package/src/middleware/__tests__/approval-gate.test.ts +8 -1
- package/src/middleware/approval-gate.ts +9 -2
- package/src/runner-manager.ts +22 -8
- package/src/runner.ts +3 -3
- package/src/shared/__tests__/mcp-oauth-detect.test.ts +147 -0
- package/src/shared/__tests__/tool-row.test.ts +35 -0
- package/src/shared/checkpointer/__tests__/factory.test.ts +27 -1
- package/src/shared/checkpointer/__tests__/sqlite-saver.test.ts +212 -0
- package/src/shared/checkpointer/factory.ts +25 -8
- package/src/shared/checkpointer/sqlite-saver.ts +384 -0
- package/src/shared/checkpointer/types.ts +8 -4
- package/src/shared/mcp-oauth-detect.ts +112 -0
- package/src/shared/tool-row.ts +14 -1
- 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,
|
|
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("
|
|
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.
|
|
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("
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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("
|
|
186
|
-
const
|
|
187
|
-
id: "call-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
]);
|
|
193
|
+
approvalAction: ApprovalAction.REJECT,
|
|
194
|
+
}]);
|
|
195
|
+
|
|
196
|
+
reconcileNonExecutingDecisions(status);
|
|
194
197
|
|
|
195
|
-
|
|
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("
|
|
200
|
-
const
|
|
201
|
-
id: "call-
|
|
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
|
-
|
|
205
|
-
const decisions = new Map([
|
|
206
|
-
["call-3", { action: ApprovalAction.REJECT, comment: "too risky" }],
|
|
207
|
-
]);
|
|
209
|
+
approvalAction: ApprovalAction.SKIP,
|
|
210
|
+
}]);
|
|
208
211
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
expect(
|
|
212
|
+
reconcileNonExecutingDecisions(status);
|
|
213
|
+
|
|
214
|
+
expect(status.messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
212
215
|
});
|
|
213
216
|
|
|
214
|
-
it("
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
213
|
-
*
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
}
|