@sweny-ai/core 0.1.76 → 0.1.77
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/dist/__tests__/cascade.test.js +3 -0
- package/dist/__tests__/executor.test.js +899 -0
- package/dist/__tests__/requires.test.d.ts +1 -0
- package/dist/__tests__/requires.test.js +82 -0
- package/dist/__tests__/retry.test.d.ts +1 -0
- package/dist/__tests__/retry.test.js +213 -0
- package/dist/__tests__/schema.test.js +75 -1
- package/dist/__tests__/spec-conformance.test.js +10 -0
- package/dist/__tests__/testing.test.js +18 -0
- package/dist/claude.d.ts +4 -0
- package/dist/claude.js +41 -0
- package/dist/executor.js +104 -43
- package/dist/executor.test.js +15 -0
- package/dist/requires.d.ts +7 -0
- package/dist/requires.js +34 -0
- package/dist/retry.d.ts +23 -0
- package/dist/retry.js +72 -0
- package/dist/schema.d.ts +603 -48
- package/dist/schema.js +97 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +7 -0
- package/dist/types.d.ts +64 -0
- package/dist/verify.d.ts +1 -0
- package/dist/verify.js +1 -1
- package/dist/workflow-builder.test.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { evaluateRequires } from "../requires.js";
|
|
3
|
+
describe("evaluateRequires", () => {
|
|
4
|
+
it("returns null when requires is undefined", () => {
|
|
5
|
+
expect(evaluateRequires(undefined, { input: {} })).toBeNull();
|
|
6
|
+
});
|
|
7
|
+
it("returns null when all checks pass", () => {
|
|
8
|
+
const requires = {
|
|
9
|
+
output_required: ["input.repoUrl"],
|
|
10
|
+
output_matches: [{ path: "triage.recommendation", equals: "implement" }],
|
|
11
|
+
};
|
|
12
|
+
const ctx = {
|
|
13
|
+
input: { repoUrl: "https://x" },
|
|
14
|
+
triage: { recommendation: "implement" },
|
|
15
|
+
};
|
|
16
|
+
expect(evaluateRequires(requires, ctx)).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
it("reports missing input field", () => {
|
|
19
|
+
const requires = { output_required: ["input.repoUrl"] };
|
|
20
|
+
const err = evaluateRequires(requires, { input: {} });
|
|
21
|
+
expect(err).not.toBeNull();
|
|
22
|
+
expect(err).toMatch(/^requires failed:/);
|
|
23
|
+
expect(err).toMatch(/'input\.repoUrl'/);
|
|
24
|
+
});
|
|
25
|
+
it("reports missing prior node output", () => {
|
|
26
|
+
const requires = { output_required: ["triage.recommendation"] };
|
|
27
|
+
const err = evaluateRequires(requires, { input: {} });
|
|
28
|
+
expect(err).not.toBeNull();
|
|
29
|
+
expect(err).toMatch(/'triage\.recommendation'/);
|
|
30
|
+
});
|
|
31
|
+
it("reports null upstream value as missing", () => {
|
|
32
|
+
const requires = { output_required: ["triage.recommendation"] };
|
|
33
|
+
const err = evaluateRequires(requires, {
|
|
34
|
+
input: {},
|
|
35
|
+
triage: { recommendation: null },
|
|
36
|
+
});
|
|
37
|
+
expect(err).toMatch(/null/);
|
|
38
|
+
});
|
|
39
|
+
it("supports `any:` wildcard semantics on requires paths", () => {
|
|
40
|
+
const requires = {
|
|
41
|
+
output_required: ["any:scan.findings[*].severity"],
|
|
42
|
+
};
|
|
43
|
+
const ctx = { input: {}, scan: { findings: [{ severity: "low" }, { severity: "high" }] } };
|
|
44
|
+
expect(evaluateRequires(requires, ctx)).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
it("reports output_matches failure with operator description", () => {
|
|
47
|
+
const requires = {
|
|
48
|
+
output_matches: [{ path: "triage.recommendation", equals: "implement" }],
|
|
49
|
+
};
|
|
50
|
+
const err = evaluateRequires(requires, {
|
|
51
|
+
input: {},
|
|
52
|
+
triage: { recommendation: "skip" },
|
|
53
|
+
});
|
|
54
|
+
expect(err).toMatch(/equals "implement"/);
|
|
55
|
+
expect(err).toMatch(/got \["skip"\]/);
|
|
56
|
+
});
|
|
57
|
+
it("aggregates multiple failures into one string", () => {
|
|
58
|
+
const requires = {
|
|
59
|
+
output_required: ["input.a", "input.b"],
|
|
60
|
+
output_matches: [{ path: "input.c", equals: 1 }],
|
|
61
|
+
};
|
|
62
|
+
const err = evaluateRequires(requires, { input: { c: 2 } });
|
|
63
|
+
expect(err).toMatch(/'input\.a'/);
|
|
64
|
+
expect(err).toMatch(/'input\.b'/);
|
|
65
|
+
expect(err).toMatch(/'input\.c'/);
|
|
66
|
+
});
|
|
67
|
+
it("does not crash when context map is empty", () => {
|
|
68
|
+
const requires = { output_required: ["input.x"] };
|
|
69
|
+
const err = evaluateRequires(requires, {});
|
|
70
|
+
expect(err).not.toBeNull();
|
|
71
|
+
});
|
|
72
|
+
// ── Test 10: malformed block with no checks declared (fix B) ──
|
|
73
|
+
it("returns failure when requires block has no checks declared (fix B defensive guard)", () => {
|
|
74
|
+
// Post-merge fix B: evaluateRequires fails loudly when a requires block
|
|
75
|
+
// exists but declares neither output_required nor output_matches.
|
|
76
|
+
// Passing on_fail directly to bypass TypeScript's NodeRequires type.
|
|
77
|
+
const err = evaluateRequires({ on_fail: "fail" }, { input: {} });
|
|
78
|
+
expect(err).not.toBeNull();
|
|
79
|
+
expect(err).toMatch(/^requires failed:/);
|
|
80
|
+
expect(err).toMatch(/no checks declared/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildRetryPreamble } from "../retry.js";
|
|
3
|
+
const tc = (tool, output) => ({ tool, input: {}, output });
|
|
4
|
+
const silentLogger = {
|
|
5
|
+
info: () => { },
|
|
6
|
+
warn: () => { },
|
|
7
|
+
error: () => { },
|
|
8
|
+
debug: () => { },
|
|
9
|
+
};
|
|
10
|
+
function fakeClaude(askResult) {
|
|
11
|
+
return {
|
|
12
|
+
run: async () => ({ status: "success", data: {}, toolCalls: [] }),
|
|
13
|
+
evaluate: async () => "x",
|
|
14
|
+
ask: vi.fn(async () => {
|
|
15
|
+
if (typeof askResult === "function")
|
|
16
|
+
return askResult();
|
|
17
|
+
return askResult;
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
describe("buildRetryPreamble", () => {
|
|
22
|
+
const verifyError = "verify failed:\n - any_tool_called: required one of [foo]";
|
|
23
|
+
const nodeInstruction = "Open a PR with the fix";
|
|
24
|
+
it("uses default preamble when no instruction is provided", async () => {
|
|
25
|
+
const claude = fakeClaude("");
|
|
26
|
+
const result = await buildRetryPreamble({
|
|
27
|
+
retry: { max: 1 },
|
|
28
|
+
verifyError,
|
|
29
|
+
context: {},
|
|
30
|
+
toolCalls: [],
|
|
31
|
+
nodeInstruction,
|
|
32
|
+
claude,
|
|
33
|
+
logger: silentLogger,
|
|
34
|
+
});
|
|
35
|
+
expect(result).toMatch(/Previous attempt failed verification/);
|
|
36
|
+
expect(result).toContain(verifyError);
|
|
37
|
+
expect(claude.ask).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
it("uses static string preamble when instruction is a string", async () => {
|
|
40
|
+
const claude = fakeClaude("");
|
|
41
|
+
const result = await buildRetryPreamble({
|
|
42
|
+
retry: { max: 1, instruction: "Remember to call linear_create_issue first." },
|
|
43
|
+
verifyError,
|
|
44
|
+
context: {},
|
|
45
|
+
toolCalls: [],
|
|
46
|
+
nodeInstruction,
|
|
47
|
+
claude,
|
|
48
|
+
logger: silentLogger,
|
|
49
|
+
});
|
|
50
|
+
expect(result).toContain("Remember to call linear_create_issue first.");
|
|
51
|
+
expect(result).toContain(verifyError);
|
|
52
|
+
expect(claude.ask).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
it("calls claude.ask with default reflection prompt when instruction.auto is true", async () => {
|
|
55
|
+
const claude = fakeClaude("Diagnosis: you forgot to call foo. Strategy: call foo first.");
|
|
56
|
+
const askSpy = claude.ask;
|
|
57
|
+
const result = await buildRetryPreamble({
|
|
58
|
+
retry: { max: 1, instruction: { auto: true } },
|
|
59
|
+
verifyError,
|
|
60
|
+
context: {},
|
|
61
|
+
toolCalls: [tc("bar", { ok: true })],
|
|
62
|
+
nodeInstruction,
|
|
63
|
+
claude,
|
|
64
|
+
logger: silentLogger,
|
|
65
|
+
});
|
|
66
|
+
expect(askSpy).toHaveBeenCalledOnce();
|
|
67
|
+
const callArg = askSpy.mock.calls[0][0];
|
|
68
|
+
expect(callArg.instruction).toMatch(/Briefly diagnose the failure/);
|
|
69
|
+
expect(callArg.instruction).toContain(nodeInstruction);
|
|
70
|
+
expect(callArg.instruction).toContain(verifyError);
|
|
71
|
+
expect(callArg.instruction).toContain("bar");
|
|
72
|
+
expect(result).toContain("Diagnosis: you forgot to call foo");
|
|
73
|
+
expect(result).toContain(verifyError);
|
|
74
|
+
});
|
|
75
|
+
it("calls claude.ask with author prompt when instruction.reflect is set", async () => {
|
|
76
|
+
const claude = fakeClaude("Custom diagnosis.");
|
|
77
|
+
const askSpy = claude.ask;
|
|
78
|
+
await buildRetryPreamble({
|
|
79
|
+
retry: { max: 1, instruction: { reflect: "Focus on the missing tool calls only." } },
|
|
80
|
+
verifyError,
|
|
81
|
+
context: {},
|
|
82
|
+
toolCalls: [],
|
|
83
|
+
nodeInstruction,
|
|
84
|
+
claude,
|
|
85
|
+
logger: silentLogger,
|
|
86
|
+
});
|
|
87
|
+
const callArg = askSpy.mock.calls[0][0];
|
|
88
|
+
expect(callArg.instruction).toContain("Focus on the missing tool calls only.");
|
|
89
|
+
expect(callArg.instruction).toContain(verifyError);
|
|
90
|
+
});
|
|
91
|
+
it("falls back to default preamble when claude.ask throws", async () => {
|
|
92
|
+
const claude = fakeClaude(() => {
|
|
93
|
+
throw new Error("network exploded");
|
|
94
|
+
});
|
|
95
|
+
const warnSpy = vi.fn();
|
|
96
|
+
const logger = { ...silentLogger, warn: warnSpy };
|
|
97
|
+
const result = await buildRetryPreamble({
|
|
98
|
+
retry: { max: 1, instruction: { auto: true } },
|
|
99
|
+
verifyError,
|
|
100
|
+
context: {},
|
|
101
|
+
toolCalls: [],
|
|
102
|
+
nodeInstruction,
|
|
103
|
+
claude,
|
|
104
|
+
logger,
|
|
105
|
+
});
|
|
106
|
+
expect(result).toMatch(/Previous attempt failed verification/);
|
|
107
|
+
expect(result).toContain(verifyError);
|
|
108
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
109
|
+
expect(warnSpy.mock.calls[0][0]).toMatch(/reflection/i);
|
|
110
|
+
});
|
|
111
|
+
it("falls back to default preamble when claude.ask returns empty string", async () => {
|
|
112
|
+
const claude = fakeClaude(" ");
|
|
113
|
+
const warnSpy = vi.fn();
|
|
114
|
+
const logger = { ...silentLogger, warn: warnSpy };
|
|
115
|
+
const result = await buildRetryPreamble({
|
|
116
|
+
retry: { max: 1, instruction: { auto: true } },
|
|
117
|
+
verifyError,
|
|
118
|
+
context: {},
|
|
119
|
+
toolCalls: [],
|
|
120
|
+
nodeInstruction,
|
|
121
|
+
claude,
|
|
122
|
+
logger,
|
|
123
|
+
});
|
|
124
|
+
expect(result).toMatch(/Previous attempt failed verification/);
|
|
125
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
126
|
+
});
|
|
127
|
+
it("includes tool call summary in reflection prompt (names + ok/error)", async () => {
|
|
128
|
+
const claude = fakeClaude("ok");
|
|
129
|
+
const askSpy = claude.ask;
|
|
130
|
+
await buildRetryPreamble({
|
|
131
|
+
retry: { max: 1, instruction: { auto: true } },
|
|
132
|
+
verifyError,
|
|
133
|
+
context: {},
|
|
134
|
+
toolCalls: [tc("foo", { ok: true }), tc("bar", { error: "boom" })],
|
|
135
|
+
nodeInstruction,
|
|
136
|
+
claude,
|
|
137
|
+
logger: silentLogger,
|
|
138
|
+
});
|
|
139
|
+
const promptText = askSpy.mock.calls[0][0].instruction;
|
|
140
|
+
expect(promptText).toContain("foo");
|
|
141
|
+
expect(promptText).toContain("bar");
|
|
142
|
+
expect(promptText).toMatch(/error/i);
|
|
143
|
+
});
|
|
144
|
+
// ── Test 5: whitespace-only ask response falls back to default preamble ──
|
|
145
|
+
it("falls back to default preamble when claude.ask returns whitespace-only string", async () => {
|
|
146
|
+
// Locks the trim() behavior at retry.ts:54 — whitespace-only is treated as empty.
|
|
147
|
+
const warnSpy = vi.fn();
|
|
148
|
+
const logger = { ...silentLogger, warn: warnSpy };
|
|
149
|
+
const claude = fakeClaude("\n\n\t ");
|
|
150
|
+
const result = await buildRetryPreamble({
|
|
151
|
+
retry: { max: 1, instruction: { auto: true } },
|
|
152
|
+
verifyError,
|
|
153
|
+
context: {},
|
|
154
|
+
toolCalls: [],
|
|
155
|
+
nodeInstruction,
|
|
156
|
+
claude,
|
|
157
|
+
logger,
|
|
158
|
+
});
|
|
159
|
+
expect(result).toMatch(/Previous attempt failed verification/);
|
|
160
|
+
expect(result).toContain(verifyError);
|
|
161
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
162
|
+
expect(warnSpy.mock.calls[0][0]).toMatch(/empty/i);
|
|
163
|
+
});
|
|
164
|
+
// ── Test 6: nodeInstruction containing the same header as defaultPreamble ──
|
|
165
|
+
it("produces a correct preamble even when nodeInstruction contains the default header verbatim", async () => {
|
|
166
|
+
// Regression: if buildReflectionPrompt did brittle string-replace on the header,
|
|
167
|
+
// embedding the header in the instruction would corrupt output. Assert it still
|
|
168
|
+
// produces a preamble starting with "## Reflection on previous attempt".
|
|
169
|
+
const tricky = "## Previous attempt failed verification\nDo the thing anyway.";
|
|
170
|
+
const claude = fakeClaude("Here is my diagnosis.");
|
|
171
|
+
const result = await buildRetryPreamble({
|
|
172
|
+
retry: { max: 1, instruction: { auto: true } },
|
|
173
|
+
verifyError,
|
|
174
|
+
context: {},
|
|
175
|
+
toolCalls: [],
|
|
176
|
+
nodeInstruction: tricky,
|
|
177
|
+
claude,
|
|
178
|
+
logger: silentLogger,
|
|
179
|
+
});
|
|
180
|
+
// The returned preamble should start with the reflection header, not the
|
|
181
|
+
// default header, because claude.ask returned a non-empty string.
|
|
182
|
+
expect(result).toMatch(/^## Reflection on previous attempt/);
|
|
183
|
+
expect(result).toContain("Here is my diagnosis.");
|
|
184
|
+
expect(result).toContain(verifyError);
|
|
185
|
+
});
|
|
186
|
+
// ── Test 7: instruction.reflect = "" (empty string, bypasses Zod) ──
|
|
187
|
+
it("uses empty string as reflect prompt when reflect is '' (documents current behavior)", async () => {
|
|
188
|
+
// Zod schema requires reflect: string.min(1), but programmatic construction
|
|
189
|
+
// can bypass it. Current code: `typeof inst.reflect === "string" ? inst.reflect : DEFAULT`
|
|
190
|
+
// An empty string IS a string, so it gets used verbatim — the reflection prompt
|
|
191
|
+
// sent to claude.ask will contain the empty reflect line.
|
|
192
|
+
// FUTURE: consider falling back to DEFAULT_REFLECTION_PROMPT on empty reflect.
|
|
193
|
+
const claude = fakeClaude("diagnosis from empty reflect.");
|
|
194
|
+
const askSpy = claude.ask;
|
|
195
|
+
await buildRetryPreamble({
|
|
196
|
+
// Cast to bypass TypeScript's NodeRetry type (which mirrors Zod min(1))
|
|
197
|
+
retry: { max: 1, instruction: { reflect: "" } },
|
|
198
|
+
verifyError,
|
|
199
|
+
context: {},
|
|
200
|
+
toolCalls: [],
|
|
201
|
+
nodeInstruction,
|
|
202
|
+
claude,
|
|
203
|
+
logger: silentLogger,
|
|
204
|
+
});
|
|
205
|
+
expect(askSpy).toHaveBeenCalledOnce();
|
|
206
|
+
// The reflection prompt is built from the empty string — it should NOT contain
|
|
207
|
+
// the default "Briefly diagnose" text because reflect="" was used instead.
|
|
208
|
+
const sentInstruction = askSpy.mock.calls[0][0].instruction;
|
|
209
|
+
expect(sentInstruction).not.toContain("Briefly diagnose");
|
|
210
|
+
// The verify error is always included regardless of reflect content.
|
|
211
|
+
expect(sentInstruction).toContain(verifyError);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { workflowZ, nodeZ, nodeVerifyZ, edgeZ, skillZ, toolZ, mcpServerConfigZ, skillDefinitionZ, parseWorkflow, validateWorkflow, workflowJsonSchema, } from "../schema.js";
|
|
2
|
+
import { workflowZ, nodeZ, nodeVerifyZ, nodeRequiresZ, nodeRetryZ, edgeZ, skillZ, toolZ, mcpServerConfigZ, skillDefinitionZ, parseWorkflow, validateWorkflow, workflowJsonSchema, } from "../schema.js";
|
|
3
3
|
import { triageWorkflow, implementWorkflow } from "../workflows/index.js";
|
|
4
4
|
// ─── Test fixtures ───────────────────────────────────────────────
|
|
5
5
|
const validWorkflow = {
|
|
@@ -125,6 +125,80 @@ describe("Zod schemas", () => {
|
|
|
125
125
|
expect(() => nodeVerifyZ.parse({ output_matches: [] })).toThrow();
|
|
126
126
|
});
|
|
127
127
|
});
|
|
128
|
+
describe("nodeRequiresZ", () => {
|
|
129
|
+
it("accepts output_required only", () => {
|
|
130
|
+
expect(() => nodeRequiresZ.parse({ output_required: ["input.x"] })).not.toThrow();
|
|
131
|
+
});
|
|
132
|
+
it("accepts output_matches only", () => {
|
|
133
|
+
expect(() => nodeRequiresZ.parse({ output_matches: [{ path: "input.x", equals: 1 }] })).not.toThrow();
|
|
134
|
+
});
|
|
135
|
+
it("accepts on_fail: 'fail'", () => {
|
|
136
|
+
expect(() => nodeRequiresZ.parse({ output_required: ["input.x"], on_fail: "fail" })).not.toThrow();
|
|
137
|
+
});
|
|
138
|
+
it("accepts on_fail: 'skip'", () => {
|
|
139
|
+
expect(() => nodeRequiresZ.parse({ output_required: ["input.x"], on_fail: "skip" })).not.toThrow();
|
|
140
|
+
});
|
|
141
|
+
it("rejects empty requires (no checks declared)", () => {
|
|
142
|
+
expect(() => nodeRequiresZ.parse({})).toThrow();
|
|
143
|
+
});
|
|
144
|
+
it("rejects on_fail other than 'fail' or 'skip'", () => {
|
|
145
|
+
expect(() => nodeRequiresZ.parse({ output_required: ["input.x"], on_fail: "throw" })).toThrow();
|
|
146
|
+
});
|
|
147
|
+
it("rejects empty output_required array", () => {
|
|
148
|
+
expect(() => nodeRequiresZ.parse({ output_required: [] })).toThrow();
|
|
149
|
+
});
|
|
150
|
+
it("nodeZ accepts a node with requires", () => {
|
|
151
|
+
expect(() => nodeZ.parse({
|
|
152
|
+
name: "Test",
|
|
153
|
+
instruction: "Do thing",
|
|
154
|
+
skills: [],
|
|
155
|
+
requires: { output_required: ["input.x"] },
|
|
156
|
+
})).not.toThrow();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe("nodeRetryZ", () => {
|
|
160
|
+
it("accepts max alone", () => {
|
|
161
|
+
expect(() => nodeRetryZ.parse({ max: 2 })).not.toThrow();
|
|
162
|
+
});
|
|
163
|
+
it("accepts max + string instruction", () => {
|
|
164
|
+
expect(() => nodeRetryZ.parse({ max: 1, instruction: "Try harder" })).not.toThrow();
|
|
165
|
+
});
|
|
166
|
+
it("accepts max + { auto: true }", () => {
|
|
167
|
+
expect(() => nodeRetryZ.parse({ max: 1, instruction: { auto: true } })).not.toThrow();
|
|
168
|
+
});
|
|
169
|
+
it("accepts max + { reflect: '...' }", () => {
|
|
170
|
+
expect(() => nodeRetryZ.parse({ max: 1, instruction: { reflect: "Focus on tool calls" } })).not.toThrow();
|
|
171
|
+
});
|
|
172
|
+
it("rejects max: 0", () => {
|
|
173
|
+
expect(() => nodeRetryZ.parse({ max: 0 })).toThrow();
|
|
174
|
+
});
|
|
175
|
+
it("rejects negative max", () => {
|
|
176
|
+
expect(() => nodeRetryZ.parse({ max: -1 })).toThrow();
|
|
177
|
+
});
|
|
178
|
+
it("rejects non-integer max", () => {
|
|
179
|
+
expect(() => nodeRetryZ.parse({ max: 1.5 })).toThrow();
|
|
180
|
+
});
|
|
181
|
+
it("rejects { auto: false }", () => {
|
|
182
|
+
expect(() => nodeRetryZ.parse({ max: 1, instruction: { auto: false } })).toThrow();
|
|
183
|
+
});
|
|
184
|
+
it("rejects empty reflect string", () => {
|
|
185
|
+
expect(() => nodeRetryZ.parse({ max: 1, instruction: { reflect: "" } })).toThrow();
|
|
186
|
+
});
|
|
187
|
+
it("rejects instruction with both auto and reflect", () => {
|
|
188
|
+
expect(() => nodeRetryZ.parse({ max: 1, instruction: { auto: true, reflect: "x" } })).toThrow();
|
|
189
|
+
});
|
|
190
|
+
it("requires max field", () => {
|
|
191
|
+
expect(() => nodeRetryZ.parse({ instruction: "x" })).toThrow();
|
|
192
|
+
});
|
|
193
|
+
it("nodeZ accepts a node with retry", () => {
|
|
194
|
+
expect(() => nodeZ.parse({
|
|
195
|
+
name: "Test",
|
|
196
|
+
instruction: "Do thing",
|
|
197
|
+
skills: [],
|
|
198
|
+
retry: { max: 2, instruction: { auto: true } },
|
|
199
|
+
})).not.toThrow();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
128
202
|
describe("edgeZ", () => {
|
|
129
203
|
it("parses a simple edge", () => {
|
|
130
204
|
const result = edgeZ.parse({ from: "a", to: "b" });
|
|
@@ -937,6 +937,16 @@ describe("spec: JSON Schema", () => {
|
|
|
937
937
|
const errors = validateWorkflow(result);
|
|
938
938
|
expect(errors).toHaveLength(0);
|
|
939
939
|
});
|
|
940
|
+
it("workflowJsonSchema declares verify, requires, and retry on node properties", () => {
|
|
941
|
+
const nodeProps = workflowJsonSchema.properties.nodes.additionalProperties.properties;
|
|
942
|
+
expect(nodeProps.verify).toBeDefined();
|
|
943
|
+
expect(nodeProps.requires).toBeDefined();
|
|
944
|
+
expect(nodeProps.retry).toBeDefined();
|
|
945
|
+
expect(nodeProps.requires.properties.output_required).toBeDefined();
|
|
946
|
+
expect(nodeProps.requires.properties.on_fail.enum).toEqual(["fail", "skip"]);
|
|
947
|
+
expect(nodeProps.retry.properties.max.type).toBe("integer");
|
|
948
|
+
expect(nodeProps.retry.properties.instruction.oneOf).toBeDefined();
|
|
949
|
+
});
|
|
940
950
|
});
|
|
941
951
|
// ─── Spec Section: Source Types ──────────────────────────────────
|
|
942
952
|
describe("spec: Source type classification", () => {
|
|
@@ -155,6 +155,24 @@ describe("MockClaude", () => {
|
|
|
155
155
|
expect(chosen).toBe("valid"); // falls back to first choice
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
|
+
describe("ask", () => {
|
|
159
|
+
it("returns scripted response from ask handler", async () => {
|
|
160
|
+
const claude = new MockClaude({
|
|
161
|
+
responses: {},
|
|
162
|
+
ask: (instruction, context) => `Got: ${instruction}; ctx keys: ${Object.keys(context).join(",")}`,
|
|
163
|
+
});
|
|
164
|
+
const result = await claude.ask({
|
|
165
|
+
instruction: "diagnose this",
|
|
166
|
+
context: { error: "x" },
|
|
167
|
+
});
|
|
168
|
+
expect(result).toBe("Got: diagnose this; ctx keys: error");
|
|
169
|
+
});
|
|
170
|
+
it("returns empty string when no ask handler is provided (exercises default-preamble fallback)", async () => {
|
|
171
|
+
const claude = new MockClaude({ responses: {} });
|
|
172
|
+
const result = await claude.ask({ instruction: "anything", context: {} });
|
|
173
|
+
expect(result).toBe("");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
158
176
|
});
|
|
159
177
|
// ─── File skill tests ────────────────────────────────────────────
|
|
160
178
|
describe("createFileSkill", () => {
|
package/dist/claude.d.ts
CHANGED
package/dist/claude.js
CHANGED
|
@@ -202,6 +202,47 @@ export class ClaudeClient {
|
|
|
202
202
|
this.logger.warn(`Could not parse route choice from: "${text.slice(0, 100)}". Falling back to first choice.`);
|
|
203
203
|
return validIds[0];
|
|
204
204
|
}
|
|
205
|
+
async ask(opts) {
|
|
206
|
+
const { instruction, context } = opts;
|
|
207
|
+
const prompt = [
|
|
208
|
+
instruction,
|
|
209
|
+
Object.keys(context).length > 0 ? `\nContext:\n\`\`\`json\n${JSON.stringify(context, null, 2)}\n\`\`\`` : "",
|
|
210
|
+
]
|
|
211
|
+
.filter(Boolean)
|
|
212
|
+
.join("\n");
|
|
213
|
+
const env = this.buildEnv();
|
|
214
|
+
let response = "";
|
|
215
|
+
try {
|
|
216
|
+
const stream = query({
|
|
217
|
+
prompt,
|
|
218
|
+
options: {
|
|
219
|
+
maxTurns: 1,
|
|
220
|
+
cwd: this.cwd,
|
|
221
|
+
env,
|
|
222
|
+
permissionMode: "bypassPermissions",
|
|
223
|
+
allowDangerouslySkipPermissions: true,
|
|
224
|
+
stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
|
|
225
|
+
...(this.model ? { model: this.model } : {}),
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
for await (const message of stream) {
|
|
229
|
+
if (message.type === "result") {
|
|
230
|
+
const resultMsg = message;
|
|
231
|
+
if (resultMsg.subtype === "success" && "result" in resultMsg) {
|
|
232
|
+
response = resultMsg.result;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
this.logger.warn(`claude.ask: SDK returned non-success subtype "${resultMsg.subtype}" — returning empty string`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
this.logger.warn(`Ask query failed: ${err.message}`);
|
|
242
|
+
return "";
|
|
243
|
+
}
|
|
244
|
+
return response.trim();
|
|
245
|
+
}
|
|
205
246
|
}
|
|
206
247
|
// ─── Tool conversion ────────────────────────────────────────────
|
|
207
248
|
/**
|