@stigmer/runner 3.5.3 → 3.7.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.
Files changed (101) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-agent.js +85 -10
  3. package/dist/activities/call-agent.js.map +1 -1
  4. package/dist/activities/discover-mcp-server.js +9 -1
  5. package/dist/activities/discover-mcp-server.js.map +1 -1
  6. package/dist/activities/execute-cursor/index.d.ts +5 -0
  7. package/dist/activities/execute-cursor/index.js +88 -14
  8. package/dist/activities/execute-cursor/index.js.map +1 -1
  9. package/dist/activities/execute-cursor/model-pricing.d.ts +9 -0
  10. package/dist/activities/execute-cursor/model-pricing.js +19 -0
  11. package/dist/activities/execute-cursor/model-pricing.js.map +1 -1
  12. package/dist/activities/execute-cursor/prompt-builder.d.ts +7 -0
  13. package/dist/activities/execute-cursor/prompt-builder.js +9 -0
  14. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  15. package/dist/activities/execute-cursor/service-tier.d.ts +68 -0
  16. package/dist/activities/execute-cursor/service-tier.js +187 -0
  17. package/dist/activities/execute-cursor/service-tier.js.map +1 -0
  18. package/dist/activities/execute-cursor/session-lifecycle.d.ts +16 -1
  19. package/dist/activities/execute-cursor/session-lifecycle.js +12 -4
  20. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  21. package/dist/activities/execute-cursor/usage-accumulator.d.ts +21 -1
  22. package/dist/activities/execute-cursor/usage-accumulator.js +23 -3
  23. package/dist/activities/execute-cursor/usage-accumulator.js.map +1 -1
  24. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +6 -0
  25. package/dist/activities/execute-deep-agent/prompt-builder.js +3 -0
  26. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/setup.js +45 -9
  28. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  29. package/dist/client/stigmer-client.d.ts +32 -1
  30. package/dist/client/stigmer-client.js +42 -2
  31. package/dist/client/stigmer-client.js.map +1 -1
  32. package/dist/main.js +18 -0
  33. package/dist/main.js.map +1 -1
  34. package/dist/runner.js +48 -0
  35. package/dist/runner.js.map +1 -1
  36. package/dist/sandbox-token-renewal.d.ts +65 -0
  37. package/dist/sandbox-token-renewal.js +169 -0
  38. package/dist/sandbox-token-renewal.js.map +1 -0
  39. package/dist/shared/artifact-storage.d.ts +17 -3
  40. package/dist/shared/artifact-storage.js +22 -4
  41. package/dist/shared/artifact-storage.js.map +1 -1
  42. package/dist/shared/caller-identity.d.ts +89 -0
  43. package/dist/shared/caller-identity.js +124 -0
  44. package/dist/shared/caller-identity.js.map +1 -0
  45. package/dist/shared/channel-attachment.d.ts +85 -0
  46. package/dist/shared/channel-attachment.js +203 -0
  47. package/dist/shared/channel-attachment.js.map +1 -0
  48. package/dist/shared/datastore-attachment.d.ts +2 -25
  49. package/dist/shared/datastore-attachment.js +1 -28
  50. package/dist/shared/datastore-attachment.js.map +1 -1
  51. package/dist/shared/synthesized-attachment.d.ts +51 -0
  52. package/dist/shared/synthesized-attachment.js +45 -0
  53. package/dist/shared/synthesized-attachment.js.map +1 -0
  54. package/dist/workflow-engine/loader.js +99 -2
  55. package/dist/workflow-engine/loader.js.map +1 -1
  56. package/dist/workflow-engine/tasks/call-agent.d.ts +0 -2
  57. package/dist/workflow-engine/tasks/call-agent.js +0 -2
  58. package/dist/workflow-engine/tasks/call-agent.js.map +1 -1
  59. package/dist/workflow-engine/types.d.ts +39 -7
  60. package/dist/workflow-engine/types.js.map +1 -1
  61. package/dist/workflows/call-agent-orchestrator.d.ts +3 -2
  62. package/dist/workflows/call-agent-orchestrator.js +8 -2
  63. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/__test-utils__/mock-client.ts +4 -0
  66. package/src/__tests__/sandbox-token-renewal.test.ts +174 -0
  67. package/src/activities/__tests__/call-agent-contracts.test.ts +4 -4
  68. package/src/activities/__tests__/call-agent.test.ts +219 -4
  69. package/src/activities/__tests__/discover-mcp-server.test.ts +49 -0
  70. package/src/activities/call-agent.ts +94 -10
  71. package/src/activities/discover-mcp-server.ts +13 -1
  72. package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +20 -0
  73. package/src/activities/execute-cursor/__tests__/service-tier.test.ts +170 -0
  74. package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +87 -1
  75. package/src/activities/execute-cursor/index.ts +121 -20
  76. package/src/activities/execute-cursor/model-pricing.ts +23 -0
  77. package/src/activities/execute-cursor/prompt-builder.ts +19 -0
  78. package/src/activities/execute-cursor/service-tier.ts +244 -0
  79. package/src/activities/execute-cursor/session-lifecycle.ts +33 -5
  80. package/src/activities/execute-cursor/usage-accumulator.ts +35 -3
  81. package/src/activities/execute-deep-agent/prompt-builder.ts +10 -0
  82. package/src/activities/execute-deep-agent/setup.ts +66 -10
  83. package/src/client/stigmer-client.ts +57 -4
  84. package/src/main.ts +20 -0
  85. package/src/runner.ts +62 -0
  86. package/src/sandbox-token-renewal.ts +212 -0
  87. package/src/shared/__tests__/caller-identity.test.ts +159 -0
  88. package/src/shared/__tests__/channel-attachment.test.ts +276 -0
  89. package/src/shared/__tests__/datastore-attachment.test.ts +4 -4
  90. package/src/shared/artifact-storage.ts +32 -7
  91. package/src/shared/caller-identity.ts +161 -0
  92. package/src/shared/channel-attachment.ts +237 -0
  93. package/src/shared/datastore-attachment.ts +2 -54
  94. package/src/shared/synthesized-attachment.ts +77 -0
  95. package/src/workflow-engine/__tests__/golden-execution.test.ts +8 -8
  96. package/src/workflow-engine/__tests__/loader.test.ts +192 -7
  97. package/src/workflow-engine/__tests__/tasks/call-agent.test.ts +9 -9
  98. package/src/workflow-engine/loader.ts +113 -2
  99. package/src/workflow-engine/tasks/call-agent.ts +0 -2
  100. package/src/workflow-engine/types.ts +40 -7
  101. package/src/workflows/call-agent-orchestrator.ts +8 -2
@@ -411,6 +411,55 @@ describe("DiscoverMcpServer activity", () => {
411
411
  expect(mockClient.getExecutionContextByExecutionId).toHaveBeenCalledWith("ctx-abc");
412
412
  });
413
413
 
414
+ it("discovers a caller-identity-templating server via the anonymous sentinel", async () => {
415
+ // Discovery has no session, so declared STIGMER_CALLER_IDENTITY_*
416
+ // placeholders resolve to the anonymous sentinel instead of failing
417
+ // with PlaceholderResolutionError — the failure mode that would
418
+ // leave an identity-consuming server's tools permanently
419
+ // unclassified. The server sees an anonymous caller and must answer
420
+ // tools/list (its authz layer refuses tool CALLS, not discovery).
421
+ const { discoverMcpServer } = await import("../discover-mcp-server.js");
422
+ const { MultiServerMCPClient } = await import("@langchain/mcp-adapters");
423
+
424
+ const spec = makeHttpSpec("https://isc-mcp.example.com/mcp");
425
+ spec.serverType.value.headers = {
426
+ "X-Stigmer-Caller-Kind": "${STIGMER_CALLER_IDENTITY_KIND}",
427
+ "X-Stigmer-Caller-Value": "${STIGMER_CALLER_IDENTITY_VALUE}",
428
+ Authorization: "Bearer ${ISC_SHARED_SECRET}",
429
+ };
430
+ spec.env = {
431
+ STIGMER_CALLER_IDENTITY_KIND: { optional: true },
432
+ STIGMER_CALLER_IDENTITY_VALUE: { optional: true },
433
+ ISC_SHARED_SECRET: { isSecret: true },
434
+ };
435
+
436
+ const mockClient = makeMockStigmerClient({
437
+ mcpServer: makeMcpServer({ metadata: { slug: "isc-gym" }, spec }),
438
+ executionContext: {
439
+ spec: { data: { ISC_SHARED_SECRET: { value: "s3cret", isSecret: true } } },
440
+ },
441
+ });
442
+
443
+ const mockMcpClient = makeMockMcpClient({
444
+ tools: [{ name: "get_gym_info", description: "Public info", inputSchema: { type: "object" } }],
445
+ });
446
+ mockInitializeConnections.mockResolvedValue({});
447
+ mockGetClient.mockResolvedValue(mockMcpClient);
448
+
449
+ const result = await discoverMcpServer(
450
+ { mcpServerId: "mcp-isc", executionContextId: "ctx-isc" },
451
+ { stigmerClient: mockClient as any, transportPosture: "stdio-forbidden" },
452
+ );
453
+
454
+ expect(result.tools).toHaveLength(1);
455
+ const connectionConfig = vi.mocked(MultiServerMCPClient).mock.calls[0][0] as any;
456
+ expect(connectionConfig["isc-gym"].headers).toEqual({
457
+ "X-Stigmer-Caller-Kind": "anonymous",
458
+ "X-Stigmer-Caller-Value": "",
459
+ Authorization: "Bearer s3cret",
460
+ });
461
+ });
462
+
414
463
  it("returns empty env when no executionContextId provided", async () => {
415
464
  const { discoverMcpServer } = await import("../discover-mcp-server.js");
416
465
 
@@ -31,6 +31,13 @@ import { AgentExecutionSpecSchema, ExecutionConfigSchema } from "@stigmer/protos
31
31
  import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
32
32
  import { SessionSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/spec_pb";
33
33
  import { Harness, ExecutionTarget } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
34
+ import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
35
+ import {
36
+ WorkspaceEntrySchema,
37
+ WorkspaceSourceSchema,
38
+ GitRepoSourceSchema,
39
+ type WorkspaceEntry,
40
+ } from "@stigmer/protos/ai/stigmer/agentic/session/v1/workspace_pb";
34
41
  import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
35
42
  import { ExecutionValueSchema } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
36
43
  import type { ExecutionValue } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
@@ -62,14 +69,15 @@ export async function callAgentAction(
62
69
  );
63
70
  }
64
71
 
65
- const orgId = resolved.org
66
- ?? (runtimeEnv["__stigmer_org_id"] as string | undefined)
67
- ?? "";
72
+ // The execution is always created in the workflow's org — the workflow
73
+ // owner pays for the run. A cross-org agent reference ("org/slug") only
74
+ // changes where the agent blueprint is looked up, never the billing org.
75
+ const orgId = (runtimeEnv["__stigmer_org_id"] as string | undefined) ?? "";
68
76
 
69
77
  if (!orgId) {
70
78
  throw new Error(
71
79
  "call:agent requires an organization context. " +
72
- "Set 'org' in the task config or ensure '__stigmer_org_id' is in the workflow environment.",
80
+ "Ensure '__stigmer_org_id' is in the workflow environment.",
73
81
  );
74
82
  }
75
83
 
@@ -142,6 +150,7 @@ export async function callAgentAction(
142
150
  harness,
143
151
  executionTarget,
144
152
  subject: "Auto-created session",
153
+ workspaceEntries: buildWorkspaceEntries(resolved.workspace_entries),
145
154
  }),
146
155
  }),
147
156
  );
@@ -170,10 +179,17 @@ export async function callAgentAction(
170
179
  );
171
180
  }
172
181
 
173
- // Task-config-level env takes precedence over auto-forwarded values
182
+ // Task-config-level env takes precedence over auto-forwarded values.
183
+ // The agent's declared secret marking survives the override: a key the
184
+ // agent declares secret stays secret no matter which channel supplied
185
+ // the value, so an explicit task-level `env:` entry cannot downgrade
186
+ // redaction (issue #358 — the override used to hardcode isSecret:false).
174
187
  if (resolved.env) {
175
188
  for (const [key, value] of Object.entries(resolved.env)) {
176
- executionRuntimeEnv[key] = { value: String(value), isSecret: false };
189
+ executionRuntimeEnv[key] = {
190
+ value: String(value),
191
+ isSecret: agentEnvDecls[key]?.isSecret ?? false,
192
+ };
177
193
  }
178
194
  }
179
195
 
@@ -196,13 +212,41 @@ export async function callAgentAction(
196
212
  const parentQueue = runtimeEnv["__stigmer_activity_task_queue"] as string | undefined;
197
213
  const activityTaskQueue = parentQueue?.startsWith("wfexec:") ? parentQueue : "";
198
214
 
199
- const hasModel = !!resolved.config?.model;
215
+ // Honest RunConfig → ExecutionConfig mapping (issue #358): every field
216
+ // the author may set is forwarded to a field the runner enforces.
217
+ // model_name replaces the agent's default outright; max_cost_usd feeds
218
+ // the harness-generic cost guards (cost-cap middleware / cursor
219
+ // cost-guard); max_tool_rounds feeds resolveRecursionLimit (native
220
+ // harness only); service_tier feeds the cursor harness's explicit
221
+ // variant selection (issue #357). Zero/unset means "no override" and is
222
+ // omitted.
223
+ const runConfig = resolved.run_config;
224
+ const hasModel = !!runConfig?.model_name;
225
+ const hasCostCap = (runConfig?.max_cost_usd ?? 0) > 0;
226
+ const hasToolRounds = (runConfig?.max_tool_rounds ?? 0) > 0;
227
+ // Loader guarantees a canonical enum name; an unknown one here means the
228
+ // loader and this mapping drifted — fail the task, never silently drop a
229
+ // pricing directive.
230
+ const SERVICE_TIER_BY_NAME: Record<string, ServiceTier> = {
231
+ SERVICE_TIER_STANDARD: ServiceTier.STANDARD,
232
+ SERVICE_TIER_FAST: ServiceTier.FAST,
233
+ };
234
+ const serviceTier = runConfig?.service_tier
235
+ ? SERVICE_TIER_BY_NAME[runConfig.service_tier]
236
+ : undefined;
237
+ if (runConfig?.service_tier && serviceTier === undefined) {
238
+ throw new Error(
239
+ `call:agent run_config.service_tier '${runConfig.service_tier}' has no proto mapping`,
240
+ );
241
+ }
242
+ const hasServiceTier = serviceTier !== undefined;
200
243
  const hasOutputSchema = !!resolved.output?.schema;
201
244
 
202
245
  console.log(
203
246
  `[CallAgent] schema propagation diagnostic: ` +
204
247
  `hasOutputSchema=${hasOutputSchema}, ` +
205
- `hasModel=${hasModel}, ` +
248
+ `hasModel=${hasModel}, hasCostCap=${hasCostCap}, hasToolRounds=${hasToolRounds}, ` +
249
+ `hasServiceTier=${hasServiceTier}, ` +
206
250
  `configKeys=[${Object.keys(resolved).join(",")}], ` +
207
251
  `hasOutput=${resolved.output !== undefined}, ` +
208
252
  `outputKeys=${resolved.output ? JSON.stringify(Object.keys(resolved.output)) : "N/A"}, ` +
@@ -220,15 +264,29 @@ export async function callAgentAction(
220
264
  runtimeEnv: runtimeEnvProto,
221
265
  });
222
266
 
223
- if (hasModel || hasOutputSchema) {
267
+ if (hasModel || hasCostCap || hasToolRounds || hasServiceTier || hasOutputSchema) {
224
268
  const execConfig = create(ExecutionConfigSchema, {});
225
- if (hasModel) execConfig.modelName = resolved.config!.model!;
269
+ if (hasModel) execConfig.modelName = runConfig!.model_name!;
270
+ if (hasCostCap) execConfig.maxCostUsd = runConfig!.max_cost_usd!;
271
+ if (hasToolRounds) execConfig.maxToolRounds = runConfig!.max_tool_rounds!;
272
+ if (hasServiceTier) execConfig.serviceTier = serviceTier!;
226
273
  if (hasOutputSchema) {
227
274
  execConfig.structuredOutputSchema = resolved.output!.schema as JsonObject;
228
275
  }
229
276
  executionSpec.executionConfig = execConfig;
230
277
  }
231
278
 
279
+ // Workflow provenance labels: the server's CreateExecutionContextStep
280
+ // keys the agent_call environment_refs resolution on these (the
281
+ // schedule-label lineage). The cloud edition additionally gates the
282
+ // branch on the trusted runner caller identity, so the labels are only
283
+ // load-bearing inside that trust boundary.
284
+ const labels: Record<string, string> = {};
285
+ if (wfExecId && taskName) {
286
+ labels["stigmer.ai/workflow-execution-id"] = wfExecId;
287
+ labels["stigmer.ai/workflow-task"] = taskName;
288
+ }
289
+
232
290
  await client.createAgentExecution(
233
291
  create(AgentExecutionSchema, {
234
292
  apiVersion: "agentic.stigmer.ai/v1",
@@ -236,6 +294,7 @@ export async function callAgentAction(
236
294
  metadata: create(ApiResourceMetadataSchema, {
237
295
  name: executionName,
238
296
  org: orgId,
297
+ labels,
239
298
  }),
240
299
  spec: executionSpec,
241
300
  }),
@@ -244,6 +303,31 @@ export async function callAgentAction(
244
303
  throw new CompleteAsyncError();
245
304
  }
246
305
 
306
+ // buildWorkspaceEntries maps the task's git-only workspace entries onto the
307
+ // shared session WorkspaceEntry proto. Provisioning credentials are NOT the
308
+ // runner's concern here: the provisioner resolves GITHUB_TOKEN from the
309
+ // merged environment (DD-018 D-4), which the task's environment_refs feed
310
+ // via server-side resolution.
311
+ function buildWorkspaceEntries(
312
+ entries: AgentCallConfig["workspace_entries"],
313
+ ): WorkspaceEntry[] {
314
+ if (!entries || entries.length === 0) return [];
315
+ return entries.map((entry) =>
316
+ create(WorkspaceEntrySchema, {
317
+ name: entry.name ?? "",
318
+ source: create(WorkspaceSourceSchema, {
319
+ source: {
320
+ case: "gitRepo",
321
+ value: create(GitRepoSourceSchema, {
322
+ url: entry.source.git_repo.url,
323
+ branch: entry.source.git_repo.branch ?? "",
324
+ }),
325
+ },
326
+ }),
327
+ }),
328
+ );
329
+ }
330
+
247
331
  function parseAgentReference(
248
332
  agentStr: string,
249
333
  defaultOrg: string,
@@ -30,6 +30,7 @@ import {
30
30
  type McpTransportPosture,
31
31
  } from "../shared/mcp-transport-guard.js";
32
32
  import { detectOAuthChallenge } from "../shared/mcp-oauth-detect.js";
33
+ import { injectAnonymousCallerIdentityForDiscovery } from "../shared/caller-identity.js";
33
34
  import { withTimeout } from "../shared/with-timeout.js";
34
35
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
35
36
  import type { Config } from "../config.js";
@@ -249,7 +250,18 @@ export async function discoverMcpServer(
249
250
  const declaredEnvKeys = mcpServer.spec.env
250
251
  ? new Set(Object.keys(mcpServer.spec.env))
251
252
  : new Set<string>();
252
- const finalEnv = injectPlatformEnv(declaredEnvKeys, envVars);
253
+ const platformEnv = injectPlatformEnv(declaredEnvKeys, envVars);
254
+
255
+ // Discovery runs with no session, so every declared caller-identity key
256
+ // resolves to the anonymous sentinel — without it, a server templating
257
+ // ${STIGMER_CALLER_IDENTITY_VALUE} in its headers would fail discovery
258
+ // with PlaceholderResolutionError and its tools would never be
259
+ // classified. Servers consuming these keys answer tools/list to
260
+ // anonymous callers by contract.
261
+ const finalEnv = injectAnonymousCallerIdentityForDiscovery(
262
+ declaredEnvKeys,
263
+ platformEnv,
264
+ );
253
265
 
254
266
  const resolved = mcpServerToResolved(mcpServer, slug, finalEnv);
255
267
  if (!resolved) {
@@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeAll } from "vitest";
7
7
  */
8
8
  describe("getCursorModelPricing — speed variant resolution", () => {
9
9
  let getCursorModelPricing: typeof import("../model-pricing.js").getCursorModelPricing;
10
+ let getCursorModelPricingForVariant: typeof import("../model-pricing.js").getCursorModelPricingForVariant;
10
11
  let computeTurnCost: typeof import("../model-pricing.js").computeTurnCost;
11
12
 
12
13
  beforeAll(async () => {
@@ -58,6 +59,7 @@ describe("getCursorModelPricing — speed variant resolution", () => {
58
59
  const mod = await import("../model-pricing.js");
59
60
  await mod.ensureLoaded();
60
61
  getCursorModelPricing = mod.getCursorModelPricing;
62
+ getCursorModelPricingForVariant = mod.getCursorModelPricingForVariant;
61
63
  computeTurnCost = mod.computeTurnCost;
62
64
  });
63
65
 
@@ -89,4 +91,22 @@ describe("getCursorModelPricing — speed variant resolution", () => {
89
91
  const cost = computeTurnCost(p, 266_945, 7_069, 0, 222_432);
90
92
  expect(cost).toBeCloseTo(0.28406, 5);
91
93
  });
94
+
95
+ it("getCursorModelPricingForVariant('fast') prices a base id at fast rates (#357)", () => {
96
+ // The explicit-tier path: the caller KNOWS the variant (it requested
97
+ // it) — no wire-id suffix inference involved.
98
+ const p = getCursorModelPricingForVariant("composer-2.5", "fast");
99
+ expect(p.inputPricePerMillion).toBe(3.0);
100
+ expect(p.outputPricePerMillion).toBe(15.0);
101
+ });
102
+
103
+ it("getCursorModelPricingForVariant(null) keeps base rates", () => {
104
+ const p = getCursorModelPricingForVariant("composer-2.5", null);
105
+ expect(p.inputPricePerMillion).toBe(0.5);
106
+ });
107
+
108
+ it("getCursorModelPricingForVariant('fast') falls back to base rates when unpriced", () => {
109
+ const p = getCursorModelPricingForVariant("claude-opus-4-6", "fast");
110
+ expect(p.inputPricePerMillion).toBe(5.0);
111
+ });
92
112
  });
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
3
+
4
+ /**
5
+ * Verifies the service-tier → Cursor variant-parameter translation (#357):
6
+ * the runner must always send an explicit selection whose price-bearing
7
+ * parameters are a deterministic function of the requested tier — never
8
+ * the catalog's (account-influenced) default variant.
9
+ *
10
+ * Catalog fixtures mirror the real shapes observed 2026-08-06: composer
11
+ * has a `fast` bool (default fast=true), haiku has a `thinking` bool
12
+ * (default thinking=true), grok has `effort` x `fast`, and Auto
13
+ * ("default") has a single variant with no parameters.
14
+ */
15
+ const listMock = vi.hoisted(() => vi.fn());
16
+
17
+ vi.mock("@cursor/sdk", () => ({
18
+ Cursor: { models: { list: listMock } },
19
+ }));
20
+
21
+ import {
22
+ resolveEffectiveServiceTier,
23
+ resolveServiceTierParams,
24
+ resetCatalogCacheForTests,
25
+ } from "../service-tier.js";
26
+
27
+ const CATALOG = [
28
+ {
29
+ id: "composer-2.5",
30
+ displayName: "Composer 2.5",
31
+ aliases: ["composer-latest", "composer"],
32
+ parameters: [
33
+ { id: "fast", values: [{ value: "false" }, { value: "true" }] },
34
+ ],
35
+ },
36
+ {
37
+ id: "claude-haiku-4-5",
38
+ displayName: "Haiku 4.5",
39
+ aliases: ["haiku"],
40
+ parameters: [
41
+ { id: "thinking", values: [{ value: "false" }, { value: "true" }] },
42
+ ],
43
+ },
44
+ {
45
+ id: "grok-4.5",
46
+ displayName: "Cursor Grok 4.5",
47
+ parameters: [
48
+ { id: "effort", values: [{ value: "low" }, { value: "medium" }, { value: "high" }] },
49
+ { id: "fast", values: [{ value: "false" }, { value: "true" }] },
50
+ ],
51
+ },
52
+ {
53
+ id: "claude-opus-4-8",
54
+ displayName: "Opus 4.8",
55
+ parameters: [
56
+ { id: "thinking", values: [{ value: "false" }, { value: "true" }] },
57
+ { id: "effort", values: [{ value: "low" }, { value: "high" }] },
58
+ { id: "fast", values: [{ value: "false" }, { value: "true" }] },
59
+ ],
60
+ },
61
+ {
62
+ id: "default",
63
+ displayName: "Auto",
64
+ aliases: ["auto"],
65
+ variants: [{ params: [], displayName: "Auto", isDefault: true }],
66
+ },
67
+ ];
68
+
69
+ function opts(modelId: string, tier: ServiceTier.STANDARD | ServiceTier.FAST) {
70
+ return { apiKey: "key-1", modelId, tier, executionId: "aex_test" };
71
+ }
72
+
73
+ beforeEach(() => {
74
+ resetCatalogCacheForTests();
75
+ listMock.mockReset();
76
+ listMock.mockResolvedValue(CATALOG);
77
+ });
78
+
79
+ describe("resolveEffectiveServiceTier", () => {
80
+ it("resolves UNSPECIFIED to STANDARD — never the account default", () => {
81
+ expect(resolveEffectiveServiceTier(ServiceTier.UNSPECIFIED)).toBe(ServiceTier.STANDARD);
82
+ expect(resolveEffectiveServiceTier(undefined)).toBe(ServiceTier.STANDARD);
83
+ });
84
+
85
+ it("preserves explicit STANDARD and FAST", () => {
86
+ expect(resolveEffectiveServiceTier(ServiceTier.STANDARD)).toBe(ServiceTier.STANDARD);
87
+ expect(resolveEffectiveServiceTier(ServiceTier.FAST)).toBe(ServiceTier.FAST);
88
+ });
89
+ });
90
+
91
+ describe("resolveServiceTierParams", () => {
92
+ it("STANDARD pins fast=false on a fast-capable model", async () => {
93
+ const params = await resolveServiceTierParams(opts("composer-2.5", ServiceTier.STANDARD));
94
+ expect(params).toEqual([{ id: "fast", value: "false" }]);
95
+ });
96
+
97
+ it("FAST pins fast=true on a fast-capable model", async () => {
98
+ const params = await resolveServiceTierParams(opts("composer-2.5", ServiceTier.FAST));
99
+ expect(params).toEqual([{ id: "fast", value: "true" }]);
100
+ });
101
+
102
+ it("STANDARD pins thinking=false on a thinking-capable model (the haiku drift)", async () => {
103
+ const params = await resolveServiceTierParams(opts("claude-haiku-4-5", ServiceTier.STANDARD));
104
+ expect(params).toEqual([{ id: "thinking", value: "false" }]);
105
+ });
106
+
107
+ it("FAST on a model with no fast parameter fails loudly, never downgrades", async () => {
108
+ await expect(
109
+ resolveServiceTierParams(opts("claude-haiku-4-5", ServiceTier.FAST)),
110
+ ).rejects.toThrow(/no "fast" parameter/);
111
+ });
112
+
113
+ it("leaves price-neutral parameters (effort) to the catalog default", async () => {
114
+ const params = await resolveServiceTierParams(opts("grok-4.5", ServiceTier.STANDARD));
115
+ expect(params).toEqual([{ id: "fast", value: "false" }]);
116
+ });
117
+
118
+ it("pins every price-bearing parameter, sorted, on multi-dimension models", async () => {
119
+ const params = await resolveServiceTierParams(opts("claude-opus-4-8", ServiceTier.FAST));
120
+ expect(params).toEqual([
121
+ { id: "fast", value: "true" },
122
+ { id: "thinking", value: "false" },
123
+ ]);
124
+ });
125
+
126
+ it("resolves models referenced by alias", async () => {
127
+ const params = await resolveServiceTierParams(opts("composer", ServiceTier.STANDARD));
128
+ expect(params).toEqual([{ id: "fast", value: "false" }]);
129
+ });
130
+
131
+ it("Auto has no tier dimension: STANDARD sends no params", async () => {
132
+ const params = await resolveServiceTierParams(opts("default", ServiceTier.STANDARD));
133
+ expect(params).toEqual([]);
134
+ // No catalog fetch needed for Auto — nothing to look up.
135
+ expect(listMock).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it("Auto + FAST is a loud failure (registry/catalog drift, not a silent no-op)", async () => {
139
+ await expect(
140
+ resolveServiceTierParams(opts("default", ServiceTier.FAST)),
141
+ ).rejects.toThrow(/requires a pinned model/);
142
+ });
143
+
144
+ it("unknown model: STANDARD degrades to no params, FAST fails loudly", async () => {
145
+ await expect(
146
+ resolveServiceTierParams(opts("not-a-model", ServiceTier.STANDARD)),
147
+ ).resolves.toEqual([]);
148
+ await expect(
149
+ resolveServiceTierParams(opts("not-a-model", ServiceTier.FAST)),
150
+ ).rejects.toThrow(/does not list that model/);
151
+ });
152
+
153
+ it("catalog fetch failure: STANDARD degrades, FAST fails loudly", async () => {
154
+ listMock.mockRejectedValue(new Error("proxy down"));
155
+ await expect(
156
+ resolveServiceTierParams(opts("composer-2.5", ServiceTier.STANDARD)),
157
+ ).resolves.toEqual([]);
158
+ resetCatalogCacheForTests();
159
+ listMock.mockRejectedValue(new Error("proxy down"));
160
+ await expect(
161
+ resolveServiceTierParams(opts("composer-2.5", ServiceTier.FAST)),
162
+ ).rejects.toThrow(/catalog fetch failed/);
163
+ });
164
+
165
+ it("caches the catalog per worker — one fetch for repeated resolutions", async () => {
166
+ await resolveServiceTierParams(opts("composer-2.5", ServiceTier.STANDARD));
167
+ await resolveServiceTierParams(opts("claude-haiku-4-5", ServiceTier.STANDARD));
168
+ expect(listMock).toHaveBeenCalledTimes(1);
169
+ });
170
+ });
@@ -1,7 +1,45 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi, beforeAll } from "vitest";
2
+ import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
2
3
 
3
4
  import { UsageAccumulator } from "../usage-accumulator.js";
4
5
 
6
+ // The accumulator's per-turn estimate reads the worker's pricing table
7
+ // (model-pricing.ts); load it from a stubbed registry so the fast-rate
8
+ // assertions run against known prices (the model-pricing.test.ts pattern).
9
+ beforeAll(async () => {
10
+ const registry = {
11
+ models: [
12
+ {
13
+ id: "composer-2.5",
14
+ displayName: "Composer 2.5",
15
+ provider: "cursor",
16
+ harness: "cursor",
17
+ costTier: "economy",
18
+ pricing: {
19
+ inputPricePerMillion: 0.5,
20
+ outputPricePerMillion: 2.5,
21
+ cacheWritePricePerMillion: 0,
22
+ cacheReadPricePerMillion: 0.2,
23
+ },
24
+ pricingVariants: {
25
+ fast: {
26
+ inputPricePerMillion: 3.0,
27
+ outputPricePerMillion: 15.0,
28
+ cacheWritePricePerMillion: 0,
29
+ cacheReadPricePerMillion: 0.2,
30
+ },
31
+ },
32
+ },
33
+ ],
34
+ };
35
+ vi.stubGlobal(
36
+ "fetch",
37
+ vi.fn(async () => ({ ok: true, status: 200, json: async () => registry })),
38
+ );
39
+ process.env.STIGMER_TOKEN = "test-token";
40
+ await (await import("../model-pricing.js")).ensureLoaded();
41
+ });
42
+
5
43
  /**
6
44
  * Guards the token-accounting convention the Usage widget and the billing
7
45
  * parity test both depend on: the Cursor SDK's inputTokens already INCLUDES
@@ -43,4 +81,52 @@ describe("UsageAccumulator", () => {
43
81
  expect(snap.outputTokens).toBe(0n);
44
82
  expect(snap.totalTokens).toBe(42n);
45
83
  });
84
+
85
+ it("records the requested tier and params into the snapshot (#357 audit trail)", () => {
86
+ const acc = new UsageAccumulator(
87
+ "composer-2.5",
88
+ ServiceTier.FAST,
89
+ [{ id: "fast", value: "true" }],
90
+ );
91
+ acc.addTurn({ inputTokens: 10, outputTokens: 5 });
92
+ const snap = acc.snapshot();
93
+ expect(snap.requestedServiceTier).toBe(ServiceTier.FAST);
94
+ expect(snap.requestedModelParams).toBe('[{"id":"fast","value":"true"}]');
95
+ });
96
+
97
+ it("records an empty params string when the runner sent none", () => {
98
+ const acc = new UsageAccumulator("default", ServiceTier.STANDARD, []);
99
+ acc.addTurn({ inputTokens: 1 });
100
+ const snap = acc.snapshot();
101
+ expect(snap.requestedServiceTier).toBe(ServiceTier.STANDARD);
102
+ expect(snap.requestedModelParams).toBe("");
103
+ });
104
+
105
+ it("estimates FAST runs at fast-variant rates, not base rates (#357)", () => {
106
+ // Revert guard for the tier→pricing wiring in addTurn: a FAST run
107
+ // priced at base rates would understate the display estimate ~6x
108
+ // relative to the authoritative bill. Rates from the stubbed registry:
109
+ // base $0.5/$2.5 per M, fast $3/$15 per M.
110
+ const turn = { inputTokens: 1_000_000, outputTokens: 1_000_000 };
111
+
112
+ const standard = new UsageAccumulator("composer-2.5", ServiceTier.STANDARD);
113
+ standard.addTurn(turn);
114
+ const fast = new UsageAccumulator("composer-2.5", ServiceTier.FAST);
115
+ fast.addTurn(turn);
116
+
117
+ expect(standard.snapshot().estimatedCostUsd).toBeCloseTo(3.0, 6);
118
+ expect(fast.snapshot().estimatedCostUsd).toBeCloseTo(18.0, 6);
119
+ });
120
+
121
+ it("estimates UNSPECIFIED at base rates (resolves to standard)", () => {
122
+ const turn = { inputTokens: 1_000_000, outputTokens: 1_000_000 };
123
+
124
+ const unspecified = new UsageAccumulator("composer-2.5");
125
+ unspecified.addTurn(turn);
126
+ const standard = new UsageAccumulator("composer-2.5", ServiceTier.STANDARD);
127
+ standard.addTurn(turn);
128
+
129
+ expect(unspecified.snapshot().estimatedCostUsd)
130
+ .toBe(standard.snapshot().estimatedCostUsd);
131
+ });
46
132
  });