@stigmer/runner 3.1.7 → 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 +1 -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/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 +1 -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__/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/tool-row.ts +14 -1
- package/src/shared/workspace/platform-dir.ts +25 -0
|
@@ -103,6 +103,19 @@ export interface HitlGateHandle {
|
|
|
103
103
|
* gate is inert between turns.
|
|
104
104
|
*/
|
|
105
105
|
gateDir: string;
|
|
106
|
+
/**
|
|
107
|
+
* Commands of FOREIGN (non-Stigmer) hooks registered on the gating events
|
|
108
|
+
* (preToolUse/beforeMCPExecution) in the workspace's pre-existing hooks.json.
|
|
109
|
+
* The merge deliberately PRESERVES them (they are the user's own config), but
|
|
110
|
+
* because Cursor runs every configured hook and a deny from ANY of them blocks
|
|
111
|
+
* the tool, a foreign hook can deny the runner's tools without writing our
|
|
112
|
+
* denial ledger — the issue #205 silent-failure trigger. Surfaced here so the
|
|
113
|
+
* runner can log the exposure at install and name the likely culprit when the
|
|
114
|
+
* turn boundary detects an unattributed hook block. Empty when the workspace
|
|
115
|
+
* had no hooks.json, only Stigmer entries, or an unparseable file (which is
|
|
116
|
+
* REPLACED for the turn, so its hooks cannot run against us).
|
|
117
|
+
*/
|
|
118
|
+
foreignGatingHooks: readonly string[];
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
/** Absolute path + restore target for a single workspace file the gate manages. */
|
|
@@ -334,12 +347,12 @@ async function installWorkspaceHook(
|
|
|
334
347
|
originalRaw = null;
|
|
335
348
|
}
|
|
336
349
|
|
|
337
|
-
const { merged, restoreTo } = buildMergedConfig(originalRaw, registrations);
|
|
350
|
+
const { merged, restoreTo, foreignGatingHooks } = buildMergedConfig(originalRaw, registrations);
|
|
338
351
|
|
|
339
352
|
await mkdir(cursorDir, { recursive: true });
|
|
340
353
|
await writeFile(hooksJsonPath, merged, "utf-8");
|
|
341
354
|
|
|
342
|
-
return { hooksJsonPath, restoreTo };
|
|
355
|
+
return { hooksJsonPath, restoreTo, foreignGatingHooks };
|
|
343
356
|
}
|
|
344
357
|
|
|
345
358
|
/**
|
|
@@ -406,21 +419,35 @@ const STANDALONE_CONFIG = (registrations: HookRegistration[]): string =>
|
|
|
406
419
|
/**
|
|
407
420
|
* Merge our registrations into a hooks object: for each event, drop any stale
|
|
408
421
|
* Stigmer entry, then append our fresh one. Returns the merged hooks object, the
|
|
409
|
-
* cleaned (Stigmer-free) hooks for restore,
|
|
422
|
+
* cleaned (Stigmer-free) hooks for restore, whether anything was stripped, and
|
|
423
|
+
* the FOREIGN commands preserved on the gating events (see
|
|
424
|
+
* {@link HitlGateHandle.foreignGatingHooks} for why they matter).
|
|
410
425
|
*/
|
|
411
426
|
function mergeHooks(
|
|
412
427
|
existingHooks: Record<string, unknown>,
|
|
413
428
|
registrations: HookRegistration[],
|
|
414
|
-
): {
|
|
429
|
+
): {
|
|
430
|
+
hooks: Record<string, unknown>;
|
|
431
|
+
cleaned: Record<string, unknown>;
|
|
432
|
+
strippedStale: boolean;
|
|
433
|
+
foreignGatingHooks: string[];
|
|
434
|
+
} {
|
|
415
435
|
const hooks: Record<string, unknown> = { ...existingHooks };
|
|
416
436
|
const cleaned: Record<string, unknown> = { ...existingHooks };
|
|
417
437
|
let strippedStale = false;
|
|
438
|
+
const foreignGatingHooks: string[] = [];
|
|
418
439
|
|
|
419
440
|
for (const { event, scriptPath } of registrations) {
|
|
420
441
|
const hadEvent = Array.isArray(existingHooks[event]);
|
|
421
442
|
const existing = hadEvent ? (existingHooks[event] as unknown[]) : [];
|
|
422
443
|
const userEntries = existing.filter((e) => !isStigmerHookEntry(e));
|
|
423
444
|
if (userEntries.length !== existing.length) strippedStale = true;
|
|
445
|
+
// The user entries surviving on a GATING event are exactly the hooks that
|
|
446
|
+
// can deny the runner's tools without touching our denial ledger.
|
|
447
|
+
for (const entry of userEntries) {
|
|
448
|
+
const command = (entry as { command?: unknown } | null)?.command;
|
|
449
|
+
if (typeof command === "string" && command) foreignGatingHooks.push(command);
|
|
450
|
+
}
|
|
424
451
|
|
|
425
452
|
hooks[event] = [...userEntries, buildHookEntry(scriptPath)];
|
|
426
453
|
// Restore target keeps the event key only if the user originally had it, so
|
|
@@ -428,12 +455,12 @@ function mergeHooks(
|
|
|
428
455
|
if (hadEvent) cleaned[event] = userEntries;
|
|
429
456
|
}
|
|
430
457
|
|
|
431
|
-
return { hooks, cleaned, strippedStale };
|
|
458
|
+
return { hooks, cleaned, strippedStale, foreignGatingHooks };
|
|
432
459
|
}
|
|
433
460
|
|
|
434
461
|
/**
|
|
435
|
-
* Compute the merged hooks.json to write for this turn
|
|
436
|
-
*
|
|
462
|
+
* Compute the merged hooks.json to write for this turn, the content to restore
|
|
463
|
+
* afterward, and the foreign gating hooks the merge preserved.
|
|
437
464
|
*
|
|
438
465
|
* - No existing file → write our standalone config; restore by deleting (null).
|
|
439
466
|
* - Existing, parseable file → append our entry to each registered event array,
|
|
@@ -448,19 +475,19 @@ function mergeHooks(
|
|
|
448
475
|
export function buildMergedConfig(
|
|
449
476
|
originalRaw: string | null,
|
|
450
477
|
registrations: HookRegistration[],
|
|
451
|
-
): { merged: string; restoreTo: string | null } {
|
|
478
|
+
): { merged: string; restoreTo: string | null; foreignGatingHooks: string[] } {
|
|
452
479
|
if (originalRaw === null) {
|
|
453
|
-
return { merged: STANDALONE_CONFIG(registrations), restoreTo: null };
|
|
480
|
+
return { merged: STANDALONE_CONFIG(registrations), restoreTo: null, foreignGatingHooks: [] };
|
|
454
481
|
}
|
|
455
482
|
|
|
456
483
|
let parsed: unknown;
|
|
457
484
|
try {
|
|
458
485
|
parsed = JSON.parse(originalRaw);
|
|
459
486
|
} catch {
|
|
460
|
-
return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw };
|
|
487
|
+
return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw, foreignGatingHooks: [] };
|
|
461
488
|
}
|
|
462
489
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
463
|
-
return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw };
|
|
490
|
+
return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw, foreignGatingHooks: [] };
|
|
464
491
|
}
|
|
465
492
|
|
|
466
493
|
const root = parsed as Record<string, unknown>;
|
|
@@ -470,13 +497,14 @@ export function buildMergedConfig(
|
|
|
470
497
|
: {};
|
|
471
498
|
const version = typeof root.version === "number" ? root.version : 1;
|
|
472
499
|
|
|
473
|
-
const { hooks: mergedHooks, cleaned, strippedStale } =
|
|
500
|
+
const { hooks: mergedHooks, cleaned, strippedStale, foreignGatingHooks } =
|
|
501
|
+
mergeHooks(hooks, registrations);
|
|
474
502
|
|
|
475
503
|
const merged = JSON.stringify({ ...root, version, hooks: mergedHooks }, null, 2);
|
|
476
504
|
|
|
477
505
|
// No stale Stigmer entry → restore the user's exact original bytes.
|
|
478
506
|
if (!strippedStale) {
|
|
479
|
-
return { merged, restoreTo: originalRaw };
|
|
507
|
+
return { merged, restoreTo: originalRaw, foreignGatingHooks };
|
|
480
508
|
}
|
|
481
509
|
|
|
482
510
|
// We stripped a stale Stigmer entry, so never restore the original bytes (that
|
|
@@ -495,5 +523,5 @@ export function buildMergedConfig(
|
|
|
495
523
|
? null
|
|
496
524
|
: JSON.stringify({ ...root, version, hooks: cleaned }, null, 2);
|
|
497
525
|
|
|
498
|
-
return { merged, restoreTo };
|
|
526
|
+
return { merged, restoreTo, foreignGatingHooks };
|
|
499
527
|
}
|
|
@@ -3,12 +3,15 @@ import { resolveEnvironment } from "../environment.js";
|
|
|
3
3
|
|
|
4
4
|
function createMockClient(execContext: any) {
|
|
5
5
|
return {
|
|
6
|
+
// No scoped token — the OSS/local shape (no runner credential to exchange).
|
|
7
|
+
acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
|
|
6
8
|
getExecutionContextByExecutionId: vi.fn().mockResolvedValue(execContext),
|
|
7
9
|
} as any;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
function createNotFoundClient() {
|
|
11
13
|
return {
|
|
14
|
+
acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
|
|
12
15
|
getExecutionContextByExecutionId: vi.fn().mockRejectedValue(
|
|
13
16
|
Object.assign(new Error("not found"), { code: 5 }),
|
|
14
17
|
),
|
|
@@ -81,6 +84,7 @@ describe("resolveEnvironment", () => {
|
|
|
81
84
|
|
|
82
85
|
it("propagates non-NOT_FOUND errors", async () => {
|
|
83
86
|
const client = {
|
|
87
|
+
acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
|
|
84
88
|
getExecutionContextByExecutionId: vi.fn().mockRejectedValue(
|
|
85
89
|
new Error("connection refused"),
|
|
86
90
|
),
|
|
@@ -92,6 +96,7 @@ describe("resolveEnvironment", () => {
|
|
|
92
96
|
|
|
93
97
|
it("handles NOT_FOUND with lowercase code", async () => {
|
|
94
98
|
const client = {
|
|
99
|
+
acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
|
|
95
100
|
getExecutionContextByExecutionId: vi.fn().mockRejectedValue(
|
|
96
101
|
Object.assign(new Error("not found"), { code: "not_found" }),
|
|
97
102
|
),
|
|
@@ -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.
|