@stigmer/runner 3.2.1 → 3.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/call-transform.js +12 -2
- package/dist/activities/call-transform.js.map +1 -1
- package/dist/activities/call-validate.js +20 -2
- package/dist/activities/call-validate.js.map +1 -1
- package/dist/activities/discover-mcp-server.js +6 -20
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +27 -2
- package/dist/activities/execute-cursor/approval-state.js +13 -1
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +38 -0
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +12 -0
- package/dist/activities/execute-cursor/index.js +72 -15
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +26 -0
- package/dist/activities/execute-cursor/message-translator.js +78 -2
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +19 -0
- package/dist/activities/execute-cursor/prompt-builder.js +21 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +45 -0
- package/dist/activities/execute-cursor/session-lifecycle.js +41 -0
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.js +15 -2
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
- package/dist/activities/execute-deep-agent/hitl.d.ts +25 -0
- package/dist/activities/execute-deep-agent/hitl.js +48 -1
- package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +10 -1
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +19 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +12 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +14 -0
- package/dist/activities/execute-deep-agent/setup.js +16 -1
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +8 -0
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +6 -0
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -0
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/activities/workflow-event-activities.js +11 -0
- package/dist/activities/workflow-event-activities.js.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.js +14 -0
- package/dist/config.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +20 -0
- package/dist/middleware/approval-gate.js +16 -1
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/runner-manager.d.ts +2 -0
- package/dist/runner-manager.js +2 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +2 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/approval-policy.d.ts +24 -1
- package/dist/shared/approval-policy.js +35 -1
- package/dist/shared/approval-policy.js.map +1 -1
- package/dist/shared/context-bridge.d.ts +30 -0
- package/dist/shared/context-bridge.js +45 -0
- package/dist/shared/context-bridge.js.map +1 -0
- package/dist/shared/sender-identity.d.ts +50 -0
- package/dist/shared/sender-identity.js +69 -0
- package/dist/shared/sender-identity.js.map +1 -0
- package/dist/shared/with-timeout.d.ts +27 -0
- package/dist/shared/with-timeout.js +47 -0
- package/dist/shared/with-timeout.js.map +1 -0
- package/dist/workflow-engine/do-executor.d.ts +7 -0
- package/dist/workflow-engine/do-executor.js +59 -1
- package/dist/workflow-engine/do-executor.js.map +1 -1
- package/dist/workflow-engine/tasks/call-function.js +68 -1
- package/dist/workflow-engine/tasks/call-function.js.map +1 -1
- package/dist/workflow-engine/tasks/human-input.js +1 -0
- package/dist/workflow-engine/tasks/human-input.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +48 -0
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/engine-core.js +5 -1
- package/dist/workflows/engine-core.js.map +1 -1
- package/dist/workflows/human-input-orchestrator.d.ts +2 -1
- package/dist/workflows/human-input-orchestrator.js +2 -1
- package/dist/workflows/human-input-orchestrator.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/config.test.ts +8 -0
- package/src/activities/__tests__/call-validate.test.ts +137 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/__tests__/workflow-event-activities.test.ts +89 -0
- package/src/activities/call-transform.ts +20 -2
- package/src/activities/call-validate.ts +27 -2
- package/src/activities/discover-mcp-server.ts +7 -28
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +6 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +65 -0
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +105 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +54 -0
- package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +122 -1
- package/src/activities/execute-cursor/approval-state.ts +32 -1
- package/src/activities/execute-cursor/hook-script.ts +40 -0
- package/src/activities/execute-cursor/index.ts +86 -17
- package/src/activities/execute-cursor/message-translator.ts +82 -1
- package/src/activities/execute-cursor/prompt-builder.ts +44 -0
- package/src/activities/execute-cursor/session-lifecycle.ts +76 -0
- package/src/activities/execute-cursor/turn-boundary.ts +23 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +103 -2
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +58 -0
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
- package/src/activities/execute-deep-agent/hitl.ts +51 -0
- package/src/activities/execute-deep-agent/index.ts +11 -1
- package/src/activities/execute-deep-agent/prompt-builder.ts +35 -0
- package/src/activities/execute-deep-agent/setup.ts +31 -0
- package/src/activities/execute-deep-agent/status-builder-shared.ts +8 -0
- package/src/activities/execute-deep-agent/status-builder.ts +7 -0
- package/src/activities/execute-deep-agent/v3-status-builder.ts +7 -0
- package/src/activities/workflow-event-activities.ts +11 -0
- package/src/config.ts +23 -0
- package/src/middleware/__tests__/approval-gate.test.ts +95 -0
- package/src/middleware/approval-gate.ts +37 -0
- package/src/runner-manager.ts +6 -1
- package/src/runner.ts +6 -1
- package/src/shared/__tests__/artifact-storage.test.ts +1 -0
- package/src/shared/__tests__/context-bridge.test.ts +51 -0
- package/src/shared/__tests__/sender-identity.test.ts +92 -0
- package/src/shared/__tests__/with-timeout.test.ts +60 -0
- package/src/shared/approval-policy.ts +41 -2
- package/src/shared/context-bridge.ts +51 -0
- package/src/shared/sender-identity.ts +85 -0
- package/src/shared/with-timeout.ts +53 -0
- package/src/workflow-engine/__tests__/do-executor.test.ts +155 -0
- package/src/workflow-engine/__tests__/tasks/call-function.test.ts +94 -0
- package/src/workflow-engine/__tests__/tasks/human-input.test.ts +40 -0
- package/src/workflow-engine/do-executor.ts +65 -1
- package/src/workflow-engine/tasks/call-function.ts +84 -3
- package/src/workflow-engine/tasks/human-input.ts +1 -0
- package/src/workflow-engine/types.ts +50 -0
- package/src/workflows/__tests__/execute-serverless-workflow.test.ts +1 -0
- package/src/workflows/engine-core.ts +5 -1
- package/src/workflows/human-input-orchestrator.ts +2 -1
|
@@ -39,12 +39,14 @@ import type { Run, ConversationTurn } from "@cursor/sdk";
|
|
|
39
39
|
|
|
40
40
|
import type { Config } from "../../config.js";
|
|
41
41
|
import { StigmerClient } from "../../client/stigmer-client.js";
|
|
42
|
-
import {
|
|
42
|
+
import { resolveAgentWithTransportRecovery } from "./session-lifecycle.js";
|
|
43
43
|
import type { AgentResolution, CreateAgentOptions, CreateCloudAgentOptions } from "./session-lifecycle.js";
|
|
44
44
|
import { CursorMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
|
|
45
45
|
import { determineCursorMode, isCloudMode } from "./cursor-mode.js";
|
|
46
46
|
import { MessageAccumulator, cancelInProgressSubAgentProtos, collapseRedundantToolCallTwins } from "./message-translator.js";
|
|
47
47
|
import { utcTimestamp, persistStatus, reportSetupProgress, slimStatus } from "../../shared/status.js";
|
|
48
|
+
import { readContextBridge } from "../../shared/context-bridge.js";
|
|
49
|
+
import { readSenderIdentity } from "../../shared/sender-identity.js";
|
|
48
50
|
import { withholdSecretContentFromMessages } from "../../shared/tool-row.js";
|
|
49
51
|
import { StallTimeoutError, formatStallFailure } from "../../shared/stall-watchdog.js";
|
|
50
52
|
import { resolveUsableArtifactStorage, loadArtifactStorageConfig, type ArtifactStorage } from "../../shared/artifact-storage.js";
|
|
@@ -55,7 +57,7 @@ import { StreamingUpdateScheduler, loadStreamingConfig } from "../../shared/stre
|
|
|
55
57
|
import { createCursorEventRecorder } from "./cursor-event-recorder.js";
|
|
56
58
|
import { resolveMcpServers, validateMcpServerEnv } from "./mcp-resolver.js";
|
|
57
59
|
import { mergeApprovalPolicies } from "./approval-policy.js";
|
|
58
|
-
import { deriveActiveLeases } from "../../shared/approval-policy.js";
|
|
60
|
+
import { deriveActiveLeases, isUnattendedApprovalMode } from "../../shared/approval-policy.js";
|
|
59
61
|
import { backfillMcpServersIfNeeded } from "./connect-backfill.js";
|
|
60
62
|
import { resolveExecutionEnv } from "./env-resolver.js";
|
|
61
63
|
import { resolveBlueprint } from "./blueprint-resolver.js";
|
|
@@ -257,6 +259,25 @@ async function executeCursorInner(
|
|
|
257
259
|
// can be classified with the same context as the run.wait() error path.
|
|
258
260
|
let errorContext = { model: "default", mode: "local", agentId: "" };
|
|
259
261
|
|
|
262
|
+
// Periodic heartbeat for the ENTIRE activity, started before any phase runs.
|
|
263
|
+
// Setup phases make network calls (blueprint resolution, workspace clone, MCP
|
|
264
|
+
// backfill, Agent.create) that can stall; the scattered manual heartbeat()
|
|
265
|
+
// pulses between them leave every individual call uncovered. The production
|
|
266
|
+
// stale-proxy incident hung inside Agent.create with zero heartbeats and
|
|
267
|
+
// surfaced as an opaque 5-minute Temporal timeout. The label names the
|
|
268
|
+
// current phase so a stall is attributed in Temporal heartbeat details, and
|
|
269
|
+
// cancellation stays observable throughout. Safe ONLY because every SDK call
|
|
270
|
+
// below is itself bounded (agentResolveTimeoutMs, stall watchdog) — an
|
|
271
|
+
// unbounded hang under a live heartbeat would keep a dead activity alive
|
|
272
|
+
// forever.
|
|
273
|
+
let heartbeatPhase = "setup";
|
|
274
|
+
const taskQueue = Context.current().info.taskQueue;
|
|
275
|
+
const shutdownSignal = getShutdownSignalForQueue(taskQueue);
|
|
276
|
+
periodicHeartbeat = startHeartbeat(30_000, () => ({
|
|
277
|
+
phase: heartbeatPhase,
|
|
278
|
+
execution: executionId,
|
|
279
|
+
}), { shutdownSignal });
|
|
280
|
+
|
|
260
281
|
try {
|
|
261
282
|
// Phase 1: Hydrate execution from DB
|
|
262
283
|
await reportSetupProgress(client, executionId, "Fetching execution");
|
|
@@ -270,6 +291,7 @@ async function executeCursorInner(
|
|
|
270
291
|
const blueprint = await resolveBlueprint(client, session, config.workspaceRootDir);
|
|
271
292
|
|
|
272
293
|
// Phase 2b: Resolve execution environment (MCP server credentials)
|
|
294
|
+
heartbeatPhase = "resolving_environment";
|
|
273
295
|
await reportSetupProgress(client, executionId, "Resolving environment");
|
|
274
296
|
const { envVars, secretKeys } = await resolveExecutionEnv(client, executionId);
|
|
275
297
|
heartbeat();
|
|
@@ -280,6 +302,7 @@ async function executeCursorInner(
|
|
|
280
302
|
// disabled the runner must provision the workspace itself, mirroring the
|
|
281
303
|
// native harness. Git provisioning is idempotent across multi-turn and
|
|
282
304
|
// HITL reinvocations.
|
|
305
|
+
heartbeatPhase = "provisioning_workspace";
|
|
283
306
|
await reportSetupProgress(client, executionId, "Provisioning workspace");
|
|
284
307
|
const workspaceProvision = await provisionCursorWorkspace(
|
|
285
308
|
config, session, envVars, sessionId ?? "",
|
|
@@ -554,6 +577,7 @@ async function executeCursorInner(
|
|
|
554
577
|
);
|
|
555
578
|
|
|
556
579
|
// Phase 4a: Connect backfill for undiscovered MCP servers
|
|
580
|
+
heartbeatPhase = "resolving_mcp_servers";
|
|
557
581
|
const sessionOrg = session.metadata?.org ?? "";
|
|
558
582
|
mcpResolution = await backfillMcpServersIfNeeded(
|
|
559
583
|
client, mcpResolution, blueprint.mergedMcpServerUsages, envVars, sessionOrg,
|
|
@@ -697,6 +721,11 @@ async function executeCursorInner(
|
|
|
697
721
|
// absent a git tree keeps gating gitignored writes and a non-git workspace
|
|
698
722
|
// falls back to the deny-gate entirely (no regression).
|
|
699
723
|
const captureIgnored = captureMode && !!artifactStorage;
|
|
724
|
+
// Unattended approval mode (DD-014): approver-less surfaces (channels,
|
|
725
|
+
// guest shares) stamp APPROVAL_MODE_UNATTENDED; the hook then records
|
|
726
|
+
// approval denials with the non-pausing "unattended" kind, so the
|
|
727
|
+
// first-denial stop never fires and the turn boundary settles the denied
|
|
728
|
+
// calls as SKIPPED instead of pausing a turn nobody can approve.
|
|
700
729
|
const approvalState = buildApprovalState(
|
|
701
730
|
mergedPolicies,
|
|
702
731
|
globalBypass,
|
|
@@ -705,6 +734,7 @@ async function executeCursorInner(
|
|
|
705
734
|
captureMode,
|
|
706
735
|
captureIgnored,
|
|
707
736
|
gitWorkspace,
|
|
737
|
+
isUnattendedApprovalMode(execution),
|
|
708
738
|
);
|
|
709
739
|
const hitlGate = await installHitlGate({
|
|
710
740
|
workspaceRoot: primaryWorkspaceDir,
|
|
@@ -815,11 +845,31 @@ async function executeCursorInner(
|
|
|
815
845
|
agents: cursorSubAgents,
|
|
816
846
|
};
|
|
817
847
|
|
|
818
|
-
|
|
819
|
-
|
|
848
|
+
// Agent.create/Agent.resume have no timeout of their own — a degraded
|
|
849
|
+
// transport (dead proxy connection, stale HTTP/2 session) hangs them
|
|
850
|
+
// forever, which the periodic heartbeat would happily keep alive. Each
|
|
851
|
+
// attempt is bounded; on expiry the wrapper resets the proxy transport
|
|
852
|
+
// and retries once, so a stale-session hang recovers without failing the
|
|
853
|
+
// execution. A second expiry propagates a plain Error to the generic
|
|
854
|
+
// catch below, which persists EXECUTION_FAILED (no Temporal retry —
|
|
855
|
+
// the activity returns rather than throws, and maximumAttempts is 1).
|
|
856
|
+
heartbeatPhase = "resolving_agent";
|
|
857
|
+
const resolveTimeoutSeconds = Math.round(config.agentResolveTimeoutMs / 1000);
|
|
858
|
+
let resolution: AgentResolution = await resolveAgentWithTransportRecovery({
|
|
859
|
+
harnessStateId: threadId,
|
|
820
860
|
createOptions,
|
|
821
|
-
agentMode,
|
|
822
|
-
|
|
861
|
+
mode: agentMode,
|
|
862
|
+
timeoutMs: config.agentResolveTimeoutMs,
|
|
863
|
+
buildTimeoutMessage: (finalAttempt) =>
|
|
864
|
+
`Cursor agent ${threadId ? "resume" : "create"} timed out after ${resolveTimeoutSeconds}s ` +
|
|
865
|
+
`(${config.proxyEndpoint ? `via proxy ${config.proxyEndpoint}` : "direct Cursor API connection"}). ` +
|
|
866
|
+
`The transport connection is likely dead. ` +
|
|
867
|
+
(finalAttempt
|
|
868
|
+
? `An automatic retry on a fresh transport connection also timed out. ` +
|
|
869
|
+
`Retry the message later; if this persists, check proxy and network health.`
|
|
870
|
+
: `Resetting the transport and retrying automatically.`),
|
|
871
|
+
resetTransport: closeProxySessions,
|
|
872
|
+
});
|
|
823
873
|
|
|
824
874
|
console.log(
|
|
825
875
|
`ExecuteCursor agent resolved: execution=${executionId}, ` +
|
|
@@ -878,6 +928,8 @@ async function executeCursorInner(
|
|
|
878
928
|
appliedToolCallIds,
|
|
879
929
|
interactionMode,
|
|
880
930
|
buildFromPlan,
|
|
931
|
+
contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
|
|
932
|
+
senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
|
|
881
933
|
});
|
|
882
934
|
|
|
883
935
|
// Phase 10a: Inject structured output instruction for Cursor harness
|
|
@@ -962,16 +1014,10 @@ async function executeCursorInner(
|
|
|
962
1014
|
maxCostUsd,
|
|
963
1015
|
};
|
|
964
1016
|
|
|
965
|
-
//
|
|
966
|
-
// (
|
|
967
|
-
//
|
|
968
|
-
|
|
969
|
-
const taskQueue = Context.current().info.taskQueue;
|
|
970
|
-
const shutdownSignal = getShutdownSignalForQueue(taskQueue);
|
|
971
|
-
periodicHeartbeat = startHeartbeat(30_000, () => ({
|
|
972
|
-
phase: "cursor_streaming",
|
|
973
|
-
execution: executionId,
|
|
974
|
-
}), { shutdownSignal });
|
|
1017
|
+
// The activity-wide periodic heartbeat (started at entry) keeps Temporal
|
|
1018
|
+
// informed during silent SDK operations (long tool calls, MCP requests,
|
|
1019
|
+
// model thinking); relabel it for the streaming phase.
|
|
1020
|
+
heartbeatPhase = "cursor_streaming";
|
|
975
1021
|
|
|
976
1022
|
// The Cursor SDK registers abort listeners on the cancellation signal for
|
|
977
1023
|
// each concurrent tool call (fetch, MCP, shell). With 10+ parallel tools,
|
|
@@ -1460,6 +1506,8 @@ async function executeCursorInner(
|
|
|
1460
1506
|
attachmentPaths,
|
|
1461
1507
|
pendingApprovals: adjudicatedApprovals,
|
|
1462
1508
|
interactionMode,
|
|
1509
|
+
contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
|
|
1510
|
+
senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
|
|
1463
1511
|
});
|
|
1464
1512
|
|
|
1465
1513
|
console.log(
|
|
@@ -1866,6 +1914,12 @@ async function executeCursorInner(
|
|
|
1866
1914
|
|
|
1867
1915
|
return slimStatus(status);
|
|
1868
1916
|
} finally {
|
|
1917
|
+
// Stop the activity-wide periodic heartbeat on EVERY exit path
|
|
1918
|
+
// (idempotent). The epilogue and catch stop it at the pause/shutdown
|
|
1919
|
+
// disambiguation points; this covers early returns (e.g. the
|
|
1920
|
+
// pure-reconcile resume) so no orphaned timer survives the activity.
|
|
1921
|
+
periodicHeartbeat?.stop();
|
|
1922
|
+
|
|
1869
1923
|
// End the OTel turn span + record metrics with the final token snapshot on
|
|
1870
1924
|
// EVERY exit path (idempotent). Placed here so the span covers any recovery
|
|
1871
1925
|
// retry (whose tokens accrue after the primary stream) and never leaks on an
|
|
@@ -2019,6 +2073,18 @@ export interface BuildPromptInput {
|
|
|
2019
2073
|
* .build_from_plan): both prompt paths carry the implement-plan directive.
|
|
2020
2074
|
*/
|
|
2021
2075
|
buildFromPlan?: boolean;
|
|
2076
|
+
/**
|
|
2077
|
+
* Rollover context bridge from `SessionSpec.metadata` (cloud DD-013).
|
|
2078
|
+
* Only the enhanced-prompt path consumes it — a resumed agent's native
|
|
2079
|
+
* context IS the previous conversation, so it needs no bridge.
|
|
2080
|
+
*/
|
|
2081
|
+
contextBridge?: string;
|
|
2082
|
+
/**
|
|
2083
|
+
* Channel sender identity from `SessionSpec.metadata`. Like the bridge,
|
|
2084
|
+
* only the enhanced-prompt path consumes it — a resumed agent's native
|
|
2085
|
+
* context already carries it from the session's first turn.
|
|
2086
|
+
*/
|
|
2087
|
+
senderIdentity?: import("../../shared/sender-identity.js").SenderIdentity;
|
|
2022
2088
|
}
|
|
2023
2089
|
|
|
2024
2090
|
/**
|
|
@@ -2083,7 +2149,8 @@ export function buildPrompt(input: BuildPromptInput): string {
|
|
|
2083
2149
|
}
|
|
2084
2150
|
|
|
2085
2151
|
// First execution, or a fresh agent created after a resume failure: there is
|
|
2086
|
-
// no prior conversation to inherit, so start a new turn with full context
|
|
2152
|
+
// no prior conversation to inherit, so start a new turn with full context —
|
|
2153
|
+
// including the rollover bridge, when the session carries one.
|
|
2087
2154
|
return buildEnhancedPrompt({
|
|
2088
2155
|
instructions,
|
|
2089
2156
|
userMessage,
|
|
@@ -2094,6 +2161,8 @@ export function buildPrompt(input: BuildPromptInput): string {
|
|
|
2094
2161
|
attachmentPaths,
|
|
2095
2162
|
interactionMode,
|
|
2096
2163
|
buildFromPlan,
|
|
2164
|
+
contextBridge: input.contextBridge,
|
|
2165
|
+
senderIdentity: input.senderIdentity,
|
|
2097
2166
|
});
|
|
2098
2167
|
}
|
|
2099
2168
|
|
|
@@ -36,7 +36,7 @@ import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/a
|
|
|
36
36
|
import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
37
37
|
import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
|
|
38
38
|
import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
|
|
39
|
-
import { MessageType, ToolCallStatus, SubAgentStatus, ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
39
|
+
import { ApprovalPolicySource, MessageType, ToolCallStatus, SubAgentStatus, ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
40
40
|
import type { SDKMessage } from "@cursor/sdk";
|
|
41
41
|
import type { MergedToolPolicy } from "./approval-policy.js";
|
|
42
42
|
import { lookupMcpToolPolicy, resolveApprovalMessage, builtInRequiresApproval, getBuiltInApprovalMessage, SALIENT_ARG_FIELDS } from "./approval-policy.js";
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
POLICY_ENGINE_VERSION,
|
|
45
45
|
resolveApprovalProvenance,
|
|
46
46
|
toProtoPolicySource,
|
|
47
|
+
unattendedSkipMessage,
|
|
47
48
|
} from "../../shared/approval-policy.js";
|
|
48
49
|
import {
|
|
49
50
|
approvalDenials,
|
|
@@ -1916,6 +1917,86 @@ export function detectUnattributedHookBlocks(
|
|
|
1916
1917
|
return blocks;
|
|
1917
1918
|
}
|
|
1918
1919
|
|
|
1920
|
+
/**
|
|
1921
|
+
* Stamp the tool calls the hook denied under UNATTENDED approval mode
|
|
1922
|
+
* (DD-014) as terminal TOOL_CALL_SKIPPED rows with UNATTENDED_SKIP
|
|
1923
|
+
* provenance — the Cursor twin of the native harness's
|
|
1924
|
+
* `reconcileUnattendedSkips`, so both harnesses persist the same honest
|
|
1925
|
+
* shape for a platform-resolved skip.
|
|
1926
|
+
*
|
|
1927
|
+
* An unattended denial never pauses the run (its ledger kind is excluded
|
|
1928
|
+
* from {@link approvalDenials}), so the stream leaves the denied call as a
|
|
1929
|
+
* FAILED row carrying Cursor's generic hook-block error — dishonest ("the
|
|
1930
|
+
* tool broke") and alarming in a transcript an org admin reviews. This pass
|
|
1931
|
+
* correlates the unattended ledger entries to their streamed calls with the
|
|
1932
|
+
* SAME two identities the rest of the file uses — exact token, then
|
|
1933
|
+
* (category, workspace-normalized path) for the abs-vs-rel drift — and
|
|
1934
|
+
* settles each match to SKIPPED with a result explaining the skip.
|
|
1935
|
+
*
|
|
1936
|
+
* Scope: hook-blocked FAILED rows and still-non-terminal (PENDING/RUNNING)
|
|
1937
|
+
* rows only — a COMPLETED row is a real execution and is never rewritten
|
|
1938
|
+
* (an unattended deny cannot produce one). `approval_action`/`approved_by`
|
|
1939
|
+
* stay untouched: server-owned, human-decision-only fields (DD-014 D-e).
|
|
1940
|
+
* Unmatched ledger entries need no synthesis — there is no pause to
|
|
1941
|
+
* surface; the model already saw the deny and adapted in-turn.
|
|
1942
|
+
*
|
|
1943
|
+
* Returns how many tool calls were stamped.
|
|
1944
|
+
*/
|
|
1945
|
+
export function stampUnattendedSkippedToolCalls(
|
|
1946
|
+
messages: readonly AgentMessage[],
|
|
1947
|
+
subAgentExecutions: readonly SubAgentExecution[],
|
|
1948
|
+
unattendedLedger: readonly DeniedLedgerEntry[],
|
|
1949
|
+
workspaceRoot?: string,
|
|
1950
|
+
): number {
|
|
1951
|
+
if (unattendedLedger.length === 0) return 0;
|
|
1952
|
+
|
|
1953
|
+
const ledgerTokens = new Set(unattendedLedger.map((e) => e.token));
|
|
1954
|
+
const ledgerNormalizedSalients = new Set<string>();
|
|
1955
|
+
if (workspaceRoot) {
|
|
1956
|
+
for (const entry of unattendedLedger) {
|
|
1957
|
+
const decoded = decodeIdentityToken(entry.token);
|
|
1958
|
+
if (!decoded) continue;
|
|
1959
|
+
const normalized = normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot);
|
|
1960
|
+
if (normalized) ledgerNormalizedSalients.add(normalized);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
const matchesLedger = (tc: ToolCall): boolean => {
|
|
1965
|
+
if (ledgerTokens.has(toolCallIdentityToken(tc))) return true;
|
|
1966
|
+
if (!workspaceRoot) return false;
|
|
1967
|
+
const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
|
|
1968
|
+
const normalized = normalizedFileSalient(id.key, id.salient, workspaceRoot);
|
|
1969
|
+
return !!normalized && ledgerNormalizedSalients.has(normalized);
|
|
1970
|
+
};
|
|
1971
|
+
|
|
1972
|
+
let stamped = 0;
|
|
1973
|
+
const apply = (msgs: readonly AgentMessage[]): void => {
|
|
1974
|
+
for (const msg of msgs) {
|
|
1975
|
+
for (const tc of msg.toolCalls) {
|
|
1976
|
+
const deniedShape =
|
|
1977
|
+
(tc.status === ToolCallStatus.TOOL_CALL_FAILED && isHookBlockError(tc.error)) ||
|
|
1978
|
+
tc.status === ToolCallStatus.TOOL_CALL_PENDING ||
|
|
1979
|
+
tc.status === ToolCallStatus.TOOL_CALL_RUNNING;
|
|
1980
|
+
if (!deniedShape || !matchesLedger(tc)) continue;
|
|
1981
|
+
tc.status = ToolCallStatus.TOOL_CALL_SKIPPED;
|
|
1982
|
+
tc.approvalPolicySource = ApprovalPolicySource.UNATTENDED_SKIP;
|
|
1983
|
+
tc.policyEngineVersion = POLICY_ENGINE_VERSION;
|
|
1984
|
+
tc.error = "";
|
|
1985
|
+
tc.result = unattendedSkipMessage(tc.name);
|
|
1986
|
+
tc.isStreaming = false;
|
|
1987
|
+
if (!tc.completedAt) tc.completedAt = utcTimestamp();
|
|
1988
|
+
stamped++;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
};
|
|
1992
|
+
|
|
1993
|
+
apply(messages);
|
|
1994
|
+
for (const subAgent of subAgentExecutions) {
|
|
1995
|
+
apply(subAgent.messages);
|
|
1996
|
+
}
|
|
1997
|
+
return stamped;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
1919
2000
|
/**
|
|
1920
2001
|
* Compute a streamed tool call's identity token in the same canonical space the
|
|
1921
2002
|
* preToolUse hook records denials in (see {@link toolIdentity} / primaryToken).
|
|
@@ -20,6 +20,11 @@ import { resolve } from "node:path";
|
|
|
20
20
|
import type { SubAgent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
21
21
|
import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
|
|
22
22
|
import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
23
|
+
import { formatContextBridgeText } from "../../shared/context-bridge.js";
|
|
24
|
+
import {
|
|
25
|
+
formatSenderIdentityText,
|
|
26
|
+
type SenderIdentity,
|
|
27
|
+
} from "../../shared/sender-identity.js";
|
|
23
28
|
import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
|
|
24
29
|
import {
|
|
25
30
|
buildImplementPlanDirective,
|
|
@@ -60,6 +65,22 @@ export interface EnhancedPromptOptions {
|
|
|
60
65
|
* The user message itself is just a short label ("Build from plan").
|
|
61
66
|
*/
|
|
62
67
|
buildFromPlan?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Rollover context bridge (cloud DD-013): a digest of the previous
|
|
70
|
+
* session's conversation, read from `SessionSpec.metadata`. Lands in the
|
|
71
|
+
* first message, so it persists in the cursor agent's own conversation
|
|
72
|
+
* store for the session's lifetime — buildEnhancedPrompt firing only on
|
|
73
|
+
* first/fresh executions is exactly the right delivery.
|
|
74
|
+
*/
|
|
75
|
+
contextBridge?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Channel sender identity (attribution, not authorization): the
|
|
78
|
+
* provider-verified identifier of the person on the channel, read from
|
|
79
|
+
* `SessionSpec.metadata`. Like the bridge, it lands in the first message
|
|
80
|
+
* and persists in the cursor agent's own conversation store — identity
|
|
81
|
+
* is constant per conversation (channel sessions are keyed per-sender).
|
|
82
|
+
*/
|
|
83
|
+
senderIdentity?: SenderIdentity;
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
/**
|
|
@@ -119,6 +140,21 @@ export function buildEnhancedPrompt(options: EnhancedPromptOptions): string {
|
|
|
119
140
|
sections.push(formatResponseRules());
|
|
120
141
|
}
|
|
121
142
|
|
|
143
|
+
// The sender identity precedes the bridge: it is standing context about
|
|
144
|
+
// WHO the conversation is with, which the carried conversation may refer
|
|
145
|
+
// back to.
|
|
146
|
+
if (options.senderIdentity) {
|
|
147
|
+
sections.push(formatSenderIdentitySection(options.senderIdentity));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The rollover context bridge sits directly before the protocol + task so
|
|
151
|
+
// the carried conversation is the freshest CONTEXT the model reads —
|
|
152
|
+
// while the approval protocol keeps its pinned last-before-task slot (it
|
|
153
|
+
// is an INSTRUCTION and must outweigh everything, including this bridge).
|
|
154
|
+
if (options.contextBridge) {
|
|
155
|
+
sections.push(formatContextBridgeSection(options.contextBridge));
|
|
156
|
+
}
|
|
157
|
+
|
|
122
158
|
// Always last before the task: the platform's tool-approval protocol. Placed
|
|
123
159
|
// here for recency so it outweighs any "ask the user first" guidance Cursor
|
|
124
160
|
// surfaces from a connected MCP server (see formatToolApprovalProtocol).
|
|
@@ -252,6 +288,14 @@ export function formatInstructions(instructions: string): string {
|
|
|
252
288
|
return `<agent_instructions>\n${instructions}\n</agent_instructions>`;
|
|
253
289
|
}
|
|
254
290
|
|
|
291
|
+
export function formatContextBridgeSection(bridge: string): string {
|
|
292
|
+
return `<previous_conversation_context>\n${formatContextBridgeText(bridge)}\n</previous_conversation_context>`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function formatSenderIdentitySection(identity: SenderIdentity): string {
|
|
296
|
+
return `<conversation_sender>\n${formatSenderIdentityText(identity)}\n</conversation_sender>`;
|
|
297
|
+
}
|
|
298
|
+
|
|
255
299
|
export function formatSkillsSection(skills: SkillMetadata[]): string {
|
|
256
300
|
const entries = skills.map(
|
|
257
301
|
(s) => `- **${s.name}**: ${s.description}\n Path: \`${s.path}\``,
|
|
@@ -41,6 +41,7 @@ import { join } from "node:path";
|
|
|
41
41
|
|
|
42
42
|
import { Agent } from "@cursor/sdk";
|
|
43
43
|
import type { SDKAgent, CursorAgentPlatformOptions, AgentDefinition } from "@cursor/sdk";
|
|
44
|
+
import { withTimeout, TimeoutError } from "../../shared/with-timeout.js";
|
|
44
45
|
import type { CursorMcpServerConfig } from "./mcp-resolver.js";
|
|
45
46
|
|
|
46
47
|
// ---------------------------------------------------------------------------
|
|
@@ -437,6 +438,81 @@ export async function resolveAgent(
|
|
|
437
438
|
};
|
|
438
439
|
}
|
|
439
440
|
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// Transport-recovery wrapper
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
export interface ResolveWithTransportRecoveryOptions {
|
|
446
|
+
harnessStateId: string;
|
|
447
|
+
createOptions: CreateAgentOptions | CreateCloudAgentOptions;
|
|
448
|
+
mode: "local" | "cloud";
|
|
449
|
+
/** Bound applied to each attempt independently. */
|
|
450
|
+
timeoutMs: number;
|
|
451
|
+
/**
|
|
452
|
+
* Builds the timeout rejection message. `finalAttempt` is true when no
|
|
453
|
+
* further automatic retry will follow, so the message can be honest about
|
|
454
|
+
* whether "retry" means the system or the user.
|
|
455
|
+
*/
|
|
456
|
+
buildTimeoutMessage: (finalAttempt: boolean) => string;
|
|
457
|
+
/**
|
|
458
|
+
* Closes the transport the hung attempt is riding on so the next attempt
|
|
459
|
+
* dials fresh. Injected (rather than imported from the http2 interceptor)
|
|
460
|
+
* to keep this module transport-agnostic and the recovery unit-testable.
|
|
461
|
+
*/
|
|
462
|
+
resetTransport: () => void;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Resolve a Cursor Agent with one automatic recovery from a transport hang.
|
|
467
|
+
*
|
|
468
|
+
* Agent.create/Agent.resume have no timeout of their own — a degraded
|
|
469
|
+
* transport (dead proxy connection, stale HTTP/2 session) hangs them forever.
|
|
470
|
+
* Each attempt is bounded by `timeoutMs`; when the first attempt expires,
|
|
471
|
+
* the transport is reset and the full resolveAgent() is retried once. Only
|
|
472
|
+
* a TimeoutError triggers recovery: deterministic failures (auth, validation)
|
|
473
|
+
* propagate immediately — resetting the transport cannot fix them.
|
|
474
|
+
*
|
|
475
|
+
* The retry re-invokes resolveAgent(), not createAgent() directly: at
|
|
476
|
+
* resolve time only the transport is suspect, not the agent handle, so a
|
|
477
|
+
* timed-out resume is retried resume-first and conversation context is
|
|
478
|
+
* preserved. (This deliberately diverges from the stream-phase recovery in
|
|
479
|
+
* index.ts, which jumps to a fresh create because there the handle itself
|
|
480
|
+
* has already failed a run.)
|
|
481
|
+
*
|
|
482
|
+
* Orphan semantics: withTimeout bounds the wait, not the work, so the first
|
|
483
|
+
* attempt's promise survives its expiry. resetTransport() closes the HTTP/2
|
|
484
|
+
* session that attempt is riding on, so the orphan rejects promptly and
|
|
485
|
+
* withTimeout's attached catch absorbs the late rejection (settled promise —
|
|
486
|
+
* no unhandledRejection). In the residual case where the orphan completes
|
|
487
|
+
* before the reset, it leaves an agent that never receives a prompt: no LLM
|
|
488
|
+
* cost, no state impact, cleaned up with the sandbox.
|
|
489
|
+
*/
|
|
490
|
+
export async function resolveAgentWithTransportRecovery(
|
|
491
|
+
opts: ResolveWithTransportRecoveryOptions,
|
|
492
|
+
): Promise<AgentResolution> {
|
|
493
|
+
const attempt = (finalAttempt: boolean) =>
|
|
494
|
+
withTimeout(
|
|
495
|
+
opts.timeoutMs,
|
|
496
|
+
() => opts.buildTimeoutMessage(finalAttempt),
|
|
497
|
+
() => resolveAgent(opts.harnessStateId, opts.createOptions, opts.mode),
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
return await attempt(false);
|
|
502
|
+
} catch (err) {
|
|
503
|
+
if (!(err instanceof TimeoutError)) throw err;
|
|
504
|
+
|
|
505
|
+
console.warn(
|
|
506
|
+
`resolveAgentWithTransportRecovery: agent resolution timed out after ` +
|
|
507
|
+
`${opts.timeoutMs}ms (sessionId=${opts.createOptions.sessionId}, mode=${opts.mode}, ` +
|
|
508
|
+
`resume=${!!opts.harnessStateId}) — resetting transport and retrying once`,
|
|
509
|
+
);
|
|
510
|
+
opts.resetTransport();
|
|
511
|
+
|
|
512
|
+
return attempt(true);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
440
516
|
/**
|
|
441
517
|
* Dispose a Cursor Agent when a session is deleted.
|
|
442
518
|
* Best-effort: logs and swallows errors.
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
denialKindOf,
|
|
43
43
|
primaryToken,
|
|
44
44
|
readDenialLedger,
|
|
45
|
+
unattendedDenials,
|
|
45
46
|
type ApprovalGrant,
|
|
46
47
|
} from "./approval-state.js";
|
|
47
48
|
import { deriveTurnCommandProvenance } from "./command-provenance.js";
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
clearProvisionalPostDenialNarration,
|
|
51
52
|
detectUnattributedHookBlocks,
|
|
52
53
|
reconcileDeniedToolCalls,
|
|
54
|
+
stampUnattendedSkippedToolCalls,
|
|
53
55
|
type UnattributedHookBlock,
|
|
54
56
|
} from "./message-translator.js";
|
|
55
57
|
|
|
@@ -302,6 +304,27 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
|
|
|
302
304
|
}
|
|
303
305
|
}
|
|
304
306
|
|
|
307
|
+
// Unattended approval mode (DD-014): denials the hook resolved with the
|
|
308
|
+
// non-pausing "unattended" kind never became gates above; settle their
|
|
309
|
+
// streamed rows (FAILED-with-hook-error or interrupted non-terminal) to
|
|
310
|
+
// honest TOOL_CALL_SKIPPED + UNATTENDED_SKIP provenance — the same shape
|
|
311
|
+
// the native harness's reconcileUnattendedSkips persists. Runs before the
|
|
312
|
+
// #205 attribution pass, which then never sees them as hook-block FAILED
|
|
313
|
+
// rows (they are in the full ledger regardless, so attribution was safe
|
|
314
|
+
// either way).
|
|
315
|
+
const stampedUnattended = stampUnattendedSkippedToolCalls(
|
|
316
|
+
status.messages,
|
|
317
|
+
status.subAgentExecutions,
|
|
318
|
+
unattendedDenials(deniedLedger),
|
|
319
|
+
primaryWorkspaceDir,
|
|
320
|
+
);
|
|
321
|
+
if (stampedUnattended > 0) {
|
|
322
|
+
console.log(
|
|
323
|
+
`ExecuteCursor turn boundary: ${stampedUnattended} unattended-mode tool call(s) ` +
|
|
324
|
+
`settled as SKIPPED (no approver on this surface; execution=${executionId})`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
305
328
|
// Issue #205 invariant: a blocked tool must never silently complete. Match
|
|
306
329
|
// this turn's hook-blocked FAILED rows against the FULL ledger (all kinds) —
|
|
307
330
|
// anything left over was denied by a hook that is not ours (or by our hook
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { Command } from "@langchain/langgraph";
|
|
3
|
-
import { resolveResumeInput, reconcileNonExecutingDecisions } from "../hitl.js";
|
|
4
|
-
import { ApprovalAction, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
3
|
+
import { resolveResumeInput, reconcileNonExecutingDecisions, reconcileUnattendedSkips } from "../hitl.js";
|
|
4
|
+
import { ApprovalAction, ApprovalPolicySource, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
5
5
|
import { create } from "@bufbuild/protobuf";
|
|
6
6
|
import {
|
|
7
7
|
AgentMessageSchema,
|
|
@@ -238,3 +238,104 @@ describe("reconcileNonExecutingDecisions", () => {
|
|
|
238
238
|
expect(status.messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
239
239
|
});
|
|
240
240
|
});
|
|
241
|
+
|
|
242
|
+
describe("reconcileUnattendedSkips (DD-014)", () => {
|
|
243
|
+
function statusWithCalls(
|
|
244
|
+
toolCalls: Array<{ id: string; status: ToolCallStatus; result?: string }>,
|
|
245
|
+
) {
|
|
246
|
+
return create(AgentExecutionStatusSchema, {
|
|
247
|
+
messages: [
|
|
248
|
+
create(AgentMessageSchema, {
|
|
249
|
+
toolCalls: toolCalls.map(tc =>
|
|
250
|
+
create(ToolCallSchema, {
|
|
251
|
+
id: tc.id,
|
|
252
|
+
name: "gated_tool",
|
|
253
|
+
status: tc.status,
|
|
254
|
+
result: tc.result ?? "",
|
|
255
|
+
}),
|
|
256
|
+
),
|
|
257
|
+
}),
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
it("terminalizes a registry-recorded call to SKIPPED with UNATTENDED_SKIP provenance", () => {
|
|
263
|
+
// The stream saw the gate's skip ToolMessage as a normal tool result and
|
|
264
|
+
// marked the call COMPLETED — the reconciler owns the honest terminal shape.
|
|
265
|
+
const status = statusWithCalls([
|
|
266
|
+
{ id: "call-1", status: ToolCallStatus.TOOL_CALL_COMPLETED, result: "was skipped" },
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
reconcileUnattendedSkips(status, new Set(["call-1"]));
|
|
270
|
+
|
|
271
|
+
const tc = status.messages[0].toolCalls[0];
|
|
272
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
273
|
+
expect(tc.approvalPolicySource).toBe(ApprovalPolicySource.UNATTENDED_SKIP);
|
|
274
|
+
expect(tc.policyEngineVersion).not.toBe("");
|
|
275
|
+
// Server-owned human-decision fields stay untouched (DD-014 D-e).
|
|
276
|
+
expect(tc.approvalAction).toBe(ApprovalAction.UNSPECIFIED);
|
|
277
|
+
expect(tc.approvedBy).toBe("");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("backfills the skip result on a row whose tool events never fired", () => {
|
|
281
|
+
const status = statusWithCalls([
|
|
282
|
+
{ id: "call-1", status: ToolCallStatus.TOOL_CALL_RUNNING },
|
|
283
|
+
]);
|
|
284
|
+
|
|
285
|
+
reconcileUnattendedSkips(status, new Set(["call-1"]));
|
|
286
|
+
|
|
287
|
+
const tc = status.messages[0].toolCalls[0];
|
|
288
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
289
|
+
expect(tc.result).toContain("skipped automatically");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("touches only registry entries and no-ops on an empty/absent registry", () => {
|
|
293
|
+
const status = statusWithCalls([
|
|
294
|
+
{ id: "call-1", status: ToolCallStatus.TOOL_CALL_COMPLETED, result: "real result" },
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
reconcileUnattendedSkips(status, new Set(["other-call"]));
|
|
298
|
+
reconcileUnattendedSkips(status, new Set());
|
|
299
|
+
reconcileUnattendedSkips(status, undefined);
|
|
300
|
+
|
|
301
|
+
const tc = status.messages[0].toolCalls[0];
|
|
302
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
303
|
+
expect(tc.result).toBe("real result");
|
|
304
|
+
expect(tc.approvalPolicySource).toBe(ApprovalPolicySource.UNSPECIFIED);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("covers sub-agent transcripts (sub-agent gates share the parent registry)", () => {
|
|
308
|
+
const status = create(AgentExecutionStatusSchema, {
|
|
309
|
+
messages: [],
|
|
310
|
+
subAgentExecutions: [{
|
|
311
|
+
messages: [
|
|
312
|
+
create(AgentMessageSchema, {
|
|
313
|
+
toolCalls: [create(ToolCallSchema, {
|
|
314
|
+
id: "sub-call-1",
|
|
315
|
+
name: "gated_tool",
|
|
316
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
317
|
+
})],
|
|
318
|
+
}),
|
|
319
|
+
],
|
|
320
|
+
}],
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
reconcileUnattendedSkips(status, new Set(["sub-call-1"]));
|
|
324
|
+
|
|
325
|
+
const tc = status.subAgentExecutions[0].messages[0].toolCalls[0];
|
|
326
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
327
|
+
expect(tc.approvalPolicySource).toBe(ApprovalPolicySource.UNATTENDED_SKIP);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("is idempotent", () => {
|
|
331
|
+
const status = statusWithCalls([
|
|
332
|
+
{ id: "call-1", status: ToolCallStatus.TOOL_CALL_COMPLETED },
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
reconcileUnattendedSkips(status, new Set(["call-1"]));
|
|
336
|
+
const after = JSON.stringify(status.messages[0].toolCalls[0]);
|
|
337
|
+
reconcileUnattendedSkips(status, new Set(["call-1"]));
|
|
338
|
+
|
|
339
|
+
expect(JSON.stringify(status.messages[0].toolCalls[0])).toBe(after);
|
|
340
|
+
});
|
|
341
|
+
});
|