@stigmer/runner 3.11.0 → 3.11.1-dev.20260812192248
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 +2 -0
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/discover-mcp-server.d.ts +7 -0
- package/dist/activities/discover-mcp-server.js +6 -4
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/emit-event.d.ts +14 -2
- package/dist/activities/emit-event.js +52 -17
- package/dist/activities/emit-event.js.map +1 -1
- package/dist/activities/execute-cursor/error-classifier.d.ts +19 -0
- package/dist/activities/execute-cursor/error-classifier.js +34 -3
- package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +46 -10
- package/dist/activities/execute-cursor/index.js +126 -36
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/model-pricing-data.d.ts +2 -0
- package/dist/activities/execute-cursor/model-pricing-data.js +13 -3
- package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +33 -0
- package/dist/activities/execute-cursor/prompt-builder.js +37 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/turn-recovery.d.ts +52 -0
- package/dist/activities/execute-cursor/turn-recovery.js +193 -0
- package/dist/activities/execute-cursor/turn-recovery.js.map +1 -0
- package/dist/activities/execute-deep-agent/setup.js +16 -7
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/shell-env.d.ts +5 -1
- package/dist/activities/execute-deep-agent/shell-env.js +7 -9
- package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js +7 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +17 -3
- package/dist/activities/execute-deep-agent/subagent-wiring.js +14 -2
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/hydrate-workflow-execution.js +8 -5
- package/dist/activities/hydrate-workflow-execution.js.map +1 -1
- package/dist/activities/run-command.d.ts +5 -2
- package/dist/activities/run-command.js +22 -10
- package/dist/activities/run-command.js.map +1 -1
- package/dist/activities/run-env.d.ts +40 -0
- package/dist/activities/run-env.js +66 -0
- package/dist/activities/run-env.js.map +1 -0
- package/dist/bootstrap.js +5 -0
- package/dist/bootstrap.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +83 -26
- package/dist/client/stigmer-client.js +88 -24
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/encryption/config.d.ts +41 -9
- package/dist/encryption/config.js +63 -23
- package/dist/encryption/config.js.map +1 -1
- package/dist/middleware/index.d.ts +3 -0
- package/dist/middleware/index.js +7 -0
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/path-normalization.d.ts +57 -0
- package/dist/middleware/path-normalization.js +109 -0
- package/dist/middleware/path-normalization.js.map +1 -0
- package/dist/middleware/types.d.ts +8 -0
- package/dist/payload-codecs.d.ts +2 -1
- package/dist/payload-codecs.js +7 -3
- package/dist/payload-codecs.js.map +1 -1
- package/dist/runner-manager.js +17 -3
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +7 -3
- package/dist/runner.js.map +1 -1
- package/dist/shared/artifact-storage.d.ts +23 -1
- package/dist/shared/artifact-storage.js +51 -10
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/checkpointer/http-saver.d.ts +4 -20
- package/dist/shared/checkpointer/http-saver.js +15 -57
- package/dist/shared/checkpointer/http-saver.js.map +1 -1
- package/dist/shared/conversation-catchup.d.ts +9 -2
- package/dist/shared/conversation-catchup.js +39 -6
- package/dist/shared/conversation-catchup.js.map +1 -1
- package/dist/shared/grpc-retry.d.ts +5 -1
- package/dist/shared/grpc-retry.js +5 -1
- package/dist/shared/grpc-retry.js.map +1 -1
- package/dist/shared/http-retry.d.ts +51 -9
- package/dist/shared/http-retry.js +60 -9
- package/dist/shared/http-retry.js.map +1 -1
- package/dist/shared/mcp-manager.d.ts +8 -4
- package/dist/shared/mcp-manager.js +30 -4
- package/dist/shared/mcp-manager.js.map +1 -1
- package/dist/shared/mcp-schema-sanitizer.d.ts +70 -0
- package/dist/shared/mcp-schema-sanitizer.js +197 -0
- package/dist/shared/mcp-schema-sanitizer.js.map +1 -0
- package/dist/shared/model-client.d.ts +6 -0
- package/dist/shared/model-client.js +14 -3
- package/dist/shared/model-client.js.map +1 -1
- package/dist/shared/model-pricing-data.d.ts +2 -0
- package/dist/shared/model-pricing-data.js +13 -3
- package/dist/shared/model-pricing-data.js.map +1 -1
- package/dist/shared/model-registry.js +3 -2
- package/dist/shared/model-registry.js.map +1 -1
- package/dist/shared/plan-mode-permissions.d.ts +7 -0
- package/dist/shared/plan-mode-permissions.js +7 -0
- package/dist/shared/plan-mode-permissions.js.map +1 -1
- package/dist/shared/registry-endpoint.d.ts +20 -0
- package/dist/shared/registry-endpoint.js +25 -0
- package/dist/shared/registry-endpoint.js.map +1 -1
- package/dist/shared/runner-credential-keys.d.ts +24 -0
- package/dist/shared/runner-credential-keys.js +47 -0
- package/dist/shared/runner-credential-keys.js.map +1 -0
- package/dist/workflow-engine/resolve.d.ts +19 -1
- package/dist/workflow-engine/resolve.js +37 -2
- package/dist/workflow-engine/resolve.js.map +1 -1
- package/dist/workflows/connect-mcp-server.js +2 -0
- package/dist/workflows/connect-mcp-server.js.map +1 -1
- package/dist/workflows/types.d.ts +8 -0
- package/package.json +3 -4
- package/src/__test-utils__/__tests__/vitest-global-setup.test.ts +37 -0
- package/src/__test-utils__/vitest-global-setup.ts +50 -0
- package/src/__tests__/bootstrap.test.ts +43 -0
- package/src/__tests__/encryption-codec.test.ts +53 -0
- package/src/__tests__/preflight.test.ts +15 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +39 -1
- package/src/activities/__tests__/error-classifier.test.ts +94 -2
- package/src/activities/__tests__/run-command.test.ts +221 -0
- package/src/activities/discover-mcp-server.ts +16 -2
- package/src/activities/emit-event.ts +65 -21
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +111 -2
- package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +62 -1
- package/src/activities/execute-cursor/__tests__/turn-recovery.test.ts +160 -0
- package/src/activities/execute-cursor/error-classifier.ts +40 -3
- package/src/activities/execute-cursor/index.ts +154 -38
- package/src/activities/execute-cursor/model-pricing-data.ts +22 -3
- package/src/activities/execute-cursor/prompt-builder.ts +51 -0
- package/src/activities/execute-cursor/turn-recovery.ts +208 -0
- package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +174 -0
- package/src/activities/execute-deep-agent/__tests__/shell-env.test.ts +24 -10
- package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +22 -17
- package/src/activities/execute-deep-agent/setup.ts +16 -8
- package/src/activities/execute-deep-agent/shell-env.ts +8 -9
- package/src/activities/execute-deep-agent/subagent-transformer.ts +7 -1
- package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -3
- package/src/activities/hydrate-workflow-execution.ts +8 -5
- package/src/activities/run-command.ts +25 -12
- package/src/activities/run-env.ts +79 -0
- package/src/bootstrap.ts +5 -0
- package/src/client/__tests__/stigmer-client.test.ts +104 -5
- package/src/client/stigmer-client.ts +128 -29
- package/src/encryption/config.ts +87 -23
- package/src/middleware/__tests__/path-normalization.test.ts +140 -0
- package/src/middleware/index.ts +8 -0
- package/src/middleware/path-normalization.ts +125 -0
- package/src/middleware/types.ts +11 -0
- package/src/payload-codecs.ts +8 -2
- package/src/runner-manager.ts +21 -3
- package/src/runner.ts +7 -3
- package/src/shared/__tests__/artifact-storage.test.ts +145 -2
- package/src/shared/__tests__/conversation-catchup.test.ts +24 -0
- package/src/shared/__tests__/http-retry.test.ts +5 -4
- package/src/shared/__tests__/mcp-manager.test.ts +53 -13
- package/src/shared/__tests__/mcp-schema-sanitizer.test.ts +267 -0
- package/src/shared/__tests__/model-client.test.ts +46 -0
- package/src/shared/__tests__/model-pricing.test.ts +62 -2
- package/src/shared/__tests__/model-registry.test.ts +22 -0
- package/src/shared/artifact-storage.ts +75 -10
- package/src/shared/checkpointer/http-saver.ts +15 -60
- package/src/shared/conversation-catchup.ts +39 -6
- package/src/shared/grpc-retry.ts +5 -1
- package/src/shared/http-retry.ts +98 -9
- package/src/shared/mcp-manager.ts +35 -4
- package/src/shared/mcp-schema-sanitizer.ts +224 -0
- package/src/shared/model-client.ts +21 -3
- package/src/shared/model-pricing-data.ts +22 -3
- package/src/shared/model-registry.ts +7 -2
- package/src/shared/plan-mode-permissions.ts +7 -0
- package/src/shared/registry-endpoint.ts +27 -0
- package/src/shared/runner-credential-keys.ts +46 -0
- package/src/workflow-engine/__tests__/tasks/emit-event.test.ts +127 -49
- package/src/workflow-engine/resolve.ts +48 -2
- package/src/workflows/__tests__/connect-mcp-server.test.ts +25 -0
- package/src/workflows/connect-mcp-server.ts +2 -0
- package/src/workflows/types.ts +8 -0
- package/dist/activities/execute-cursor/mcp-config.d.ts +0 -30
- package/dist/activities/execute-cursor/mcp-config.js +0 -39
- package/dist/activities/execute-cursor/mcp-config.js.map +0 -1
- package/src/activities/execute-cursor/mcp-config.ts +0 -66
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
synthesizeError,
|
|
12
|
+
formatClassifiedError,
|
|
13
|
+
shouldRetryWithFreshAgent,
|
|
14
|
+
DETAIL_FREE_FALLBACK_USER_PREFIX,
|
|
15
|
+
TRANSPORT_TIMEOUT_USER_PREFIX,
|
|
16
|
+
} from "../execute-cursor/error-classifier.js";
|
|
11
17
|
|
|
12
18
|
describe("synthesizeError", () => {
|
|
13
19
|
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
|
@@ -213,7 +219,7 @@ describe("synthesizeError", () => {
|
|
|
213
219
|
|
|
214
220
|
expect(result.category).toBe("unknown");
|
|
215
221
|
expect(result.source).toBe("fallback");
|
|
216
|
-
expect(result.message).toContain(
|
|
222
|
+
expect(result.message).toContain(DETAIL_FREE_FALLBACK_USER_PREFIX);
|
|
217
223
|
expect(result.message).toContain("claude-sonnet-4");
|
|
218
224
|
});
|
|
219
225
|
|
|
@@ -261,6 +267,92 @@ describe("synthesizeError", () => {
|
|
|
261
267
|
expect(result.message).toContain("agentId=agent-123");
|
|
262
268
|
});
|
|
263
269
|
|
|
270
|
+
// The exact prod shape from oss#492: the Composer 2.5 capacity incident
|
|
271
|
+
// rejected every run with a bare { status: "error" } — all five detail
|
|
272
|
+
// channels empty, fresh agent (the poisoned-handle retry produces this
|
|
273
|
+
// same shape with isResumedHandle: false). End users read this message
|
|
274
|
+
// verbatim in embedded surfaces, so it must lead with actionable copy,
|
|
275
|
+
// keep the diagnostic parenthetical for operators, and be retryable
|
|
276
|
+
// (provider capacity is transient).
|
|
277
|
+
it("detail-free failure on a fresh agent leads with user-facing copy and is retryable (oss#492)", () => {
|
|
278
|
+
const result = synthesizeError({
|
|
279
|
+
sdkError: undefined,
|
|
280
|
+
sdkResultFields: undefined,
|
|
281
|
+
streamErrorMessage: undefined,
|
|
282
|
+
capturedRejection: undefined,
|
|
283
|
+
conversationErrorText: undefined,
|
|
284
|
+
isResumedHandle: false,
|
|
285
|
+
fallbackContext: { model: "composer-2.5", mode: "local", agentId: "agent-abc" },
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(result.category).toBe("unknown");
|
|
289
|
+
expect(result.source).toBe("fallback");
|
|
290
|
+
expect(result.retryable).toBe(true);
|
|
291
|
+
expect(result.message.startsWith(DETAIL_FREE_FALLBACK_USER_PREFIX)).toBe(true);
|
|
292
|
+
// Diagnostic context survives for operators (and the env integration
|
|
293
|
+
// test's Model= matcher).
|
|
294
|
+
expect(result.message).toContain("Model=composer-2.5");
|
|
295
|
+
expect(result.message).toContain("mode=local");
|
|
296
|
+
expect(result.message).toContain("agentId=agent-abc");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// sdk-react's isInterruptedError reframes any error containing the phrase
|
|
300
|
+
// "retry or resume" as a neutral resumable notice instead of a failure
|
|
301
|
+
// alert. A capacity failure must render as a failure — guard the copy
|
|
302
|
+
// (formatted end-to-end, tail included) against ever matching that regex.
|
|
303
|
+
it("user-facing fallback copy never trips the sdk-react interrupted-error reframe", () => {
|
|
304
|
+
const interruptedReframe = /\[StallTimeoutError\]|execution interrupted|retry or resume/i;
|
|
305
|
+
for (const prefix of [DETAIL_FREE_FALLBACK_USER_PREFIX, TRANSPORT_TIMEOUT_USER_PREFIX]) {
|
|
306
|
+
expect(prefix).not.toMatch(interruptedReframe);
|
|
307
|
+
}
|
|
308
|
+
const formatted = formatClassifiedError(synthesizeError({
|
|
309
|
+
sdkResultFields: undefined,
|
|
310
|
+
streamErrorMessage: undefined,
|
|
311
|
+
capturedRejection: undefined,
|
|
312
|
+
isResumedHandle: false,
|
|
313
|
+
fallbackContext,
|
|
314
|
+
}));
|
|
315
|
+
expect(formatted).not.toMatch(interruptedReframe);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Transport-timeout heuristic (0 messages, ~default SDK timeout)
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
it("classifies a 0-message ~30s failure as retryable network with user-facing copy", () => {
|
|
323
|
+
const result = synthesizeError({
|
|
324
|
+
sdkResultFields: undefined,
|
|
325
|
+
streamErrorMessage: undefined,
|
|
326
|
+
capturedRejection: undefined,
|
|
327
|
+
isResumedHandle: false,
|
|
328
|
+
fallbackContext,
|
|
329
|
+
durationMs: 30012,
|
|
330
|
+
messageCount: 0,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
expect(result.category).toBe("network");
|
|
334
|
+
expect(result.source).toBe("fallback");
|
|
335
|
+
expect(result.retryable).toBe(true);
|
|
336
|
+
expect(result.message.startsWith(TRANSPORT_TIMEOUT_USER_PREFIX)).toBe(true);
|
|
337
|
+
expect(result.message).toContain("30012ms");
|
|
338
|
+
expect(result.message).toContain("Model=claude-sonnet-4");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("does not apply the transport heuristic outside the default-timeout window", () => {
|
|
342
|
+
const result = synthesizeError({
|
|
343
|
+
sdkResultFields: undefined,
|
|
344
|
+
streamErrorMessage: undefined,
|
|
345
|
+
capturedRejection: undefined,
|
|
346
|
+
isResumedHandle: false,
|
|
347
|
+
fallbackContext,
|
|
348
|
+
durationMs: 120000,
|
|
349
|
+
messageCount: 0,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(result.category).toBe("unknown");
|
|
353
|
+
expect(result.message.startsWith(DETAIL_FREE_FALLBACK_USER_PREFIX)).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
|
|
264
356
|
// ---------------------------------------------------------------------------
|
|
265
357
|
// Source priority
|
|
266
358
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env contract tests for the workflow `run` task (oss#384).
|
|
3
|
+
*
|
|
4
|
+
* These spawn REAL subprocesses through the RunShell / RunScript
|
|
5
|
+
* activities and inspect the environment the child actually received —
|
|
6
|
+
* the leak-reproduction style established by the #256 fix (PR #383).
|
|
7
|
+
* Subprocess output is env variable NAMES only, never values.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { createRunCommandActivities } from "../run-command.js";
|
|
12
|
+
import { buildRunEnv, RUN_ENV_BASE_KEYS } from "../run-env.js";
|
|
13
|
+
import { RuntimePlaceholderResolutionError } from "../../workflow-engine/resolve.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Credentials planted into the runner process env before each test.
|
|
17
|
+
* Includes a name outside every known denylist to prove the contract
|
|
18
|
+
* is declare-to-receive, not deny-known-names.
|
|
19
|
+
*/
|
|
20
|
+
const PLANTED_SENTINELS: Record<string, string> = {
|
|
21
|
+
STIGMER_RUNNER_HITL_SECRET: "sentinel-hitl",
|
|
22
|
+
STIGMER_TOKEN: "sentinel-token",
|
|
23
|
+
CURSOR_API_KEY: "sentinel-cursor",
|
|
24
|
+
ANTHROPIC_API_KEY: "sentinel-anthropic",
|
|
25
|
+
OPERATOR_PRIVATE_CREDENTIAL: "sentinel-arbitrary",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const PRINT_ENV_KEYS_SHELL = `node -p 'JSON.stringify(Object.keys(process.env))'`;
|
|
29
|
+
const PRINT_ENV_KEYS_SCRIPT = `console.log(JSON.stringify(Object.keys(process.env)))`;
|
|
30
|
+
|
|
31
|
+
const activities = createRunCommandActivities();
|
|
32
|
+
|
|
33
|
+
async function childEnvKeysViaShell(
|
|
34
|
+
environment?: Record<string, string>,
|
|
35
|
+
runtimeEnv: Record<string, unknown> = {},
|
|
36
|
+
): Promise<string[]> {
|
|
37
|
+
const stdout = await activities.RunShell({
|
|
38
|
+
mode: "shell",
|
|
39
|
+
command: PRINT_ENV_KEYS_SHELL,
|
|
40
|
+
environment,
|
|
41
|
+
runtimeEnv,
|
|
42
|
+
});
|
|
43
|
+
return JSON.parse(String(stdout)) as string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function childEnvKeysViaScript(
|
|
47
|
+
environment?: Record<string, string>,
|
|
48
|
+
runtimeEnv: Record<string, unknown> = {},
|
|
49
|
+
): Promise<string[]> {
|
|
50
|
+
const stdout = await activities.RunScript({
|
|
51
|
+
mode: "script",
|
|
52
|
+
language: "js",
|
|
53
|
+
code: PRINT_ENV_KEYS_SCRIPT,
|
|
54
|
+
environment,
|
|
55
|
+
runtimeEnv,
|
|
56
|
+
});
|
|
57
|
+
return JSON.parse(String(stdout)) as string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("run task env contract (oss#384)", () => {
|
|
61
|
+
const saved: Record<string, string | undefined> = {};
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
for (const [key, value] of Object.entries(PLANTED_SENTINELS)) {
|
|
65
|
+
saved[key] = process.env[key];
|
|
66
|
+
process.env[key] = value;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
for (const key of Object.keys(PLANTED_SENTINELS)) {
|
|
72
|
+
if (saved[key] === undefined) {
|
|
73
|
+
delete process.env[key];
|
|
74
|
+
} else {
|
|
75
|
+
process.env[key] = saved[key];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("RunShell", () => {
|
|
81
|
+
it("does not leak runner process credentials to the child", async () => {
|
|
82
|
+
const keys = await childEnvKeysViaShell();
|
|
83
|
+
|
|
84
|
+
for (const sentinel of Object.keys(PLANTED_SENTINELS)) {
|
|
85
|
+
expect(keys, `runner credential "${sentinel}" leaked to shell child`)
|
|
86
|
+
.not.toContain(sentinel);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("provides the minimal base env and the declared overlay", async () => {
|
|
91
|
+
const keys = await childEnvKeysViaShell({ DECLARED_VAR: "value" });
|
|
92
|
+
|
|
93
|
+
for (const base of RUN_ENV_BASE_KEYS) {
|
|
94
|
+
if (process.env[base] !== undefined) {
|
|
95
|
+
expect(keys, `base key "${base}" missing from shell child`).toContain(base);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
expect(keys).toContain("DECLARED_VAR");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("resolves ${.secrets.KEY} placeholders in declared values just-in-time", async () => {
|
|
102
|
+
const stdout = await activities.RunShell({
|
|
103
|
+
mode: "shell",
|
|
104
|
+
command: `node -p 'process.env.API_KEY'`,
|
|
105
|
+
environment: { API_KEY: "${.secrets.TEST_API_KEY}" },
|
|
106
|
+
runtimeEnv: { TEST_API_KEY: "resolved-secret-value" },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(stdout).toBe("resolved-secret-value");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("fails non-retryably, naming the variable, when a placeholder key is missing", async () => {
|
|
113
|
+
await expect(
|
|
114
|
+
activities.RunShell({
|
|
115
|
+
mode: "shell",
|
|
116
|
+
command: "true",
|
|
117
|
+
environment: { API_KEY: "${.secrets.NOT_PROVIDED}" },
|
|
118
|
+
runtimeEnv: {},
|
|
119
|
+
}),
|
|
120
|
+
).rejects.toMatchObject({
|
|
121
|
+
nonRetryable: true,
|
|
122
|
+
type: "RUN_ENV_UNRESOLVED_PLACEHOLDER",
|
|
123
|
+
message: expect.stringContaining("NOT_PROVIDED"),
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("RunScript", () => {
|
|
129
|
+
it("does not leak runner process credentials to the child", async () => {
|
|
130
|
+
const keys = await childEnvKeysViaScript();
|
|
131
|
+
|
|
132
|
+
for (const sentinel of Object.keys(PLANTED_SENTINELS)) {
|
|
133
|
+
expect(keys, `runner credential "${sentinel}" leaked to script child`)
|
|
134
|
+
.not.toContain(sentinel);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("provides the minimal base env and the declared overlay", async () => {
|
|
139
|
+
const keys = await childEnvKeysViaScript({ DECLARED_VAR: "value" });
|
|
140
|
+
|
|
141
|
+
for (const base of RUN_ENV_BASE_KEYS) {
|
|
142
|
+
if (process.env[base] !== undefined) {
|
|
143
|
+
expect(keys, `base key "${base}" missing from script child`).toContain(base);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
expect(keys).toContain("DECLARED_VAR");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("resolves ${.env_vars.KEY} placeholders in declared values just-in-time", async () => {
|
|
150
|
+
const stdout = await activities.RunScript({
|
|
151
|
+
mode: "script",
|
|
152
|
+
language: "js",
|
|
153
|
+
code: "console.log(process.env.REGION)",
|
|
154
|
+
environment: { REGION: "${.env_vars.DEPLOY_REGION}" },
|
|
155
|
+
runtimeEnv: { DEPLOY_REGION: "us-east-1" },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(stdout).toBe("us-east-1");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("buildRunEnv", () => {
|
|
164
|
+
it("copies only the base keys present in the runner env", () => {
|
|
165
|
+
const base = {
|
|
166
|
+
PATH: "/usr/bin",
|
|
167
|
+
HOME: "/home/runner",
|
|
168
|
+
TERM: "xterm",
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const env = buildRunEnv(undefined, {}, base);
|
|
172
|
+
|
|
173
|
+
expect(env).toEqual({ PATH: "/usr/bin", HOME: "/home/runner", TERM: "xterm" });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("never copies undeclared runner variables — the oss#384 tripwire", () => {
|
|
177
|
+
// Guards against a future reintroduction of `{ ...process.env }`:
|
|
178
|
+
// credentials must be structurally absent, whatever their names.
|
|
179
|
+
const base = {
|
|
180
|
+
PATH: "/usr/bin",
|
|
181
|
+
STIGMER_RUNNER_HITL_SECRET: "secret",
|
|
182
|
+
ANY_FUTURE_CREDENTIAL: "secret",
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const env = buildRunEnv({ DECLARED: "yes" }, {}, base);
|
|
186
|
+
|
|
187
|
+
expect(Object.keys(env).sort()).toEqual(["DECLARED", "PATH"]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("lets a declared variable override a base variable", () => {
|
|
191
|
+
const base = { PATH: "/usr/bin" };
|
|
192
|
+
|
|
193
|
+
const env = buildRunEnv({ PATH: "/custom/bin" }, {}, base);
|
|
194
|
+
|
|
195
|
+
expect(env.PATH).toBe("/custom/bin");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("passes non-placeholder declared values through untouched", () => {
|
|
199
|
+
const env = buildRunEnv(
|
|
200
|
+
{ LITERAL: "plain-value", TEMPLATED: "prefix-${.secrets.KEY}-suffix" },
|
|
201
|
+
{ KEY: "mid" },
|
|
202
|
+
{},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
expect(env.LITERAL).toBe("plain-value");
|
|
206
|
+
expect(env.TEMPLATED).toBe("prefix-mid-suffix");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("throws a named error for a missing placeholder key", () => {
|
|
210
|
+
expect(() =>
|
|
211
|
+
buildRunEnv({ API_KEY: "${.secrets.MISSING}" }, {}, {}),
|
|
212
|
+
).toThrowError(RuntimePlaceholderResolutionError);
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
buildRunEnv({ API_KEY: "${.secrets.MISSING}" }, {}, {});
|
|
216
|
+
} catch (err) {
|
|
217
|
+
expect((err as RuntimePlaceholderResolutionError).variableName).toBe("MISSING");
|
|
218
|
+
expect((err as Error).message).toContain('environment "API_KEY"');
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -81,6 +81,13 @@ const PLATFORM_INJECTABLE_MAP: Record<string, string> = {
|
|
|
81
81
|
export interface DiscoverMcpServerInput {
|
|
82
82
|
mcpServerId: string;
|
|
83
83
|
executionContextId?: string | null;
|
|
84
|
+
/**
|
|
85
|
+
* Execution-scoped token unlocking the connect EC's decrypted credentials
|
|
86
|
+
* (oss#535). Minted by the OSS handler and carried with the work item —
|
|
87
|
+
* discovery has no execution of its own to exchange for one. Absent on
|
|
88
|
+
* cloud, where the ambient connect_sandbox credential decrypts.
|
|
89
|
+
*/
|
|
90
|
+
executionContextToken?: string | null;
|
|
84
91
|
invokerIdentityAccountId?: string | null;
|
|
85
92
|
}
|
|
86
93
|
|
|
@@ -280,7 +287,7 @@ export async function discoverMcpServer(
|
|
|
280
287
|
input: DiscoverMcpServerInput,
|
|
281
288
|
deps: DiscoverDeps,
|
|
282
289
|
): Promise<DiscoverMcpServerOutput> {
|
|
283
|
-
const { mcpServerId, executionContextId } = input;
|
|
290
|
+
const { mcpServerId, executionContextId, executionContextToken } = input;
|
|
284
291
|
const { stigmerClient } = deps;
|
|
285
292
|
|
|
286
293
|
console.log(
|
|
@@ -301,6 +308,7 @@ export async function discoverMcpServer(
|
|
|
301
308
|
const envVars = await resolveEnvVarsForDiscovery(
|
|
302
309
|
stigmerClient,
|
|
303
310
|
executionContextId ?? null,
|
|
311
|
+
executionContextToken ?? null,
|
|
304
312
|
slug,
|
|
305
313
|
declaredEnv,
|
|
306
314
|
);
|
|
@@ -382,6 +390,7 @@ export async function discoverMcpServer(
|
|
|
382
390
|
async function resolveEnvVarsForDiscovery(
|
|
383
391
|
client: StigmerClient,
|
|
384
392
|
executionContextId: string | null,
|
|
393
|
+
executionContextToken: string | null,
|
|
385
394
|
slug: string,
|
|
386
395
|
declaredEnv: Record<string, EnvVarDeclaration>,
|
|
387
396
|
): Promise<Record<string, string>> {
|
|
@@ -394,7 +403,12 @@ async function resolveEnvVarsForDiscovery(
|
|
|
394
403
|
|
|
395
404
|
let execCtx: Awaited<ReturnType<typeof client.getExecutionContextByExecutionId>>;
|
|
396
405
|
try {
|
|
397
|
-
|
|
406
|
+
// The payload-carried token authenticates the read on OSS (oss#535);
|
|
407
|
+
// undefined on cloud, where the ambient credential applies instead.
|
|
408
|
+
execCtx = await client.getExecutionContextByExecutionId(
|
|
409
|
+
executionContextId,
|
|
410
|
+
executionContextToken ?? undefined,
|
|
411
|
+
);
|
|
398
412
|
} catch (err) {
|
|
399
413
|
const cause = err instanceof Error ? err.message : String(err);
|
|
400
414
|
if (credentialsExpected) {
|
|
@@ -9,12 +9,24 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Supported delivery targets:
|
|
11
11
|
* - webhook: HTTP POST with Content-Type: application/cloudevents+json
|
|
12
|
-
* - signal:
|
|
12
|
+
* - signal: signal to another workflow execution's listen task, routed
|
|
13
|
+
* through the server's SendSignal lane
|
|
14
|
+
*
|
|
15
|
+
* Signal delivery is deliberately server-mediated (oss#517): a direct
|
|
16
|
+
* Temporal client here would bypass the authorization boundary (any
|
|
17
|
+
* workflow could signal any workflow id in the namespace) and is
|
|
18
|
+
* structurally incompatible with payload encryption — emit→listen is
|
|
19
|
+
* the platform's only runner-to-runner channel, and under per-identity
|
|
20
|
+
* runner keys a sender-encrypted signal fails closed at a receiver
|
|
21
|
+
* holding a different key. The server re-produces the payload, so each
|
|
22
|
+
* side's codec passes it through.
|
|
13
23
|
*
|
|
14
24
|
* CloudEvents spec: https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md
|
|
15
25
|
*/
|
|
16
26
|
|
|
17
27
|
import { randomUUID } from "node:crypto";
|
|
28
|
+
import type { JsonObject } from "@bufbuild/protobuf";
|
|
29
|
+
import { StigmerClient } from "../client/stigmer-client.js";
|
|
18
30
|
import { loadConfig } from "../config.js";
|
|
19
31
|
import { resolveRuntimePlaceholders } from "../workflow-engine/resolve.js";
|
|
20
32
|
|
|
@@ -24,7 +36,9 @@ export interface WebhookDeliveryTarget {
|
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
export interface SignalDeliveryTarget {
|
|
27
|
-
|
|
39
|
+
/** Target workflow execution id ("wfx_..."), as returned by run/create. */
|
|
40
|
+
readonly execution_id: string;
|
|
41
|
+
/** Signal name matching the target's listen task event id (verbatim). */
|
|
28
42
|
readonly signal_name: string;
|
|
29
43
|
}
|
|
30
44
|
|
|
@@ -54,6 +68,11 @@ export interface EmitEventResult {
|
|
|
54
68
|
|
|
55
69
|
const WEBHOOK_TIMEOUT_MS = 30_000;
|
|
56
70
|
|
|
71
|
+
// Delivery is best-effort by contract, so no single target may consume the
|
|
72
|
+
// CallFunction activity's whole 5m startToClose budget. Same bound as the
|
|
73
|
+
// webhook arm.
|
|
74
|
+
const SIGNAL_TIMEOUT_MS = 30_000;
|
|
75
|
+
|
|
57
76
|
function buildEnvelope(
|
|
58
77
|
config: EmitEventConfig,
|
|
59
78
|
executionId: string,
|
|
@@ -120,33 +139,53 @@ async function deliverWebhook(
|
|
|
120
139
|
}
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
function buildClient(): StigmerClient {
|
|
143
|
+
const config = loadConfig();
|
|
144
|
+
return new StigmerClient({
|
|
145
|
+
endpoint: config.stigmerBackendEndpoint,
|
|
146
|
+
token: config.stigmerToken,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
123
150
|
async function deliverSignal(
|
|
124
151
|
envelope: Record<string, unknown>,
|
|
125
152
|
target: SignalDeliveryTarget,
|
|
153
|
+
client: StigmerClient,
|
|
126
154
|
): Promise<DeliveryError | null> {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
155
|
+
// Refuse the pre-oss#517 field by name: direct-addressing raw Temporal
|
|
156
|
+
// workflow ids is exactly the capability server mediation removes.
|
|
157
|
+
const legacyWorkflowId = (target as { workflow_id?: unknown }).workflow_id;
|
|
158
|
+
if (!target.execution_id) {
|
|
159
|
+
const reason = legacyWorkflowId
|
|
160
|
+
? "signal delivery addresses workflow executions by 'execution_id' (\"wfx_...\"); 'workflow_id' is not supported"
|
|
161
|
+
: "signal delivery requires 'execution_id'";
|
|
162
|
+
return {
|
|
163
|
+
target: `signal:${String(legacyWorkflowId ?? "")}/${target.signal_name ?? ""}`,
|
|
164
|
+
error: reason,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (!target.signal_name) {
|
|
168
|
+
return {
|
|
169
|
+
target: `signal:${target.execution_id}/`,
|
|
170
|
+
error: "signal delivery requires 'signal_name'",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
144
173
|
|
|
174
|
+
try {
|
|
175
|
+
await client.sendWorkflowSignal(
|
|
176
|
+
target.execution_id,
|
|
177
|
+
target.signal_name,
|
|
178
|
+
envelope as JsonObject,
|
|
179
|
+
{ timeoutMs: SIGNAL_TIMEOUT_MS },
|
|
180
|
+
);
|
|
145
181
|
return null;
|
|
146
182
|
} catch (err) {
|
|
183
|
+
// Server refusals arrive as ConnectErrors whose messages carry the code
|
|
184
|
+
// (e.g. [not_found], [failed_precondition] for terminal executions) —
|
|
185
|
+
// surfaced verbatim in delivery_errors, same contract as the webhook arm.
|
|
147
186
|
const message = err instanceof Error ? err.message : String(err);
|
|
148
187
|
return {
|
|
149
|
-
target: `signal:${target.
|
|
188
|
+
target: `signal:${target.execution_id}/${target.signal_name}`,
|
|
150
189
|
error: message,
|
|
151
190
|
};
|
|
152
191
|
}
|
|
@@ -173,13 +212,18 @@ export async function emitEventAction(
|
|
|
173
212
|
const errors: DeliveryError[] = [];
|
|
174
213
|
const env = runtimeEnv ?? {};
|
|
175
214
|
|
|
215
|
+
// One client serves all signal targets of this emit; constructed lazily so
|
|
216
|
+
// webhook-only emits never pay for a gRPC transport.
|
|
217
|
+
let client: StigmerClient | undefined;
|
|
218
|
+
|
|
176
219
|
for (const target of config.delivery) {
|
|
177
220
|
let err: DeliveryError | null = null;
|
|
178
221
|
|
|
179
222
|
if ("webhook" in target) {
|
|
180
223
|
err = await deliverWebhook(envelope, target.webhook, env);
|
|
181
224
|
} else if ("signal" in target) {
|
|
182
|
-
|
|
225
|
+
client ??= buildClient();
|
|
226
|
+
err = await deliverSignal(envelope, target.signal, client);
|
|
183
227
|
}
|
|
184
228
|
|
|
185
229
|
if (err) {
|
|
@@ -14,7 +14,7 @@ import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agen
|
|
|
14
14
|
import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
|
|
15
15
|
import { ApiResourceReferenceSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
16
16
|
|
|
17
|
-
import { buildPrompt, isHitlReinvocation } from "../index.js";
|
|
17
|
+
import { appendStructuredOutputDirective, buildPrompt, isHitlReinvocation, primarySendCarriesImages } from "../index.js";
|
|
18
18
|
import type { BuildPromptInput } from "../index.js";
|
|
19
19
|
import { buildReinvocationPrompt, formatInteractionModePrefix, formatImplementPlanSection, formatToolApprovalProtocol, buildToolApprovalRuleFile } from "../prompt-builder.js";
|
|
20
20
|
import { PLAN_MODE_DIRECTIVE } from "../../../shared/plan-mode-prompt.js";
|
|
@@ -255,7 +255,7 @@ describe("buildPrompt", () => {
|
|
|
255
255
|
});
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
-
describe("isHitlReinvocation (
|
|
258
|
+
describe("isHitlReinvocation (paired with resolution.reason at every consumer — issue #366)", () => {
|
|
259
259
|
it("is false with no decisions and false with an empty map", () => {
|
|
260
260
|
expect(isHitlReinvocation(undefined)).toBe(false);
|
|
261
261
|
expect(isHitlReinvocation(new Map())).toBe(false);
|
|
@@ -268,6 +268,115 @@ describe("isHitlReinvocation (the single discriminator for message-accompanied p
|
|
|
268
268
|
});
|
|
269
269
|
});
|
|
270
270
|
|
|
271
|
+
describe("HITL recovery — fresh agent mid-HITL (issue #366)", () => {
|
|
272
|
+
const decisions = () =>
|
|
273
|
+
new Map<string, ApprovalAction>([["tool-call-1", ApprovalAction.APPROVE]]);
|
|
274
|
+
const approvals = () => [
|
|
275
|
+
create(PendingApprovalSchema, {
|
|
276
|
+
toolCallId: "tool-call-1",
|
|
277
|
+
toolName: "Write",
|
|
278
|
+
message: "Write file: gated.txt",
|
|
279
|
+
}),
|
|
280
|
+
];
|
|
281
|
+
const recoveryInput = (overrides: Partial<BuildPromptInput> = {}) =>
|
|
282
|
+
input({
|
|
283
|
+
resolution: resolution("local", "created_after_resume_failure"),
|
|
284
|
+
approvalDecisions: decisions(),
|
|
285
|
+
pendingApprovals: approvals(),
|
|
286
|
+
...overrides,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("keeps the resumed-HITL prompt byte-identical to the bare reinvocation prompt (regression guard)", () => {
|
|
290
|
+
const prompt = buildPrompt(
|
|
291
|
+
input({
|
|
292
|
+
resolution: resolution("local", "resumed_successfully"),
|
|
293
|
+
approvalDecisions: decisions(),
|
|
294
|
+
pendingApprovals: approvals(),
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
expect(prompt).toBe(buildReinvocationPrompt(approvals(), decisions()));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("rebuilds full context for a fresh agent mid-HITL: instructions, the ORIGINAL user message, the state-loss disclosure, and the decisions", () => {
|
|
301
|
+
const prompt = buildPrompt(recoveryInput());
|
|
302
|
+
// The pre-fix failure mode: bare decisions with no story.
|
|
303
|
+
expect(prompt).not.toBe(buildReinvocationPrompt(approvals(), decisions()));
|
|
304
|
+
expect(prompt).toContain("<agent_instructions>");
|
|
305
|
+
expect(prompt).toContain("<tool_approval_protocol>");
|
|
306
|
+
expect(prompt).toContain(`<user_request>\n${USER_MESSAGE}\n</user_request>`);
|
|
307
|
+
expect(prompt).toContain("<turn_recovery>");
|
|
308
|
+
expect(prompt).toContain("Write file: gated.txt");
|
|
309
|
+
expect(prompt).toContain("APPROVED");
|
|
310
|
+
// The opaque tool-call id must not leak into the prompt (reinvocation rule).
|
|
311
|
+
expect(prompt).not.toContain("tool-call-1");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("orders the recovery chronologically: request, then the recovered work, then the decisions, ending on the continue directive", () => {
|
|
315
|
+
const prompt = buildPrompt(
|
|
316
|
+
recoveryInput({ turnRecoveryDigest: "Tool: Write file: draft.txt — completed" }),
|
|
317
|
+
);
|
|
318
|
+
const requestIdx = prompt.indexOf("<user_request>");
|
|
319
|
+
const recoveryIdx = prompt.indexOf("<turn_recovery>");
|
|
320
|
+
const decisionsIdx = prompt.indexOf("APPROVED");
|
|
321
|
+
expect(requestIdx).toBeGreaterThan(-1);
|
|
322
|
+
expect(recoveryIdx).toBeGreaterThan(requestIdx);
|
|
323
|
+
expect(decisionsIdx).toBeGreaterThan(recoveryIdx);
|
|
324
|
+
expect(prompt).toContain("Tool: Write file: draft.txt — completed");
|
|
325
|
+
expect(prompt.trimEnd().endsWith("do not ask the user for permission in prose.")).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("discloses the state loss even with NO transcript — otherwise the decisions read as reactions to proposals this agent never made", () => {
|
|
329
|
+
const prompt = buildPrompt(recoveryInput({ turnRecoveryDigest: undefined }));
|
|
330
|
+
expect(prompt).toContain("<turn_recovery>");
|
|
331
|
+
expect(prompt).toContain("no transcript of your progress is available");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("preserves already-applied semantics: exact-applied writes are described as done, not as work to redo", () => {
|
|
335
|
+
const prompt = buildPrompt(
|
|
336
|
+
recoveryInput({ appliedToolCallIds: new Set(["tool-call-1"]) }),
|
|
337
|
+
);
|
|
338
|
+
expect(prompt).toContain("ALREADY applied");
|
|
339
|
+
expect(prompt).not.toContain("Carry them out now");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("carries the session-standing context a first turn would get — the replacement agent inherits the whole story, not just the blueprint", () => {
|
|
343
|
+
const prompt = buildPrompt(
|
|
344
|
+
recoveryInput({ contextBridge: "User: earlier thread\nAssistant: earlier reply" }),
|
|
345
|
+
);
|
|
346
|
+
expect(prompt).toContain("<previous_conversation_context>");
|
|
347
|
+
expect(prompt).toContain("User: earlier thread");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("selects the recovery shape for ANY non-resumed reason — decisions-only is safe only when native context is guaranteed", () => {
|
|
351
|
+
// created_first_execution + decisions cannot happen in practice
|
|
352
|
+
// (decisions are reconstructed from a persisted transcript, which
|
|
353
|
+
// implies a prior invocation) — but if it ever did, the bare decisions
|
|
354
|
+
// prompt would reproduce the #366 amnesia. Fail safe.
|
|
355
|
+
const prompt = buildPrompt(
|
|
356
|
+
recoveryInput({ resolution: resolution("local", "created_first_execution") }),
|
|
357
|
+
);
|
|
358
|
+
expect(prompt).toContain("<turn_recovery>");
|
|
359
|
+
expect(prompt).toContain("<agent_instructions>");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("primary send skips images ONLY for a resumed HITL agent (which holds them natively); every fresh agent gets the re-delivery", () => {
|
|
363
|
+
expect(primarySendCarriesImages(decisions(), "resumed_successfully")).toBe(false);
|
|
364
|
+
// The #366 vision corollary: fresh agent mid-HITL after a
|
|
365
|
+
// resolution-time resume failure.
|
|
366
|
+
expect(primarySendCarriesImages(decisions(), "created_after_resume_failure")).toBe(true);
|
|
367
|
+
// Non-HITL turns always carry the message's images, resumed or not.
|
|
368
|
+
expect(primarySendCarriesImages(undefined, "resumed_successfully")).toBe(true);
|
|
369
|
+
expect(primarySendCarriesImages(undefined, "created_first_execution")).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("the structured-output directive is a pure append shared by the primary and recovery sends — absent schema, absent suffix", () => {
|
|
373
|
+
expect(appendStructuredOutputDirective("base", undefined)).toBe("base");
|
|
374
|
+
const withSchema = appendStructuredOutputDirective("base", { type: "object" });
|
|
375
|
+
expect(withSchema.startsWith("base\n\n---\nCRITICAL OUTPUT REQUIREMENT:")).toBe(true);
|
|
376
|
+
expect(withSchema).toContain('"type": "object"');
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
271
380
|
describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)", () => {
|
|
272
381
|
const RESUMED = { resolution: resolution("local", "resumed_successfully") };
|
|
273
382
|
|