@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.
Files changed (177) hide show
  1. package/README.md +2 -0
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/discover-mcp-server.d.ts +7 -0
  4. package/dist/activities/discover-mcp-server.js +6 -4
  5. package/dist/activities/discover-mcp-server.js.map +1 -1
  6. package/dist/activities/emit-event.d.ts +14 -2
  7. package/dist/activities/emit-event.js +52 -17
  8. package/dist/activities/emit-event.js.map +1 -1
  9. package/dist/activities/execute-cursor/error-classifier.d.ts +19 -0
  10. package/dist/activities/execute-cursor/error-classifier.js +34 -3
  11. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  12. package/dist/activities/execute-cursor/index.d.ts +46 -10
  13. package/dist/activities/execute-cursor/index.js +126 -36
  14. package/dist/activities/execute-cursor/index.js.map +1 -1
  15. package/dist/activities/execute-cursor/model-pricing-data.d.ts +2 -0
  16. package/dist/activities/execute-cursor/model-pricing-data.js +13 -3
  17. package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
  18. package/dist/activities/execute-cursor/prompt-builder.d.ts +33 -0
  19. package/dist/activities/execute-cursor/prompt-builder.js +37 -0
  20. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  21. package/dist/activities/execute-cursor/turn-recovery.d.ts +52 -0
  22. package/dist/activities/execute-cursor/turn-recovery.js +193 -0
  23. package/dist/activities/execute-cursor/turn-recovery.js.map +1 -0
  24. package/dist/activities/execute-deep-agent/setup.js +16 -7
  25. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/shell-env.d.ts +5 -1
  27. package/dist/activities/execute-deep-agent/shell-env.js +7 -9
  28. package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
  29. package/dist/activities/execute-deep-agent/subagent-transformer.js +7 -1
  30. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  31. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +17 -3
  32. package/dist/activities/execute-deep-agent/subagent-wiring.js +14 -2
  33. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  34. package/dist/activities/hydrate-workflow-execution.js +8 -5
  35. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  36. package/dist/activities/run-command.d.ts +5 -2
  37. package/dist/activities/run-command.js +22 -10
  38. package/dist/activities/run-command.js.map +1 -1
  39. package/dist/activities/run-env.d.ts +40 -0
  40. package/dist/activities/run-env.js +66 -0
  41. package/dist/activities/run-env.js.map +1 -0
  42. package/dist/bootstrap.js +5 -0
  43. package/dist/bootstrap.js.map +1 -1
  44. package/dist/client/stigmer-client.d.ts +83 -26
  45. package/dist/client/stigmer-client.js +88 -24
  46. package/dist/client/stigmer-client.js.map +1 -1
  47. package/dist/encryption/config.d.ts +41 -9
  48. package/dist/encryption/config.js +63 -23
  49. package/dist/encryption/config.js.map +1 -1
  50. package/dist/middleware/index.d.ts +3 -0
  51. package/dist/middleware/index.js +7 -0
  52. package/dist/middleware/index.js.map +1 -1
  53. package/dist/middleware/path-normalization.d.ts +57 -0
  54. package/dist/middleware/path-normalization.js +109 -0
  55. package/dist/middleware/path-normalization.js.map +1 -0
  56. package/dist/middleware/types.d.ts +8 -0
  57. package/dist/payload-codecs.d.ts +2 -1
  58. package/dist/payload-codecs.js +7 -3
  59. package/dist/payload-codecs.js.map +1 -1
  60. package/dist/runner-manager.js +17 -3
  61. package/dist/runner-manager.js.map +1 -1
  62. package/dist/runner.js +7 -3
  63. package/dist/runner.js.map +1 -1
  64. package/dist/shared/artifact-storage.d.ts +23 -1
  65. package/dist/shared/artifact-storage.js +51 -10
  66. package/dist/shared/artifact-storage.js.map +1 -1
  67. package/dist/shared/checkpointer/http-saver.d.ts +4 -20
  68. package/dist/shared/checkpointer/http-saver.js +15 -57
  69. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  70. package/dist/shared/conversation-catchup.d.ts +9 -2
  71. package/dist/shared/conversation-catchup.js +39 -6
  72. package/dist/shared/conversation-catchup.js.map +1 -1
  73. package/dist/shared/grpc-retry.d.ts +5 -1
  74. package/dist/shared/grpc-retry.js +5 -1
  75. package/dist/shared/grpc-retry.js.map +1 -1
  76. package/dist/shared/http-retry.d.ts +51 -9
  77. package/dist/shared/http-retry.js +60 -9
  78. package/dist/shared/http-retry.js.map +1 -1
  79. package/dist/shared/mcp-manager.d.ts +8 -4
  80. package/dist/shared/mcp-manager.js +30 -4
  81. package/dist/shared/mcp-manager.js.map +1 -1
  82. package/dist/shared/mcp-schema-sanitizer.d.ts +70 -0
  83. package/dist/shared/mcp-schema-sanitizer.js +197 -0
  84. package/dist/shared/mcp-schema-sanitizer.js.map +1 -0
  85. package/dist/shared/model-client.d.ts +6 -0
  86. package/dist/shared/model-client.js +14 -3
  87. package/dist/shared/model-client.js.map +1 -1
  88. package/dist/shared/model-pricing-data.d.ts +2 -0
  89. package/dist/shared/model-pricing-data.js +13 -3
  90. package/dist/shared/model-pricing-data.js.map +1 -1
  91. package/dist/shared/model-registry.js +3 -2
  92. package/dist/shared/model-registry.js.map +1 -1
  93. package/dist/shared/plan-mode-permissions.d.ts +7 -0
  94. package/dist/shared/plan-mode-permissions.js +7 -0
  95. package/dist/shared/plan-mode-permissions.js.map +1 -1
  96. package/dist/shared/registry-endpoint.d.ts +20 -0
  97. package/dist/shared/registry-endpoint.js +25 -0
  98. package/dist/shared/registry-endpoint.js.map +1 -1
  99. package/dist/shared/runner-credential-keys.d.ts +24 -0
  100. package/dist/shared/runner-credential-keys.js +47 -0
  101. package/dist/shared/runner-credential-keys.js.map +1 -0
  102. package/dist/workflow-engine/resolve.d.ts +19 -1
  103. package/dist/workflow-engine/resolve.js +37 -2
  104. package/dist/workflow-engine/resolve.js.map +1 -1
  105. package/dist/workflows/connect-mcp-server.js +2 -0
  106. package/dist/workflows/connect-mcp-server.js.map +1 -1
  107. package/dist/workflows/types.d.ts +8 -0
  108. package/package.json +3 -4
  109. package/src/__test-utils__/__tests__/vitest-global-setup.test.ts +37 -0
  110. package/src/__test-utils__/vitest-global-setup.ts +50 -0
  111. package/src/__tests__/bootstrap.test.ts +43 -0
  112. package/src/__tests__/encryption-codec.test.ts +53 -0
  113. package/src/__tests__/preflight.test.ts +15 -0
  114. package/src/activities/__tests__/discover-mcp-server.test.ts +39 -1
  115. package/src/activities/__tests__/error-classifier.test.ts +94 -2
  116. package/src/activities/__tests__/run-command.test.ts +221 -0
  117. package/src/activities/discover-mcp-server.ts +16 -2
  118. package/src/activities/emit-event.ts +65 -21
  119. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +111 -2
  120. package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +62 -1
  121. package/src/activities/execute-cursor/__tests__/turn-recovery.test.ts +160 -0
  122. package/src/activities/execute-cursor/error-classifier.ts +40 -3
  123. package/src/activities/execute-cursor/index.ts +154 -38
  124. package/src/activities/execute-cursor/model-pricing-data.ts +22 -3
  125. package/src/activities/execute-cursor/prompt-builder.ts +51 -0
  126. package/src/activities/execute-cursor/turn-recovery.ts +208 -0
  127. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +174 -0
  128. package/src/activities/execute-deep-agent/__tests__/shell-env.test.ts +24 -10
  129. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +22 -17
  130. package/src/activities/execute-deep-agent/setup.ts +16 -8
  131. package/src/activities/execute-deep-agent/shell-env.ts +8 -9
  132. package/src/activities/execute-deep-agent/subagent-transformer.ts +7 -1
  133. package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -3
  134. package/src/activities/hydrate-workflow-execution.ts +8 -5
  135. package/src/activities/run-command.ts +25 -12
  136. package/src/activities/run-env.ts +79 -0
  137. package/src/bootstrap.ts +5 -0
  138. package/src/client/__tests__/stigmer-client.test.ts +104 -5
  139. package/src/client/stigmer-client.ts +128 -29
  140. package/src/encryption/config.ts +87 -23
  141. package/src/middleware/__tests__/path-normalization.test.ts +140 -0
  142. package/src/middleware/index.ts +8 -0
  143. package/src/middleware/path-normalization.ts +125 -0
  144. package/src/middleware/types.ts +11 -0
  145. package/src/payload-codecs.ts +8 -2
  146. package/src/runner-manager.ts +21 -3
  147. package/src/runner.ts +7 -3
  148. package/src/shared/__tests__/artifact-storage.test.ts +145 -2
  149. package/src/shared/__tests__/conversation-catchup.test.ts +24 -0
  150. package/src/shared/__tests__/http-retry.test.ts +5 -4
  151. package/src/shared/__tests__/mcp-manager.test.ts +53 -13
  152. package/src/shared/__tests__/mcp-schema-sanitizer.test.ts +267 -0
  153. package/src/shared/__tests__/model-client.test.ts +46 -0
  154. package/src/shared/__tests__/model-pricing.test.ts +62 -2
  155. package/src/shared/__tests__/model-registry.test.ts +22 -0
  156. package/src/shared/artifact-storage.ts +75 -10
  157. package/src/shared/checkpointer/http-saver.ts +15 -60
  158. package/src/shared/conversation-catchup.ts +39 -6
  159. package/src/shared/grpc-retry.ts +5 -1
  160. package/src/shared/http-retry.ts +98 -9
  161. package/src/shared/mcp-manager.ts +35 -4
  162. package/src/shared/mcp-schema-sanitizer.ts +224 -0
  163. package/src/shared/model-client.ts +21 -3
  164. package/src/shared/model-pricing-data.ts +22 -3
  165. package/src/shared/model-registry.ts +7 -2
  166. package/src/shared/plan-mode-permissions.ts +7 -0
  167. package/src/shared/registry-endpoint.ts +27 -0
  168. package/src/shared/runner-credential-keys.ts +46 -0
  169. package/src/workflow-engine/__tests__/tasks/emit-event.test.ts +127 -49
  170. package/src/workflow-engine/resolve.ts +48 -2
  171. package/src/workflows/__tests__/connect-mcp-server.test.ts +25 -0
  172. package/src/workflows/connect-mcp-server.ts +2 -0
  173. package/src/workflows/types.ts +8 -0
  174. package/dist/activities/execute-cursor/mcp-config.d.ts +0 -30
  175. package/dist/activities/execute-cursor/mcp-config.js +0 -39
  176. package/dist/activities/execute-cursor/mcp-config.js.map +0 -1
  177. package/src/activities/execute-cursor/mcp-config.ts +0 -66
@@ -1,4 +1,9 @@
1
- import { describe, it, expect, vi, beforeAll } from "vitest";
1
+ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ getPricingTable,
4
+ _resetPricingCache,
5
+ DEFAULT_PRICING,
6
+ } from "../model-pricing-data.js";
2
7
 
3
8
  /**
4
9
  * Verifies the cursor-runner display estimate resolves Cursor speed variants
@@ -110,3 +115,59 @@ describe("getCursorModelPricing — speed variant resolution", () => {
110
115
  expect(p.inputPricePerMillion).toBe(5.0);
111
116
  });
112
117
  });
118
+
119
+ describe("getPricingTable failure caching (#468)", () => {
120
+ beforeEach(() => {
121
+ _resetPricingCache();
122
+ });
123
+
124
+ afterEach(() => {
125
+ _resetPricingCache();
126
+ vi.useRealTimers();
127
+ });
128
+
129
+ it("retries after the short failure TTL instead of pinning DEFAULT_PRICING for an hour", async () => {
130
+ // The model-registry.ts failure-cache policy, applied here: wrong default
131
+ // rates for cost tracking must not persist a full success TTL.
132
+ vi.useFakeTimers();
133
+ const registryResponse = () =>
134
+ new Response(
135
+ JSON.stringify({
136
+ models: [
137
+ {
138
+ id: "composer-2.5",
139
+ displayName: "Composer 2.5",
140
+ provider: "cursor",
141
+ harness: "cursor",
142
+ costTier: "economy",
143
+ pricing: {
144
+ inputPricePerMillion: 0.5,
145
+ outputPricePerMillion: 2.5,
146
+ cacheWritePricePerMillion: 0,
147
+ cacheReadPricePerMillion: 0.2,
148
+ },
149
+ },
150
+ ],
151
+ }),
152
+ { status: 200, headers: { "Content-Type": "application/json" } },
153
+ );
154
+ const fetchSpy = vi
155
+ .fn<typeof fetch>()
156
+ .mockRejectedValueOnce(new Error("network error"))
157
+ .mockResolvedValueOnce(registryResponse());
158
+ vi.stubGlobal("fetch", fetchSpy);
159
+
160
+ // First call fails and degrades to the default table.
161
+ expect((await getPricingTable())[0]).toBe(DEFAULT_PRICING);
162
+
163
+ // Within the failure TTL the fallback stays cached (no refetch).
164
+ vi.advanceTimersByTime(30_000);
165
+ expect((await getPricingTable())[0]).toBe(DEFAULT_PRICING);
166
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
167
+
168
+ // Past the failure TTL the registry is refetched and real rates recover.
169
+ vi.advanceTimersByTime(31_000);
170
+ expect((await getPricingTable())[0]?.model).toBe("composer-2.5");
171
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
172
+ });
173
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Unit tests for the turn-recovery digest (issue #366).
3
+ *
4
+ * The digest is the replacement agent's only account of the work its lost
5
+ * predecessor did, so these pins cover the three doctrine properties it
6
+ * inherits from the DD-013 bridge composer — bounded lines, drop-oldest
7
+ * budget enforcement with disclosure, never-throw — plus the rendering
8
+ * contract per message/tool-call kind.
9
+ */
10
+
11
+ import { describe, it, expect } from "vitest";
12
+ import { create } from "@bufbuild/protobuf";
13
+ import {
14
+ AgentMessageSchema,
15
+ ToolCallSchema,
16
+ type AgentMessage,
17
+ type ToolCall,
18
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
19
+ import { MessageType, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
20
+
21
+ import { composeTurnRecoveryDigest, formatTurnRecoveryText } from "../turn-recovery.js";
22
+
23
+ function aiMessage(content: string, toolCalls: ToolCall[] = []): AgentMessage {
24
+ return create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, content, toolCalls });
25
+ }
26
+
27
+ type ToolCallFields = Partial<
28
+ Pick<ToolCall, "name" | "argsPreview" | "approvalMessage" | "status" | "error">
29
+ >;
30
+
31
+ function toolCall(overrides: ToolCallFields): ToolCall {
32
+ return create(ToolCallSchema, {
33
+ id: "tc-1",
34
+ name: "Shell",
35
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
36
+ ...overrides,
37
+ });
38
+ }
39
+
40
+ describe("composeTurnRecoveryDigest", () => {
41
+ it("renders assistant text and tool calls oldest-first", () => {
42
+ const digest = composeTurnRecoveryDigest([
43
+ aiMessage("Let me check the config first.", [
44
+ toolCall({ name: "Read", argsPreview: '{"path":"config.yaml"}' }),
45
+ ]),
46
+ aiMessage("The port is wrong — fixing it."),
47
+ ]);
48
+ expect(digest).toBe(
49
+ [
50
+ "Assistant: Let me check the config first.",
51
+ 'Tool: Read({"path":"config.yaml"}) — completed',
52
+ "Assistant: The port is wrong — fixing it.",
53
+ ].join("\n"),
54
+ );
55
+ });
56
+
57
+ it("prefers the resolved approval message for a tool line — the same description the user approved against", () => {
58
+ const digest = composeTurnRecoveryDigest([
59
+ aiMessage("", [
60
+ toolCall({
61
+ approvalMessage: "Write file: gated.txt",
62
+ argsPreview: '{"path":"gated.txt","content":"..."}',
63
+ status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
64
+ }),
65
+ ]),
66
+ ]);
67
+ expect(digest).toBe("Tool: Write file: gated.txt — paused for user approval");
68
+ });
69
+
70
+ it("maps each terminal status honestly and treats in-flight calls as interrupted", () => {
71
+ const digest = composeTurnRecoveryDigest([
72
+ aiMessage("", [
73
+ toolCall({ name: "A", status: ToolCallStatus.TOOL_CALL_COMPLETED }),
74
+ toolCall({ name: "B", status: ToolCallStatus.TOOL_CALL_FAILED, error: "exit 1" }),
75
+ toolCall({ name: "C", status: ToolCallStatus.TOOL_CALL_FAILED }),
76
+ toolCall({ name: "D", status: ToolCallStatus.TOOL_CALL_SKIPPED }),
77
+ toolCall({ name: "E", status: ToolCallStatus.TOOL_CALL_RUNNING }),
78
+ toolCall({ name: "F", status: ToolCallStatus.TOOL_CALL_PENDING }),
79
+ ]),
80
+ ]);
81
+ expect(digest).toBe(
82
+ [
83
+ "Tool: A — completed",
84
+ "Tool: B — failed: exit 1",
85
+ "Tool: C — failed",
86
+ "Tool: D — skipped",
87
+ "Tool: E — interrupted before it finished",
88
+ "Tool: F — interrupted before it finished",
89
+ ].join("\n"),
90
+ );
91
+ });
92
+
93
+ it("keeps system notices but skips human messages (already in <user_request>), thinking, and blanks", () => {
94
+ const digest = composeTurnRecoveryDigest([
95
+ create(AgentMessageSchema, { type: MessageType.MESSAGE_HUMAN, content: "Fix the build" }),
96
+ create(AgentMessageSchema, { type: MessageType.MESSAGE_SYSTEM, content: "Budget warning: 80% used" }),
97
+ create(AgentMessageSchema, { type: MessageType.MESSAGE_THINKING, content: "hmm, maybe the lockfile" }),
98
+ aiMessage(" "),
99
+ ]);
100
+ expect(digest).toBe("System: Budget warning: 80% used");
101
+ });
102
+
103
+ it("returns undefined when nothing renders", () => {
104
+ expect(composeTurnRecoveryDigest([])).toBeUndefined();
105
+ expect(
106
+ composeTurnRecoveryDigest([
107
+ create(AgentMessageSchema, { type: MessageType.MESSAGE_HUMAN, content: "only the request" }),
108
+ ]),
109
+ ).toBeUndefined();
110
+ });
111
+
112
+ it("truncates a long assistant line at the per-line budget with an ellipsis", () => {
113
+ const digest = composeTurnRecoveryDigest([aiMessage("x".repeat(1000))]);
114
+ expect(digest).toBe(`Assistant: ${"x".repeat(400)}\u2026`);
115
+ });
116
+
117
+ it("drops the OLDEST lines when over the whole-digest budget and discloses the omission", () => {
118
+ // 30 lines of ~311 chars each (~9.6k total) against the 4000-char budget:
119
+ // the newest lines must survive, the oldest go, and the notice leads.
120
+ const messages = Array.from({ length: 30 }, (_, i) =>
121
+ aiMessage(`step ${String(i).padStart(2, "0")} ${"y".repeat(300)}`),
122
+ );
123
+ const digest = composeTurnRecoveryDigest(messages);
124
+ expect(digest).toBeDefined();
125
+ expect(digest!.length).toBeLessThanOrEqual(4000);
126
+ const lines = digest!.split("\n");
127
+ expect(lines[0]).toBe("[\u2026 earlier activity in this turn omitted for length]");
128
+ // Recency wins: the last line is the newest entry, the first entries are gone.
129
+ expect(lines[lines.length - 1]).toContain("step 29");
130
+ expect(digest).not.toContain("step 00");
131
+ });
132
+
133
+ it("never throws — a malformed message degrades to no digest, not a failed recovery", () => {
134
+ // Force the internal iteration to blow up: content getter that throws.
135
+ const poison = new Proxy(aiMessage("ok"), {
136
+ get(target, prop, receiver) {
137
+ if (prop === "content") throw new Error("corrupt row");
138
+ return Reflect.get(target, prop, receiver);
139
+ },
140
+ });
141
+ expect(composeTurnRecoveryDigest([poison as AgentMessage])).toBeUndefined();
142
+ });
143
+ });
144
+
145
+ describe("formatTurnRecoveryText", () => {
146
+ it("frames a digest with the work-already-done preamble", () => {
147
+ const text = formatTurnRecoveryText("Assistant: did things");
148
+ expect(text).toContain("session holding that conversation was lost");
149
+ expect(text).toContain("do not start the task over");
150
+ expect(text.endsWith("Assistant: did things")).toBe(true);
151
+ });
152
+
153
+ it("still discloses the state loss when there is no transcript — without it the appended decisions would read as reactions to proposals this agent never made", () => {
154
+ for (const empty of [undefined, "", " "]) {
155
+ const text = formatTurnRecoveryText(empty);
156
+ expect(text).toContain("session holding that conversation was lost");
157
+ expect(text).toContain("no transcript of your progress is available");
158
+ }
159
+ });
160
+ });
@@ -131,6 +131,29 @@ function extractFromCandidate(v: unknown): RunErrorSources | undefined {
131
131
  return text.length > 0 ? { sdkError: undefined, sdkResultFields: text } : undefined;
132
132
  }
133
133
 
134
+ /**
135
+ * Stable lead sentence of the detail-free fallback error (all five detail
136
+ * channels empty — empirically the shape of Cursor-side capacity rejections,
137
+ * oss#492). This is what end users read in embedded surfaces, so it must be
138
+ * actionable product copy, not a diagnostic; the diagnostic context follows
139
+ * in a parenthetical. Mirrors the COST_LIMIT_ERROR_PREFIX convention: a
140
+ * consumer that needs to recognize this failure can match on the prefix.
141
+ * Do not reword without checking consumers, and NEVER include the phrase
142
+ * "retry or resume" — sdk-react's isInterruptedError (MessageThread.tsx)
143
+ * reframes any error containing it as a neutral resumable notice instead of
144
+ * a failure alert.
145
+ */
146
+ export const DETAIL_FREE_FALLBACK_USER_PREFIX =
147
+ "The model may be temporarily overloaded — please retry, or switch to a different model.";
148
+
149
+ /**
150
+ * Stable lead sentence of the transport-timeout fallback error (0 messages,
151
+ * ~30s duration — the SDK's default timeout with no stream established).
152
+ * Same user-facing rules as DETAIL_FREE_FALLBACK_USER_PREFIX above.
153
+ */
154
+ export const TRANSPORT_TIMEOUT_USER_PREFIX =
155
+ "The connection to the model could not be established — please retry.";
156
+
134
157
  const AUTH_PATTERNS = [
135
158
  "unauthenticated", "unauthorized", "401", "forbidden",
136
159
  "permission_denied", "invalid api key", "not logged in",
@@ -317,7 +340,10 @@ function classifyFromSources(opts: SynthesizeErrorOpts): ClassifiedError {
317
340
  const { model, mode, agentId } = opts.fallbackContext;
318
341
  return {
319
342
  category: "network",
320
- message: `Transport timeout (${opts.durationMs}ms, 0 messages received). Model=${model}, mode=${mode}, agentId=${agentId}`,
343
+ message:
344
+ `${TRANSPORT_TIMEOUT_USER_PREFIX} ` +
345
+ `(Transport timeout: ${opts.durationMs}ms, 0 messages received. ` +
346
+ `Model=${model}, mode=${mode}, agentId=${agentId})`,
321
347
  retryable: true,
322
348
  source: "fallback",
323
349
  };
@@ -332,11 +358,22 @@ function classifyFromSources(opts: SynthesizeErrorOpts): ClassifiedError {
332
358
  };
333
359
  }
334
360
 
361
+ // The honest last resort: every detail channel was empty. Observed in prod
362
+ // only during provider capacity incidents (oss#492: Composer 2.5 degradation
363
+ // — the SDK rejection that carries ERROR_RESOURCE_EXHAUSTED in Cursor's IDE
364
+ // arrives here detail-free), so the copy leads with the capacity hypothesis
365
+ // hedged ("may be"), and retryable is true: the observed cause is transient
366
+ // by nature, and nothing gates a recovery loop on unknown+retryable. The
367
+ // parenthetical keeps the exact Model=/mode=/agentId= tokens for log-grep
368
+ // continuity and the env integration test's matcher.
335
369
  const { model, mode, agentId } = opts.fallbackContext;
336
370
  return {
337
371
  category: "unknown",
338
- message: `Cursor run failed (no detail from SDK). Model=${model}, mode=${mode}, agentId=${agentId}`,
339
- retryable: false,
372
+ message:
373
+ `${DETAIL_FREE_FALLBACK_USER_PREFIX} ` +
374
+ `(No error detail from the Cursor SDK. ` +
375
+ `Model=${model}, mode=${mode}, agentId=${agentId})`,
376
+ retryable: true,
340
377
  source: "fallback",
341
378
  };
342
379
  }
@@ -41,7 +41,7 @@ import type { Config } from "../../config.js";
41
41
  import { StigmerClient } from "../../client/stigmer-client.js";
42
42
  import { describeExecutionError } from "../../shared/model-error.js";
43
43
  import { resolveAgentWithTransportRecovery } from "./session-lifecycle.js";
44
- import type { AgentResolution, CreateAgentOptions, CreateCloudAgentOptions } from "./session-lifecycle.js";
44
+ import type { AgentResolution, AgentResolutionReason, CreateAgentOptions, CreateCloudAgentOptions } from "./session-lifecycle.js";
45
45
  import { CursorMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
46
46
  import { determineCursorMode, isCloudMode } from "./cursor-mode.js";
47
47
  import { MessageAccumulator, cancelInProgressSubAgentProtos, collapseRedundantToolCallTwins } from "./message-translator.js";
@@ -92,7 +92,8 @@ import { buildCursorSubAgentDefinitions } from "./subagent-config.js";
92
92
  import { resolveSkills } from "./skill-resolver.js";
93
93
  import { removeStigmerSymlink } from "../../shared/workspace/stigmer-link.js";
94
94
  import { resolveAttachments } from "./attachment-resolver.js";
95
- import { buildEnhancedPrompt, buildReinvocationPrompt, formatConversationCatchupSection, formatInputFiles, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
95
+ import { buildEnhancedPrompt, buildHitlRecoveryPrompt, buildReinvocationPrompt, formatConversationCatchupSection, formatInputFiles, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
96
+ import { composeTurnRecoveryDigest } from "./turn-recovery.js";
96
97
  import { installHitlGate, removeHitlGate } from "./workspace-setup.js";
97
98
  import { ensureHitlDir } from "../../shared/workspace/platform-dir.js";
98
99
  import {
@@ -1161,28 +1162,43 @@ async function executeCursorInner(
1161
1162
  senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
1162
1163
  sessionContext: readSessionContext(blueprint.sessionSpec.metadata),
1163
1164
  conversationCatchup: readConversationCatchup(spec.conversationCatchup),
1165
+ // The turn's recorded transcript, seeded from the persisted execution
1166
+ // on a reinvocation (Phase 3). Consumed only by the HITL-recovery
1167
+ // shape — reached from HERE when the stored handle failed to resume
1168
+ // at resolution time (issue #366 crossing 2).
1169
+ turnRecoveryDigest: isReinvocation
1170
+ ? composeTurnRecoveryDigest(status.messages)
1171
+ : undefined,
1164
1172
  });
1165
1173
 
1166
- // Phase 10a: Inject structured output instruction for Cursor harness
1167
- let effectivePrompt = prompt;
1168
- if (structuredOutputSchema) {
1169
- const schemaStr = JSON.stringify(structuredOutputSchema, null, 2);
1170
- effectivePrompt += `\n\n---\nCRITICAL OUTPUT REQUIREMENT:\nYour final response MUST be a single valid JSON object (no markdown, no commentary, no code fences) that matches this schema:\n${schemaStr}\n\nRespond with ONLY the JSON object. Nothing else.`;
1171
- }
1174
+ // Phase 10a: Inject the structured output instruction for the Cursor
1175
+ // harness. A per-turn directive, so like buildFromPlan it must ride every
1176
+ // prompt this turn sends — the primary AND the poisoned-handle recovery
1177
+ // rebuild (the transport retry re-sends effectivePrompt and inherits it).
1178
+ const withStructuredOutputDirective = (basePrompt: string): string =>
1179
+ appendStructuredOutputDirective(basePrompt, structuredOutputSchema);
1180
+ const effectivePrompt = withStructuredOutputDirective(prompt);
1172
1181
 
1173
1182
  // Phase 10a1: The turn's vision payload. The invariant is "images
1174
- // accompany the user's turn message where the message goes, they go":
1175
- // every send that delivers this turn's message carries them (the primary
1176
- // send and both fresh-agent recovery retries, whose empty conversations
1177
- // genuinely need the re-send), while a HITL re-invocation whose prompt
1178
- // carries no user message and whose resumed agent already holds the
1179
- // images in its native conversation carries none. Computed ONCE here so
1180
- // all send sites agree by construction.
1181
- const turnImages = isHitlReinvocation(approvalDecisions)
1182
- ? []
1183
- : toCursorImages(visionImages);
1184
- const toSendMessage = (sendPrompt: string): string | SDKUserMessage =>
1185
- turnImages.length > 0 ? { text: sendPrompt, images: turnImages } : sendPrompt;
1183
+ // accompany the user's turn message, wherever the conversation does not
1184
+ // already hold them" (primarySendCarriesImages): the ONLY send that
1185
+ // skips them is a HITL re-invocation of a successfully RESUMED agent,
1186
+ // whose native conversation carries the images from the original send.
1187
+ // Every send that starts an empty conversation re-delivers them the
1188
+ // ordinary first/fresh-agent primary send, the HITL primary send after a
1189
+ // resolution-time resume failure, and both mid-send recovery retries
1190
+ // (which always run on a fresh agent, so their sites pass turnImages
1191
+ // unconditionally). Attachments re-resolve on every invocation
1192
+ // (Phase 5b), so the bytes are in hand even on a re-invocation.
1193
+ const turnImages = toCursorImages(visionImages);
1194
+ const primarySendImages = primarySendCarriesImages(approvalDecisions, resolution.reason)
1195
+ ? turnImages
1196
+ : [];
1197
+ const toSendMessage = (
1198
+ sendPrompt: string,
1199
+ images: { data: string; mimeType: string }[],
1200
+ ): string | SDKUserMessage =>
1201
+ images.length > 0 ? { text: sendPrompt, images } : sendPrompt;
1186
1202
 
1187
1203
  // Phase 10a2: Log Stigmer preamble size for context trimming diagnostics
1188
1204
  const promptChars = effectivePrompt.length;
@@ -1298,7 +1314,7 @@ async function executeCursorInner(
1298
1314
  // The stall watchdog is armed inside consumeCursorTurnStream (it needs the
1299
1315
  // run to cancel), stored on turnState.stallWatchdog so this shared onDelta can
1300
1316
  // reset it and the activity's finally can stop it as a backstop.
1301
- const run = await resolution.agent.send(toSendMessage(effectivePrompt), {
1317
+ const run = await resolution.agent.send(toSendMessage(effectivePrompt, primarySendImages), {
1302
1318
  onDelta: (event) => {
1303
1319
  if (!turnFirstEventEmitted) {
1304
1320
  turnFirstEventEmitted = true;
@@ -1669,10 +1685,11 @@ async function executeCursorInner(
1669
1685
  // poisoned-handle path leaked the fresh agent — it closed the stale one.)
1670
1686
  resolution = { ...resolution, agent: freshAgent, agentId: freshAgent.agentId, isNew: true };
1671
1687
  turnState.streamErrorMessage = undefined;
1672
- // The retry carries the turn's images too (same toSendMessage): the
1673
- // fresh agent's conversation is empty, so skipping them here would
1674
- // silently lose the user's photo on a recovered turn.
1675
- const retryRun = await freshAgent.send(toSendMessage(retryPrompt), {
1688
+ // The retry always carries the turn's full image payload never the
1689
+ // primary send's HITL-trimmed set: the fresh agent's conversation is
1690
+ // empty, so skipping them here would silently lose the user's photo on
1691
+ // a recovered turn (issue #366's vision corollary).
1692
+ const retryRun = await freshAgent.send(toSendMessage(retryPrompt, turnImages), {
1676
1693
  onDelta: makeCursorTurnOnDelta(onDeltaDeps),
1677
1694
  });
1678
1695
  await consumeCursorTurnStream(retryRun, streamDeps);
@@ -1812,6 +1829,10 @@ async function executeCursorInner(
1812
1829
  attachments: attachmentEntries,
1813
1830
  vision: visionPromptInfo,
1814
1831
  pendingApprovals: adjudicatedApprovals,
1832
+ // Without the applied set, the HITL-recovery prompt would tell
1833
+ // the fresh agent to carry out writes the runner already
1834
+ // exact-applied (the primary call at Phase 10 passes it too).
1835
+ appliedToolCallIds,
1815
1836
  interactionMode,
1816
1837
  // buildFromPlan was silently dropped here until T03 Sitting 3 —
1817
1838
  // a build turn that hit handle recovery lost its directive. The
@@ -1822,6 +1843,10 @@ async function executeCursorInner(
1822
1843
  senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
1823
1844
  sessionContext: readSessionContext(blueprint.sessionSpec.metadata),
1824
1845
  conversationCatchup: readConversationCatchup(spec.conversationCatchup),
1846
+ // Composed fresh (not reused from Phase 10): the failed primary
1847
+ // stream may have appended partial work onto status.messages,
1848
+ // and the replacement agent should know about that too.
1849
+ turnRecoveryDigest: composeTurnRecoveryDigest(status.messages),
1825
1850
  });
1826
1851
 
1827
1852
  console.log(
@@ -1837,7 +1862,14 @@ async function executeCursorInner(
1837
1862
  console.warn("Failed to update session with fresh agentId (non-fatal):", updateErr);
1838
1863
  }
1839
1864
 
1840
- const outcome = await runRecoveryStream(freshAgent, freshPrompt);
1865
+ // Same per-turn directive rule as buildFromPlan above: a
1866
+ // structured-output turn keeps its output contract on the rebuilt
1867
+ // prompt (the transport retry re-sends effectivePrompt and
1868
+ // inherits it without help).
1869
+ const outcome = await runRecoveryStream(
1870
+ freshAgent,
1871
+ withStructuredOutputDirective(freshPrompt),
1872
+ );
1841
1873
  if (!outcome.proceeded) {
1842
1874
  if (outcome.terminal.kind === "return") return slimStatus(status);
1843
1875
  throw new CancelledFailure(outcome.terminal.message);
@@ -2400,6 +2432,15 @@ export interface BuildPromptInput {
2400
2432
  * usually blank.
2401
2433
  */
2402
2434
  conversationCatchup?: string;
2435
+ /**
2436
+ * The turn's recorded transcript rendered as digest lines
2437
+ * (turn-recovery.ts), composed from `status.messages` at the call site.
2438
+ * Consumed ONLY by the HITL-recovery shape — a fresh agent that replaced
2439
+ * a lost one mid-HITL needs the story of the work it no longer remembers
2440
+ * (issue #366); every other shape either has native context or no prior
2441
+ * work to tell.
2442
+ */
2443
+ turnRecoveryDigest?: string;
2403
2444
  }
2404
2445
 
2405
2446
  /**
@@ -2411,20 +2452,28 @@ export interface BuildPromptInput {
2411
2452
  * volume, or cloud server-side state) — there is no separate continuation
2412
2453
  * store. The prompt therefore depends only on how the agent was resolved:
2413
2454
  *
2414
- * 1. HITL reinvocation -> buildReinvocationPrompt (approval decisions;
2415
- * the resumed agent's native context carries
2416
- * the prior conversation)
2417
- * 2. resumed_successfully -> raw userMessage (native context carries it)
2418
- * 3. first execution / fresh -> buildEnhancedPrompt (full instructions +
2455
+ * 1. HITL reinvocation, -> buildReinvocationPrompt (approval decisions
2456
+ * resumed agent only; the resumed agent's native context
2457
+ * carries the prior conversation)
2458
+ * 2. HITL reinvocation, -> buildHitlRecoveryPrompt (full context +
2459
+ * fresh agent after the turn's recorded transcript + decisions;
2460
+ * resume failure the replacement agent's conversation is
2461
+ * empty, and both fresh-agent crossings —
2462
+ * resolution-time resume failure and mid-send
2463
+ * poisoned-handle recovery — land here by
2464
+ * keying on the reason, issue #366)
2465
+ * 3. resumed_successfully -> raw userMessage (native context carries it)
2466
+ * 4. first execution / fresh -> buildEnhancedPrompt (full instructions +
2419
2467
  * agent after resume failure skills; no prior conversation to inherit)
2420
2468
  */
2421
2469
  /**
2422
2470
  * Whether this activity invocation is a HITL re-invocation — the turn resumes
2423
2471
  * an agent purely to convey approval decisions, carrying NO user message.
2424
- * The single discriminator for everything that must ride with the user's
2425
- * message and nothing else: the reinvocation prompt shape (below) and the
2426
- * vision payload (images accompany the message; a resumed agent already holds
2427
- * them in its native conversation).
2472
+ * Discriminates the two surfaces that depend on the agent already holding
2473
+ * this turn's content natively the prompt shape (below) and the primary
2474
+ * send's vision payload but never alone: both pair it with
2475
+ * `resolution.reason`, because a FRESH agent mid-HITL holds nothing and
2476
+ * needs the full re-delivery (issue #366).
2428
2477
  */
2429
2478
  export function isHitlReinvocation(
2430
2479
  approvalDecisions: Map<string, ApprovalAction> | undefined,
@@ -2432,6 +2481,39 @@ export function isHitlReinvocation(
2432
2481
  return approvalDecisions !== undefined && approvalDecisions.size > 0;
2433
2482
  }
2434
2483
 
2484
+ /**
2485
+ * Whether the PRIMARY send delivers the turn's vision payload. The invariant
2486
+ * is "images accompany the user's turn message, wherever the conversation
2487
+ * does not already hold them" — so the only send that skips them is a HITL
2488
+ * re-invocation of a successfully RESUMED agent, whose native conversation
2489
+ * carries the images from the original send. A fresh agent mid-HITL
2490
+ * (resolution-time resume failure — issue #366's vision corollary) holds
2491
+ * nothing and needs the re-delivery. The mid-send recovery retries always
2492
+ * run on a fresh agent, so their send sites carry the payload
2493
+ * unconditionally rather than consulting this.
2494
+ */
2495
+ export function primarySendCarriesImages(
2496
+ approvalDecisions: Map<string, ApprovalAction> | undefined,
2497
+ reason: AgentResolutionReason,
2498
+ ): boolean {
2499
+ return !(isHitlReinvocation(approvalDecisions) && reason === "resumed_successfully");
2500
+ }
2501
+
2502
+ /**
2503
+ * Append the structured-output contract to a prompt when the execution
2504
+ * requests one. A per-turn directive (the buildFromPlan rule): it must ride
2505
+ * every prompt this turn sends — the primary send AND the poisoned-handle
2506
+ * recovery rebuild, which previously lost it (issue #366 ride-along).
2507
+ */
2508
+ export function appendStructuredOutputDirective(
2509
+ basePrompt: string,
2510
+ schema: Record<string, unknown> | undefined,
2511
+ ): string {
2512
+ if (!schema) return basePrompt;
2513
+ const schemaStr = JSON.stringify(schema, null, 2);
2514
+ return basePrompt + `\n\n---\nCRITICAL OUTPUT REQUIREMENT:\nYour final response MUST be a single valid JSON object (no markdown, no commentary, no code fences) that matches this schema:\n${schemaStr}\n\nRespond with ONLY the JSON object. Nothing else.`;
2515
+ }
2516
+
2435
2517
  export function buildPrompt(input: BuildPromptInput): string {
2436
2518
  const {
2437
2519
  resolution,
@@ -2448,10 +2530,44 @@ export function buildPrompt(input: BuildPromptInput): string {
2448
2530
  conversationCatchup,
2449
2531
  } = input;
2450
2532
 
2451
- // HITL reinvocation: the agent is resumed, so its native context carries the
2452
- // prior conversation; the reinvocation prompt conveys the approval decisions
2453
- // (and which approved writes the runner already exact-applied).
2533
+ // HITL reinvocation: the decisions-only prompt is correct ONLY while the
2534
+ // agent's native context still carries the prior conversation which only
2535
+ // resumed_successfully guarantees. Any other reason means a fresh agent
2536
+ // mid-HITL (in practice created_after_resume_failure: the stored handle
2537
+ // failed to resume, or a poisoned handle was replaced mid-send), which
2538
+ // gets the full recovery shape instead — enhanced context + the turn's
2539
+ // recorded transcript + the same decisions — because the bare decisions on
2540
+ // an empty conversation strand the agent with instructions and no story,
2541
+ // and the session inherits that amnesia permanently (issue #366).
2454
2542
  if (isHitlReinvocation(approvalDecisions)) {
2543
+ if (resolution.reason !== "resumed_successfully") {
2544
+ return buildHitlRecoveryPrompt(
2545
+ {
2546
+ instructions,
2547
+ userMessage,
2548
+ skills,
2549
+ datastoreUsages: input.datastoreUsages ?? [],
2550
+ channelMessaging: input.channelMessaging ?? [],
2551
+ subAgents,
2552
+ workspaceDirs,
2553
+ workspaceFileRefs,
2554
+ attachments,
2555
+ vision: input.vision,
2556
+ interactionMode,
2557
+ buildFromPlan,
2558
+ contextBridge: input.contextBridge,
2559
+ senderIdentity: input.senderIdentity,
2560
+ sessionContext: input.sessionContext,
2561
+ conversationCatchup,
2562
+ },
2563
+ {
2564
+ turnDigest: input.turnRecoveryDigest,
2565
+ pendingApprovals: input.pendingApprovals,
2566
+ approvalDecisions,
2567
+ appliedToolCallIds: input.appliedToolCallIds,
2568
+ },
2569
+ );
2570
+ }
2455
2571
  return buildReinvocationPrompt(
2456
2572
  input.pendingApprovals,
2457
2573
  approvalDecisions,
@@ -8,7 +8,12 @@
8
8
  * endpoint is unreachable.
9
9
  */
10
10
 
11
- import { resolveModelRegistryUrl, buildRegistryHeaders } from "../../shared/registry-endpoint.js";
11
+ import {
12
+ resolveModelRegistryUrl,
13
+ buildRegistryHeaders,
14
+ REGISTRY_RETRY_POLICY,
15
+ } from "../../shared/registry-endpoint.js";
16
+ import { fetchWithRetry } from "../../shared/http-retry.js";
12
17
 
13
18
  /** Per-million rates for a speed/mode variant (e.g. "fast") of a base model. */
14
19
  export interface CursorVariantPricing {
@@ -53,6 +58,10 @@ interface RegistryEntry {
53
58
  }
54
59
 
55
60
  const CACHE_TTL_MS = 3_600_000; // 1 hour
61
+ // Failed fetches are cached much shorter than successes (the model-registry.ts
62
+ // policy): a transient failure must not pin DEFAULT_PRICING — wrong rates for
63
+ // cost tracking — for a full hour.
64
+ const FAILURE_CACHE_TTL_MS = 60_000;
56
65
 
57
66
  const DEFAULT_PRICING: CursorModelPricing = {
58
67
  model: "unknown",
@@ -104,7 +113,11 @@ function parseVariants(
104
113
  }
105
114
 
106
115
  async function fetchFromApi(): Promise<readonly CursorModelPricing[]> {
107
- const res = await fetch(resolveModelRegistryUrl(), { headers: buildRegistryHeaders() });
116
+ const res = await fetchWithRetry(
117
+ resolveModelRegistryUrl(),
118
+ { headers: buildRegistryHeaders() },
119
+ REGISTRY_RETRY_POLICY,
120
+ );
108
121
  if (!res.ok) throw new Error(`Model registry fetch failed: ${res.status}`);
109
122
  const data: unknown = await res.json();
110
123
  const table = parsePricingTable(data);
@@ -138,7 +151,7 @@ export async function getPricingTable(): Promise<readonly CursorModelPricing[]>
138
151
  `Failed to fetch model registry from API, using default pricing: ${err}`,
139
152
  );
140
153
  const fallback = [DEFAULT_PRICING];
141
- cache = { data: fallback, expiresAt: Date.now() + CACHE_TTL_MS };
154
+ cache = { data: fallback, expiresAt: Date.now() + FAILURE_CACHE_TTL_MS };
142
155
  return fallback;
143
156
  })
144
157
  .finally(() => {
@@ -148,4 +161,10 @@ export async function getPricingTable(): Promise<readonly CursorModelPricing[]>
148
161
  return inflightFetch;
149
162
  }
150
163
 
164
+ /** Exposed for testing — resets the in-memory cache. */
165
+ export function _resetPricingCache(): void {
166
+ cache = null;
167
+ inflightFetch = null;
168
+ }
169
+
151
170
  export { DEFAULT_PRICING };