@kirrosh/zond 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +130 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/package.json +53 -0
- package/src/bun-types.d.ts +5 -0
- package/src/cli/commands/add-api.ts +51 -0
- package/src/cli/commands/ai-generate.ts +106 -0
- package/src/cli/commands/chat.ts +43 -0
- package/src/cli/commands/ci-init.ts +163 -0
- package/src/cli/commands/collections.ts +41 -0
- package/src/cli/commands/compare.ts +129 -0
- package/src/cli/commands/coverage.ts +156 -0
- package/src/cli/commands/doctor.ts +127 -0
- package/src/cli/commands/init.ts +84 -0
- package/src/cli/commands/mcp.ts +16 -0
- package/src/cli/commands/run.ts +156 -0
- package/src/cli/commands/runs.ts +108 -0
- package/src/cli/commands/serve.ts +22 -0
- package/src/cli/commands/update.ts +142 -0
- package/src/cli/commands/validate.ts +18 -0
- package/src/cli/index.ts +529 -0
- package/src/cli/output.ts +24 -0
- package/src/cli/runtime.ts +7 -0
- package/src/core/agent/agent-loop.ts +116 -0
- package/src/core/agent/context-manager.ts +41 -0
- package/src/core/agent/system-prompt.ts +28 -0
- package/src/core/agent/tools/diagnose-failure.ts +51 -0
- package/src/core/agent/tools/explore-api.ts +40 -0
- package/src/core/agent/tools/index.ts +46 -0
- package/src/core/agent/tools/query-results.ts +40 -0
- package/src/core/agent/tools/run-tests.ts +38 -0
- package/src/core/agent/tools/send-request.ts +44 -0
- package/src/core/agent/tools/validate-tests.ts +23 -0
- package/src/core/agent/types.ts +22 -0
- package/src/core/diagnostics/failure-hints.ts +63 -0
- package/src/core/generator/ai/ai-generator.ts +61 -0
- package/src/core/generator/ai/llm-client.ts +159 -0
- package/src/core/generator/ai/output-parser.ts +307 -0
- package/src/core/generator/ai/prompt-builder.ts +153 -0
- package/src/core/generator/ai/types.ts +56 -0
- package/src/core/generator/chunker.ts +47 -0
- package/src/core/generator/coverage-scanner.ts +87 -0
- package/src/core/generator/data-factory.ts +115 -0
- package/src/core/generator/endpoint-warnings.ts +43 -0
- package/src/core/generator/index.ts +12 -0
- package/src/core/generator/openapi-reader.ts +143 -0
- package/src/core/generator/schema-utils.ts +52 -0
- package/src/core/generator/serializer.ts +189 -0
- package/src/core/generator/types.ts +48 -0
- package/src/core/parser/filter.ts +14 -0
- package/src/core/parser/index.ts +21 -0
- package/src/core/parser/schema.ts +175 -0
- package/src/core/parser/types.ts +52 -0
- package/src/core/parser/variables.ts +154 -0
- package/src/core/parser/yaml-parser.ts +85 -0
- package/src/core/reporter/console.ts +175 -0
- package/src/core/reporter/index.ts +23 -0
- package/src/core/reporter/json.ts +9 -0
- package/src/core/reporter/junit.ts +78 -0
- package/src/core/reporter/types.ts +12 -0
- package/src/core/runner/assertions.ts +173 -0
- package/src/core/runner/execute-run.ts +97 -0
- package/src/core/runner/executor.ts +183 -0
- package/src/core/runner/http-client.ts +69 -0
- package/src/core/runner/index.ts +12 -0
- package/src/core/runner/types.ts +48 -0
- package/src/core/setup-api.ts +113 -0
- package/src/core/utils.ts +9 -0
- package/src/db/queries.ts +774 -0
- package/src/db/schema.ts +159 -0
- package/src/mcp/descriptions.ts +88 -0
- package/src/mcp/server.ts +52 -0
- package/src/mcp/tools/ci-init.ts +54 -0
- package/src/mcp/tools/coverage-analysis.ts +141 -0
- package/src/mcp/tools/describe-endpoint.ts +241 -0
- package/src/mcp/tools/explore-api.ts +84 -0
- package/src/mcp/tools/generate-and-save.ts +129 -0
- package/src/mcp/tools/generate-missing-tests.ts +91 -0
- package/src/mcp/tools/generate-tests-guide.ts +391 -0
- package/src/mcp/tools/manage-server.ts +86 -0
- package/src/mcp/tools/query-db.ts +255 -0
- package/src/mcp/tools/run-tests.ts +71 -0
- package/src/mcp/tools/save-test-suite.ts +218 -0
- package/src/mcp/tools/send-request.ts +63 -0
- package/src/mcp/tools/set-work-dir.ts +35 -0
- package/src/mcp/tools/setup-api.ts +84 -0
- package/src/mcp/tools/validate-tests.ts +43 -0
- package/src/tui/chat-ui.ts +150 -0
- package/src/web/data/collection-state.ts +360 -0
- package/src/web/routes/api.ts +234 -0
- package/src/web/routes/dashboard.ts +313 -0
- package/src/web/routes/runs.ts +64 -0
- package/src/web/schemas.ts +121 -0
- package/src/web/server.ts +134 -0
- package/src/web/static/htmx.min.js +1 -0
- package/src/web/static/style.css +827 -0
- package/src/web/views/endpoints-tab.ts +170 -0
- package/src/web/views/health-strip.ts +92 -0
- package/src/web/views/layout.ts +48 -0
- package/src/web/views/results.ts +209 -0
- package/src/web/views/runs-tab.ts +126 -0
- package/src/web/views/suites-tab.ts +153 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CoreMessageFormat } from "../../db/queries.ts";
|
|
2
|
+
|
|
3
|
+
const MAX_MESSAGES = 20;
|
|
4
|
+
const KEEP_RECENT_TURNS = 6; // 6 turns = 12 messages (user + assistant pairs)
|
|
5
|
+
const KEEP_RECENT_MESSAGES = KEEP_RECENT_TURNS * 2;
|
|
6
|
+
|
|
7
|
+
export function trimContext(messages: CoreMessageFormat[]): CoreMessageFormat[] {
|
|
8
|
+
if (messages.length <= MAX_MESSAGES) {
|
|
9
|
+
return messages;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const oldMessages = messages.slice(0, messages.length - KEEP_RECENT_MESSAGES);
|
|
13
|
+
const recentMessages = messages.slice(messages.length - KEEP_RECENT_MESSAGES);
|
|
14
|
+
|
|
15
|
+
const summary = buildSummary(oldMessages);
|
|
16
|
+
|
|
17
|
+
// Use role "user" for the summary so that the conversation always starts with a user message.
|
|
18
|
+
// Some providers require conversations to begin with a user turn.
|
|
19
|
+
return [
|
|
20
|
+
{ role: "user" as const, content: summary },
|
|
21
|
+
...recentMessages,
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildSummary(messages: CoreMessageFormat[]): string {
|
|
26
|
+
const userMessages = messages.filter((m) => m.role === "user");
|
|
27
|
+
const topics = userMessages
|
|
28
|
+
.map((m) => m.content.slice(0, 80))
|
|
29
|
+
.slice(0, 5);
|
|
30
|
+
|
|
31
|
+
const topicList = topics.length > 0
|
|
32
|
+
? topics.map((t) => `- ${t}`).join("\n")
|
|
33
|
+
: "- General conversation";
|
|
34
|
+
|
|
35
|
+
return `[Conversation summary — ${messages.length} earlier messages condensed]
|
|
36
|
+
|
|
37
|
+
Topics discussed:
|
|
38
|
+
${topicList}
|
|
39
|
+
|
|
40
|
+
The conversation continues below with the most recent messages.`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const AGENT_SYSTEM_PROMPT = `You are an API testing assistant powered by zond. You help users run, create, validate, and diagnose API tests.
|
|
2
|
+
|
|
3
|
+
You have access to the following tools:
|
|
4
|
+
|
|
5
|
+
- **run_tests**: Execute API test suites from YAML files or directories. Returns pass/fail summary with run ID.
|
|
6
|
+
- **validate_tests**: Validate YAML test files without executing them. Check syntax and structure.
|
|
7
|
+
- **query_results**: Query historical test run results and collections from the database.
|
|
8
|
+
- **diagnose_failure**: Analyze a failed test run to identify root causes and suggest fixes.
|
|
9
|
+
|
|
10
|
+
Tool usage examples:
|
|
11
|
+
- run_tests: { testPath: "tests/api.yaml" } or { testPath: "tests/", envName: "staging", safe: true }
|
|
12
|
+
- validate_tests: { testPath: "tests/api.yaml" }
|
|
13
|
+
- query_results: action must be "list_runs", "get_run" (requires runId), or "list_collections"
|
|
14
|
+
- List runs: { action: "list_runs", limit: 10 }
|
|
15
|
+
- Get run details: { action: "get_run", runId: 1 }
|
|
16
|
+
- List collections: { action: "list_collections" }
|
|
17
|
+
- diagnose_failure: { runId: 1 }
|
|
18
|
+
|
|
19
|
+
Guidelines:
|
|
20
|
+
- When asked to run tests, use the run_tests tool and report results clearly.
|
|
21
|
+
- When a test run has failures, proactively use diagnose_failure to analyze the issues.
|
|
22
|
+
- When asked about past results, use query_results to look up run history.
|
|
23
|
+
- Always provide actionable suggestions when tests fail.
|
|
24
|
+
- Be concise but thorough in your explanations.
|
|
25
|
+
- If a tool call fails with a validation error, re-read the tool schema and retry with corrected arguments.
|
|
26
|
+
- When in safe mode, only GET (read-only) tests will be executed.
|
|
27
|
+
- When using thinking/reasoning, keep your internal reasoning focused and share conclusions with the user.
|
|
28
|
+
`;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getDb } from "../../../db/schema.ts";
|
|
4
|
+
import { getRunById, getResultsByRunId } from "../../../db/queries.ts";
|
|
5
|
+
|
|
6
|
+
export const diagnoseFailureTool = tool({
|
|
7
|
+
description: "Diagnose failures in a test run by analyzing failed steps and their errors",
|
|
8
|
+
inputSchema: z.object({
|
|
9
|
+
runId: z.number().describe("Run ID to diagnose"),
|
|
10
|
+
}),
|
|
11
|
+
execute: async (args) => {
|
|
12
|
+
try {
|
|
13
|
+
getDb();
|
|
14
|
+
|
|
15
|
+
const run = getRunById(args.runId);
|
|
16
|
+
if (!run) return { error: `Run ${args.runId} not found` };
|
|
17
|
+
|
|
18
|
+
const results = getResultsByRunId(args.runId);
|
|
19
|
+
const failures = results
|
|
20
|
+
.filter((r) => r.status === "fail" || r.status === "error")
|
|
21
|
+
.map((r) => ({
|
|
22
|
+
suite_name: r.suite_name,
|
|
23
|
+
test_name: r.test_name,
|
|
24
|
+
status: r.status,
|
|
25
|
+
error_message: r.error_message,
|
|
26
|
+
request_method: r.request_method,
|
|
27
|
+
request_url: r.request_url,
|
|
28
|
+
response_status: r.response_status,
|
|
29
|
+
assertions: r.assertions,
|
|
30
|
+
duration_ms: r.duration_ms,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
run: {
|
|
35
|
+
id: run.id,
|
|
36
|
+
started_at: run.started_at,
|
|
37
|
+
environment: run.environment,
|
|
38
|
+
duration_ms: run.duration_ms,
|
|
39
|
+
},
|
|
40
|
+
summary: {
|
|
41
|
+
total: run.total,
|
|
42
|
+
passed: run.passed,
|
|
43
|
+
failed: run.failed,
|
|
44
|
+
},
|
|
45
|
+
failures,
|
|
46
|
+
};
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return { error: (err as Error).message };
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readOpenApiSpec, extractEndpoints, extractSecuritySchemes } from "../../generator/index.ts";
|
|
4
|
+
|
|
5
|
+
export const exploreApiTool = tool({
|
|
6
|
+
description: "Explore an OpenAPI spec — list endpoints with method, path, and summary. Optionally filter by tag.",
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
specPath: z.string().describe("Path to OpenAPI spec file (JSON or YAML)"),
|
|
9
|
+
tag: z.string().optional().describe("Filter endpoints by tag"),
|
|
10
|
+
}),
|
|
11
|
+
execute: async (args) => {
|
|
12
|
+
try {
|
|
13
|
+
const doc = await readOpenApiSpec(args.specPath);
|
|
14
|
+
const allEndpoints = extractEndpoints(doc);
|
|
15
|
+
const securitySchemes = extractSecuritySchemes(doc);
|
|
16
|
+
const servers = ((doc as any).servers ?? []) as Array<{ url: string }>;
|
|
17
|
+
|
|
18
|
+
const endpoints = args.tag
|
|
19
|
+
? allEndpoints.filter(ep => ep.tags.includes(args.tag!))
|
|
20
|
+
: allEndpoints;
|
|
21
|
+
|
|
22
|
+
// Compact output — method + path + summary only
|
|
23
|
+
return {
|
|
24
|
+
title: (doc as any).info?.title,
|
|
25
|
+
version: (doc as any).info?.version,
|
|
26
|
+
servers: servers.map(s => s.url),
|
|
27
|
+
securitySchemes: securitySchemes.map(s => s.name),
|
|
28
|
+
totalEndpoints: allEndpoints.length,
|
|
29
|
+
...(args.tag ? { filteredByTag: args.tag, matchingEndpoints: endpoints.length } : {}),
|
|
30
|
+
endpoints: endpoints.map(ep => ({
|
|
31
|
+
method: ep.method,
|
|
32
|
+
path: ep.path,
|
|
33
|
+
summary: ep.summary,
|
|
34
|
+
})),
|
|
35
|
+
};
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return { error: (err as Error).message };
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { runTestsTool } from "./run-tests.ts";
|
|
3
|
+
import { validateTestsTool } from "./validate-tests.ts";
|
|
4
|
+
import { queryResultsTool } from "./query-results.ts";
|
|
5
|
+
import { diagnoseFailureTool } from "./diagnose-failure.ts";
|
|
6
|
+
import { sendRequestTool } from "./send-request.ts";
|
|
7
|
+
import { exploreApiTool } from "./explore-api.ts";
|
|
8
|
+
import type { AgentConfig } from "../types.ts";
|
|
9
|
+
|
|
10
|
+
export function buildAgentTools(config: AgentConfig) {
|
|
11
|
+
// In safe mode, wrap run_tests to force safe=true
|
|
12
|
+
const run_tests = config.safeMode
|
|
13
|
+
? tool({
|
|
14
|
+
description: runTestsTool.description,
|
|
15
|
+
inputSchema: runTestsTool.inputSchema,
|
|
16
|
+
execute: async (args, options) => {
|
|
17
|
+
return runTestsTool.execute!({ ...args, safe: true }, options);
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
: runTestsTool;
|
|
21
|
+
|
|
22
|
+
// In safe mode, wrap send_request to only allow GET
|
|
23
|
+
const send_request = config.safeMode
|
|
24
|
+
? tool({
|
|
25
|
+
description: sendRequestTool.description,
|
|
26
|
+
inputSchema: sendRequestTool.inputSchema,
|
|
27
|
+
execute: async (args, options) => {
|
|
28
|
+
if (args.method !== "GET") {
|
|
29
|
+
return { error: "Safe mode: only GET requests are allowed" };
|
|
30
|
+
}
|
|
31
|
+
return sendRequestTool.execute!(args, options);
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
: sendRequestTool;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
run_tests,
|
|
38
|
+
validate_tests: validateTestsTool,
|
|
39
|
+
query_results: queryResultsTool,
|
|
40
|
+
diagnose_failure: diagnoseFailureTool,
|
|
41
|
+
send_request,
|
|
42
|
+
explore_api: exploreApiTool,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { runTestsTool, validateTestsTool, queryResultsTool, diagnoseFailureTool, sendRequestTool, exploreApiTool };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getDb } from "../../../db/schema.ts";
|
|
4
|
+
import { listRuns, getRunById, getResultsByRunId, listCollections } from "../../../db/queries.ts";
|
|
5
|
+
|
|
6
|
+
export const queryResultsTool = tool({
|
|
7
|
+
description: "Query test run results and collections from the database",
|
|
8
|
+
inputSchema: z.object({
|
|
9
|
+
action: z.enum(["list_runs", "get_run", "list_collections"]).describe("Action to perform"),
|
|
10
|
+
runId: z.number().optional().describe("Run ID (for get_run action)"),
|
|
11
|
+
limit: z.number().optional().describe("Max results to return (default: 20)"),
|
|
12
|
+
}),
|
|
13
|
+
execute: async (args) => {
|
|
14
|
+
try {
|
|
15
|
+
getDb();
|
|
16
|
+
|
|
17
|
+
switch (args.action) {
|
|
18
|
+
case "list_runs": {
|
|
19
|
+
const runs = listRuns(args.limit ?? 20);
|
|
20
|
+
return { runs };
|
|
21
|
+
}
|
|
22
|
+
case "get_run": {
|
|
23
|
+
if (args.runId == null) return { error: "runId is required for get_run action" };
|
|
24
|
+
const run = getRunById(args.runId);
|
|
25
|
+
if (!run) return { error: `Run ${args.runId} not found` };
|
|
26
|
+
const results = getResultsByRunId(args.runId);
|
|
27
|
+
return { run, results };
|
|
28
|
+
}
|
|
29
|
+
case "list_collections": {
|
|
30
|
+
const collections = listCollections();
|
|
31
|
+
return { collections };
|
|
32
|
+
}
|
|
33
|
+
default:
|
|
34
|
+
return { error: `Unknown action: ${args.action}` };
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return { error: (err as Error).message };
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { executeRun } from "../../runner/execute-run.ts";
|
|
4
|
+
|
|
5
|
+
export const runTestsTool = tool({
|
|
6
|
+
description: "Run API test suites from a YAML file or directory and return results summary",
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
testPath: z.string().describe("Path to test YAML file or directory"),
|
|
9
|
+
envName: z.string().optional().describe("Environment name (loads .env.<name>.yaml)"),
|
|
10
|
+
safe: z.boolean().optional().describe("Run only GET tests (read-only, safe mode)"),
|
|
11
|
+
}),
|
|
12
|
+
execute: async (args) => {
|
|
13
|
+
try {
|
|
14
|
+
const { runId, results } = await executeRun({
|
|
15
|
+
testPath: args.testPath,
|
|
16
|
+
envName: args.envName,
|
|
17
|
+
safe: args.safe,
|
|
18
|
+
trigger: "agent",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const total = results.reduce((s, r) => s + r.total, 0);
|
|
22
|
+
const passed = results.reduce((s, r) => s + r.passed, 0);
|
|
23
|
+
const failed = results.reduce((s, r) => s + r.failed, 0);
|
|
24
|
+
const skipped = results.reduce((s, r) => s + r.skipped, 0);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
runId,
|
|
28
|
+
total,
|
|
29
|
+
passed,
|
|
30
|
+
failed,
|
|
31
|
+
skipped,
|
|
32
|
+
status: failed > 0 ? "has_failures" : "all_passed",
|
|
33
|
+
};
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return { error: (err as Error).message };
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { executeRequest } from "../../runner/http-client.ts";
|
|
4
|
+
import { loadEnvironment, substituteString, substituteDeep } from "../../parser/variables.ts";
|
|
5
|
+
|
|
6
|
+
export const sendRequestTool = tool({
|
|
7
|
+
description: "Send an ad-hoc HTTP request. Supports variable interpolation from environments (e.g. {{base_url}}).",
|
|
8
|
+
inputSchema: z.object({
|
|
9
|
+
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).describe("HTTP method"),
|
|
10
|
+
url: z.string().describe("Request URL (supports {{variable}} interpolation)"),
|
|
11
|
+
headers: z.record(z.string(), z.string()).optional().describe("Request headers"),
|
|
12
|
+
body: z.string().optional().describe("Request body (JSON string)"),
|
|
13
|
+
timeout: z.number().int().positive().optional().describe("Request timeout in ms"),
|
|
14
|
+
envName: z.string().optional().describe("Environment name for variable interpolation"),
|
|
15
|
+
}),
|
|
16
|
+
execute: async (args) => {
|
|
17
|
+
try {
|
|
18
|
+
const vars = await loadEnvironment(args.envName);
|
|
19
|
+
|
|
20
|
+
const resolvedUrl = substituteString(args.url, vars) as string;
|
|
21
|
+
const resolvedHeaders = args.headers ? substituteDeep(args.headers, vars) : {};
|
|
22
|
+
const resolvedBody = args.body ? substituteString(args.body, vars) as string : undefined;
|
|
23
|
+
|
|
24
|
+
const response = await executeRequest(
|
|
25
|
+
{
|
|
26
|
+
method: args.method,
|
|
27
|
+
url: resolvedUrl,
|
|
28
|
+
headers: resolvedHeaders,
|
|
29
|
+
body: resolvedBody,
|
|
30
|
+
},
|
|
31
|
+
args.timeout ? { timeout: args.timeout } : undefined,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Compact output for agent — skip response headers
|
|
35
|
+
return {
|
|
36
|
+
status: response.status,
|
|
37
|
+
body: response.body_parsed ?? response.body,
|
|
38
|
+
duration_ms: response.duration_ms,
|
|
39
|
+
};
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { error: (err as Error).message };
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { parse } from "../../parser/yaml-parser.ts";
|
|
4
|
+
|
|
5
|
+
export const validateTestsTool = tool({
|
|
6
|
+
description: "Validate YAML test files without running them. Returns parsed suite info or validation errors.",
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
testPath: z.string().describe("Path to test YAML file or directory"),
|
|
9
|
+
}),
|
|
10
|
+
execute: async (args) => {
|
|
11
|
+
try {
|
|
12
|
+
const suites = await parse(args.testPath);
|
|
13
|
+
return {
|
|
14
|
+
valid: true,
|
|
15
|
+
suiteCount: suites.length,
|
|
16
|
+
totalTests: suites.reduce((s, suite) => s + suite.tests.length, 0),
|
|
17
|
+
suites: suites.map((s) => ({ name: s.name, testCount: s.tests.length })),
|
|
18
|
+
};
|
|
19
|
+
} catch (err) {
|
|
20
|
+
return { valid: false, error: (err as Error).message };
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AIProviderConfig } from "../generator/ai/types.ts";
|
|
2
|
+
|
|
3
|
+
export interface AgentConfig {
|
|
4
|
+
provider: AIProviderConfig;
|
|
5
|
+
safeMode?: boolean;
|
|
6
|
+
dbPath?: string;
|
|
7
|
+
maxSteps?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ToolEvent {
|
|
11
|
+
toolName: string;
|
|
12
|
+
args: Record<string, unknown>;
|
|
13
|
+
result: unknown;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AgentTurnResult {
|
|
18
|
+
text: string;
|
|
19
|
+
toolEvents: ToolEvent[];
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
outputTokens: number;
|
|
22
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failure classification and diagnostic hints.
|
|
3
|
+
* Extracted from query-db.ts for reuse in Web UI.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function statusHint(status: number | null | undefined): string | null {
|
|
7
|
+
if (!status) return null;
|
|
8
|
+
if (status >= 500) return "Server-side error — inspect response_body for errorMessage/errorDetail; likely a backend bug";
|
|
9
|
+
if (status === 401 || status === 403) return "Auth failure — check auth_token/api_key in .env.yaml";
|
|
10
|
+
if (status === 404) return "Resource not found — verify the path and ID";
|
|
11
|
+
if (status === 400 || status === 422) return "Validation error — check request body fields match the schema";
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function classifyFailure(status: string, responseStatus: number | null): "api_error" | "assertion_failed" | "network_error" {
|
|
16
|
+
if (status === "error" && (responseStatus === null || responseStatus < 500)) return "network_error";
|
|
17
|
+
if (responseStatus !== null && responseStatus >= 500) return "api_error";
|
|
18
|
+
return "assertion_failed";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function envHint(url: string | null, errorMessage: string | null, envFilePath?: string): string | null {
|
|
22
|
+
const envFile = envFilePath ? envFilePath : ".env.yaml in your API directory";
|
|
23
|
+
|
|
24
|
+
if (url && /\{\{[^}]+\}\}/.test(url)) {
|
|
25
|
+
return `URL contains unresolved variable: "${url}" — variable name may not match the key in ${envFile}`;
|
|
26
|
+
}
|
|
27
|
+
if (url && !url.startsWith("http://") && !url.startsWith("https://")) {
|
|
28
|
+
return `base_url is not set or empty — URL resolved to "${url}". Add base_url to ${envFile}`;
|
|
29
|
+
}
|
|
30
|
+
if (errorMessage?.includes("base_url is not configured")) {
|
|
31
|
+
return `base_url is missing or empty. Add base_url: https://your-api.com to ${envFile}`;
|
|
32
|
+
}
|
|
33
|
+
if (errorMessage?.includes("URL is invalid") || errorMessage?.includes("Failed to parse URL")) {
|
|
34
|
+
return `URL is malformed — likely base_url is empty or invalid. Check base_url in ${envFile}`;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function envCategory(hint: string | undefined): string | null {
|
|
40
|
+
if (!hint) return null;
|
|
41
|
+
if (hint.includes("base_url is not set") || hint.includes("base_url is missing") || hint.includes("base_url is not configured")) return "base_url_missing";
|
|
42
|
+
if (hint.includes("unresolved variable")) return "unresolved_variable";
|
|
43
|
+
if (hint.includes("URL is malformed")) return "url_malformed";
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function computeSharedEnvIssue(
|
|
48
|
+
failures: Array<{ hint?: string }>,
|
|
49
|
+
envFilePath?: string,
|
|
50
|
+
): string | null {
|
|
51
|
+
const categories = new Set(failures.map(f => envCategory(f.hint)).filter(Boolean));
|
|
52
|
+
if (categories.size !== 1) return null;
|
|
53
|
+
|
|
54
|
+
const envFile = envFilePath ?? ".env.yaml";
|
|
55
|
+
if (categories.has("base_url_missing")) {
|
|
56
|
+
return `All failures: base_url is not set — add base_url to ${envFile}`;
|
|
57
|
+
}
|
|
58
|
+
if (categories.has("unresolved_variable")) {
|
|
59
|
+
return `All failures: some variables are not substituted — check variable names in ${envFile}`;
|
|
60
|
+
}
|
|
61
|
+
// url_malformed
|
|
62
|
+
return [...failures.map(f => f.hint).filter(Boolean)][0] ?? null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AIGenerateOptions, AIGenerateResult } from "./types.ts";
|
|
2
|
+
import { readOpenApiSpec, extractEndpoints, extractSecuritySchemes } from "../openapi-reader.ts";
|
|
3
|
+
import { buildMessages } from "./prompt-builder.ts";
|
|
4
|
+
import { chatCompletion } from "./llm-client.ts";
|
|
5
|
+
import { parseAIResponse } from "./output-parser.ts";
|
|
6
|
+
|
|
7
|
+
export async function generateWithAI(options: AIGenerateOptions): Promise<AIGenerateResult> {
|
|
8
|
+
// 1. Read OpenAPI spec
|
|
9
|
+
const doc = await readOpenApiSpec(options.specPath);
|
|
10
|
+
|
|
11
|
+
// 2. Extract endpoints + security schemes
|
|
12
|
+
let endpoints = extractEndpoints(doc);
|
|
13
|
+
if (endpoints.length === 0) {
|
|
14
|
+
throw new Error("No endpoints found in the OpenAPI spec");
|
|
15
|
+
}
|
|
16
|
+
const securitySchemes = extractSecuritySchemes(doc);
|
|
17
|
+
|
|
18
|
+
// Filter to single endpoint if requested
|
|
19
|
+
if (options.filterEndpoint) {
|
|
20
|
+
const { method, path } = options.filterEndpoint;
|
|
21
|
+
const filtered = endpoints.filter(
|
|
22
|
+
(ep) => ep.method === method.toUpperCase() && ep.path === path,
|
|
23
|
+
);
|
|
24
|
+
if (filtered.length === 0) {
|
|
25
|
+
throw new Error(`Endpoint ${method} ${path} not found in spec`);
|
|
26
|
+
}
|
|
27
|
+
endpoints = filtered;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Determine base URL: explicit option, or from spec servers[0]
|
|
31
|
+
const baseUrl = options.baseUrl ?? (doc as any).servers?.[0]?.url as string | undefined;
|
|
32
|
+
|
|
33
|
+
// 3. Build prompt
|
|
34
|
+
const messages = buildMessages(endpoints, securitySchemes, options.prompt, baseUrl);
|
|
35
|
+
|
|
36
|
+
// 4. Call LLM
|
|
37
|
+
const startTime = Date.now();
|
|
38
|
+
const llmResult = await chatCompletion(options.provider, messages);
|
|
39
|
+
const durationMs = Date.now() - startTime;
|
|
40
|
+
|
|
41
|
+
// 5. Parse + validate output
|
|
42
|
+
const parsed = parseAIResponse(llmResult.content);
|
|
43
|
+
|
|
44
|
+
if (parsed.suites.length === 0) {
|
|
45
|
+
const errorDetail = parsed.errors.length > 0
|
|
46
|
+
? parsed.errors.join("; ")
|
|
47
|
+
: "No valid suites in response";
|
|
48
|
+
throw new Error(`AI generation failed: ${errorDetail}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// If there are validation errors but we still got suites, include them as warnings
|
|
52
|
+
const yaml = parsed.yaml;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
yaml,
|
|
56
|
+
rawResponse: llmResult.content,
|
|
57
|
+
promptTokens: llmResult.usage.promptTokens,
|
|
58
|
+
completionTokens: llmResult.usage.completionTokens,
|
|
59
|
+
model: options.provider.model,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { AIProviderConfig } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export interface ChatMessage {
|
|
4
|
+
role: "system" | "user" | "assistant";
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ChatCompletionResult {
|
|
9
|
+
content: string;
|
|
10
|
+
usage: {
|
|
11
|
+
promptTokens?: number;
|
|
12
|
+
completionTokens?: number;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function chatCompletion(
|
|
17
|
+
config: AIProviderConfig,
|
|
18
|
+
messages: ChatMessage[],
|
|
19
|
+
): Promise<ChatCompletionResult> {
|
|
20
|
+
if (config.provider === "anthropic") {
|
|
21
|
+
return callAnthropic(config, messages);
|
|
22
|
+
}
|
|
23
|
+
return callOpenAICompatible(config, messages);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function callOpenAICompatible(
|
|
27
|
+
config: AIProviderConfig,
|
|
28
|
+
messages: ChatMessage[],
|
|
29
|
+
): Promise<ChatCompletionResult> {
|
|
30
|
+
const url = `${config.baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
|
31
|
+
|
|
32
|
+
// For ollama/custom providers, inject system prompt into first user message
|
|
33
|
+
// to avoid issues with thinking models (e.g. qwen3) that break with separate system messages
|
|
34
|
+
let apiMessages: Array<{ role: string; content: string }>;
|
|
35
|
+
if (config.provider === "ollama" || config.provider === "custom") {
|
|
36
|
+
const systemMsgs = messages.filter((m) => m.role === "system");
|
|
37
|
+
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
38
|
+
if (systemMsgs.length > 0 && nonSystem.length > 0) {
|
|
39
|
+
const systemText = systemMsgs.map((m) => m.content).join("\n\n");
|
|
40
|
+
apiMessages = nonSystem.map((m, i) =>
|
|
41
|
+
i === 0 ? { role: m.role, content: `${systemText}\n\n${m.content}` } : { role: m.role, content: m.content }
|
|
42
|
+
);
|
|
43
|
+
} else {
|
|
44
|
+
apiMessages = messages.map((m) => ({ role: m.role, content: m.content }));
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
apiMessages = messages.map((m) => ({ role: m.role, content: m.content }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const body: Record<string, unknown> = {
|
|
51
|
+
model: config.model,
|
|
52
|
+
messages: apiMessages,
|
|
53
|
+
temperature: config.temperature ?? 0.2,
|
|
54
|
+
max_tokens: config.maxTokens ?? 4096,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Request JSON output where supported (OpenAI, newer Ollama models)
|
|
58
|
+
if (config.provider === "openai") {
|
|
59
|
+
body.response_format = { type: "json_object" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const headers: Record<string, string> = {
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
};
|
|
65
|
+
if (config.apiKey) {
|
|
66
|
+
headers["Authorization"] = `Bearer ${config.apiKey}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const resp = await fetch(url, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers,
|
|
72
|
+
body: JSON.stringify(body),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!resp.ok) {
|
|
76
|
+
const text = await resp.text();
|
|
77
|
+
throw new Error(`LLM request failed (${resp.status}): ${text}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const data = (await resp.json()) as {
|
|
81
|
+
choices: Array<{ message: { content: string; reasoning?: string } }>;
|
|
82
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const msg = data.choices?.[0]?.message;
|
|
86
|
+
// Thinking models (e.g. qwen3) may put output in `reasoning` with empty `content`
|
|
87
|
+
const content = msg?.content || msg?.reasoning || "";
|
|
88
|
+
return {
|
|
89
|
+
content,
|
|
90
|
+
usage: {
|
|
91
|
+
promptTokens: data.usage?.prompt_tokens,
|
|
92
|
+
completionTokens: data.usage?.completion_tokens,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function callAnthropic(
|
|
98
|
+
config: AIProviderConfig,
|
|
99
|
+
messages: ChatMessage[],
|
|
100
|
+
): Promise<ChatCompletionResult> {
|
|
101
|
+
const url = `${config.baseUrl.replace(/\/+$/, "")}/v1/messages`;
|
|
102
|
+
|
|
103
|
+
// Separate system prompt from user/assistant messages
|
|
104
|
+
const systemMessages = messages.filter((m) => m.role === "system");
|
|
105
|
+
const nonSystemMessages = messages.filter((m) => m.role !== "system");
|
|
106
|
+
|
|
107
|
+
const systemText = systemMessages.map((m) => m.content).join("\n\n");
|
|
108
|
+
|
|
109
|
+
const body: Record<string, unknown> = {
|
|
110
|
+
model: config.model,
|
|
111
|
+
max_tokens: config.maxTokens ?? 4096,
|
|
112
|
+
temperature: config.temperature ?? 0.2,
|
|
113
|
+
messages: nonSystemMessages.map((m) => ({
|
|
114
|
+
role: m.role,
|
|
115
|
+
content: m.content,
|
|
116
|
+
})),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (systemText) {
|
|
120
|
+
body.system = systemText;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const headers: Record<string, string> = {
|
|
124
|
+
"Content-Type": "application/json",
|
|
125
|
+
"anthropic-version": "2023-06-01",
|
|
126
|
+
};
|
|
127
|
+
if (config.apiKey) {
|
|
128
|
+
headers["x-api-key"] = config.apiKey;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const resp = await fetch(url, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers,
|
|
134
|
+
body: JSON.stringify(body),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (!resp.ok) {
|
|
138
|
+
const text = await resp.text();
|
|
139
|
+
throw new Error(`Anthropic request failed (${resp.status}): ${text}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const data = (await resp.json()) as {
|
|
143
|
+
content: Array<{ type: string; text: string }>;
|
|
144
|
+
usage?: { input_tokens?: number; output_tokens?: number };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const content = data.content
|
|
148
|
+
?.filter((b) => b.type === "text")
|
|
149
|
+
.map((b) => b.text)
|
|
150
|
+
.join("") ?? "";
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
content,
|
|
154
|
+
usage: {
|
|
155
|
+
promptTokens: data.usage?.input_tokens,
|
|
156
|
+
completionTokens: data.usage?.output_tokens,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|