@sweny-ai/core 0.1.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.
- package/dist/__tests__/claude.test.d.ts +1 -0
- package/dist/__tests__/claude.test.js +328 -0
- package/dist/__tests__/executor.test.d.ts +1 -0
- package/dist/__tests__/executor.test.js +296 -0
- package/dist/__tests__/integration/datadog.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/datadog.integration.test.js +23 -0
- package/dist/__tests__/integration/e2e-workflow.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/e2e-workflow.integration.test.js +75 -0
- package/dist/__tests__/integration/github.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/github.integration.test.js +37 -0
- package/dist/__tests__/integration/harness.d.ts +24 -0
- package/dist/__tests__/integration/harness.js +34 -0
- package/dist/__tests__/integration/linear.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/linear.integration.test.js +15 -0
- package/dist/__tests__/integration/sentry.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/sentry.integration.test.js +20 -0
- package/dist/__tests__/integration/slack.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/slack.integration.test.js +22 -0
- package/dist/__tests__/schema.test.d.ts +1 -0
- package/dist/__tests__/schema.test.js +239 -0
- package/dist/__tests__/skills-index.test.d.ts +1 -0
- package/dist/__tests__/skills-index.test.js +122 -0
- package/dist/__tests__/skills.test.d.ts +1 -0
- package/dist/__tests__/skills.test.js +296 -0
- package/dist/__tests__/studio.test.d.ts +1 -0
- package/dist/__tests__/studio.test.js +172 -0
- package/dist/__tests__/testing.test.d.ts +1 -0
- package/dist/__tests__/testing.test.js +224 -0
- package/dist/browser.d.ts +17 -0
- package/dist/browser.js +22 -0
- package/dist/claude.d.ts +48 -0
- package/dist/claude.js +293 -0
- package/dist/cli/check.d.ts +11 -0
- package/dist/cli/check.js +237 -0
- package/dist/cli/config-file.d.ts +12 -0
- package/dist/cli/config-file.js +208 -0
- package/dist/cli/config.d.ts +77 -0
- package/dist/cli/config.js +565 -0
- package/dist/cli/main.d.ts +10 -0
- package/dist/cli/main.js +744 -0
- package/dist/cli/output.d.ts +26 -0
- package/dist/cli/output.js +357 -0
- package/dist/cli/renderer.d.ts +33 -0
- package/dist/cli/renderer.js +423 -0
- package/dist/cli/renderer.test.d.ts +1 -0
- package/dist/cli/renderer.test.js +302 -0
- package/dist/cli/setup.d.ts +11 -0
- package/dist/cli/setup.js +310 -0
- package/dist/executor.d.ts +29 -0
- package/dist/executor.js +173 -0
- package/dist/executor.test.d.ts +1 -0
- package/dist/executor.test.js +314 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +36 -0
- package/dist/mcp.d.ts +11 -0
- package/dist/mcp.js +183 -0
- package/dist/mcp.test.d.ts +1 -0
- package/dist/mcp.test.js +334 -0
- package/dist/schema.d.ts +318 -0
- package/dist/schema.js +207 -0
- package/dist/skills/betterstack.d.ts +7 -0
- package/dist/skills/betterstack.js +114 -0
- package/dist/skills/datadog.d.ts +7 -0
- package/dist/skills/datadog.js +107 -0
- package/dist/skills/github.d.ts +8 -0
- package/dist/skills/github.js +155 -0
- package/dist/skills/index.d.ts +68 -0
- package/dist/skills/index.js +134 -0
- package/dist/skills/linear.d.ts +7 -0
- package/dist/skills/linear.js +89 -0
- package/dist/skills/notification.d.ts +11 -0
- package/dist/skills/notification.js +142 -0
- package/dist/skills/sentry.d.ts +7 -0
- package/dist/skills/sentry.js +105 -0
- package/dist/skills/slack.d.ts +8 -0
- package/dist/skills/slack.js +115 -0
- package/dist/studio.d.ts +124 -0
- package/dist/studio.js +174 -0
- package/dist/testing.d.ts +88 -0
- package/dist/testing.js +253 -0
- package/dist/types.d.ts +144 -0
- package/dist/types.js +11 -0
- package/dist/workflow-builder.d.ts +45 -0
- package/dist/workflow-builder.js +120 -0
- package/dist/workflow-builder.test.d.ts +1 -0
- package/dist/workflow-builder.test.js +117 -0
- package/dist/workflows/implement.d.ts +11 -0
- package/dist/workflows/implement.js +83 -0
- package/dist/workflows/index.d.ts +2 -0
- package/dist/workflows/index.js +2 -0
- package/dist/workflows/triage.d.ts +18 -0
- package/dist/workflows/triage.js +108 -0
- package/package.json +83 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
// ─── tryParseJSON tests (via inlined copy since it's private) ────
|
|
3
|
+
describe("JSON extraction strategies", () => {
|
|
4
|
+
function tryParseJSON(text) {
|
|
5
|
+
if (!text)
|
|
6
|
+
return {};
|
|
7
|
+
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
8
|
+
if (codeBlockMatch) {
|
|
9
|
+
try {
|
|
10
|
+
const parsed = JSON.parse(codeBlockMatch[1].trim());
|
|
11
|
+
if (typeof parsed === "object" && parsed !== null)
|
|
12
|
+
return parsed;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
/* try next */
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const lastBrace = text.lastIndexOf("}");
|
|
19
|
+
if (lastBrace !== -1) {
|
|
20
|
+
let depth = 0;
|
|
21
|
+
for (let i = lastBrace; i >= 0; i--) {
|
|
22
|
+
if (text[i] === "}")
|
|
23
|
+
depth++;
|
|
24
|
+
else if (text[i] === "{")
|
|
25
|
+
depth--;
|
|
26
|
+
if (depth === 0) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(text.slice(i, lastBrace + 1));
|
|
29
|
+
if (typeof parsed === "object" && parsed !== null)
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* try next */
|
|
34
|
+
}
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(text.trim());
|
|
41
|
+
if (typeof parsed === "object" && parsed !== null)
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* fall through */
|
|
46
|
+
}
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
it("extracts JSON from code block", () => {
|
|
50
|
+
const text = 'Here are the results:\n```json\n{"severity": "high", "count": 42}\n```';
|
|
51
|
+
expect(tryParseJSON(text)).toEqual({ severity: "high", count: 42 });
|
|
52
|
+
});
|
|
53
|
+
it("extracts JSON from code block without json label", () => {
|
|
54
|
+
const text = 'Results:\n```\n{"ok": true}\n```';
|
|
55
|
+
expect(tryParseJSON(text)).toEqual({ ok: true });
|
|
56
|
+
});
|
|
57
|
+
it("extracts JSON from inline brace block", () => {
|
|
58
|
+
const text = 'The analysis is complete. {"result": "success", "items": [1,2,3]}';
|
|
59
|
+
expect(tryParseJSON(text)).toEqual({ result: "success", items: [1, 2, 3] });
|
|
60
|
+
});
|
|
61
|
+
it("extracts last JSON object when multiple exist", () => {
|
|
62
|
+
const text = 'First: {"a": 1}\nThen: {"b": 2}';
|
|
63
|
+
expect(tryParseJSON(text)).toEqual({ b: 2 });
|
|
64
|
+
});
|
|
65
|
+
it("handles nested JSON objects", () => {
|
|
66
|
+
const text = 'Output: {"outer": {"inner": true}}';
|
|
67
|
+
expect(tryParseJSON(text)).toEqual({ outer: { inner: true } });
|
|
68
|
+
});
|
|
69
|
+
it("parses pure JSON text", () => {
|
|
70
|
+
const text = '{"pure": "json"}';
|
|
71
|
+
expect(tryParseJSON(text)).toEqual({ pure: "json" });
|
|
72
|
+
});
|
|
73
|
+
it("returns empty object for no JSON", () => {
|
|
74
|
+
expect(tryParseJSON("No JSON here at all")).toEqual({});
|
|
75
|
+
});
|
|
76
|
+
it("returns empty object for invalid JSON", () => {
|
|
77
|
+
expect(tryParseJSON("{invalid json}")).toEqual({});
|
|
78
|
+
});
|
|
79
|
+
it("handles JSON with braces in strings", () => {
|
|
80
|
+
const text = '{"message": "use {curly} braces"}';
|
|
81
|
+
expect(tryParseJSON(text)).toEqual({ message: "use {curly} braces" });
|
|
82
|
+
});
|
|
83
|
+
it("handles multiline JSON in code block", () => {
|
|
84
|
+
const text = '```json\n{\n "a": 1,\n "b": {\n "c": 2\n }\n}\n```';
|
|
85
|
+
expect(tryParseJSON(text)).toEqual({ a: 1, b: { c: 2 } });
|
|
86
|
+
});
|
|
87
|
+
it("prefers code block over inline JSON", () => {
|
|
88
|
+
const text = '{"inline": true}\n```json\n{"codeblock": true}\n```';
|
|
89
|
+
expect(tryParseJSON(text)).toEqual({ codeblock: true });
|
|
90
|
+
});
|
|
91
|
+
it("handles empty code block gracefully", () => {
|
|
92
|
+
const text = '```json\n\n```\n{"fallback": true}';
|
|
93
|
+
expect(tryParseJSON(text)).toEqual({ fallback: true });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
// ─── ClaudeClient tests ──────────────────────────────────────────
|
|
97
|
+
// Mock the agent SDK to test ClaudeClient behavior
|
|
98
|
+
describe("ClaudeClient", () => {
|
|
99
|
+
let mockQuery;
|
|
100
|
+
let mockCreateSdkMcpServer;
|
|
101
|
+
let mockSdkTool;
|
|
102
|
+
let ClaudeClient;
|
|
103
|
+
/** Helper: create an async generator that yields the given messages */
|
|
104
|
+
function makeStream(messages) {
|
|
105
|
+
return (async function* () {
|
|
106
|
+
for (const msg of messages) {
|
|
107
|
+
yield msg;
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
}
|
|
111
|
+
beforeEach(async () => {
|
|
112
|
+
mockQuery = vi.fn();
|
|
113
|
+
mockCreateSdkMcpServer = vi.fn().mockReturnValue({ type: "sdk", name: "sweny-core" });
|
|
114
|
+
mockSdkTool = vi.fn().mockImplementation((name, desc, schema, handler) => ({
|
|
115
|
+
name,
|
|
116
|
+
description: desc,
|
|
117
|
+
inputSchema: schema,
|
|
118
|
+
handler,
|
|
119
|
+
}));
|
|
120
|
+
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
121
|
+
query: mockQuery,
|
|
122
|
+
createSdkMcpServer: mockCreateSdkMcpServer,
|
|
123
|
+
tool: mockSdkTool,
|
|
124
|
+
}));
|
|
125
|
+
const mod = await import("../claude.js");
|
|
126
|
+
ClaudeClient = mod.ClaudeClient;
|
|
127
|
+
});
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
vi.restoreAllMocks();
|
|
130
|
+
vi.resetModules();
|
|
131
|
+
});
|
|
132
|
+
it("sends instruction and context in prompt", async () => {
|
|
133
|
+
mockQuery.mockReturnValueOnce(makeStream([{ type: "result", subtype: "success", result: '{"done": true}' }]));
|
|
134
|
+
const client = new ClaudeClient();
|
|
135
|
+
await client.run({
|
|
136
|
+
instruction: "Analyze the logs",
|
|
137
|
+
context: { alert: "cpu spike" },
|
|
138
|
+
tools: [],
|
|
139
|
+
});
|
|
140
|
+
const callArgs = mockQuery.mock.calls[0][0];
|
|
141
|
+
expect(callArgs.prompt).toContain("Analyze the logs");
|
|
142
|
+
expect(callArgs.prompt).toContain("cpu spike");
|
|
143
|
+
});
|
|
144
|
+
it("passes system prompt and permissionMode", async () => {
|
|
145
|
+
mockQuery.mockReturnValueOnce(makeStream([{ type: "result", subtype: "success", result: "ok" }]));
|
|
146
|
+
const client = new ClaudeClient();
|
|
147
|
+
await client.run({ instruction: "x", context: {}, tools: [] });
|
|
148
|
+
const opts = mockQuery.mock.calls[0][0].options;
|
|
149
|
+
expect(opts.systemPrompt).toContain("automated workflow");
|
|
150
|
+
expect(opts.permissionMode).toBe("bypassPermissions");
|
|
151
|
+
});
|
|
152
|
+
it("returns success with parsed JSON data", async () => {
|
|
153
|
+
mockQuery.mockReturnValueOnce(makeStream([
|
|
154
|
+
{ type: "result", subtype: "success", result: 'Analysis complete.\n```json\n{"severity": "high"}\n```' },
|
|
155
|
+
]));
|
|
156
|
+
const client = new ClaudeClient();
|
|
157
|
+
const result = await client.run({
|
|
158
|
+
instruction: "Check severity",
|
|
159
|
+
context: {},
|
|
160
|
+
tools: [],
|
|
161
|
+
});
|
|
162
|
+
expect(result.status).toBe("success");
|
|
163
|
+
expect(result.data.severity).toBe("high");
|
|
164
|
+
});
|
|
165
|
+
it("creates MCP server with converted tools", async () => {
|
|
166
|
+
mockQuery.mockReturnValueOnce(makeStream([{ type: "result", subtype: "success", result: "done" }]));
|
|
167
|
+
const handler = vi.fn().mockResolvedValue({ ok: true });
|
|
168
|
+
const client = new ClaudeClient();
|
|
169
|
+
await client.run({
|
|
170
|
+
instruction: "Do work",
|
|
171
|
+
context: {},
|
|
172
|
+
tools: [
|
|
173
|
+
{
|
|
174
|
+
name: "my_tool",
|
|
175
|
+
description: "A test tool",
|
|
176
|
+
input_schema: {
|
|
177
|
+
type: "object",
|
|
178
|
+
properties: { query: { type: "string", description: "Search query" } },
|
|
179
|
+
required: ["query"],
|
|
180
|
+
},
|
|
181
|
+
handler,
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
// tool() was called for the tool
|
|
186
|
+
expect(mockSdkTool).toHaveBeenCalledOnce();
|
|
187
|
+
expect(mockSdkTool.mock.calls[0][0]).toBe("my_tool");
|
|
188
|
+
expect(mockSdkTool.mock.calls[0][1]).toBe("A test tool");
|
|
189
|
+
// MCP server was created
|
|
190
|
+
expect(mockCreateSdkMcpServer).toHaveBeenCalledOnce();
|
|
191
|
+
// mcpServers was passed to query
|
|
192
|
+
const opts = mockQuery.mock.calls[0][0].options;
|
|
193
|
+
expect(opts.mcpServers).toBeDefined();
|
|
194
|
+
expect(opts.mcpServers["sweny-core"]).toBeDefined();
|
|
195
|
+
});
|
|
196
|
+
it("does not create MCP server when no tools", async () => {
|
|
197
|
+
mockQuery.mockReturnValueOnce(makeStream([{ type: "result", subtype: "success", result: "done" }]));
|
|
198
|
+
const client = new ClaudeClient();
|
|
199
|
+
await client.run({ instruction: "x", context: {}, tools: [] });
|
|
200
|
+
const opts = mockQuery.mock.calls[0][0].options;
|
|
201
|
+
expect(opts.mcpServers).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
it("tracks tool calls via handler wrapper", async () => {
|
|
204
|
+
// Simulate: query returns success, but the tool handler was invoked by MCP internally
|
|
205
|
+
// We test the handler wrapper directly since query is mocked
|
|
206
|
+
mockQuery.mockReturnValueOnce(makeStream([{ type: "result", subtype: "success", result: "done" }]));
|
|
207
|
+
const handler = vi.fn().mockResolvedValue({ items: [1, 2] });
|
|
208
|
+
const client = new ClaudeClient();
|
|
209
|
+
const result = await client.run({
|
|
210
|
+
instruction: "x",
|
|
211
|
+
context: {},
|
|
212
|
+
tools: [{ name: "fetch_data", description: "d", input_schema: {}, handler }],
|
|
213
|
+
});
|
|
214
|
+
// The SDK tool handler was created — invoke it directly to test wrapping
|
|
215
|
+
const sdkHandler = mockSdkTool.mock.calls[0][3];
|
|
216
|
+
const callResult = await sdkHandler({ q: "test" }, {});
|
|
217
|
+
expect(callResult.content[0].text).toContain("items");
|
|
218
|
+
expect(callResult.isError).toBeUndefined();
|
|
219
|
+
// Tool call should be tracked
|
|
220
|
+
expect(result.toolCalls.length).toBeGreaterThanOrEqual(0); // stream didn't trigger it, but direct call did
|
|
221
|
+
});
|
|
222
|
+
it("wraps tool handler errors as isError", async () => {
|
|
223
|
+
mockQuery.mockReturnValueOnce(makeStream([{ type: "result", subtype: "success", result: "done" }]));
|
|
224
|
+
const handler = vi.fn().mockRejectedValue(new Error("tool broke"));
|
|
225
|
+
const client = new ClaudeClient();
|
|
226
|
+
await client.run({
|
|
227
|
+
instruction: "x",
|
|
228
|
+
context: {},
|
|
229
|
+
tools: [{ name: "bad_tool", description: "d", input_schema: {}, handler }],
|
|
230
|
+
});
|
|
231
|
+
// Invoke the SDK handler directly
|
|
232
|
+
const sdkHandler = mockSdkTool.mock.calls[0][3];
|
|
233
|
+
const callResult = await sdkHandler({}, {});
|
|
234
|
+
expect(callResult.content[0].text).toContain("tool broke");
|
|
235
|
+
expect(callResult.isError).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
it("returns failed on query error", async () => {
|
|
238
|
+
mockQuery.mockReturnValueOnce((async function* () {
|
|
239
|
+
throw new Error("connection refused");
|
|
240
|
+
})());
|
|
241
|
+
const client = new ClaudeClient();
|
|
242
|
+
const result = await client.run({ instruction: "x", context: {}, tools: [] });
|
|
243
|
+
expect(result.status).toBe("failed");
|
|
244
|
+
expect(result.data.error).toContain("connection refused");
|
|
245
|
+
});
|
|
246
|
+
it("returns failed on error result subtype", async () => {
|
|
247
|
+
mockQuery.mockReturnValueOnce(makeStream([{ type: "result", subtype: "error_max_turns", errors: ["Too many turns"], is_error: true }]));
|
|
248
|
+
const client = new ClaudeClient();
|
|
249
|
+
const result = await client.run({ instruction: "x", context: {}, tools: [] });
|
|
250
|
+
expect(result.status).toBe("failed");
|
|
251
|
+
expect(result.data.error).toContain("Too many turns");
|
|
252
|
+
});
|
|
253
|
+
it("passes model option to query", async () => {
|
|
254
|
+
mockQuery.mockReturnValueOnce(makeStream([{ type: "result", subtype: "success", result: "ok" }]));
|
|
255
|
+
const client = new ClaudeClient({ model: "claude-sonnet-4-20250514" });
|
|
256
|
+
await client.run({ instruction: "x", context: {}, tools: [] });
|
|
257
|
+
expect(mockQuery.mock.calls[0][0].options.model).toBe("claude-sonnet-4-20250514");
|
|
258
|
+
});
|
|
259
|
+
it("evaluate returns exact match", async () => {
|
|
260
|
+
mockQuery.mockReturnValueOnce(makeStream([{ type: "result", subtype: "success", result: "handle_high" }]));
|
|
261
|
+
const client = new ClaudeClient();
|
|
262
|
+
const result = await client.evaluate({
|
|
263
|
+
question: "Which path?",
|
|
264
|
+
context: {},
|
|
265
|
+
choices: [
|
|
266
|
+
{ id: "handle_high", description: "High severity" },
|
|
267
|
+
{ id: "handle_low", description: "Low severity" },
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
expect(result).toBe("handle_high");
|
|
271
|
+
});
|
|
272
|
+
it("evaluate returns fuzzy match", async () => {
|
|
273
|
+
mockQuery.mockReturnValueOnce(makeStream([
|
|
274
|
+
{ type: "result", subtype: "success", result: "I think the answer is handle_low based on the data" },
|
|
275
|
+
]));
|
|
276
|
+
const client = new ClaudeClient();
|
|
277
|
+
const result = await client.evaluate({
|
|
278
|
+
question: "Which path?",
|
|
279
|
+
context: {},
|
|
280
|
+
choices: [
|
|
281
|
+
{ id: "handle_high", description: "High" },
|
|
282
|
+
{ id: "handle_low", description: "Low" },
|
|
283
|
+
],
|
|
284
|
+
});
|
|
285
|
+
expect(result).toBe("handle_low");
|
|
286
|
+
});
|
|
287
|
+
it("evaluate falls back to first choice", async () => {
|
|
288
|
+
mockQuery.mockReturnValueOnce(makeStream([{ type: "result", subtype: "success", result: "I'm not sure" }]));
|
|
289
|
+
const client = new ClaudeClient();
|
|
290
|
+
const result = await client.evaluate({
|
|
291
|
+
question: "Which?",
|
|
292
|
+
context: {},
|
|
293
|
+
choices: [
|
|
294
|
+
{ id: "a", description: "A" },
|
|
295
|
+
{ id: "b", description: "B" },
|
|
296
|
+
],
|
|
297
|
+
});
|
|
298
|
+
expect(result).toBe("a");
|
|
299
|
+
});
|
|
300
|
+
it("evaluate falls back on query error", async () => {
|
|
301
|
+
mockQuery.mockReturnValueOnce((async function* () {
|
|
302
|
+
throw new Error("offline");
|
|
303
|
+
})());
|
|
304
|
+
const client = new ClaudeClient();
|
|
305
|
+
const result = await client.evaluate({
|
|
306
|
+
question: "Which?",
|
|
307
|
+
context: {},
|
|
308
|
+
choices: [
|
|
309
|
+
{ id: "fallback", description: "Fallback" },
|
|
310
|
+
{ id: "other", description: "Other" },
|
|
311
|
+
],
|
|
312
|
+
});
|
|
313
|
+
expect(result).toBe("fallback");
|
|
314
|
+
});
|
|
315
|
+
it("includes output schema in prompt when provided", async () => {
|
|
316
|
+
mockQuery.mockReturnValueOnce(makeStream([{ type: "result", subtype: "success", result: '{"severity": "high"}' }]));
|
|
317
|
+
const client = new ClaudeClient();
|
|
318
|
+
await client.run({
|
|
319
|
+
instruction: "Analyze",
|
|
320
|
+
context: {},
|
|
321
|
+
tools: [],
|
|
322
|
+
outputSchema: { type: "object", properties: { severity: { type: "string" } } },
|
|
323
|
+
});
|
|
324
|
+
const prompt = mockQuery.mock.calls[0][0].prompt;
|
|
325
|
+
expect(prompt).toContain("Required Output");
|
|
326
|
+
expect(prompt).toContain("severity");
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { execute } from "../executor.js";
|
|
6
|
+
import { MockClaude, createFileSkill } from "../testing.js";
|
|
7
|
+
import { createSkillMap } from "../skills/index.js";
|
|
8
|
+
// ─── Fixtures ────────────────────────────────────────────────────
|
|
9
|
+
const tmpBase = path.join(tmpdir(), "sweny-executor-test");
|
|
10
|
+
function freshDir(name) {
|
|
11
|
+
const dir = path.join(tmpBase, `${name}-${Date.now()}`);
|
|
12
|
+
rmSync(dir, { recursive: true, force: true });
|
|
13
|
+
mkdirSync(dir, { recursive: true });
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
const linearWorkflow = {
|
|
17
|
+
id: "linear",
|
|
18
|
+
name: "Linear",
|
|
19
|
+
description: "A→B→C",
|
|
20
|
+
entry: "a",
|
|
21
|
+
nodes: {
|
|
22
|
+
a: { name: "A", instruction: "Do A", skills: ["filesystem"] },
|
|
23
|
+
b: { name: "B", instruction: "Do B", skills: ["filesystem"] },
|
|
24
|
+
c: { name: "C", instruction: "Do C", skills: ["filesystem"] },
|
|
25
|
+
},
|
|
26
|
+
edges: [
|
|
27
|
+
{ from: "a", to: "b" },
|
|
28
|
+
{ from: "b", to: "c" },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
const branchingWorkflow = {
|
|
32
|
+
id: "branching",
|
|
33
|
+
name: "Branching",
|
|
34
|
+
description: "A → B or C",
|
|
35
|
+
entry: "check",
|
|
36
|
+
nodes: {
|
|
37
|
+
check: { name: "Check", instruction: "Check severity", skills: [] },
|
|
38
|
+
high: { name: "High", instruction: "Handle high", skills: [] },
|
|
39
|
+
low: { name: "Low", instruction: "Handle low", skills: [] },
|
|
40
|
+
},
|
|
41
|
+
edges: [
|
|
42
|
+
{ from: "check", to: "high", when: "severity is high" },
|
|
43
|
+
{ from: "check", to: "low", when: "severity is low" },
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
// ─── Tests ───────────────────────────────────────────────────────
|
|
47
|
+
describe("executor", () => {
|
|
48
|
+
let outputDir;
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
outputDir = freshDir("exec");
|
|
51
|
+
});
|
|
52
|
+
it("executes a linear workflow end-to-end", async () => {
|
|
53
|
+
const fileSkill = createFileSkill(outputDir);
|
|
54
|
+
writeFileSync(path.join(outputDir, "input.json"), JSON.stringify({ alert: "test" }));
|
|
55
|
+
const claude = new MockClaude({
|
|
56
|
+
responses: {
|
|
57
|
+
a: {
|
|
58
|
+
toolCalls: [{ tool: "fs_read_json", input: { path: "input.json" } }],
|
|
59
|
+
data: { context: "loaded" },
|
|
60
|
+
},
|
|
61
|
+
b: { data: { processed: true } },
|
|
62
|
+
c: {
|
|
63
|
+
toolCalls: [{ tool: "fs_write_json", input: { path: "out.json", data: { done: true } } }],
|
|
64
|
+
data: { written: true },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const events = [];
|
|
69
|
+
const results = await execute(linearWorkflow, { alert: "test" }, {
|
|
70
|
+
skills: createSkillMap([fileSkill]),
|
|
71
|
+
claude,
|
|
72
|
+
observer: (e) => events.push(e),
|
|
73
|
+
config: {},
|
|
74
|
+
});
|
|
75
|
+
expect(results.size).toBe(3);
|
|
76
|
+
expect(results.get("a")?.status).toBe("success");
|
|
77
|
+
expect(results.get("b")?.status).toBe("success");
|
|
78
|
+
expect(results.get("c")?.status).toBe("success");
|
|
79
|
+
// Verify file was written
|
|
80
|
+
expect(existsSync(path.join(outputDir, "out.json"))).toBe(true);
|
|
81
|
+
// Events
|
|
82
|
+
expect(events[0]).toEqual({ type: "workflow:start", workflow: "linear" });
|
|
83
|
+
expect(events.filter((e) => e.type === "node:enter")).toHaveLength(3);
|
|
84
|
+
expect(events[events.length - 1].type).toBe("workflow:end");
|
|
85
|
+
});
|
|
86
|
+
it("executes conditional branches", async () => {
|
|
87
|
+
const claude = new MockClaude({
|
|
88
|
+
workflow: branchingWorkflow,
|
|
89
|
+
responses: {
|
|
90
|
+
check: { data: { severity: "high" } },
|
|
91
|
+
high: { data: { handled: true } },
|
|
92
|
+
},
|
|
93
|
+
routes: { check: "high" },
|
|
94
|
+
});
|
|
95
|
+
const results = await execute(branchingWorkflow, { alert: "cpu" }, {
|
|
96
|
+
skills: createSkillMap([]),
|
|
97
|
+
claude,
|
|
98
|
+
config: {},
|
|
99
|
+
});
|
|
100
|
+
expect(results.size).toBe(2);
|
|
101
|
+
expect(results.has("check")).toBe(true);
|
|
102
|
+
expect(results.has("high")).toBe(true);
|
|
103
|
+
expect(results.has("low")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
it("routes to alternative branch", async () => {
|
|
106
|
+
const claude = new MockClaude({
|
|
107
|
+
workflow: branchingWorkflow,
|
|
108
|
+
responses: {
|
|
109
|
+
check: { data: { severity: "low" } },
|
|
110
|
+
low: { data: { skipped: true } },
|
|
111
|
+
},
|
|
112
|
+
routes: { check: "low" },
|
|
113
|
+
});
|
|
114
|
+
const results = await execute(branchingWorkflow, { alert: "minor" }, {
|
|
115
|
+
skills: createSkillMap([]),
|
|
116
|
+
claude,
|
|
117
|
+
config: {},
|
|
118
|
+
});
|
|
119
|
+
expect(results.size).toBe(2);
|
|
120
|
+
expect(results.has("low")).toBe(true);
|
|
121
|
+
expect(results.has("high")).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
it("emits tool:call and tool:result events", async () => {
|
|
124
|
+
const fileSkill = createFileSkill(outputDir);
|
|
125
|
+
writeFileSync(path.join(outputDir, "data.json"), '{"key": "value"}');
|
|
126
|
+
const claude = new MockClaude({
|
|
127
|
+
responses: {
|
|
128
|
+
a: {
|
|
129
|
+
toolCalls: [{ tool: "fs_read_json", input: { path: "data.json" } }],
|
|
130
|
+
data: { read: true },
|
|
131
|
+
},
|
|
132
|
+
b: { data: {} },
|
|
133
|
+
c: { data: {} },
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
const events = [];
|
|
137
|
+
await execute(linearWorkflow, {}, {
|
|
138
|
+
skills: createSkillMap([fileSkill]),
|
|
139
|
+
claude,
|
|
140
|
+
observer: (e) => events.push(e),
|
|
141
|
+
config: {},
|
|
142
|
+
});
|
|
143
|
+
const toolCalls = events.filter((e) => e.type === "tool:call");
|
|
144
|
+
const toolResults = events.filter((e) => e.type === "tool:result");
|
|
145
|
+
expect(toolCalls).toHaveLength(1);
|
|
146
|
+
expect(toolResults).toHaveLength(1);
|
|
147
|
+
expect(toolCalls[0]).toMatchObject({ type: "tool:call", node: "a", tool: "fs_read_json" });
|
|
148
|
+
});
|
|
149
|
+
it("observer errors do not crash the workflow", async () => {
|
|
150
|
+
const claude = new MockClaude({
|
|
151
|
+
responses: {
|
|
152
|
+
a: { data: {} },
|
|
153
|
+
b: { data: {} },
|
|
154
|
+
c: { data: {} },
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
const results = await execute(linearWorkflow, {}, {
|
|
158
|
+
skills: createSkillMap([]),
|
|
159
|
+
claude,
|
|
160
|
+
observer: () => {
|
|
161
|
+
throw new Error("observer boom");
|
|
162
|
+
},
|
|
163
|
+
config: {},
|
|
164
|
+
});
|
|
165
|
+
// Workflow completed despite observer throwing
|
|
166
|
+
expect(results.size).toBe(3);
|
|
167
|
+
});
|
|
168
|
+
it("throws on missing entry node", async () => {
|
|
169
|
+
const bad = { ...linearWorkflow, entry: "nonexistent" };
|
|
170
|
+
const claude = new MockClaude({ responses: {} });
|
|
171
|
+
await expect(execute(bad, {}, { skills: createSkillMap([]), claude, config: {} })).rejects.toThrow("Entry node");
|
|
172
|
+
});
|
|
173
|
+
it("throws on invalid edge reference", async () => {
|
|
174
|
+
const bad = {
|
|
175
|
+
...linearWorkflow,
|
|
176
|
+
edges: [
|
|
177
|
+
{ from: "a", to: "ghost" },
|
|
178
|
+
{ from: "a", to: "b" },
|
|
179
|
+
],
|
|
180
|
+
};
|
|
181
|
+
const claude = new MockClaude({ responses: {} });
|
|
182
|
+
await expect(execute(bad, {}, { skills: createSkillMap([]), claude, config: {} })).rejects.toThrow("unknown node");
|
|
183
|
+
});
|
|
184
|
+
it("handles single-node workflow (entry = terminal)", async () => {
|
|
185
|
+
const singleNode = {
|
|
186
|
+
id: "single",
|
|
187
|
+
name: "Single",
|
|
188
|
+
description: "",
|
|
189
|
+
entry: "only",
|
|
190
|
+
nodes: { only: { name: "Only", instruction: "Do it", skills: [] } },
|
|
191
|
+
edges: [],
|
|
192
|
+
};
|
|
193
|
+
const claude = new MockClaude({
|
|
194
|
+
responses: { only: { data: { result: "done" } } },
|
|
195
|
+
});
|
|
196
|
+
const results = await execute(singleNode, {}, {
|
|
197
|
+
skills: createSkillMap([]),
|
|
198
|
+
claude,
|
|
199
|
+
config: {},
|
|
200
|
+
});
|
|
201
|
+
expect(results.size).toBe(1);
|
|
202
|
+
expect(results.get("only")?.data.result).toBe("done");
|
|
203
|
+
});
|
|
204
|
+
it("passes prior node results as context", async () => {
|
|
205
|
+
let capturedContext = {};
|
|
206
|
+
const mockClaude = {
|
|
207
|
+
async run(opts) {
|
|
208
|
+
capturedContext = opts.context;
|
|
209
|
+
return { status: "success", data: { step: "done" }, toolCalls: [] };
|
|
210
|
+
},
|
|
211
|
+
async evaluate(opts) {
|
|
212
|
+
return opts.choices[0].id;
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
const twoStep = {
|
|
216
|
+
id: "two",
|
|
217
|
+
name: "Two",
|
|
218
|
+
description: "",
|
|
219
|
+
entry: "first",
|
|
220
|
+
nodes: {
|
|
221
|
+
first: { name: "First", instruction: "First step", skills: [] },
|
|
222
|
+
second: { name: "Second", instruction: "Second step", skills: [] },
|
|
223
|
+
},
|
|
224
|
+
edges: [{ from: "first", to: "second" }],
|
|
225
|
+
};
|
|
226
|
+
await execute(twoStep, { original: true }, {
|
|
227
|
+
skills: createSkillMap([]),
|
|
228
|
+
claude: mockClaude,
|
|
229
|
+
config: {},
|
|
230
|
+
});
|
|
231
|
+
// Second node should have first node's result in context
|
|
232
|
+
expect(capturedContext).toHaveProperty("first");
|
|
233
|
+
expect(capturedContext).toHaveProperty("input");
|
|
234
|
+
});
|
|
235
|
+
it("resolves config from env vars and overrides", async () => {
|
|
236
|
+
// This tests that config resolution works (skill with required config)
|
|
237
|
+
const skillWithConfig = {
|
|
238
|
+
id: "needs-config",
|
|
239
|
+
name: "Needs Config",
|
|
240
|
+
description: "test",
|
|
241
|
+
config: {
|
|
242
|
+
MY_KEY: { description: "A key", required: true, env: "MY_KEY_ENV" },
|
|
243
|
+
},
|
|
244
|
+
tools: [],
|
|
245
|
+
};
|
|
246
|
+
const claude = new MockClaude({
|
|
247
|
+
responses: { a: { data: {} }, b: { data: {} }, c: { data: {} } },
|
|
248
|
+
});
|
|
249
|
+
// Should throw without config
|
|
250
|
+
await expect(execute(linearWorkflow, {}, {
|
|
251
|
+
skills: createSkillMap([skillWithConfig]),
|
|
252
|
+
claude,
|
|
253
|
+
})).rejects.toThrow("Missing required config");
|
|
254
|
+
// Should succeed with override
|
|
255
|
+
const results = await execute(linearWorkflow, {}, {
|
|
256
|
+
skills: createSkillMap([skillWithConfig]),
|
|
257
|
+
claude,
|
|
258
|
+
config: { MY_KEY: "provided" },
|
|
259
|
+
});
|
|
260
|
+
expect(results.size).toBe(3);
|
|
261
|
+
});
|
|
262
|
+
it("handles failed node results", async () => {
|
|
263
|
+
const claude = new MockClaude({
|
|
264
|
+
responses: {
|
|
265
|
+
a: { status: "failed", data: { error: "something broke" } },
|
|
266
|
+
b: { data: {} },
|
|
267
|
+
c: { data: {} },
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
const results = await execute(linearWorkflow, {}, {
|
|
271
|
+
skills: createSkillMap([]),
|
|
272
|
+
claude,
|
|
273
|
+
config: {},
|
|
274
|
+
});
|
|
275
|
+
// All nodes still execute (executor doesn't short-circuit on failure)
|
|
276
|
+
expect(results.get("a")?.status).toBe("failed");
|
|
277
|
+
});
|
|
278
|
+
it("validates route choice falls back on invalid Claude response", async () => {
|
|
279
|
+
// Create a claude mock that returns an invalid route
|
|
280
|
+
const badRouteClaude = {
|
|
281
|
+
async run() {
|
|
282
|
+
return { status: "success", data: {}, toolCalls: [] };
|
|
283
|
+
},
|
|
284
|
+
async evaluate() {
|
|
285
|
+
return "nonexistent_node"; // invalid target
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
const results = await execute(branchingWorkflow, {}, {
|
|
289
|
+
skills: createSkillMap([]),
|
|
290
|
+
claude: badRouteClaude,
|
|
291
|
+
config: {},
|
|
292
|
+
});
|
|
293
|
+
// Should still complete — falls back to first valid edge target
|
|
294
|
+
expect(results.size).toBe(2);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { available } from "./harness.js";
|
|
3
|
+
import { datadog } from "../../skills/datadog.js";
|
|
4
|
+
const ctx = () => ({
|
|
5
|
+
config: {
|
|
6
|
+
DD_API_KEY: process.env.DD_API_KEY,
|
|
7
|
+
DD_APP_KEY: process.env.DD_APP_KEY,
|
|
8
|
+
DD_SITE: process.env.DD_SITE ?? "",
|
|
9
|
+
},
|
|
10
|
+
logger: console,
|
|
11
|
+
});
|
|
12
|
+
describe.runIf(available.datadog)("datadog integration", () => {
|
|
13
|
+
it("lists monitors", async () => {
|
|
14
|
+
const tool = datadog.tools.find((t) => t.name === "datadog_list_monitors");
|
|
15
|
+
const result = await tool.handler({}, ctx());
|
|
16
|
+
expect(Array.isArray(result)).toBe(true);
|
|
17
|
+
}, 15_000);
|
|
18
|
+
it("searches logs", async () => {
|
|
19
|
+
const tool = datadog.tools.find((t) => t.name === "datadog_search_logs");
|
|
20
|
+
const result = await tool.handler({ query: "*", from: "now-5m", limit: 5 }, ctx());
|
|
21
|
+
expect(result).toHaveProperty("data");
|
|
22
|
+
}, 15_000);
|
|
23
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|