@stigmer/runner 3.6.0 → 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 (65) 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/execute-cursor/index.js +39 -5
  5. package/dist/activities/execute-cursor/index.js.map +1 -1
  6. package/dist/activities/execute-cursor/model-pricing.d.ts +9 -0
  7. package/dist/activities/execute-cursor/model-pricing.js +19 -0
  8. package/dist/activities/execute-cursor/model-pricing.js.map +1 -1
  9. package/dist/activities/execute-cursor/service-tier.d.ts +68 -0
  10. package/dist/activities/execute-cursor/service-tier.js +187 -0
  11. package/dist/activities/execute-cursor/service-tier.js.map +1 -0
  12. package/dist/activities/execute-cursor/session-lifecycle.d.ts +16 -1
  13. package/dist/activities/execute-cursor/session-lifecycle.js +12 -4
  14. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  15. package/dist/activities/execute-cursor/usage-accumulator.d.ts +21 -1
  16. package/dist/activities/execute-cursor/usage-accumulator.js +23 -3
  17. package/dist/activities/execute-cursor/usage-accumulator.js.map +1 -1
  18. package/dist/client/stigmer-client.d.ts +6 -1
  19. package/dist/client/stigmer-client.js +5 -2
  20. package/dist/client/stigmer-client.js.map +1 -1
  21. package/dist/main.js +18 -0
  22. package/dist/main.js.map +1 -1
  23. package/dist/runner.js +48 -0
  24. package/dist/runner.js.map +1 -1
  25. package/dist/sandbox-token-renewal.d.ts +65 -0
  26. package/dist/sandbox-token-renewal.js +169 -0
  27. package/dist/sandbox-token-renewal.js.map +1 -0
  28. package/dist/shared/artifact-storage.d.ts +17 -3
  29. package/dist/shared/artifact-storage.js +22 -4
  30. package/dist/shared/artifact-storage.js.map +1 -1
  31. package/dist/workflow-engine/loader.js +99 -2
  32. package/dist/workflow-engine/loader.js.map +1 -1
  33. package/dist/workflow-engine/tasks/call-agent.d.ts +0 -2
  34. package/dist/workflow-engine/tasks/call-agent.js +0 -2
  35. package/dist/workflow-engine/tasks/call-agent.js.map +1 -1
  36. package/dist/workflow-engine/types.d.ts +39 -7
  37. package/dist/workflow-engine/types.js.map +1 -1
  38. package/dist/workflows/call-agent-orchestrator.d.ts +3 -2
  39. package/dist/workflows/call-agent-orchestrator.js +8 -2
  40. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/__tests__/sandbox-token-renewal.test.ts +174 -0
  43. package/src/activities/__tests__/call-agent-contracts.test.ts +4 -4
  44. package/src/activities/__tests__/call-agent.test.ts +219 -4
  45. package/src/activities/call-agent.ts +94 -10
  46. package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +20 -0
  47. package/src/activities/execute-cursor/__tests__/service-tier.test.ts +170 -0
  48. package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +87 -1
  49. package/src/activities/execute-cursor/index.ts +49 -7
  50. package/src/activities/execute-cursor/model-pricing.ts +23 -0
  51. package/src/activities/execute-cursor/service-tier.ts +244 -0
  52. package/src/activities/execute-cursor/session-lifecycle.ts +33 -5
  53. package/src/activities/execute-cursor/usage-accumulator.ts +35 -3
  54. package/src/client/stigmer-client.ts +11 -4
  55. package/src/main.ts +20 -0
  56. package/src/runner.ts +62 -0
  57. package/src/sandbox-token-renewal.ts +212 -0
  58. package/src/shared/artifact-storage.ts +32 -7
  59. package/src/workflow-engine/__tests__/golden-execution.test.ts +8 -8
  60. package/src/workflow-engine/__tests__/loader.test.ts +192 -7
  61. package/src/workflow-engine/__tests__/tasks/call-agent.test.ts +9 -9
  62. package/src/workflow-engine/loader.ts +113 -2
  63. package/src/workflow-engine/tasks/call-agent.ts +0 -2
  64. package/src/workflow-engine/types.ts +40 -7
  65. package/src/workflows/call-agent-orchestrator.ts +8 -2
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
3
+ import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
3
4
 
4
5
  let mockGetAgentByReference: ReturnType<typeof vi.fn>;
5
6
  let mockCreateSession: ReturnType<typeof vi.fn>;
@@ -93,11 +94,11 @@ describe("callAgentAction", () => {
93
94
  expect(ref.slug).toBe("my-agent");
94
95
  });
95
96
 
96
- it("uses config.org over __stigmer_org_id when both are present", async () => {
97
+ it("org/slug agent reference overrides the workflow org for lookup only", async () => {
97
98
  await expect(
98
99
  callAgentAction(
99
- { agent: "my-agent", message: "Hello", org: "explicit-org" },
100
- { __stigmer_org_id: "fallback-org" },
100
+ { agent: "explicit-org/my-agent", message: "Hello" },
101
+ { __stigmer_org_id: "workflow-org" },
101
102
  "wfl_parent789",
102
103
  ),
103
104
  ).rejects.toThrow("CompleteAsyncError");
@@ -106,6 +107,11 @@ describe("callAgentAction", () => {
106
107
  expect(ref.org).toBe("explicit-org");
107
108
  expect(ref.slug).toBe("my-agent");
108
109
  expect(ref.kind).toBe(ApiResourceKind.agent);
110
+
111
+ // The execution itself is still created in the workflow's org — the
112
+ // cross-org reference changes agent lookup, never the billing org.
113
+ const execution = mockCreateAgentExecution.mock.calls[0][0];
114
+ expect(execution.metadata?.org).toBe("workflow-org");
109
115
  });
110
116
  });
111
117
 
@@ -120,7 +126,7 @@ describe("callAgentAction", () => {
120
126
  ).rejects.toThrow("call:agent requires an organization context");
121
127
  });
122
128
 
123
- it("falls back to __stigmer_org_id from runtime env", async () => {
129
+ it("uses __stigmer_org_id from runtime env", async () => {
124
130
  await expect(
125
131
  callAgentAction(
126
132
  { agent: "my-agent", message: "Hello" },
@@ -260,4 +266,213 @@ describe("callAgentAction", () => {
260
266
  expect(execution.spec.activityTaskQueue).toBe("");
261
267
  });
262
268
  });
269
+
270
+ describe("run_config → ExecutionConfig mapping (#358)", () => {
271
+ it("maps model_name, max_cost_usd, and max_tool_rounds onto ExecutionConfig", async () => {
272
+ await expect(
273
+ callAgentAction(
274
+ {
275
+ agent: "my-agent",
276
+ message: "Hello",
277
+ run_config: {
278
+ model_name: "claude-sonnet-4-6",
279
+ max_cost_usd: 0.75,
280
+ max_tool_rounds: 15,
281
+ },
282
+ },
283
+ { __stigmer_org_id: "test-org" },
284
+ "wfl_parent",
285
+ ),
286
+ ).rejects.toThrow("CompleteAsyncError");
287
+
288
+ const execution = mockCreateAgentExecution.mock.calls[0][0];
289
+ expect(execution.spec.executionConfig).toBeDefined();
290
+ expect(execution.spec.executionConfig.modelName).toBe("claude-sonnet-4-6");
291
+ expect(execution.spec.executionConfig.maxCostUsd).toBe(0.75);
292
+ expect(execution.spec.executionConfig.maxToolRounds).toBe(15);
293
+ });
294
+
295
+ it("maps a canonical service_tier onto ExecutionConfig.serviceTier (#357)", async () => {
296
+ await expect(
297
+ callAgentAction(
298
+ {
299
+ agent: "my-agent",
300
+ message: "Hello",
301
+ run_config: { service_tier: "SERVICE_TIER_FAST" },
302
+ },
303
+ { __stigmer_org_id: "test-org" },
304
+ "wfl_parent",
305
+ ),
306
+ ).rejects.toThrow("CompleteAsyncError");
307
+
308
+ const execution = mockCreateAgentExecution.mock.calls[0][0];
309
+ expect(execution.spec.executionConfig).toBeDefined();
310
+ expect(execution.spec.executionConfig.serviceTier).toBe(ServiceTier.FAST);
311
+ });
312
+
313
+ it("fails loudly on a service_tier value with no proto mapping", async () => {
314
+ // The loader canonicalizes tiers; an unmapped value reaching the
315
+ // activity means loader/activity drift. A pricing directive must
316
+ // never be silently dropped.
317
+ await expect(
318
+ callAgentAction(
319
+ {
320
+ agent: "my-agent",
321
+ message: "Hello",
322
+ run_config: { service_tier: "SERVICE_TIER_TURBO" },
323
+ },
324
+ { __stigmer_org_id: "test-org" },
325
+ "wfl_parent",
326
+ ),
327
+ ).rejects.toThrow(
328
+ "call:agent run_config.service_tier 'SERVICE_TIER_TURBO' has no proto mapping",
329
+ );
330
+ });
331
+
332
+ it("omits ExecutionConfig entirely when run_config and output are absent", async () => {
333
+ await expect(
334
+ callAgentAction(
335
+ { agent: "my-agent", message: "Hello" },
336
+ { __stigmer_org_id: "test-org" },
337
+ "wfl_parent",
338
+ ),
339
+ ).rejects.toThrow("CompleteAsyncError");
340
+
341
+ const execution = mockCreateAgentExecution.mock.calls[0][0];
342
+ expect(execution.spec.executionConfig).toBeUndefined();
343
+ });
344
+
345
+ it("treats zero bounds as no override", async () => {
346
+ await expect(
347
+ callAgentAction(
348
+ {
349
+ agent: "my-agent",
350
+ message: "Hello",
351
+ run_config: { model_name: "claude-sonnet-4-6", max_cost_usd: 0, max_tool_rounds: 0 },
352
+ },
353
+ { __stigmer_org_id: "test-org" },
354
+ "wfl_parent",
355
+ ),
356
+ ).rejects.toThrow("CompleteAsyncError");
357
+
358
+ const execution = mockCreateAgentExecution.mock.calls[0][0];
359
+ expect(execution.spec.executionConfig.modelName).toBe("claude-sonnet-4-6");
360
+ // Proto zero values: unset numerics read back as 0, but nothing was
361
+ // deliberately written — the guards must not have set them from a
362
+ // zero "no override" input.
363
+ expect(execution.spec.executionConfig.maxCostUsd).toBe(0);
364
+ expect(execution.spec.executionConfig.maxToolRounds).toBe(0);
365
+ });
366
+ });
367
+
368
+ describe("workspace entries and workflow provenance (#358 Phase 2)", () => {
369
+ it("maps git workspace entries onto the created session's spec", async () => {
370
+ await expect(
371
+ callAgentAction(
372
+ {
373
+ agent: "my-agent",
374
+ message: "Hello",
375
+ workspace_entries: [
376
+ { name: "app", source: { git_repo: { url: "https://github.com/acme/app", branch: "main" } } },
377
+ { source: { git_repo: { url: "https://github.com/acme/lib" } } },
378
+ ],
379
+ },
380
+ { __stigmer_org_id: "test-org" },
381
+ "wfl_parent",
382
+ ),
383
+ ).rejects.toThrow("CompleteAsyncError");
384
+
385
+ const session = mockCreateSession.mock.calls[0][0];
386
+ const entries = session.spec.workspaceEntries;
387
+ expect(entries).toHaveLength(2);
388
+ expect(entries[0].name).toBe("app");
389
+ expect(entries[0].source.source.case).toBe("gitRepo");
390
+ expect(entries[0].source.source.value.url).toBe("https://github.com/acme/app");
391
+ expect(entries[0].source.source.value.branch).toBe("main");
392
+ expect(entries[1].source.source.value.url).toBe("https://github.com/acme/lib");
393
+ });
394
+
395
+ it("creates sessions without workspace entries when none are configured", async () => {
396
+ await expect(
397
+ callAgentAction(
398
+ { agent: "my-agent", message: "Hello" },
399
+ { __stigmer_org_id: "test-org" },
400
+ "wfl_parent",
401
+ ),
402
+ ).rejects.toThrow("CompleteAsyncError");
403
+
404
+ const session = mockCreateSession.mock.calls[0][0];
405
+ expect(session.spec.workspaceEntries).toHaveLength(0);
406
+ });
407
+
408
+ it("stamps workflow provenance labels the server keys environment resolution on", async () => {
409
+ await expect(
410
+ callAgentAction(
411
+ {
412
+ agent: "my-agent",
413
+ message: "Hello",
414
+ __wfExecId: "wex_prov1",
415
+ __taskName: "triage",
416
+ } as any,
417
+ { __stigmer_org_id: "test-org" },
418
+ "wfl_parent",
419
+ ),
420
+ ).rejects.toThrow("CompleteAsyncError");
421
+
422
+ const execution = mockCreateAgentExecution.mock.calls[0][0];
423
+ expect(execution.metadata.labels["stigmer.ai/workflow-execution-id"]).toBe("wex_prov1");
424
+ expect(execution.metadata.labels["stigmer.ai/workflow-task"]).toBe("triage");
425
+ });
426
+
427
+ it("stamps no provenance labels without workflow context", async () => {
428
+ await expect(
429
+ callAgentAction(
430
+ { agent: "my-agent", message: "Hello" },
431
+ { __stigmer_org_id: "test-org" },
432
+ "wfl_parent",
433
+ ),
434
+ ).rejects.toThrow("CompleteAsyncError");
435
+
436
+ const execution = mockCreateAgentExecution.mock.calls[0][0];
437
+ expect(Object.keys(execution.metadata.labels ?? {})).toHaveLength(0);
438
+ });
439
+ });
440
+
441
+ describe("env secret marking", () => {
442
+ it("preserves the agent-declared secret flag when a task env override supplies the value", async () => {
443
+ // The agent declares API_TOKEN as secret. A task-level env override
444
+ // provides the value — the secret marking must survive (#358: the
445
+ // override used to hardcode isSecret:false, a redaction downgrade).
446
+ mockGetAgentByReference.mockResolvedValue({
447
+ metadata: { id: "agt_test123" },
448
+ status: { defaultInstanceId: "ain_default456" },
449
+ spec: {
450
+ env: {
451
+ API_TOKEN: { isSecret: true },
452
+ REGION: { isSecret: false },
453
+ },
454
+ },
455
+ });
456
+
457
+ await expect(
458
+ callAgentAction(
459
+ {
460
+ agent: "my-agent",
461
+ message: "Hello",
462
+ env: { API_TOKEN: "resolved-secret-value", REGION: "us-east-1", EXTRA: "plain" },
463
+ },
464
+ { __stigmer_org_id: "test-org" },
465
+ "wfl_parent",
466
+ ),
467
+ ).rejects.toThrow("CompleteAsyncError");
468
+
469
+ const execution = mockCreateAgentExecution.mock.calls[0][0];
470
+ const runtimeEnv = execution.spec.runtimeEnv;
471
+ expect(runtimeEnv.API_TOKEN.value).toBe("resolved-secret-value");
472
+ expect(runtimeEnv.API_TOKEN.isSecret).toBe(true);
473
+ expect(runtimeEnv.REGION.isSecret).toBe(false);
474
+ // Keys the agent never declared stay non-secret.
475
+ expect(runtimeEnv.EXTRA.isSecret).toBe(false);
476
+ });
477
+ });
263
478
  });
@@ -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,
@@ -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
+ });