@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.
- package/README.md +1 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/model-pricing-data.d.ts +5 -8
- package/dist/activities/execute-cursor/model-pricing-data.js +7 -22
- package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
- package/dist/activities/promote-task-output.d.ts +6 -1
- package/dist/activities/promote-task-output.js +10 -5
- package/dist/activities/promote-task-output.js.map +1 -1
- package/dist/activities/workflow-event-activities.js +10 -1
- package/dist/activities/workflow-event-activities.js.map +1 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.js +4 -1
- package/dist/config.js.map +1 -1
- package/dist/shared/model-pricing-data.js +2 -8
- package/dist/shared/model-pricing-data.js.map +1 -1
- package/dist/shared/model-registry.d.ts +2 -1
- package/dist/shared/model-registry.js +14 -11
- package/dist/shared/model-registry.js.map +1 -1
- package/dist/shared/registry-endpoint.d.ts +32 -0
- package/dist/shared/registry-endpoint.js +49 -0
- package/dist/shared/registry-endpoint.js.map +1 -0
- package/dist/workflow-engine/loader.js +4 -0
- package/dist/workflow-engine/loader.js.map +1 -1
- package/dist/workflow-engine/resolve.js +9 -2
- package/dist/workflow-engine/resolve.js.map +1 -1
- package/dist/workflow-engine/task-status-accumulator.d.ts +8 -1
- package/dist/workflow-engine/task-status-accumulator.js +2 -1
- package/dist/workflow-engine/task-status-accumulator.js.map +1 -1
- package/dist/workflow-engine/tasks/human-input.d.ts +7 -0
- package/dist/workflow-engine/tasks/human-input.js +70 -4
- package/dist/workflow-engine/tasks/human-input.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +19 -1
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/engine-core.js +1 -1
- package/dist/workflows/engine-core.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/execute-cursor/model-pricing-data.ts +8 -24
- package/src/activities/promote-task-output.ts +10 -4
- package/src/activities/workflow-event-activities.ts +11 -2
- package/src/config.ts +4 -1
- package/src/shared/__tests__/model-registry.test.ts +74 -1
- package/src/shared/__tests__/registry-endpoint.test.ts +80 -0
- package/src/shared/model-pricing-data.ts +3 -8
- package/src/shared/model-registry.ts +17 -11
- package/src/shared/registry-endpoint.ts +53 -0
- package/src/workflow-engine/__tests__/loader.test.ts +93 -0
- package/src/workflow-engine/__tests__/resolve.test.ts +50 -0
- package/src/workflow-engine/__tests__/task-status-accumulator.test.ts +35 -0
- package/src/workflow-engine/__tests__/tasks/human-input.test.ts +248 -2
- package/src/workflow-engine/loader.ts +4 -0
- package/src/workflow-engine/resolve.ts +9 -1
- package/src/workflow-engine/task-status-accumulator.ts +9 -1
- package/src/workflow-engine/tasks/human-input.ts +111 -15
- package/src/workflow-engine/types.ts +18 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
43
|
-
const
|
|
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(
|
|
68
|
-
|
|
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", () => {
|