@stigmer/runner 3.1.9 → 3.1.11

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 (55) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/execute-cursor/model-pricing-data.d.ts +5 -8
  4. package/dist/activities/execute-cursor/model-pricing-data.js +7 -22
  5. package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
  6. package/dist/activities/promote-task-output.d.ts +6 -1
  7. package/dist/activities/promote-task-output.js +10 -5
  8. package/dist/activities/promote-task-output.js.map +1 -1
  9. package/dist/activities/workflow-event-activities.js +10 -1
  10. package/dist/activities/workflow-event-activities.js.map +1 -1
  11. package/dist/config.d.ts +8 -0
  12. package/dist/config.js +4 -1
  13. package/dist/config.js.map +1 -1
  14. package/dist/shared/model-pricing-data.js +2 -8
  15. package/dist/shared/model-pricing-data.js.map +1 -1
  16. package/dist/shared/model-registry.d.ts +2 -1
  17. package/dist/shared/model-registry.js +14 -11
  18. package/dist/shared/model-registry.js.map +1 -1
  19. package/dist/shared/registry-endpoint.d.ts +32 -0
  20. package/dist/shared/registry-endpoint.js +49 -0
  21. package/dist/shared/registry-endpoint.js.map +1 -0
  22. package/dist/workflow-engine/loader.js +4 -0
  23. package/dist/workflow-engine/loader.js.map +1 -1
  24. package/dist/workflow-engine/resolve.js +9 -2
  25. package/dist/workflow-engine/resolve.js.map +1 -1
  26. package/dist/workflow-engine/task-status-accumulator.d.ts +8 -1
  27. package/dist/workflow-engine/task-status-accumulator.js +2 -1
  28. package/dist/workflow-engine/task-status-accumulator.js.map +1 -1
  29. package/dist/workflow-engine/tasks/human-input.d.ts +7 -0
  30. package/dist/workflow-engine/tasks/human-input.js +70 -4
  31. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  32. package/dist/workflow-engine/types.d.ts +19 -1
  33. package/dist/workflow-engine/types.js.map +1 -1
  34. package/dist/workflows/engine-core.js +1 -1
  35. package/dist/workflows/engine-core.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/activities/execute-cursor/model-pricing-data.ts +8 -24
  38. package/src/activities/promote-task-output.ts +10 -4
  39. package/src/activities/workflow-event-activities.ts +11 -2
  40. package/src/config.ts +4 -1
  41. package/src/shared/__tests__/model-registry.test.ts +74 -1
  42. package/src/shared/__tests__/registry-endpoint.test.ts +80 -0
  43. package/src/shared/model-pricing-data.ts +3 -8
  44. package/src/shared/model-registry.ts +17 -11
  45. package/src/shared/registry-endpoint.ts +53 -0
  46. package/src/workflow-engine/__tests__/loader.test.ts +93 -0
  47. package/src/workflow-engine/__tests__/resolve.test.ts +50 -0
  48. package/src/workflow-engine/__tests__/task-status-accumulator.test.ts +35 -0
  49. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +248 -2
  50. package/src/workflow-engine/loader.ts +4 -0
  51. package/src/workflow-engine/resolve.ts +9 -1
  52. package/src/workflow-engine/task-status-accumulator.ts +9 -1
  53. package/src/workflow-engine/tasks/human-input.ts +111 -15
  54. package/src/workflow-engine/types.ts +18 -0
  55. package/src/workflows/engine-core.ts +2 -2
package/src/config.ts CHANGED
@@ -230,8 +230,11 @@ function requireEnv(name: string): string {
230
230
  /**
231
231
  * Ensures the endpoint has an HTTP(S) scheme. Port 443 implies TLS;
232
232
  * all other bare host:port endpoints get http://.
233
+ *
234
+ * Exported so registry-endpoint.ts applies the same normalization to
235
+ * STIGMER_BACKEND_ENDPOINT that this module applies when loading config.
233
236
  */
234
- function normalizeEndpoint(endpoint: string): string {
237
+ export function normalizeEndpoint(endpoint: string): string {
235
238
  if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) {
236
239
  return endpoint;
237
240
  }
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { getSummarizationModel, getEconomyModel, getDefaultModel, _resetRegistryCache } from "../model-registry.js";
2
+ import {
3
+ getSummarizationModel,
4
+ getEconomyModel,
5
+ getDefaultModel,
6
+ resolveToApiModelId,
7
+ _resetRegistryCache,
8
+ } from "../model-registry.js";
3
9
 
4
10
  describe("getSummarizationModel", () => {
5
11
  beforeEach(() => {
@@ -178,6 +184,73 @@ describe("getDefaultModel", () => {
178
184
  });
179
185
  });
180
186
 
187
+ describe("resolveToApiModelId", () => {
188
+ beforeEach(() => {
189
+ _resetRegistryCache();
190
+ vi.restoreAllMocks();
191
+ });
192
+
193
+ afterEach(() => {
194
+ _resetRegistryCache();
195
+ vi.useRealTimers();
196
+ });
197
+
198
+ it("resolves a canonical registry id to the provider api id", async () => {
199
+ mockRegistryResponse([
200
+ { id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic", costTier: "economy", harness: "native" },
201
+ ]);
202
+
203
+ const result = await resolveToApiModelId("claude-haiku-4.5");
204
+ expect(result).toBe("claude-haiku-4-5-20251001");
205
+ });
206
+
207
+ it("passes unknown ids through unchanged (provider api ids stay verbatim)", async () => {
208
+ mockRegistryResponse([
209
+ { id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic", costTier: "economy", harness: "native" },
210
+ ]);
211
+
212
+ const result = await resolveToApiModelId("claude-haiku-4-5-20251001");
213
+ expect(result).toBe("claude-haiku-4-5-20251001");
214
+ });
215
+
216
+ it("degrades to pass-through when the registry fetch fails", async () => {
217
+ vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("network error"));
218
+
219
+ const result = await resolveToApiModelId("claude-haiku-4.5");
220
+ expect(result).toBe("claude-haiku-4.5");
221
+ });
222
+
223
+ it("retries after the short failure TTL instead of caching the failure for an hour", async () => {
224
+ vi.useFakeTimers();
225
+ const fetchSpy = vi
226
+ .spyOn(globalThis, "fetch")
227
+ .mockRejectedValueOnce(new Error("network error"))
228
+ .mockResolvedValueOnce(
229
+ new Response(
230
+ JSON.stringify({
231
+ models: [
232
+ { id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic", costTier: "economy", harness: "native" },
233
+ ],
234
+ }),
235
+ { status: 200, headers: { "Content-Type": "application/json" } },
236
+ ),
237
+ );
238
+
239
+ // First call fails and degrades to pass-through.
240
+ expect(await resolveToApiModelId("claude-haiku-4.5")).toBe("claude-haiku-4.5");
241
+
242
+ // Within the failure TTL the empty result stays cached (no refetch).
243
+ vi.advanceTimersByTime(30_000);
244
+ expect(await resolveToApiModelId("claude-haiku-4.5")).toBe("claude-haiku-4.5");
245
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
246
+
247
+ // Past the failure TTL the registry is refetched and resolution recovers.
248
+ vi.advanceTimersByTime(31_000);
249
+ expect(await resolveToApiModelId("claude-haiku-4.5")).toBe("claude-haiku-4-5-20251001");
250
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
251
+ });
252
+ });
253
+
181
254
  interface MockModel {
182
255
  id: string;
183
256
  apiModelId?: string;
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ resolveRegistryBaseUrl,
4
+ resolveModelRegistryUrl,
5
+ buildRegistryHeaders,
6
+ } from "../registry-endpoint.js";
7
+
8
+ describe("resolveRegistryBaseUrl", () => {
9
+ it("prefers STIGMER_CLOUD_API_URL over everything (explicit override)", () => {
10
+ const env = {
11
+ STIGMER_CLOUD_API_URL: "https://override.example.com",
12
+ STIGMER_PROXY_ENDPOINT: "https://proxy.example.com",
13
+ STIGMER_BACKEND_ENDPOINT: "http://localhost:7234",
14
+ };
15
+ expect(resolveRegistryBaseUrl(env)).toBe("https://override.example.com");
16
+ });
17
+
18
+ it("uses the proxy origin in proxy mode (it serves /v1/proxy/model-registry)", () => {
19
+ const env = {
20
+ STIGMER_PROXY_ENDPOINT: "https://api.stigmer.ai",
21
+ STIGMER_BACKEND_ENDPOINT: "http://localhost:7234",
22
+ };
23
+ expect(resolveRegistryBaseUrl(env)).toBe("https://api.stigmer.ai");
24
+ });
25
+
26
+ it("uses the backend endpoint in direct/local mode (no proxy, no override)", () => {
27
+ const env = { STIGMER_BACKEND_ENDPOINT: "http://localhost:7234" };
28
+ expect(resolveRegistryBaseUrl(env)).toBe("http://localhost:7234");
29
+ });
30
+
31
+ it("defaults to the local stigmer-server when nothing is set", () => {
32
+ expect(resolveRegistryBaseUrl({})).toBe("http://localhost:7234");
33
+ });
34
+
35
+ it("normalizes bare host:port endpoints (443 implies TLS)", () => {
36
+ expect(resolveRegistryBaseUrl({ STIGMER_BACKEND_ENDPOINT: "api.stigmer.ai:443" }))
37
+ .toBe("https://api.stigmer.ai:443");
38
+ expect(resolveRegistryBaseUrl({ STIGMER_BACKEND_ENDPOINT: "backend:7234" }))
39
+ .toBe("http://backend:7234");
40
+ expect(resolveRegistryBaseUrl({ STIGMER_PROXY_ENDPOINT: "proxy.internal:443" }))
41
+ .toBe("https://proxy.internal:443");
42
+ });
43
+
44
+ it("treats empty-string env values as unset", () => {
45
+ const env = {
46
+ STIGMER_CLOUD_API_URL: "",
47
+ STIGMER_PROXY_ENDPOINT: "",
48
+ STIGMER_BACKEND_ENDPOINT: "http://localhost:9999",
49
+ };
50
+ expect(resolveRegistryBaseUrl(env)).toBe("http://localhost:9999");
51
+ });
52
+ });
53
+
54
+ describe("resolveModelRegistryUrl", () => {
55
+ it("appends the model registry path to the resolved base", () => {
56
+ const env = { STIGMER_BACKEND_ENDPOINT: "http://localhost:7234" };
57
+ expect(resolveModelRegistryUrl(env)).toBe("http://localhost:7234/v1/proxy/model-registry");
58
+ });
59
+ });
60
+
61
+ describe("buildRegistryHeaders", () => {
62
+ it("returns bearer auth when STIGMER_TOKEN is set", () => {
63
+ expect(buildRegistryHeaders({ STIGMER_TOKEN: "tok-1" }))
64
+ .toEqual({ Authorization: "Bearer tok-1" });
65
+ });
66
+
67
+ it("falls back to STIGMER_AUTH_TOKEN", () => {
68
+ expect(buildRegistryHeaders({ STIGMER_AUTH_TOKEN: "tok-2" }))
69
+ .toEqual({ Authorization: "Bearer tok-2" });
70
+ });
71
+
72
+ it("prefers STIGMER_TOKEN over STIGMER_AUTH_TOKEN", () => {
73
+ expect(buildRegistryHeaders({ STIGMER_TOKEN: "tok-1", STIGMER_AUTH_TOKEN: "tok-2" }))
74
+ .toEqual({ Authorization: "Bearer tok-1" });
75
+ });
76
+
77
+ it("returns no headers when tokenless (local server needs none)", () => {
78
+ expect(buildRegistryHeaders({})).toEqual({});
79
+ });
80
+ });
@@ -30,7 +30,8 @@ interface RegistryEntry {
30
30
  };
31
31
  }
32
32
 
33
- const DEFAULT_API_URL = "https://api.stigmer.ai";
33
+ import { resolveModelRegistryUrl, buildRegistryHeaders } from "./registry-endpoint.js";
34
+
34
35
  const CACHE_TTL_MS = 3_600_000;
35
36
 
36
37
  export const DEFAULT_PRICING: ModelPricing = {
@@ -65,13 +66,7 @@ function parsePricingTable(json: unknown): ModelPricing[] {
65
66
  }
66
67
 
67
68
  async function fetchFromApi(): Promise<readonly ModelPricing[]> {
68
- const url = `${process.env.STIGMER_CLOUD_API_URL ?? DEFAULT_API_URL}/v1/proxy/model-registry`;
69
- const headers: Record<string, string> = {};
70
- const token = process.env.STIGMER_TOKEN ?? process.env.STIGMER_AUTH_TOKEN;
71
- if (token) {
72
- headers["Authorization"] = `Bearer ${token}`;
73
- }
74
- const res = await fetch(url, { headers });
69
+ const res = await fetch(resolveModelRegistryUrl(), { headers: buildRegistryHeaders() });
75
70
  if (!res.ok) throw new Error(`Model registry fetch failed: ${res.status}`);
76
71
  const data: unknown = await res.json();
77
72
  const table = parsePricingTable(data);
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * Model registry — provider lookup and economy-tier model derivation.
3
3
  *
4
- * Fetches the model registry from the Stigmer API (same endpoint as
4
+ * Fetches the model registry from the runner's control plane (see
5
+ * registry-endpoint.ts for endpoint resolution — same endpoint as
5
6
  * model-pricing-data.ts) and uses `costTier` + `harness` fields to
6
7
  * dynamically resolve economy-tier models for extraction/summarization.
7
8
  */
8
9
 
9
- const DEFAULT_API_URL = "https://api.stigmer.ai";
10
+ import { resolveModelRegistryUrl, buildRegistryHeaders } from "./registry-endpoint.js";
11
+
10
12
  const CACHE_TTL_MS = 3_600_000;
13
+ // Failed fetches are cached much shorter than successes: a transient failure
14
+ // must not poison id -> apiModelId resolution (and thereby fail every llm_call
15
+ // with LLM_MODEL_NOT_FOUND) for a full hour.
16
+ const FAILURE_CACHE_TTL_MS = 60_000;
11
17
 
12
18
  interface RegistryModel {
13
19
  id: string;
@@ -39,13 +45,8 @@ function parseRegistry(json: unknown): RegistryModel[] {
39
45
  }
40
46
 
41
47
  async function fetchRegistry(): Promise<readonly RegistryModel[]> {
42
- const url = `${process.env.STIGMER_CLOUD_API_URL ?? DEFAULT_API_URL}/v1/proxy/model-registry`;
43
- const headers: Record<string, string> = {};
44
- const token = process.env.STIGMER_TOKEN ?? process.env.STIGMER_AUTH_TOKEN;
45
- if (token) {
46
- headers["Authorization"] = `Bearer ${token}`;
47
- }
48
- const res = await fetch(url, { headers });
48
+ const url = resolveModelRegistryUrl();
49
+ const res = await fetch(url, { headers: buildRegistryHeaders() });
49
50
  if (!res.ok) throw new Error(`Model registry fetch failed: ${res.status}`);
50
51
  const data: unknown = await res.json();
51
52
  return parseRegistry(data);
@@ -64,8 +65,13 @@ async function getRegistry(): Promise<readonly RegistryModel[]> {
64
65
  return models;
65
66
  })
66
67
  .catch((err) => {
67
- console.warn(`Failed to fetch model registry for provider lookup: ${err}`);
68
- cache = { models: [], expiresAt: Date.now() + CACHE_TTL_MS };
68
+ console.warn(
69
+ `Failed to fetch model registry from ${resolveModelRegistryUrl()}: ${err}. ` +
70
+ `Model id resolution degrades to pass-through until the next attempt ` +
71
+ `(${FAILURE_CACHE_TTL_MS / 1000}s). Check that the control plane is ` +
72
+ `reachable and, for cloud endpoints, that STIGMER_TOKEN is set.`,
73
+ );
74
+ cache = { models: [], expiresAt: Date.now() + FAILURE_CACHE_TTL_MS };
69
75
  return [] as readonly RegistryModel[];
70
76
  })
71
77
  .finally(() => {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Registry endpoint resolution — where the runner fetches the model registry.
3
+ *
4
+ * The model registry (canonical id → provider apiModelId, pricing, tiers) is
5
+ * served at `/v1/proxy/model-registry` by whichever control plane the runner
6
+ * talks to: the cloud API in proxy mode, or the local stigmer-server in
7
+ * direct/local mode (stigmer/stigmer#240). Resolution is therefore
8
+ * topology-based, not token-based:
9
+ *
10
+ * 1. STIGMER_CLOUD_API_URL — explicit override (tests, unusual topologies)
11
+ * 2. STIGMER_PROXY_ENDPOINT — proxy mode: the origin that serves the rest of
12
+ * the runner's /v1/proxy surface (LLM proxy, checkpoints, artifacts) also
13
+ * serves the registry
14
+ * 3. STIGMER_BACKEND_ENDPOINT — direct/local mode: the runner's own control
15
+ * plane, defaulting to the local stigmer-server address
16
+ *
17
+ * A hardcoded cloud URL is deliberately absent: every runner already knows a
18
+ * control-plane origin that serves the registry, so falling back to the hosted
19
+ * API would only mask misconfiguration (and breaks offline/local use).
20
+ */
21
+
22
+ import { normalizeEndpoint } from "../config.js";
23
+
24
+ /** Default local stigmer-server origin — mirrors config.ts's local-mode default. */
25
+ const DEFAULT_LOCAL_BACKEND = "http://localhost:7234";
26
+
27
+ /**
28
+ * Resolve the base URL (origin, no path) for model-registry and pricing
29
+ * fetches. See module doc for the tier order.
30
+ */
31
+ export function resolveRegistryBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
32
+ const override = env.STIGMER_CLOUD_API_URL;
33
+ if (override) return normalizeEndpoint(override);
34
+
35
+ const proxyEndpoint = env.STIGMER_PROXY_ENDPOINT;
36
+ if (proxyEndpoint) return normalizeEndpoint(proxyEndpoint);
37
+
38
+ return normalizeEndpoint(env.STIGMER_BACKEND_ENDPOINT ?? DEFAULT_LOCAL_BACKEND);
39
+ }
40
+
41
+ /**
42
+ * Build request headers for registry fetches: bearer auth when a token is
43
+ * present (cloud requires it; the local server ignores it).
44
+ */
45
+ export function buildRegistryHeaders(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
46
+ const token = env.STIGMER_TOKEN ?? env.STIGMER_AUTH_TOKEN;
47
+ return token ? { Authorization: `Bearer ${token}` } : {};
48
+ }
49
+
50
+ /** Full URL of the model registry endpoint for the resolved control plane. */
51
+ export function resolveModelRegistryUrl(env: NodeJS.ProcessEnv = process.env): string {
52
+ return `${resolveRegistryBaseUrl(env)}/v1/proxy/model-registry`;
53
+ }
@@ -1343,6 +1343,99 @@ do:
1343
1343
  }
1344
1344
  });
1345
1345
 
1346
+ it("call: human_input with payload and ui_hint", () => {
1347
+ const yaml = `
1348
+ document:
1349
+ dsl: '1.0.0'
1350
+ name: test
1351
+ do:
1352
+ - reviewGate:
1353
+ call: human_input
1354
+ with:
1355
+ prompt: "Review the proposal"
1356
+ payload: \${ $context.proposal }
1357
+ ui_hint: infra-proposal
1358
+ `;
1359
+ const model = loadWorkflowFromYaml(yaml);
1360
+ const task = model.do[0].task;
1361
+ expect(task.kind).toBe("human_input");
1362
+ if (task.kind === "human_input") {
1363
+ expect(task.humanInput.payload).toBe("${ $context.proposal }");
1364
+ expect(task.humanInput.uiHint).toBe("infra-proposal");
1365
+ }
1366
+ });
1367
+
1368
+ it("call: human_input with an inline object payload", () => {
1369
+ const yaml = `
1370
+ document:
1371
+ dsl: '1.0.0'
1372
+ name: test
1373
+ do:
1374
+ - reviewGate:
1375
+ call: human_input
1376
+ with:
1377
+ prompt: "Review the summary"
1378
+ payload:
1379
+ summary: "Severity: \${ $context.triage.severity }"
1380
+ static: true
1381
+ `;
1382
+ const model = loadWorkflowFromYaml(yaml);
1383
+ const task = model.do[0].task;
1384
+ expect(task.kind).toBe("human_input");
1385
+ if (task.kind === "human_input") {
1386
+ expect(task.humanInput.payload).toEqual({
1387
+ summary: "Severity: ${ $context.triage.severity }",
1388
+ static: true,
1389
+ });
1390
+ }
1391
+ });
1392
+
1393
+ it("call: human_input without payload/ui_hint leaves both undefined", () => {
1394
+ const yaml = `
1395
+ document:
1396
+ dsl: '1.0.0'
1397
+ name: test
1398
+ do:
1399
+ - plainGate:
1400
+ call: human_input
1401
+ with:
1402
+ prompt: "Just approve"
1403
+ `;
1404
+ const model = loadWorkflowFromYaml(yaml);
1405
+ const task = model.do[0].task;
1406
+ expect(task.kind).toBe("human_input");
1407
+ if (task.kind === "human_input") {
1408
+ expect(task.humanInput.payload).toBeUndefined();
1409
+ expect(task.humanInput.uiHint).toBeUndefined();
1410
+ }
1411
+ });
1412
+
1413
+ it("call: human_input drops a non-string or empty ui_hint", () => {
1414
+ const yaml = `
1415
+ document:
1416
+ dsl: '1.0.0'
1417
+ name: test
1418
+ do:
1419
+ - badHint:
1420
+ call: human_input
1421
+ with:
1422
+ prompt: "Approve"
1423
+ ui_hint: 42
1424
+ - emptyHint:
1425
+ call: human_input
1426
+ with:
1427
+ prompt: "Approve"
1428
+ ui_hint: ""
1429
+ `;
1430
+ const model = loadWorkflowFromYaml(yaml);
1431
+ for (const entry of model.do) {
1432
+ expect(entry.task.kind).toBe("human_input");
1433
+ if (entry.task.kind === "human_input") {
1434
+ expect(entry.task.humanInput.uiHint).toBeUndefined();
1435
+ }
1436
+ }
1437
+ });
1438
+
1346
1439
  it("call: human_input missing prompt throws", () => {
1347
1440
  const yaml = `
1348
1441
  document:
@@ -136,6 +136,56 @@ describe("resolveConfigExpressions", () => {
136
136
  expect(result.value).toBe(3);
137
137
  expect(frozen.value).toBe("${ 1 + 2 }");
138
138
  });
139
+
140
+ it("never re-interprets literal ${ } text nested inside a Phase-1 object result (injection safety)", async () => {
141
+ // A strict expression resolving to external data (webhook body, API
142
+ // response, document under review) may contain literal `${ ... }`
143
+ // text at any depth. Phase 2 must skip the entire resolved subtree —
144
+ // not just a string at the exact resolved path.
145
+ const config = { payload: "${ $context.article }" };
146
+
147
+ const state = createState();
148
+ state.context = {
149
+ article: {
150
+ body: "Use ${ .secrets.KEY } to authenticate",
151
+ steps: ["run ${ $env.HOME }/setup.sh"],
152
+ },
153
+ };
154
+
155
+ const result = await resolveConfigExpressions(
156
+ config,
157
+ null,
158
+ state,
159
+ evaluateExpressionBatch,
160
+ );
161
+
162
+ expect(result.payload).toEqual({
163
+ body: "Use ${ .secrets.KEY } to authenticate",
164
+ steps: ["run ${ $env.HOME }/setup.sh"],
165
+ });
166
+ });
167
+
168
+ it("still interpolates embedded expressions in sibling fields alongside a Phase-1 object result", async () => {
169
+ // The subtree skip is scoped to the resolved path only — template
170
+ // strings elsewhere in the config keep working.
171
+ const config = {
172
+ payload: "${ $context.doc }",
173
+ note: "Reviewing ${ $context.doc.title }",
174
+ };
175
+
176
+ const state = createState();
177
+ state.context = { doc: { title: "Q3 plan", raw: "keep ${ .this } intact" } };
178
+
179
+ const result = await resolveConfigExpressions(
180
+ config,
181
+ null,
182
+ state,
183
+ evaluateExpressionBatch,
184
+ );
185
+
186
+ expect(result.payload).toEqual({ title: "Q3 plan", raw: "keep ${ .this } intact" });
187
+ expect(result.note).toBe("Reviewing Q3 plan");
188
+ });
139
189
  });
140
190
 
141
191
  describe("isRuntimePlaceholder", () => {
@@ -221,6 +221,41 @@ describe("TaskStatusAccumulator", () => {
221
221
  expect(entry.status).toBe("waiting_approval");
222
222
  expect(entry.input).toEqual({ prompt: "Approve?" });
223
223
  });
224
+
225
+ it("records the review-surface uiHint", () => {
226
+ const acc = new TaskStatusAccumulator();
227
+ acc.taskStarted("gate", "human_input");
228
+ acc.taskWaitingApproval("gate", "article-diff");
229
+ const [entry] = acc.toArray();
230
+ expect(entry.uiHint).toBe("article-diff");
231
+ });
232
+
233
+ it("leaves uiHint undefined when the gate declares no hint", () => {
234
+ const acc = new TaskStatusAccumulator();
235
+ acc.taskStarted("gate", "human_input");
236
+ acc.taskWaitingApproval("gate");
237
+ const [entry] = acc.toArray();
238
+ expect(entry.uiHint).toBeUndefined();
239
+ });
240
+
241
+ it("retains uiHint after the gate resolves to completed", () => {
242
+ const acc = new TaskStatusAccumulator();
243
+ acc.taskStarted("gate", "human_input");
244
+ acc.taskWaitingApproval("gate", "article-diff");
245
+ acc.taskCompleted("gate", 500);
246
+ const [entry] = acc.toArray();
247
+ expect(entry.status).toBe("completed");
248
+ expect(entry.uiHint).toBe("article-diff");
249
+ });
250
+
251
+ it("preserves a previously recorded uiHint when re-entering without one", () => {
252
+ const acc = new TaskStatusAccumulator();
253
+ acc.taskStarted("gate", "human_input");
254
+ acc.taskWaitingApproval("gate", "article-diff");
255
+ acc.taskWaitingApproval("gate");
256
+ const [entry] = acc.toArray();
257
+ expect(entry.uiHint).toBe("article-diff");
258
+ });
224
259
  });
225
260
 
226
261
  describe("taskSkipped", () => {