@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.
Files changed (95) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-transform.js +12 -2
  3. package/dist/activities/call-transform.js.map +1 -1
  4. package/dist/activities/call-validate.js +20 -2
  5. package/dist/activities/call-validate.js.map +1 -1
  6. package/dist/activities/discover-mcp-server.js +6 -20
  7. package/dist/activities/discover-mcp-server.js.map +1 -1
  8. package/dist/activities/execute-cursor/cost-guard.d.ts +41 -0
  9. package/dist/activities/execute-cursor/cost-guard.js +50 -0
  10. package/dist/activities/execute-cursor/cost-guard.js.map +1 -0
  11. package/dist/activities/execute-cursor/index.d.ts +12 -0
  12. package/dist/activities/execute-cursor/index.js +83 -12
  13. package/dist/activities/execute-cursor/index.js.map +1 -1
  14. package/dist/activities/execute-cursor/prompt-builder.d.ts +19 -0
  15. package/dist/activities/execute-cursor/prompt-builder.js +21 -0
  16. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  17. package/dist/activities/execute-cursor/turn-stream.d.ts +10 -1
  18. package/dist/activities/execute-cursor/turn-stream.js +42 -9
  19. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  20. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +19 -0
  21. package/dist/activities/execute-deep-agent/prompt-builder.js +12 -0
  22. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/setup.js +4 -0
  24. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  25. package/dist/activities/workflow-event-activities.js +2 -0
  26. package/dist/activities/workflow-event-activities.js.map +1 -1
  27. package/dist/config.d.ts +17 -0
  28. package/dist/config.js +14 -0
  29. package/dist/config.js.map +1 -1
  30. package/dist/runner-manager.d.ts +2 -0
  31. package/dist/runner-manager.js +2 -1
  32. package/dist/runner-manager.js.map +1 -1
  33. package/dist/runner.d.ts +2 -0
  34. package/dist/runner.js +2 -1
  35. package/dist/runner.js.map +1 -1
  36. package/dist/shared/context-bridge.d.ts +30 -0
  37. package/dist/shared/context-bridge.js +45 -0
  38. package/dist/shared/context-bridge.js.map +1 -0
  39. package/dist/shared/sender-identity.d.ts +50 -0
  40. package/dist/shared/sender-identity.js +69 -0
  41. package/dist/shared/sender-identity.js.map +1 -0
  42. package/dist/shared/with-timeout.d.ts +17 -0
  43. package/dist/shared/with-timeout.js +34 -0
  44. package/dist/shared/with-timeout.js.map +1 -0
  45. package/dist/workflow-engine/do-executor.d.ts +7 -0
  46. package/dist/workflow-engine/do-executor.js +59 -1
  47. package/dist/workflow-engine/do-executor.js.map +1 -1
  48. package/dist/workflow-engine/tasks/call-function.js +68 -1
  49. package/dist/workflow-engine/tasks/call-function.js.map +1 -1
  50. package/dist/workflow-engine/types.d.ts +26 -0
  51. package/dist/workflow-engine/types.js.map +1 -1
  52. package/dist/workflows/engine-core.js +5 -1
  53. package/dist/workflows/engine-core.js.map +1 -1
  54. package/package.json +2 -2
  55. package/src/__tests__/config.test.ts +8 -0
  56. package/src/activities/__tests__/call-validate.test.ts +137 -0
  57. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  58. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  59. package/src/activities/__tests__/workflow-event-activities.test.ts +60 -0
  60. package/src/activities/call-transform.ts +20 -2
  61. package/src/activities/call-validate.ts +27 -2
  62. package/src/activities/discover-mcp-server.ts +7 -28
  63. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +65 -0
  64. package/src/activities/execute-cursor/__tests__/cost-guard.test.ts +64 -0
  65. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +118 -0
  66. package/src/activities/execute-cursor/cost-guard.ts +56 -0
  67. package/src/activities/execute-cursor/index.ts +101 -15
  68. package/src/activities/execute-cursor/prompt-builder.ts +44 -0
  69. package/src/activities/execute-cursor/turn-stream.ts +61 -10
  70. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
  71. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  72. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  73. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  74. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +58 -0
  75. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  76. package/src/activities/execute-deep-agent/prompt-builder.ts +35 -0
  77. package/src/activities/execute-deep-agent/setup.ts +4 -0
  78. package/src/activities/workflow-event-activities.ts +2 -0
  79. package/src/config.ts +23 -0
  80. package/src/runner-manager.ts +6 -1
  81. package/src/runner.ts +6 -1
  82. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  83. package/src/shared/__tests__/context-bridge.test.ts +51 -0
  84. package/src/shared/__tests__/sender-identity.test.ts +92 -0
  85. package/src/shared/__tests__/with-timeout.test.ts +45 -0
  86. package/src/shared/context-bridge.ts +51 -0
  87. package/src/shared/sender-identity.ts +85 -0
  88. package/src/shared/with-timeout.ts +39 -0
  89. package/src/workflow-engine/__tests__/do-executor.test.ts +155 -0
  90. package/src/workflow-engine/__tests__/tasks/call-function.test.ts +94 -0
  91. package/src/workflow-engine/do-executor.ts +65 -1
  92. package/src/workflow-engine/tasks/call-function.ts +84 -3
  93. package/src/workflow-engine/types.ts +27 -0
  94. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +1 -0
  95. 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
+ });
@@ -622,6 +622,7 @@ function makeConfig() {
622
622
  checkpointerProxyEndpoint: null,
623
623
  primaryModel: "gpt-4.1",
624
624
  cursorStreamStallTimeoutMs: 180000,
625
+ agentResolveTimeoutMs: 120000,
625
626
  workspaceLockTimeoutMs: 900000,
626
627
  };
627
628
  }
@@ -601,6 +601,7 @@ function makeConfig() {
601
601
  checkpointerProxyEndpoint: null,
602
602
  primaryModel: "gpt-4.1",
603
603
  cursorStreamStallTimeoutMs: 180000,
604
+ agentResolveTimeoutMs: 120000,
604
605
  workspaceLockTimeoutMs: 900000,
605
606
  };
606
607
  }
@@ -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 { evaluateExpression } from "../workflow-engine/expression.js";
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
- return evaluateExpression(config.expression, data, {});
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 { evaluateExpression } from "../workflow-engine/expression.js";
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
- const result = await evaluateExpression(rule.expression, data, {});
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
- await withTimeout(SESSION_INIT_TIMEOUT_MS, slug, async () => {
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
  });