@stigmer/runner 3.2.0 → 3.2.2
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/cost-guard.d.ts +41 -0
- package/dist/activities/execute-cursor/cost-guard.js +50 -0
- package/dist/activities/execute-cursor/cost-guard.js.map +1 -0
- package/dist/activities/execute-cursor/index.d.ts +12 -0
- package/dist/activities/execute-cursor/index.js +83 -12
- package/dist/activities/execute-cursor/index.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/turn-stream.d.ts +10 -1
- package/dist/activities/execute-cursor/turn-stream.js +42 -9
- package/dist/activities/execute-cursor/turn-stream.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.js +4 -0
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/workflow-event-activities.js +2 -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/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/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 +17 -0
- package/dist/shared/with-timeout.js +34 -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/types.d.ts +26 -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/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 +60 -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/__tests__/build-prompt.test.ts +65 -0
- package/src/activities/execute-cursor/__tests__/cost-guard.test.ts +64 -0
- package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +118 -0
- package/src/activities/execute-cursor/cost-guard.ts +56 -0
- package/src/activities/execute-cursor/index.ts +101 -15
- package/src/activities/execute-cursor/prompt-builder.ts +44 -0
- package/src/activities/execute-cursor/turn-stream.ts +61 -10
- 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__/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/prompt-builder.ts +35 -0
- package/src/activities/execute-deep-agent/setup.ts +4 -0
- package/src/activities/workflow-event-activities.ts +2 -0
- package/src/config.ts +23 -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 +45 -0
- package/src/shared/context-bridge.ts +51 -0
- package/src/shared/sender-identity.ts +85 -0
- package/src/shared/with-timeout.ts +39 -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/do-executor.ts +65 -1
- package/src/workflow-engine/tasks/call-function.ts +84 -3
- package/src/workflow-engine/types.ts +27 -0
- package/src/workflows/__tests__/execute-serverless-workflow.test.ts +1 -0
- package/src/workflows/engine-core.ts +5 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { validateAction } from "../call-validate.js";
|
|
3
|
+
import { transformAction } from "../call-transform.js";
|
|
4
|
+
|
|
5
|
+
// Regression coverage for the `expr.includes is not a function` crash
|
|
6
|
+
// (workflow-execution-ux-parity upstream #7): rule expressions are
|
|
7
|
+
// deferred code that must reach this activity as jq strings — in either
|
|
8
|
+
// the strict `${ ... }` wrapper or the bare form — and be evaluated here
|
|
9
|
+
// against the validated data, never pre-resolved by the config resolver.
|
|
10
|
+
|
|
11
|
+
describe("validateAction — rule expressions", () => {
|
|
12
|
+
// The exact shape of WF1 ux-linear-basics' check_order task after the
|
|
13
|
+
// call-function builder resolves `input` and defers the rule.
|
|
14
|
+
const checkOrderConfig = {
|
|
15
|
+
input: {
|
|
16
|
+
order_id: "ORD-2026-0716",
|
|
17
|
+
customer: "Ada Lovelace",
|
|
18
|
+
currency: "USD",
|
|
19
|
+
line_count: 2,
|
|
20
|
+
total: 150,
|
|
21
|
+
},
|
|
22
|
+
schema: {
|
|
23
|
+
type: "object",
|
|
24
|
+
required: ["order_id", "total", "line_count"],
|
|
25
|
+
properties: {
|
|
26
|
+
order_id: { type: "string" },
|
|
27
|
+
total: { type: "number" },
|
|
28
|
+
line_count: { type: "integer" },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
rules: [
|
|
32
|
+
{
|
|
33
|
+
name: "total_is_positive",
|
|
34
|
+
expression: "${ .total > 0 }",
|
|
35
|
+
message: "Order total must be positive",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
on_fail: "VALIDATION_FAIL_RAISE",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
it("evaluates a ${ }-wrapped rule against the validate input (WF1 check_order)", async () => {
|
|
42
|
+
const result = await validateAction(checkOrderConfig);
|
|
43
|
+
expect(result.valid).toBe(true);
|
|
44
|
+
expect(result.errors).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("evaluates a bare jq rule expression", async () => {
|
|
48
|
+
const result = await validateAction({
|
|
49
|
+
input: { total: 150 },
|
|
50
|
+
rules: [{ name: "positive", expression: ".total > 0" }],
|
|
51
|
+
on_fail: "VALIDATION_FAIL_WARN",
|
|
52
|
+
});
|
|
53
|
+
expect(result.valid).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("reports the rule message when the predicate fails", async () => {
|
|
57
|
+
const result = await validateAction({
|
|
58
|
+
input: { total: -5 },
|
|
59
|
+
rules: [
|
|
60
|
+
{
|
|
61
|
+
name: "total_is_positive",
|
|
62
|
+
expression: "${ .total > 0 }",
|
|
63
|
+
message: "Order total must be positive",
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
on_fail: "VALIDATION_FAIL_WARN",
|
|
67
|
+
});
|
|
68
|
+
expect(result.valid).toBe(false);
|
|
69
|
+
expect(result.errors).toEqual([
|
|
70
|
+
{ rule: "total_is_positive", message: "Order total must be positive" },
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("turns a non-string expression into a named config error, not a TypeError", async () => {
|
|
75
|
+
// The pre-fix failure mode: the resolver substituted the evaluated
|
|
76
|
+
// boolean back into the rule. The guard must name the rule and the
|
|
77
|
+
// expected shape instead of crashing in the jq engine.
|
|
78
|
+
const result = await validateAction({
|
|
79
|
+
input: { total: 150 },
|
|
80
|
+
rules: [
|
|
81
|
+
{ name: "broken_rule", expression: true as unknown as string },
|
|
82
|
+
],
|
|
83
|
+
on_fail: "VALIDATION_FAIL_WARN",
|
|
84
|
+
});
|
|
85
|
+
expect(result.valid).toBe(false);
|
|
86
|
+
expect(result.errors).toHaveLength(1);
|
|
87
|
+
expect(result.errors[0].rule).toBe("broken_rule");
|
|
88
|
+
expect(result.errors[0].message).toContain("expected a jq predicate string");
|
|
89
|
+
expect(result.errors[0].message).toContain("boolean");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("raises on failed rules when on_fail is RAISE", async () => {
|
|
93
|
+
await expect(
|
|
94
|
+
validateAction({
|
|
95
|
+
input: { total: -5 },
|
|
96
|
+
rules: [
|
|
97
|
+
{
|
|
98
|
+
name: "total_is_positive",
|
|
99
|
+
expression: "${ .total > 0 }",
|
|
100
|
+
message: "Order total must be positive",
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
on_fail: "VALIDATION_FAIL_RAISE",
|
|
104
|
+
}),
|
|
105
|
+
).rejects.toThrow("Order total must be positive");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("transformAction — deferred expression forms", () => {
|
|
110
|
+
it("accepts a ${ }-wrapped expression", async () => {
|
|
111
|
+
const result = await transformAction({
|
|
112
|
+
engine: "TRANSFORM_ENGINE_JQ",
|
|
113
|
+
expression: "${ { doubled: (.qty * 2) } }",
|
|
114
|
+
input: { qty: 21 },
|
|
115
|
+
});
|
|
116
|
+
expect(result).toEqual({ doubled: 42 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("accepts the bare jq form the converter emits", async () => {
|
|
120
|
+
const result = await transformAction({
|
|
121
|
+
engine: "TRANSFORM_ENGINE_JQ",
|
|
122
|
+
expression: "{ total: ([.items[] | .qty * .unit_price] | add) }",
|
|
123
|
+
input: { items: [{ qty: 2, unit_price: 25.5 }, { qty: 1, unit_price: 99 }] },
|
|
124
|
+
});
|
|
125
|
+
expect(result).toEqual({ total: 150 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("rejects a non-string expression with a clear config error", async () => {
|
|
129
|
+
await expect(
|
|
130
|
+
transformAction({
|
|
131
|
+
engine: "JQ",
|
|
132
|
+
expression: { not: "a string" } as unknown as string,
|
|
133
|
+
input: {},
|
|
134
|
+
}),
|
|
135
|
+
).rejects.toThrow("must be a jq string");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -240,6 +240,35 @@ describe("toProtoEvent", () => {
|
|
|
240
240
|
expect(evt.payload.value.taskKind).toBe(0);
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
+
it("maps inputSummary onto the payload Struct", () => {
|
|
244
|
+
const evt = toProtoEvent({
|
|
245
|
+
type: "task_started",
|
|
246
|
+
taskName: "seed_order",
|
|
247
|
+
occurredAt: NOW,
|
|
248
|
+
taskKind: "set",
|
|
249
|
+
attemptNumber: 1,
|
|
250
|
+
inputSummary: { variables: { order_id: "ORD-1" } },
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (evt.payload.case !== "taskStarted") throw new Error("unexpected");
|
|
254
|
+
expect(evt.payload.value.inputSummary).toEqual({
|
|
255
|
+
variables: { order_id: "ORD-1" },
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("leaves inputSummary unset when the descriptor omits it", () => {
|
|
260
|
+
const evt = toProtoEvent({
|
|
261
|
+
type: "task_started",
|
|
262
|
+
taskName: "t",
|
|
263
|
+
occurredAt: NOW,
|
|
264
|
+
taskKind: "set",
|
|
265
|
+
attemptNumber: 1,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (evt.payload.case !== "taskStarted") throw new Error("unexpected");
|
|
269
|
+
expect(evt.payload.value.inputSummary).toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
243
272
|
it("maps call:agent to proto agent_call (13), not http_call (2)", () => {
|
|
244
273
|
const evt = toProtoEvent({
|
|
245
274
|
type: "task_started",
|
|
@@ -352,6 +381,37 @@ describe("toProtoEvent", () => {
|
|
|
352
381
|
expect(evt.payload.value.costMicros).toBe(BigInt(100000));
|
|
353
382
|
expect(evt.payload.value.tokensUsed).toBe(BigInt(800));
|
|
354
383
|
});
|
|
384
|
+
|
|
385
|
+
it("maps outputSummary onto the payload Struct", () => {
|
|
386
|
+
const evt = toProtoEvent({
|
|
387
|
+
type: "task_completed",
|
|
388
|
+
taskName: "total_order",
|
|
389
|
+
occurredAt: NOW,
|
|
390
|
+
taskKind: "call:function:transform",
|
|
391
|
+
durationMs: 160,
|
|
392
|
+
costMicros: 0,
|
|
393
|
+
tokensUsed: 0,
|
|
394
|
+
outputSummary: { total: 150, line_count: 2 },
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
if (evt.payload.case !== "taskCompleted") throw new Error("unexpected");
|
|
398
|
+
expect(evt.payload.value.outputSummary).toEqual({ total: 150, line_count: 2 });
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("leaves outputSummary unset when the descriptor omits it", () => {
|
|
402
|
+
const evt = toProtoEvent({
|
|
403
|
+
type: "task_completed",
|
|
404
|
+
taskName: "t",
|
|
405
|
+
occurredAt: NOW,
|
|
406
|
+
taskKind: "set",
|
|
407
|
+
durationMs: 1,
|
|
408
|
+
costMicros: 0,
|
|
409
|
+
tokensUsed: 0,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
if (evt.payload.case !== "taskCompleted") throw new Error("unexpected");
|
|
413
|
+
expect(evt.payload.value.outputSummary).toBeUndefined();
|
|
414
|
+
});
|
|
355
415
|
});
|
|
356
416
|
|
|
357
417
|
describe("task_failed", () => {
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { ApplicationFailure } from "@temporalio/activity";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
evaluateExpression,
|
|
19
|
+
isStrictExpr,
|
|
20
|
+
sanitizeExpr,
|
|
21
|
+
} from "../workflow-engine/expression.js";
|
|
18
22
|
|
|
19
23
|
export interface TransformConfig {
|
|
20
24
|
readonly engine: string;
|
|
@@ -38,6 +42,13 @@ export async function transformAction(
|
|
|
38
42
|
"TRANSFORM_MISSING_EXPRESSION",
|
|
39
43
|
);
|
|
40
44
|
}
|
|
45
|
+
if (typeof config.expression !== "string") {
|
|
46
|
+
throw ApplicationFailure.nonRetryable(
|
|
47
|
+
`transform: 'expression' must be a jq string, got ${typeof config.expression}. ` +
|
|
48
|
+
"Fix the expression in the workflow's transform task_config.",
|
|
49
|
+
"TRANSFORM_INVALID_EXPRESSION",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
41
52
|
|
|
42
53
|
const engine = normalizeEngine(config.engine || "JQ");
|
|
43
54
|
|
|
@@ -50,5 +61,12 @@ export async function transformAction(
|
|
|
50
61
|
|
|
51
62
|
const data = config.input !== undefined ? config.input : taskInput;
|
|
52
63
|
|
|
53
|
-
|
|
64
|
+
// The expression arrives unresolved (deferred code — see the
|
|
65
|
+
// call-function builder). Accept both the strict `${ ... }` wrapper
|
|
66
|
+
// and the bare jq form the converter emits.
|
|
67
|
+
const expression = isStrictExpr(config.expression)
|
|
68
|
+
? sanitizeExpr(config.expression)
|
|
69
|
+
: config.expression;
|
|
70
|
+
|
|
71
|
+
return evaluateExpression(expression, data, {});
|
|
54
72
|
}
|
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { ApplicationFailure } from "@temporalio/activity";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
evaluateExpression,
|
|
23
|
+
isStrictExpr,
|
|
24
|
+
sanitizeExpr,
|
|
25
|
+
} from "../workflow-engine/expression.js";
|
|
22
26
|
|
|
23
27
|
export interface ValidateConfig {
|
|
24
28
|
readonly input: unknown;
|
|
@@ -160,7 +164,28 @@ async function validateRules(
|
|
|
160
164
|
const errors: ValidationError[] = [];
|
|
161
165
|
|
|
162
166
|
for (const rule of rules) {
|
|
163
|
-
|
|
167
|
+
// Config guard: the expression must be a jq string. Anything else is
|
|
168
|
+
// a workflow-definition defect (or an upstream resolution bug) — name
|
|
169
|
+
// the rule and what to fix instead of crashing in the jq engine.
|
|
170
|
+
if (typeof rule.expression !== "string" || rule.expression.length === 0) {
|
|
171
|
+
errors.push({
|
|
172
|
+
rule: rule.name,
|
|
173
|
+
message:
|
|
174
|
+
`Rule '${rule.name}' has an invalid 'expression': expected a jq ` +
|
|
175
|
+
`predicate string, got ${typeof rule.expression}. Fix the rule in ` +
|
|
176
|
+
`the workflow's validate task_config.`,
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Rule expressions arrive unresolved (deferred code — see the
|
|
182
|
+
// call-function builder). Accept both the strict `${ ... }` wrapper
|
|
183
|
+
// and a bare jq predicate.
|
|
184
|
+
const expr = isStrictExpr(rule.expression)
|
|
185
|
+
? sanitizeExpr(rule.expression)
|
|
186
|
+
: rule.expression;
|
|
187
|
+
|
|
188
|
+
const result = await evaluateExpression(expr, data, {});
|
|
164
189
|
if (!result) {
|
|
165
190
|
errors.push({
|
|
166
191
|
rule: rule.name,
|
|
@@ -25,6 +25,7 @@ import { StigmerClient } from "../client/stigmer-client.js";
|
|
|
25
25
|
import { mcpServerToResolved } from "../shared/mcp-resolver.js";
|
|
26
26
|
import { toMcpClientConfig } from "../shared/mcp-manager.js";
|
|
27
27
|
import { detectOAuthChallenge } from "../shared/mcp-oauth-detect.js";
|
|
28
|
+
import { withTimeout } from "../shared/with-timeout.js";
|
|
28
29
|
import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
29
30
|
import type { Config } from "../config.js";
|
|
30
31
|
|
|
@@ -326,7 +327,12 @@ async function connectAndDiscover(
|
|
|
326
327
|
const resourceTemplates: DiscoveredResourceTemplateResult[] = [];
|
|
327
328
|
|
|
328
329
|
try {
|
|
329
|
-
|
|
330
|
+
const timeoutMessage =
|
|
331
|
+
`MCP server '${slug}' did not respond within ` +
|
|
332
|
+
`${Math.round(SESSION_INIT_TIMEOUT_MS / 1000)}s. If this server requires compilation or ` +
|
|
333
|
+
`package installation on first run (e.g. go run, npx), the cold ` +
|
|
334
|
+
`start may have exceeded the discovery timeout.`;
|
|
335
|
+
await withTimeout(SESSION_INIT_TIMEOUT_MS, timeoutMessage, async () => {
|
|
330
336
|
await client.initializeConnections();
|
|
331
337
|
|
|
332
338
|
const mcpClient = await client.getClient(slug);
|
|
@@ -411,33 +417,6 @@ async function classifyHttpOAuthFailure(
|
|
|
411
417
|
return detectOAuthChallenge(connection.url, connection.headers, slug);
|
|
412
418
|
}
|
|
413
419
|
|
|
414
|
-
async function withTimeout<T>(
|
|
415
|
-
ms: number,
|
|
416
|
-
serverSlug: string,
|
|
417
|
-
fn: () => Promise<T>,
|
|
418
|
-
): Promise<T> {
|
|
419
|
-
return new Promise<T>((resolve, reject) => {
|
|
420
|
-
const timer = setTimeout(() => {
|
|
421
|
-
reject(new Error(
|
|
422
|
-
`MCP server '${serverSlug}' did not respond within ` +
|
|
423
|
-
`${Math.round(ms / 1000)}s. If this server requires compilation or ` +
|
|
424
|
-
`package installation on first run (e.g. go run, npx), the cold ` +
|
|
425
|
-
`start may have exceeded the discovery timeout.`,
|
|
426
|
-
));
|
|
427
|
-
}, ms);
|
|
428
|
-
|
|
429
|
-
fn()
|
|
430
|
-
.then((result) => {
|
|
431
|
-
clearTimeout(timer);
|
|
432
|
-
resolve(result);
|
|
433
|
-
})
|
|
434
|
-
.catch((err) => {
|
|
435
|
-
clearTimeout(timer);
|
|
436
|
-
reject(err);
|
|
437
|
-
});
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
|
|
441
420
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
442
421
|
// Temporal Activity Factory
|
|
443
422
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -87,6 +87,71 @@ describe("buildPrompt", () => {
|
|
|
87
87
|
expect(prompt).toContain(USER_MESSAGE);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
it("carries the rollover context bridge on the first execution (DD-013)", () => {
|
|
91
|
+
const prompt = buildPrompt(
|
|
92
|
+
input({
|
|
93
|
+
resolution: resolution("local", "created_first_execution"),
|
|
94
|
+
contextBridge: "Subject: Orders\nUser: where is my order?\nAssistant: Shipped.",
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
expect(prompt).toContain("<previous_conversation_context>");
|
|
98
|
+
expect(prompt).toContain("User: where is my order?");
|
|
99
|
+
// The bridge is CONTEXT; the approval protocol keeps its pinned
|
|
100
|
+
// last-before-task slot so instructions outweigh it.
|
|
101
|
+
expect(prompt.indexOf("<previous_conversation_context>"))
|
|
102
|
+
.toBeLessThan(prompt.indexOf("<tool_approval_protocol>"));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("never bridges a successfully resumed agent — its native context IS the conversation", () => {
|
|
106
|
+
const prompt = buildPrompt(
|
|
107
|
+
input({
|
|
108
|
+
resolution: resolution("local", "resumed_successfully"),
|
|
109
|
+
contextBridge: "Subject: Orders\nUser: hi\nAssistant: hello",
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
expect(prompt).toBe(USER_MESSAGE);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("omits the bridge section when the session carries none", () => {
|
|
116
|
+
const prompt = buildPrompt(
|
|
117
|
+
input({ resolution: resolution("local", "created_first_execution") }),
|
|
118
|
+
);
|
|
119
|
+
expect(prompt).not.toContain("<previous_conversation_context>");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("carries the channel sender identity on the first execution", () => {
|
|
123
|
+
const prompt = buildPrompt(
|
|
124
|
+
input({
|
|
125
|
+
resolution: resolution("local", "created_first_execution"),
|
|
126
|
+
senderIdentity: { value: "15550001111", kind: "whatsapp_phone" },
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
expect(prompt).toContain("<conversation_sender>");
|
|
130
|
+
expect(prompt).toContain("WhatsApp phone number");
|
|
131
|
+
expect(prompt).toContain("15550001111");
|
|
132
|
+
// Identity is CONTEXT like the bridge; the approval protocol keeps its
|
|
133
|
+
// pinned last-before-task slot.
|
|
134
|
+
expect(prompt.indexOf("<conversation_sender>"))
|
|
135
|
+
.toBeLessThan(prompt.indexOf("<tool_approval_protocol>"));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("never re-sends the identity to a successfully resumed agent — its native context carries it", () => {
|
|
139
|
+
const prompt = buildPrompt(
|
|
140
|
+
input({
|
|
141
|
+
resolution: resolution("local", "resumed_successfully"),
|
|
142
|
+
senderIdentity: { value: "U0USER", kind: "slack_user_id" },
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
expect(prompt).toBe(USER_MESSAGE);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("omits the sender section when the session carries no identity (console sessions)", () => {
|
|
149
|
+
const prompt = buildPrompt(
|
|
150
|
+
input({ resolution: resolution("local", "created_first_execution") }),
|
|
151
|
+
);
|
|
152
|
+
expect(prompt).not.toContain("<conversation_sender>");
|
|
153
|
+
});
|
|
154
|
+
|
|
90
155
|
it("uses the reinvocation prompt for a HITL reinvocation (human-meaningful, no opaque ids)", () => {
|
|
91
156
|
const approvalDecisions = new Map<string, ApprovalAction>([
|
|
92
157
|
["tool-call-1", ApprovalAction.APPROVE],
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Cursor harness's max_cost_usd guard (cost-guard.ts).
|
|
3
|
+
*
|
|
4
|
+
* The guard is the cursor-side analog of the native cost-cap middleware
|
|
5
|
+
* (middleware/cost-cap.ts): same proto semantics (0/unset = no cap), same
|
|
6
|
+
* inclusive boundary (`>=`), same estimation basis. These tests pin those
|
|
7
|
+
* semantics so the two harnesses cannot silently diverge.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from "vitest";
|
|
11
|
+
import {
|
|
12
|
+
costCapExceeded,
|
|
13
|
+
formatCostLimitError,
|
|
14
|
+
COST_LIMIT_ERROR_PREFIX,
|
|
15
|
+
COST_LIMIT_USER_COPY,
|
|
16
|
+
} from "../cost-guard.js";
|
|
17
|
+
|
|
18
|
+
describe("costCapExceeded", () => {
|
|
19
|
+
it("never fires when no cap is configured (0 = unset per the proto contract)", () => {
|
|
20
|
+
expect(costCapExceeded(0, 999)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("never fires for a negative cap", () => {
|
|
24
|
+
expect(costCapExceeded(-1, 999)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("does not fire below the cap", () => {
|
|
28
|
+
expect(costCapExceeded(0.5, 0.4999)).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("fires exactly at the cap (inclusive boundary, matching the native middleware)", () => {
|
|
32
|
+
expect(costCapExceeded(0.5, 0.5)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("fires above the cap", () => {
|
|
36
|
+
expect(costCapExceeded(0.5, 0.51)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not fire when nothing has been spent", () => {
|
|
40
|
+
expect(costCapExceeded(0.5, 0)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("cost-limit terminal copy", () => {
|
|
45
|
+
it("pins the error prefix consumers match on (no structured termination reason exists)", () => {
|
|
46
|
+
// Mirrors the TOOL_CALL_LIMIT_ERROR_PREFIX guard in
|
|
47
|
+
// streaming-terminal.test.ts: a reword silently downgrades any consumer
|
|
48
|
+
// matching this prefix to generic error copy.
|
|
49
|
+
expect(COST_LIMIT_ERROR_PREFIX).toBe("Agent reached the cost limit");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("formats the error with the prefix, both amounts, and a continuation hint", () => {
|
|
53
|
+
const err = formatCostLimitError(0.5, 0.5123);
|
|
54
|
+
expect(err.startsWith(COST_LIMIT_ERROR_PREFIX)).toBe(true);
|
|
55
|
+
expect(err).toContain("$0.5123");
|
|
56
|
+
expect(err).toContain("$0.50");
|
|
57
|
+
expect(err).toContain("Send another message to continue");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("user copy is honest about the limit and that work is saved", () => {
|
|
61
|
+
expect(COST_LIMIT_USER_COPY).toContain("cost limit");
|
|
62
|
+
expect(COST_LIMIT_USER_COPY).toContain("saved");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -111,6 +111,7 @@ function buildDeps(overrides: Partial<CursorTurnStreamDeps> = {}): BuiltDeps {
|
|
|
111
111
|
promptEstimatedTokens: 100,
|
|
112
112
|
executionId: "exec-test",
|
|
113
113
|
state,
|
|
114
|
+
maxCostUsd: 0,
|
|
114
115
|
// CursorTurnStreamDeps
|
|
115
116
|
status,
|
|
116
117
|
accumulator,
|
|
@@ -261,6 +262,78 @@ describe("consumeCursorTurnStream", () => {
|
|
|
261
262
|
});
|
|
262
263
|
});
|
|
263
264
|
|
|
265
|
+
describe("cost-cap stop", () => {
|
|
266
|
+
it("cancels the run, stops the stream, and returns 'cost-cap' when onDelta flagged the overrun", async () => {
|
|
267
|
+
const state = newTurnStreamState();
|
|
268
|
+
state.costCapExceeded = true; // onDelta's write, simulated
|
|
269
|
+
const { deps, accumulator } = buildDeps({ state });
|
|
270
|
+
const run = mockRun([ev({ type: "assistant" }), ev({ type: "assistant" })]);
|
|
271
|
+
|
|
272
|
+
const reason = await consumeCursorTurnStream(run, deps);
|
|
273
|
+
|
|
274
|
+
expect(reason).toBe("cost-cap");
|
|
275
|
+
expect(run.cancel).toHaveBeenCalled();
|
|
276
|
+
// The break happens before the event is processed — no post-cap content.
|
|
277
|
+
expect(accumulator.processEvent).not.toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("detects the flag mid-stream when onDelta sets it between events", async () => {
|
|
281
|
+
const state = newTurnStreamState();
|
|
282
|
+
const { deps, accumulator } = buildDeps({ state });
|
|
283
|
+
// Simulate onDelta's write landing between event 1 and event 2 — the
|
|
284
|
+
// realistic shape: a turn-ended usage delta crosses the cap while the
|
|
285
|
+
// loop is between stream events.
|
|
286
|
+
async function* gen(): AsyncIterable<SDKMessage> {
|
|
287
|
+
yield ev({ type: "assistant" });
|
|
288
|
+
state.costCapExceeded = true;
|
|
289
|
+
yield ev({ type: "assistant" });
|
|
290
|
+
}
|
|
291
|
+
const run: MockRun = {
|
|
292
|
+
stream: () => gen(),
|
|
293
|
+
supports: () => true,
|
|
294
|
+
cancel: vi.fn(async () => {}),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const reason = await consumeCursorTurnStream(run, deps);
|
|
298
|
+
|
|
299
|
+
expect(reason).toBe("cost-cap");
|
|
300
|
+
expect(run.cancel).toHaveBeenCalled();
|
|
301
|
+
// Event 1 was processed; event 2 hit the break before processing.
|
|
302
|
+
expect(accumulator.processEvent).toHaveBeenCalledTimes(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("swallows the cancel-induced teardown rejection (expected, not a failure)", async () => {
|
|
306
|
+
const state = newTurnStreamState();
|
|
307
|
+
state.costCapExceeded = true;
|
|
308
|
+
const { deps } = buildDeps({ state });
|
|
309
|
+
// The break exits the for-await, which invokes the iterator's return();
|
|
310
|
+
// a cancelled SDK run can reject there. The catch must exempt cost-cap
|
|
311
|
+
// teardown exactly like stall/first-denial teardown.
|
|
312
|
+
const events = [ev({ type: "assistant" })];
|
|
313
|
+
let i = 0;
|
|
314
|
+
const run: MockRun = {
|
|
315
|
+
stream: () => ({
|
|
316
|
+
[Symbol.asyncIterator]: () => ({
|
|
317
|
+
next: async () =>
|
|
318
|
+
i < events.length
|
|
319
|
+
? { value: events[i++], done: false as const }
|
|
320
|
+
: { value: undefined, done: true as const },
|
|
321
|
+
return: async () => {
|
|
322
|
+
throw new Error("run cancelled");
|
|
323
|
+
},
|
|
324
|
+
}),
|
|
325
|
+
}),
|
|
326
|
+
supports: () => true,
|
|
327
|
+
cancel: vi.fn(async () => {}),
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const reason = await consumeCursorTurnStream(run, deps);
|
|
331
|
+
|
|
332
|
+
expect(reason).toBe("cost-cap");
|
|
333
|
+
expect(run.cancel).toHaveBeenCalled();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
264
337
|
it("returns 'stalled' and cancels the run when the stall watchdog fires", async () => {
|
|
265
338
|
vi.useFakeTimers();
|
|
266
339
|
// The generator yields one event, then awaits a promise resolved only by
|
|
@@ -305,6 +378,7 @@ describe("makeCursorTurnOnDelta", () => {
|
|
|
305
378
|
promptEstimatedTokens: 10,
|
|
306
379
|
executionId: "e",
|
|
307
380
|
state,
|
|
381
|
+
maxCostUsd: 0,
|
|
308
382
|
});
|
|
309
383
|
|
|
310
384
|
expect(() => onDelta({ update: { type: "text" } as never })).not.toThrow();
|
|
@@ -322,6 +396,7 @@ describe("makeCursorTurnOnDelta", () => {
|
|
|
322
396
|
promptEstimatedTokens: 10,
|
|
323
397
|
executionId: "e",
|
|
324
398
|
state,
|
|
399
|
+
maxCostUsd: 0,
|
|
325
400
|
});
|
|
326
401
|
|
|
327
402
|
expect(() => onDelta({ update: { type: "text" } as never })).toThrow("boom");
|
|
@@ -338,6 +413,7 @@ describe("makeCursorTurnOnDelta", () => {
|
|
|
338
413
|
promptEstimatedTokens: 10,
|
|
339
414
|
executionId: "e",
|
|
340
415
|
state,
|
|
416
|
+
maxCostUsd: 0,
|
|
341
417
|
});
|
|
342
418
|
|
|
343
419
|
onDelta({ update: { type: "turn-ended", usage: { inputTokens: 100 } } as never });
|
|
@@ -346,4 +422,46 @@ describe("makeCursorTurnOnDelta", () => {
|
|
|
346
422
|
expect(usageAccumulator.addTurn).toHaveBeenCalledTimes(2);
|
|
347
423
|
expect(state.firstTurnAttributionLogged).toBe(true);
|
|
348
424
|
});
|
|
425
|
+
|
|
426
|
+
it("flags costCapExceeded when a turn-ended usage delta pushes the estimate past the cap", () => {
|
|
427
|
+
const state = newTurnStreamState();
|
|
428
|
+
const usageAccumulator = {
|
|
429
|
+
...stubUsageAccumulator(),
|
|
430
|
+
snapshot: vi.fn(() => ({ estimatedCostUsd: 0.51 })),
|
|
431
|
+
};
|
|
432
|
+
const onDelta = makeCursorTurnOnDelta({
|
|
433
|
+
usageAccumulator: usageAccumulator as never,
|
|
434
|
+
deltaEnricher: stubEnricher() as never,
|
|
435
|
+
heartbeat: vi.fn(),
|
|
436
|
+
promptEstimatedTokens: 10,
|
|
437
|
+
executionId: "e",
|
|
438
|
+
state,
|
|
439
|
+
maxCostUsd: 0.5,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
onDelta({ update: { type: "turn-ended", usage: { inputTokens: 100 } } as never });
|
|
443
|
+
|
|
444
|
+
expect(state.costCapExceeded).toBe(true);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("never flags costCapExceeded when no cap is configured", () => {
|
|
448
|
+
const state = newTurnStreamState();
|
|
449
|
+
const usageAccumulator = {
|
|
450
|
+
...stubUsageAccumulator(),
|
|
451
|
+
snapshot: vi.fn(() => ({ estimatedCostUsd: 999 })),
|
|
452
|
+
};
|
|
453
|
+
const onDelta = makeCursorTurnOnDelta({
|
|
454
|
+
usageAccumulator: usageAccumulator as never,
|
|
455
|
+
deltaEnricher: stubEnricher() as never,
|
|
456
|
+
heartbeat: vi.fn(),
|
|
457
|
+
promptEstimatedTokens: 10,
|
|
458
|
+
executionId: "e",
|
|
459
|
+
state,
|
|
460
|
+
maxCostUsd: 0,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
onDelta({ update: { type: "turn-ended", usage: { inputTokens: 100 } } as never });
|
|
464
|
+
|
|
465
|
+
expect(state.costCapExceeded).toBe(false);
|
|
466
|
+
});
|
|
349
467
|
});
|