@stigmer/runner 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/discover-mcp-server.d.ts +7 -0
- package/dist/activities/discover-mcp-server.js +6 -4
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/emit-event.d.ts +14 -2
- package/dist/activities/emit-event.js +52 -17
- package/dist/activities/emit-event.js.map +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.d.ts +11 -0
- package/dist/activities/execute-cursor/attachment-resolver.js +8 -0
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
- package/dist/activities/execute-cursor/blueprint-resolver.js +0 -1
- package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/error-classifier.d.ts +19 -0
- package/dist/activities/execute-cursor/error-classifier.js +34 -3
- package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +52 -12
- package/dist/activities/execute-cursor/index.js +141 -67
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/model-pricing-data.d.ts +2 -0
- package/dist/activities/execute-cursor/model-pricing-data.js +13 -3
- package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +48 -11
- package/dist/activities/execute-cursor/prompt-builder.js +57 -10
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/turn-recovery.d.ts +52 -0
- package/dist/activities/execute-cursor/turn-recovery.js +193 -0
- package/dist/activities/execute-cursor/turn-recovery.js.map +1 -0
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +11 -0
- package/dist/activities/execute-deep-agent/attachment-injector.js +7 -1
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +0 -15
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/mcp-gate.d.ts +0 -2
- package/dist/activities/execute-deep-agent/mcp-gate.js +0 -1
- package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -8
- package/dist/activities/execute-deep-agent/prompt-builder.js +14 -7
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +0 -10
- package/dist/activities/execute-deep-agent/setup.js +22 -49
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/shell-env.d.ts +5 -1
- package/dist/activities/execute-deep-agent/shell-env.js +7 -9
- package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js +7 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +17 -3
- package/dist/activities/execute-deep-agent/subagent-wiring.js +14 -2
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/hydrate-workflow-execution.js +8 -5
- package/dist/activities/hydrate-workflow-execution.js.map +1 -1
- package/dist/activities/run-command.d.ts +5 -2
- package/dist/activities/run-command.js +22 -10
- package/dist/activities/run-command.js.map +1 -1
- package/dist/activities/run-env.d.ts +40 -0
- package/dist/activities/run-env.js +66 -0
- package/dist/activities/run-env.js.map +1 -0
- package/dist/bootstrap.js +5 -0
- package/dist/bootstrap.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +83 -26
- package/dist/client/stigmer-client.js +88 -24
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/config.d.ts +6 -5
- package/dist/config.js.map +1 -1
- package/dist/encryption/config.d.ts +41 -9
- package/dist/encryption/config.js +63 -23
- package/dist/encryption/config.js.map +1 -1
- package/dist/middleware/index.d.ts +3 -0
- package/dist/middleware/index.js +7 -0
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/path-normalization.d.ts +57 -0
- package/dist/middleware/path-normalization.js +109 -0
- package/dist/middleware/path-normalization.js.map +1 -0
- package/dist/middleware/types.d.ts +8 -0
- package/dist/payload-codecs.d.ts +2 -1
- package/dist/payload-codecs.js +7 -3
- package/dist/payload-codecs.js.map +1 -1
- package/dist/runner-manager.js +17 -3
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +7 -3
- package/dist/runner.js.map +1 -1
- package/dist/shared/artifact-storage.d.ts +34 -1
- package/dist/shared/artifact-storage.js +53 -10
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/attachment-download-urls.d.ts +62 -0
- package/dist/shared/attachment-download-urls.js +79 -0
- package/dist/shared/attachment-download-urls.js.map +1 -0
- package/dist/shared/channel-attachment.d.ts +1 -1
- package/dist/shared/channel-attachment.js +1 -1
- package/dist/shared/checkpointer/http-saver.d.ts +4 -20
- package/dist/shared/checkpointer/http-saver.js +15 -57
- package/dist/shared/checkpointer/http-saver.js.map +1 -1
- package/dist/shared/conversation-attachment.d.ts +2 -3
- package/dist/shared/conversation-attachment.js +2 -3
- package/dist/shared/conversation-attachment.js.map +1 -1
- package/dist/shared/conversation-catchup.d.ts +9 -2
- package/dist/shared/conversation-catchup.js +39 -6
- package/dist/shared/conversation-catchup.js.map +1 -1
- package/dist/shared/grpc-retry.d.ts +5 -1
- package/dist/shared/grpc-retry.js +5 -1
- package/dist/shared/grpc-retry.js.map +1 -1
- package/dist/shared/http-retry.d.ts +51 -9
- package/dist/shared/http-retry.js +60 -9
- package/dist/shared/http-retry.js.map +1 -1
- package/dist/shared/mcp-manager.d.ts +8 -4
- package/dist/shared/mcp-manager.js +30 -4
- package/dist/shared/mcp-manager.js.map +1 -1
- package/dist/shared/mcp-schema-sanitizer.d.ts +70 -0
- package/dist/shared/mcp-schema-sanitizer.js +197 -0
- package/dist/shared/mcp-schema-sanitizer.js.map +1 -0
- package/dist/shared/mcp-transport-guard.d.ts +2 -2
- package/dist/shared/mcp-transport-guard.js +2 -2
- package/dist/shared/model-client.d.ts +6 -0
- package/dist/shared/model-client.js +14 -3
- package/dist/shared/model-client.js.map +1 -1
- package/dist/shared/model-pricing-data.d.ts +2 -0
- package/dist/shared/model-pricing-data.js +13 -3
- package/dist/shared/model-pricing-data.js.map +1 -1
- package/dist/shared/model-registry.js +3 -2
- package/dist/shared/model-registry.js.map +1 -1
- package/dist/shared/plan-mode-permissions.d.ts +7 -0
- package/dist/shared/plan-mode-permissions.js +7 -0
- package/dist/shared/plan-mode-permissions.js.map +1 -1
- package/dist/shared/registry-endpoint.d.ts +20 -0
- package/dist/shared/registry-endpoint.js +25 -0
- package/dist/shared/registry-endpoint.js.map +1 -1
- package/dist/shared/runner-credential-keys.d.ts +24 -0
- package/dist/shared/runner-credential-keys.js +47 -0
- package/dist/shared/runner-credential-keys.js.map +1 -0
- package/dist/shared/synthesized-attachment.d.ts +4 -4
- package/dist/shared/synthesized-attachment.js +4 -4
- package/dist/workflow-engine/resolve.d.ts +19 -1
- package/dist/workflow-engine/resolve.js +37 -2
- package/dist/workflow-engine/resolve.js.map +1 -1
- package/dist/workflows/connect-mcp-server.js +2 -0
- package/dist/workflows/connect-mcp-server.js.map +1 -1
- package/dist/workflows/types.d.ts +8 -0
- package/package.json +3 -4
- package/src/__test-utils__/__tests__/vitest-global-setup.test.ts +37 -0
- package/src/__test-utils__/fake-artifact-storage.ts +10 -1
- package/src/__test-utils__/vitest-global-setup.ts +50 -0
- package/src/__tests__/bootstrap.test.ts +43 -0
- package/src/__tests__/encryption-codec.test.ts +53 -0
- package/src/__tests__/preflight.test.ts +15 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +39 -1
- package/src/activities/__tests__/error-classifier.test.ts +94 -2
- package/src/activities/__tests__/run-command.test.ts +221 -0
- package/src/activities/discover-mcp-server.ts +16 -2
- package/src/activities/emit-event.ts +65 -21
- package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +63 -3
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +173 -31
- package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +62 -1
- package/src/activities/execute-cursor/__tests__/turn-recovery.test.ts +160 -0
- package/src/activities/execute-cursor/attachment-resolver.ts +24 -0
- package/src/activities/execute-cursor/blueprint-resolver.ts +1 -10
- package/src/activities/execute-cursor/error-classifier.ts +40 -3
- package/src/activities/execute-cursor/index.ts +175 -74
- package/src/activities/execute-cursor/model-pricing-data.ts +22 -3
- package/src/activities/execute-cursor/prompt-builder.ts +89 -22
- package/src/activities/execute-cursor/turn-recovery.ts +208 -0
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +105 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +0 -2
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +0 -1
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +0 -1
- package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +2 -7
- package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +174 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +71 -0
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +0 -1
- package/src/activities/execute-deep-agent/__tests__/shell-env.test.ts +24 -10
- package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +22 -17
- package/src/activities/execute-deep-agent/attachment-injector.ts +20 -1
- package/src/activities/execute-deep-agent/index.ts +0 -19
- package/src/activities/execute-deep-agent/mcp-gate.ts +0 -3
- package/src/activities/execute-deep-agent/prompt-builder.ts +27 -15
- package/src/activities/execute-deep-agent/setup.ts +22 -73
- package/src/activities/execute-deep-agent/shell-env.ts +8 -9
- package/src/activities/execute-deep-agent/subagent-transformer.ts +7 -1
- package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -3
- package/src/activities/hydrate-workflow-execution.ts +8 -5
- package/src/activities/run-command.ts +25 -12
- package/src/activities/run-env.ts +79 -0
- package/src/bootstrap.ts +5 -0
- package/src/client/__tests__/stigmer-client.test.ts +104 -5
- package/src/client/stigmer-client.ts +128 -29
- package/src/config.ts +6 -5
- package/src/encryption/config.ts +87 -23
- package/src/middleware/__tests__/path-normalization.test.ts +140 -0
- package/src/middleware/index.ts +8 -0
- package/src/middleware/path-normalization.ts +125 -0
- package/src/middleware/types.ts +11 -0
- package/src/payload-codecs.ts +8 -2
- package/src/runner-manager.ts +21 -3
- package/src/runner.ts +7 -3
- package/src/shared/__tests__/artifact-storage.test.ts +154 -2
- package/src/shared/__tests__/attachment-download-urls.test.ts +70 -0
- package/src/shared/__tests__/channel-attachment.test.ts +2 -2
- package/src/shared/__tests__/conversation-catchup.test.ts +24 -0
- package/src/shared/__tests__/http-retry.test.ts +5 -4
- package/src/shared/__tests__/mcp-manager.test.ts +53 -13
- package/src/shared/__tests__/mcp-schema-sanitizer.test.ts +267 -0
- package/src/shared/__tests__/model-client.test.ts +46 -0
- package/src/shared/__tests__/model-pricing.test.ts +62 -2
- package/src/shared/__tests__/model-registry.test.ts +22 -0
- package/src/shared/__tests__/synthesized-attachment.test.ts +19 -41
- package/src/shared/artifact-storage.ts +86 -10
- package/src/shared/attachment-download-urls.ts +98 -0
- package/src/shared/channel-attachment.ts +1 -1
- package/src/shared/checkpointer/http-saver.ts +15 -60
- package/src/shared/conversation-attachment.ts +2 -3
- package/src/shared/conversation-catchup.ts +39 -6
- package/src/shared/grpc-retry.ts +5 -1
- package/src/shared/http-retry.ts +98 -9
- package/src/shared/mcp-manager.ts +35 -4
- package/src/shared/mcp-schema-sanitizer.ts +224 -0
- package/src/shared/mcp-transport-guard.ts +2 -2
- package/src/shared/model-client.ts +21 -3
- package/src/shared/model-pricing-data.ts +22 -3
- package/src/shared/model-registry.ts +7 -2
- package/src/shared/plan-mode-permissions.ts +7 -0
- package/src/shared/registry-endpoint.ts +27 -0
- package/src/shared/runner-credential-keys.ts +46 -0
- package/src/shared/synthesized-attachment.ts +4 -4
- package/src/workflow-engine/__tests__/tasks/emit-event.test.ts +127 -49
- package/src/workflow-engine/resolve.ts +48 -2
- package/src/workflows/__tests__/connect-mcp-server.test.ts +25 -0
- package/src/workflows/connect-mcp-server.ts +2 -0
- package/src/workflows/types.ts +8 -0
- package/dist/activities/execute-cursor/mcp-config.d.ts +0 -30
- package/dist/activities/execute-cursor/mcp-config.js +0 -39
- package/dist/activities/execute-cursor/mcp-config.js.map +0 -1
- package/dist/shared/datastore-attachment.d.ts +0 -99
- package/dist/shared/datastore-attachment.js +0 -184
- package/dist/shared/datastore-attachment.js.map +0 -1
- package/src/activities/execute-cursor/mcp-config.ts +0 -66
- package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +0 -104
- package/src/shared/__tests__/datastore-attachment.test.ts +0 -293
- package/src/shared/datastore-attachment.ts +0 -209
|
@@ -9,12 +9,24 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Supported delivery targets:
|
|
11
11
|
* - webhook: HTTP POST with Content-Type: application/cloudevents+json
|
|
12
|
-
* - signal:
|
|
12
|
+
* - signal: signal to another workflow execution's listen task, routed
|
|
13
|
+
* through the server's SendSignal lane
|
|
14
|
+
*
|
|
15
|
+
* Signal delivery is deliberately server-mediated (oss#517): a direct
|
|
16
|
+
* Temporal client here would bypass the authorization boundary (any
|
|
17
|
+
* workflow could signal any workflow id in the namespace) and is
|
|
18
|
+
* structurally incompatible with payload encryption — emit→listen is
|
|
19
|
+
* the platform's only runner-to-runner channel, and under per-identity
|
|
20
|
+
* runner keys a sender-encrypted signal fails closed at a receiver
|
|
21
|
+
* holding a different key. The server re-produces the payload, so each
|
|
22
|
+
* side's codec passes it through.
|
|
13
23
|
*
|
|
14
24
|
* CloudEvents spec: https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md
|
|
15
25
|
*/
|
|
16
26
|
|
|
17
27
|
import { randomUUID } from "node:crypto";
|
|
28
|
+
import type { JsonObject } from "@bufbuild/protobuf";
|
|
29
|
+
import { StigmerClient } from "../client/stigmer-client.js";
|
|
18
30
|
import { loadConfig } from "../config.js";
|
|
19
31
|
import { resolveRuntimePlaceholders } from "../workflow-engine/resolve.js";
|
|
20
32
|
|
|
@@ -24,7 +36,9 @@ export interface WebhookDeliveryTarget {
|
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
export interface SignalDeliveryTarget {
|
|
27
|
-
|
|
39
|
+
/** Target workflow execution id ("wfx_..."), as returned by run/create. */
|
|
40
|
+
readonly execution_id: string;
|
|
41
|
+
/** Signal name matching the target's listen task event id (verbatim). */
|
|
28
42
|
readonly signal_name: string;
|
|
29
43
|
}
|
|
30
44
|
|
|
@@ -54,6 +68,11 @@ export interface EmitEventResult {
|
|
|
54
68
|
|
|
55
69
|
const WEBHOOK_TIMEOUT_MS = 30_000;
|
|
56
70
|
|
|
71
|
+
// Delivery is best-effort by contract, so no single target may consume the
|
|
72
|
+
// CallFunction activity's whole 5m startToClose budget. Same bound as the
|
|
73
|
+
// webhook arm.
|
|
74
|
+
const SIGNAL_TIMEOUT_MS = 30_000;
|
|
75
|
+
|
|
57
76
|
function buildEnvelope(
|
|
58
77
|
config: EmitEventConfig,
|
|
59
78
|
executionId: string,
|
|
@@ -120,33 +139,53 @@ async function deliverWebhook(
|
|
|
120
139
|
}
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
function buildClient(): StigmerClient {
|
|
143
|
+
const config = loadConfig();
|
|
144
|
+
return new StigmerClient({
|
|
145
|
+
endpoint: config.stigmerBackendEndpoint,
|
|
146
|
+
token: config.stigmerToken,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
123
150
|
async function deliverSignal(
|
|
124
151
|
envelope: Record<string, unknown>,
|
|
125
152
|
target: SignalDeliveryTarget,
|
|
153
|
+
client: StigmerClient,
|
|
126
154
|
): Promise<DeliveryError | null> {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
155
|
+
// Refuse the pre-oss#517 field by name: direct-addressing raw Temporal
|
|
156
|
+
// workflow ids is exactly the capability server mediation removes.
|
|
157
|
+
const legacyWorkflowId = (target as { workflow_id?: unknown }).workflow_id;
|
|
158
|
+
if (!target.execution_id) {
|
|
159
|
+
const reason = legacyWorkflowId
|
|
160
|
+
? "signal delivery addresses workflow executions by 'execution_id' (\"wfx_...\"); 'workflow_id' is not supported"
|
|
161
|
+
: "signal delivery requires 'execution_id'";
|
|
162
|
+
return {
|
|
163
|
+
target: `signal:${String(legacyWorkflowId ?? "")}/${target.signal_name ?? ""}`,
|
|
164
|
+
error: reason,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (!target.signal_name) {
|
|
168
|
+
return {
|
|
169
|
+
target: `signal:${target.execution_id}/`,
|
|
170
|
+
error: "signal delivery requires 'signal_name'",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
144
173
|
|
|
174
|
+
try {
|
|
175
|
+
await client.sendWorkflowSignal(
|
|
176
|
+
target.execution_id,
|
|
177
|
+
target.signal_name,
|
|
178
|
+
envelope as JsonObject,
|
|
179
|
+
{ timeoutMs: SIGNAL_TIMEOUT_MS },
|
|
180
|
+
);
|
|
145
181
|
return null;
|
|
146
182
|
} catch (err) {
|
|
183
|
+
// Server refusals arrive as ConnectErrors whose messages carry the code
|
|
184
|
+
// (e.g. [not_found], [failed_precondition] for terminal executions) —
|
|
185
|
+
// surfaced verbatim in delivery_errors, same contract as the webhook arm.
|
|
147
186
|
const message = err instanceof Error ? err.message : String(err);
|
|
148
187
|
return {
|
|
149
|
-
target: `signal:${target.
|
|
188
|
+
target: `signal:${target.execution_id}/${target.signal_name}`,
|
|
150
189
|
error: message,
|
|
151
190
|
};
|
|
152
191
|
}
|
|
@@ -173,13 +212,18 @@ export async function emitEventAction(
|
|
|
173
212
|
const errors: DeliveryError[] = [];
|
|
174
213
|
const env = runtimeEnv ?? {};
|
|
175
214
|
|
|
215
|
+
// One client serves all signal targets of this emit; constructed lazily so
|
|
216
|
+
// webhook-only emits never pay for a gRPC transport.
|
|
217
|
+
let client: StigmerClient | undefined;
|
|
218
|
+
|
|
176
219
|
for (const target of config.delivery) {
|
|
177
220
|
let err: DeliveryError | null = null;
|
|
178
221
|
|
|
179
222
|
if ("webhook" in target) {
|
|
180
223
|
err = await deliverWebhook(envelope, target.webhook, env);
|
|
181
224
|
} else if ("signal" in target) {
|
|
182
|
-
|
|
225
|
+
client ??= buildClient();
|
|
226
|
+
err = await deliverSignal(envelope, target.signal, client);
|
|
183
227
|
}
|
|
184
228
|
|
|
185
229
|
if (err) {
|
|
@@ -78,7 +78,11 @@ describe("resolveAttachments", () => {
|
|
|
78
78
|
const result = await resolveAttachments([makeAttachment()], options({ storage }));
|
|
79
79
|
|
|
80
80
|
expect(result).toEqual([
|
|
81
|
-
{
|
|
81
|
+
{
|
|
82
|
+
filename: "plan.md",
|
|
83
|
+
relativePath: ".stigmer/inputs/plan.md",
|
|
84
|
+
downloadUrl: "mem://attachments/01ABC/plan.md",
|
|
85
|
+
},
|
|
82
86
|
]);
|
|
83
87
|
expect(readFileSync(join(platformDir, "inputs", "plan.md"), "utf-8")).toBe("# The Plan");
|
|
84
88
|
});
|
|
@@ -130,11 +134,16 @@ describe("resolveAttachments", () => {
|
|
|
130
134
|
);
|
|
131
135
|
|
|
132
136
|
expect(result).toEqual([
|
|
133
|
-
{
|
|
137
|
+
{
|
|
138
|
+
filename: "report.pdf",
|
|
139
|
+
relativePath: ".stigmer/inputs/report.pdf",
|
|
140
|
+
downloadUrl: "mem://attachments/01AAA/report.pdf",
|
|
141
|
+
},
|
|
134
142
|
{
|
|
135
143
|
filename: "report-2.pdf",
|
|
136
144
|
relativePath: ".stigmer/inputs/report-2.pdf",
|
|
137
145
|
renamedFrom: "report.pdf",
|
|
146
|
+
downloadUrl: "mem://attachments/01BBB/report.pdf",
|
|
138
147
|
},
|
|
139
148
|
]);
|
|
140
149
|
expect(readFileSync(join(platformDir, "inputs", "report.pdf"), "utf-8")).toBe("first bytes");
|
|
@@ -155,12 +164,15 @@ describe("resolveAttachments", () => {
|
|
|
155
164
|
options({ storage }),
|
|
156
165
|
);
|
|
157
166
|
|
|
167
|
+
// The key-less local file lists no URL; the storage twin does — the
|
|
168
|
+
// per-file split the prompt renders.
|
|
158
169
|
expect(result).toEqual([
|
|
159
170
|
{ filename: "notes.md", relativePath: ".stigmer/inputs/notes.md" },
|
|
160
171
|
{
|
|
161
172
|
filename: "notes-2.md",
|
|
162
173
|
relativePath: ".stigmer/inputs/notes-2.md",
|
|
163
174
|
renamedFrom: "notes.md",
|
|
175
|
+
downloadUrl: "mem://attachments/01ABC/notes.md",
|
|
164
176
|
},
|
|
165
177
|
]);
|
|
166
178
|
expect(readFileSync(join(platformDir, "inputs", "notes.md"), "utf-8")).toBe("local copy");
|
|
@@ -225,7 +237,11 @@ describe("resolveAttachments", () => {
|
|
|
225
237
|
);
|
|
226
238
|
|
|
227
239
|
expect(result).toEqual([
|
|
228
|
-
{
|
|
240
|
+
{
|
|
241
|
+
filename: "evil.md",
|
|
242
|
+
relativePath: ".stigmer/inputs/evil.md",
|
|
243
|
+
downloadUrl: "mem://attachments/01ABC/x",
|
|
244
|
+
},
|
|
229
245
|
]);
|
|
230
246
|
expect(readFileSync(join(platformDir, "inputs", "evil.md"), "utf-8")).toBe("contained");
|
|
231
247
|
// Nothing escaped two levels up (where `../../evil.md` would have landed).
|
|
@@ -247,6 +263,50 @@ describe("resolveAttachments", () => {
|
|
|
247
263
|
expect(readFileSync(join(platformDir, "inputs", "evil.txt"), "utf-8")).toBe("local-contained");
|
|
248
264
|
});
|
|
249
265
|
|
|
266
|
+
// ── Download-URL hand-off (issue #532) ───────────────────────────────────
|
|
267
|
+
// The URL is strictly additive (shared/attachment-download-urls.ts): every
|
|
268
|
+
// case below also asserts the file materialized exactly as it would
|
|
269
|
+
// without one.
|
|
270
|
+
|
|
271
|
+
describe("download-URL minting during resolution", () => {
|
|
272
|
+
it("degrades to no URL when the mint fails — file still materialized, turn proceeds", async () => {
|
|
273
|
+
const { storage } = makeInMemoryArtifactStorage();
|
|
274
|
+
await storage.upload("attachments/01ABC/plan.md", Buffer.from("# The Plan"), "text/markdown");
|
|
275
|
+
storage.getDownloadUrl.mockRejectedValueOnce(new Error("presign endpoint unreachable"));
|
|
276
|
+
|
|
277
|
+
const result = await resolveAttachments([makeAttachment()], options({ storage }));
|
|
278
|
+
|
|
279
|
+
expect(result).toEqual([
|
|
280
|
+
{ filename: "plan.md", relativePath: ".stigmer/inputs/plan.md" },
|
|
281
|
+
]);
|
|
282
|
+
expect(readFileSync(join(platformDir, "inputs", "plan.md"), "utf-8")).toBe("# The Plan");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("mints a URL on the localPath fast path when an uploaded copy also exists", async () => {
|
|
286
|
+
// The fast path skips storage for the BYTES; the mint rule is
|
|
287
|
+
// branch-independent, so the uploaded copy still backs the URL.
|
|
288
|
+
const srcPath = join(workspaceDir, "src.csv");
|
|
289
|
+
writeFileSync(srcPath, "a,b,c");
|
|
290
|
+
const { storage } = makeInMemoryArtifactStorage();
|
|
291
|
+
|
|
292
|
+
const result = await resolveAttachments(
|
|
293
|
+
[makeAttachment({ filename: "data.csv", storageKey: "attachments/01ABC/data.csv", localPath: srcPath })],
|
|
294
|
+
options({ storage }),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
expect(result).toEqual([
|
|
298
|
+
{
|
|
299
|
+
filename: "data.csv",
|
|
300
|
+
relativePath: ".stigmer/inputs/data.csv",
|
|
301
|
+
downloadUrl: "mem://attachments/01ABC/data.csv",
|
|
302
|
+
},
|
|
303
|
+
]);
|
|
304
|
+
// Bytes came off local disk, not storage — the fast path is intact.
|
|
305
|
+
expect(storage.download).not.toHaveBeenCalled();
|
|
306
|
+
expect(readFileSync(join(platformDir, "inputs", "data.csv"), "utf-8")).toBe("a,b,c");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
250
310
|
// ── Vision selection (T04) ────────────────────────────────────────────────
|
|
251
311
|
// Vision is strictly additive: every case below also asserts the file
|
|
252
312
|
// materialized exactly as it would without a budget.
|
|
@@ -9,12 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
import { describe, it, expect } from "vitest";
|
|
11
11
|
import { create } from "@bufbuild/protobuf";
|
|
12
|
-
import { DatastoreUsageSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
13
12
|
import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
14
13
|
import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
|
|
15
|
-
import { ApiResourceReferenceSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
16
14
|
|
|
17
|
-
import { buildPrompt, isHitlReinvocation } from "../index.js";
|
|
15
|
+
import { appendStructuredOutputDirective, buildPrompt, isHitlReinvocation, primarySendCarriesImages } from "../index.js";
|
|
18
16
|
import type { BuildPromptInput } from "../index.js";
|
|
19
17
|
import { buildReinvocationPrompt, formatInteractionModePrefix, formatImplementPlanSection, formatToolApprovalProtocol, buildToolApprovalRuleFile } from "../prompt-builder.js";
|
|
20
18
|
import { PLAN_MODE_DIRECTIVE } from "../../../shared/plan-mode-prompt.js";
|
|
@@ -89,33 +87,6 @@ describe("buildPrompt", () => {
|
|
|
89
87
|
expect(prompt).toContain(USER_MESSAGE);
|
|
90
88
|
});
|
|
91
89
|
|
|
92
|
-
it("renders the <available_datastores> section on the first execution (T05)", () => {
|
|
93
|
-
const prompt = buildPrompt(
|
|
94
|
-
input({
|
|
95
|
-
resolution: resolution("local", "created_first_execution"),
|
|
96
|
-
datastoreUsages: [
|
|
97
|
-
create(DatastoreUsageSchema, {
|
|
98
|
-
datastoreRef: create(ApiResourceReferenceSchema, { slug: "clinic" }),
|
|
99
|
-
}),
|
|
100
|
-
],
|
|
101
|
-
}),
|
|
102
|
-
);
|
|
103
|
-
expect(prompt).toContain("<available_datastores>");
|
|
104
|
-
expect(prompt).toContain("- clinic");
|
|
105
|
-
expect(prompt).toContain("describe_datastore");
|
|
106
|
-
// The standing failure-disclosure instruction (issue #325) is this
|
|
107
|
-
// harness's ONLY outage coverage: the Cursor SDK connects MCP itself,
|
|
108
|
-
// so the runner can never reconcile the live roster here.
|
|
109
|
-
expect(prompt).toContain("do not answer from memory");
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("omits the datastores section when the agent uses no datastores", () => {
|
|
113
|
-
const prompt = buildPrompt(
|
|
114
|
-
input({ resolution: resolution("local", "created_first_execution") }),
|
|
115
|
-
);
|
|
116
|
-
expect(prompt).not.toContain("<available_datastores>");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
90
|
it("carries the rollover context bridge on the first execution (DD-013)", () => {
|
|
120
91
|
const prompt = buildPrompt(
|
|
121
92
|
input({
|
|
@@ -255,7 +226,7 @@ describe("buildPrompt", () => {
|
|
|
255
226
|
});
|
|
256
227
|
});
|
|
257
228
|
|
|
258
|
-
describe("isHitlReinvocation (
|
|
229
|
+
describe("isHitlReinvocation (paired with resolution.reason at every consumer — issue #366)", () => {
|
|
259
230
|
it("is false with no decisions and false with an empty map", () => {
|
|
260
231
|
expect(isHitlReinvocation(undefined)).toBe(false);
|
|
261
232
|
expect(isHitlReinvocation(new Map())).toBe(false);
|
|
@@ -268,6 +239,115 @@ describe("isHitlReinvocation (the single discriminator for message-accompanied p
|
|
|
268
239
|
});
|
|
269
240
|
});
|
|
270
241
|
|
|
242
|
+
describe("HITL recovery — fresh agent mid-HITL (issue #366)", () => {
|
|
243
|
+
const decisions = () =>
|
|
244
|
+
new Map<string, ApprovalAction>([["tool-call-1", ApprovalAction.APPROVE]]);
|
|
245
|
+
const approvals = () => [
|
|
246
|
+
create(PendingApprovalSchema, {
|
|
247
|
+
toolCallId: "tool-call-1",
|
|
248
|
+
toolName: "Write",
|
|
249
|
+
message: "Write file: gated.txt",
|
|
250
|
+
}),
|
|
251
|
+
];
|
|
252
|
+
const recoveryInput = (overrides: Partial<BuildPromptInput> = {}) =>
|
|
253
|
+
input({
|
|
254
|
+
resolution: resolution("local", "created_after_resume_failure"),
|
|
255
|
+
approvalDecisions: decisions(),
|
|
256
|
+
pendingApprovals: approvals(),
|
|
257
|
+
...overrides,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("keeps the resumed-HITL prompt byte-identical to the bare reinvocation prompt (regression guard)", () => {
|
|
261
|
+
const prompt = buildPrompt(
|
|
262
|
+
input({
|
|
263
|
+
resolution: resolution("local", "resumed_successfully"),
|
|
264
|
+
approvalDecisions: decisions(),
|
|
265
|
+
pendingApprovals: approvals(),
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
expect(prompt).toBe(buildReinvocationPrompt(approvals(), decisions()));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("rebuilds full context for a fresh agent mid-HITL: instructions, the ORIGINAL user message, the state-loss disclosure, and the decisions", () => {
|
|
272
|
+
const prompt = buildPrompt(recoveryInput());
|
|
273
|
+
// The pre-fix failure mode: bare decisions with no story.
|
|
274
|
+
expect(prompt).not.toBe(buildReinvocationPrompt(approvals(), decisions()));
|
|
275
|
+
expect(prompt).toContain("<agent_instructions>");
|
|
276
|
+
expect(prompt).toContain("<tool_approval_protocol>");
|
|
277
|
+
expect(prompt).toContain(`<user_request>\n${USER_MESSAGE}\n</user_request>`);
|
|
278
|
+
expect(prompt).toContain("<turn_recovery>");
|
|
279
|
+
expect(prompt).toContain("Write file: gated.txt");
|
|
280
|
+
expect(prompt).toContain("APPROVED");
|
|
281
|
+
// The opaque tool-call id must not leak into the prompt (reinvocation rule).
|
|
282
|
+
expect(prompt).not.toContain("tool-call-1");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("orders the recovery chronologically: request, then the recovered work, then the decisions, ending on the continue directive", () => {
|
|
286
|
+
const prompt = buildPrompt(
|
|
287
|
+
recoveryInput({ turnRecoveryDigest: "Tool: Write file: draft.txt — completed" }),
|
|
288
|
+
);
|
|
289
|
+
const requestIdx = prompt.indexOf("<user_request>");
|
|
290
|
+
const recoveryIdx = prompt.indexOf("<turn_recovery>");
|
|
291
|
+
const decisionsIdx = prompt.indexOf("APPROVED");
|
|
292
|
+
expect(requestIdx).toBeGreaterThan(-1);
|
|
293
|
+
expect(recoveryIdx).toBeGreaterThan(requestIdx);
|
|
294
|
+
expect(decisionsIdx).toBeGreaterThan(recoveryIdx);
|
|
295
|
+
expect(prompt).toContain("Tool: Write file: draft.txt — completed");
|
|
296
|
+
expect(prompt.trimEnd().endsWith("do not ask the user for permission in prose.")).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("discloses the state loss even with NO transcript — otherwise the decisions read as reactions to proposals this agent never made", () => {
|
|
300
|
+
const prompt = buildPrompt(recoveryInput({ turnRecoveryDigest: undefined }));
|
|
301
|
+
expect(prompt).toContain("<turn_recovery>");
|
|
302
|
+
expect(prompt).toContain("no transcript of your progress is available");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("preserves already-applied semantics: exact-applied writes are described as done, not as work to redo", () => {
|
|
306
|
+
const prompt = buildPrompt(
|
|
307
|
+
recoveryInput({ appliedToolCallIds: new Set(["tool-call-1"]) }),
|
|
308
|
+
);
|
|
309
|
+
expect(prompt).toContain("ALREADY applied");
|
|
310
|
+
expect(prompt).not.toContain("Carry them out now");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("carries the session-standing context a first turn would get — the replacement agent inherits the whole story, not just the blueprint", () => {
|
|
314
|
+
const prompt = buildPrompt(
|
|
315
|
+
recoveryInput({ contextBridge: "User: earlier thread\nAssistant: earlier reply" }),
|
|
316
|
+
);
|
|
317
|
+
expect(prompt).toContain("<previous_conversation_context>");
|
|
318
|
+
expect(prompt).toContain("User: earlier thread");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("selects the recovery shape for ANY non-resumed reason — decisions-only is safe only when native context is guaranteed", () => {
|
|
322
|
+
// created_first_execution + decisions cannot happen in practice
|
|
323
|
+
// (decisions are reconstructed from a persisted transcript, which
|
|
324
|
+
// implies a prior invocation) — but if it ever did, the bare decisions
|
|
325
|
+
// prompt would reproduce the #366 amnesia. Fail safe.
|
|
326
|
+
const prompt = buildPrompt(
|
|
327
|
+
recoveryInput({ resolution: resolution("local", "created_first_execution") }),
|
|
328
|
+
);
|
|
329
|
+
expect(prompt).toContain("<turn_recovery>");
|
|
330
|
+
expect(prompt).toContain("<agent_instructions>");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("primary send skips images ONLY for a resumed HITL agent (which holds them natively); every fresh agent gets the re-delivery", () => {
|
|
334
|
+
expect(primarySendCarriesImages(decisions(), "resumed_successfully")).toBe(false);
|
|
335
|
+
// The #366 vision corollary: fresh agent mid-HITL after a
|
|
336
|
+
// resolution-time resume failure.
|
|
337
|
+
expect(primarySendCarriesImages(decisions(), "created_after_resume_failure")).toBe(true);
|
|
338
|
+
// Non-HITL turns always carry the message's images, resumed or not.
|
|
339
|
+
expect(primarySendCarriesImages(undefined, "resumed_successfully")).toBe(true);
|
|
340
|
+
expect(primarySendCarriesImages(undefined, "created_first_execution")).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("the structured-output directive is a pure append shared by the primary and recovery sends — absent schema, absent suffix", () => {
|
|
344
|
+
expect(appendStructuredOutputDirective("base", undefined)).toBe("base");
|
|
345
|
+
const withSchema = appendStructuredOutputDirective("base", { type: "object" });
|
|
346
|
+
expect(withSchema.startsWith("base\n\n---\nCRITICAL OUTPUT REQUIREMENT:")).toBe(true);
|
|
347
|
+
expect(withSchema).toContain('"type": "object"');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
271
351
|
describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)", () => {
|
|
272
352
|
const RESUMED = { resolution: resolution("local", "resumed_successfully") };
|
|
273
353
|
|
|
@@ -345,6 +425,68 @@ describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)"
|
|
|
345
425
|
expect(prompt).toContain("Attached inline and visible to you, in order: 1. a.jpg");
|
|
346
426
|
});
|
|
347
427
|
|
|
428
|
+
it("lists a minted download URL beside its file with the presigned hand-off line (issue #532)", () => {
|
|
429
|
+
const prompt = buildPrompt(
|
|
430
|
+
input({
|
|
431
|
+
...RESUMED,
|
|
432
|
+
attachments: [
|
|
433
|
+
{ path: ".stigmer/inputs/lease.pdf", downloadUrl: "https://r2.example/lease?sig=abc" },
|
|
434
|
+
{ path: ".stigmer/inputs/notes.md" },
|
|
435
|
+
],
|
|
436
|
+
downloadUrlKind: "presigned",
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
expect(prompt).toContain(
|
|
440
|
+
"- `.stigmer/inputs/lease.pdf` — download URL: https://r2.example/lease?sig=abc",
|
|
441
|
+
);
|
|
442
|
+
// The URL-less file keeps a clean entry.
|
|
443
|
+
expect(prompt).toContain("- `.stigmer/inputs/notes.md`\n");
|
|
444
|
+
expect(prompt).toContain("These URLs are time-limited");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("words a local-serve URL honestly — reachable only from this machine", () => {
|
|
448
|
+
const prompt = buildPrompt(
|
|
449
|
+
input({
|
|
450
|
+
...RESUMED,
|
|
451
|
+
attachments: [
|
|
452
|
+
{ path: ".stigmer/inputs/lease.pdf", downloadUrl: "http://localhost:7235/attachments/01A/lease.pdf" },
|
|
453
|
+
],
|
|
454
|
+
downloadUrlKind: "local-serve",
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
expect(prompt).toContain("download URL: http://localhost:7235/attachments/01A/lease.pdf");
|
|
458
|
+
expect(prompt).toContain("reachable only from this machine");
|
|
459
|
+
expect(prompt).not.toContain("time-limited");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("renders no hand-off line when no listed file carries a URL (kind alone is not enough)", () => {
|
|
463
|
+
const prompt = buildPrompt(
|
|
464
|
+
input({
|
|
465
|
+
...RESUMED,
|
|
466
|
+
attachments: [{ path: ".stigmer/inputs/local-only.csv" }],
|
|
467
|
+
downloadUrlKind: "presigned",
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
expect(prompt).toContain("<input_files>");
|
|
471
|
+
expect(prompt).not.toContain("download URL");
|
|
472
|
+
expect(prompt).not.toContain("time-limited");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("renders the download URL inside the enhanced first-turn prompt too", () => {
|
|
476
|
+
const prompt = buildPrompt(
|
|
477
|
+
input({
|
|
478
|
+
resolution: resolution("local", "created_first_execution"),
|
|
479
|
+
attachments: [
|
|
480
|
+
{ path: ".stigmer/inputs/lease.pdf", downloadUrl: "https://r2.example/lease?sig=abc" },
|
|
481
|
+
],
|
|
482
|
+
downloadUrlKind: "presigned",
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
expect(prompt).toContain("<input_files>");
|
|
486
|
+
expect(prompt).toContain("download URL: https://r2.example/lease?sig=abc");
|
|
487
|
+
expect(prompt).toContain("These URLs are time-limited");
|
|
488
|
+
});
|
|
489
|
+
|
|
348
490
|
it("a HITL re-invocation carries no input-files section — its prompt has no user message at all", () => {
|
|
349
491
|
const prompt = buildPrompt(
|
|
350
492
|
input({
|
|
@@ -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
|
+
});
|