Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5753e2dc96 | |||
| 815c928a43 |
@@ -224,7 +224,8 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
cwd: context.project_path,
|
cwd: context.project_path,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
this.warn(`Specify commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -275,6 +276,21 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
this.log("Researching project domain...");
|
this.log("Researching project domain...");
|
||||||
this.decisionEngine!.setPhase(1);
|
this.decisionEngine!.setPhase(1);
|
||||||
|
|
||||||
|
const archMd = this.ciFiles!.readArchitectureMd();
|
||||||
|
if (!archMd) {
|
||||||
|
this.log("No ARCHITECTURE.md found — mechanical research cannot proceed without backend");
|
||||||
|
return {
|
||||||
|
phase: this.pipelineState!.current_phase,
|
||||||
|
stage: "research",
|
||||||
|
success: false,
|
||||||
|
artifacts_created: artifactsCreated,
|
||||||
|
decisions_made: decisionsMade,
|
||||||
|
escalations_raised: escalationsRaised,
|
||||||
|
duration_ms: Date.now() - stageStart,
|
||||||
|
error: "Research stage requires intelligence backend or existing ARCHITECTURE.md",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
||||||
const researchCommit = CommitBuilder.buildResearchCommit(
|
const researchCommit = CommitBuilder.buildResearchCommit(
|
||||||
1,
|
1,
|
||||||
@@ -288,7 +304,8 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
cwd: context.project_path,
|
cwd: context.project_path,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
this.warn(`Research commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,11 +326,42 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
|
|
||||||
case "execute":
|
case "execute":
|
||||||
this.log("Executing implementation...");
|
this.log("Executing implementation...");
|
||||||
|
if (!context.backend) {
|
||||||
|
this.log("No backend available — mechanical execution cannot implement code changes");
|
||||||
|
return {
|
||||||
|
phase: this.pipelineState!.current_phase,
|
||||||
|
stage: "execute",
|
||||||
|
success: false,
|
||||||
|
artifacts_created: artifactsCreated,
|
||||||
|
decisions_made: decisionsMade,
|
||||||
|
escalations_raised: escalationsRaised,
|
||||||
|
duration_ms: Date.now() - stageStart,
|
||||||
|
error: "Execute stage requires intelligence backend for code implementation",
|
||||||
|
};
|
||||||
|
}
|
||||||
this.pipelineState!.execute_completed = true;
|
this.pipelineState!.execute_completed = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "verify": {
|
case "verify": {
|
||||||
this.log("Running verification...");
|
this.log("Running verification...");
|
||||||
|
|
||||||
|
const { VerificationPipeline } = await import("../verification/index.js");
|
||||||
|
const verification = new VerificationPipeline(context.project_path);
|
||||||
|
const verifyResult = await verification.run(this.pipelineState!.current_phase || 1);
|
||||||
|
|
||||||
|
if (!verifyResult.all_passed) {
|
||||||
|
return {
|
||||||
|
phase: this.pipelineState!.current_phase,
|
||||||
|
stage: "verify",
|
||||||
|
success: false,
|
||||||
|
artifacts_created: artifactsCreated,
|
||||||
|
decisions_made: decisionsMade,
|
||||||
|
escalations_raised: escalationsRaised,
|
||||||
|
duration_ms: Date.now() - stageStart,
|
||||||
|
error: `Verification failed: ${verifyResult.escalations_needed.join("; ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.pipelineState!.verify_completed = true;
|
this.pipelineState!.verify_completed = true;
|
||||||
|
|
||||||
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
||||||
@@ -329,7 +377,8 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
cwd: context.project_path,
|
cwd: context.project_path,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
this.warn(`Verify commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +403,8 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
cwd: context.project_path,
|
cwd: context.project_path,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
this.warn(`Completion commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { OllamaLocalBackend } from "../backends/ollama-local.js";
|
||||||
|
import { OllamaCloudBackend } from "../backends/ollama-cloud.js";
|
||||||
|
import { OpencodeBackend } from "../backends/opencode.js";
|
||||||
|
import { resolveBackend, createBackend } from "../backends/index.js";
|
||||||
|
import { DEFAULT_BACKEND_CONFIG, BackendUnavailableError } from "../backends/types.js";
|
||||||
|
|
||||||
|
describe("Backend Availability Detection", () => {
|
||||||
|
describe("OllamaLocalBackend.isAvailable", () => {
|
||||||
|
it("returns false for unreachable host", async () => {
|
||||||
|
const backend = new OllamaLocalBackend({
|
||||||
|
base_url: "http://localhost:1",
|
||||||
|
model_profile: "balanced",
|
||||||
|
});
|
||||||
|
expect(await backend.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for invalid URL", async () => {
|
||||||
|
const backend = new OllamaLocalBackend({
|
||||||
|
base_url: "not-a-url",
|
||||||
|
model_profile: "balanced",
|
||||||
|
});
|
||||||
|
expect(await backend.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for timeout", async () => {
|
||||||
|
const backend = new OllamaLocalBackend({
|
||||||
|
base_url: "http://192.0.2.1",
|
||||||
|
model_profile: "balanced",
|
||||||
|
});
|
||||||
|
expect(await backend.isAvailable()).toBe(false);
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OllamaCloudBackend.isAvailable", () => {
|
||||||
|
it("returns false when base_url is empty", async () => {
|
||||||
|
const backend = new OllamaCloudBackend({
|
||||||
|
base_url: "",
|
||||||
|
api_key_env: "OLLAMA_CLOUD_API_KEY",
|
||||||
|
model_profile: "quality",
|
||||||
|
});
|
||||||
|
expect(await backend.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no API key in env", async () => {
|
||||||
|
const backend = new OllamaCloudBackend({
|
||||||
|
base_url: "https://api.example.com",
|
||||||
|
api_key_env: "NONEXISTENT_ENV_VAR_12345",
|
||||||
|
model_profile: "quality",
|
||||||
|
timeout_ms: 5000,
|
||||||
|
});
|
||||||
|
expect(await backend.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OpencodeBackend.isAvailable", () => {
|
||||||
|
it("returns false when executable not found", async () => {
|
||||||
|
const backend = new OpencodeBackend({
|
||||||
|
enabled: true,
|
||||||
|
executable: "nonexistent-opencode-binary-xyz",
|
||||||
|
});
|
||||||
|
expect(await backend.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when disabled", async () => {
|
||||||
|
const backend = new OpencodeBackend({ enabled: false });
|
||||||
|
expect(await backend.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveBackend auto-detection", () => {
|
||||||
|
it("throws BackendUnavailableError when no backends available", async () => {
|
||||||
|
const config = {
|
||||||
|
...DEFAULT_BACKEND_CONFIG,
|
||||||
|
llm_backends: {
|
||||||
|
"ollama-local": { base_url: "http://localhost:1", model_profile: "balanced" as const },
|
||||||
|
"ollama-cloud": { base_url: "", api_key_env: "NONEXISTENT_12345", model_profile: "quality" as const },
|
||||||
|
},
|
||||||
|
agent_backends: {
|
||||||
|
opencode: { enabled: true, executable: "nonexistent-opencode-binary-xyz" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(resolveBackend(config)).rejects.toThrow(BackendUnavailableError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tries opencode before ollama-local", async () => {
|
||||||
|
expect(DEFAULT_BACKEND_CONFIG.provider).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createBackend throws for unknown provider", () => {
|
||||||
|
expect(() => createBackend("unknown-provider" as "opencode", DEFAULT_BACKEND_CONFIG)).toThrow(BackendUnavailableError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BackendUnavailableError", () => {
|
||||||
|
it("contains installation hints", () => {
|
||||||
|
const err = new BackendUnavailableError("auto");
|
||||||
|
expect(err.message).toContain("opencode");
|
||||||
|
expect(err.message).toContain("Ollama");
|
||||||
|
expect(err.message).toContain("OLLAMA_CLOUD_API_KEY");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import { OllamaBaseBackend, OllamaMessage, OllamaChatResponse } from "../backends/ollama-base.js";
|
||||||
|
import { ToolRegistry } from "../backends/tool-registry.js";
|
||||||
|
import { BackendRequest } from "../backends/types.js";
|
||||||
|
|
||||||
|
class TestableOllamaBaseBackend extends OllamaBaseBackend {
|
||||||
|
readonly name = "test-base";
|
||||||
|
private mockResponse: OllamaChatResponse;
|
||||||
|
private callCount: number;
|
||||||
|
|
||||||
|
constructor(mockResponse: OllamaChatResponse) {
|
||||||
|
super(undefined);
|
||||||
|
this.mockResponse = mockResponse;
|
||||||
|
this.callCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCallCount(): number {
|
||||||
|
return this.callCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async callModel(
|
||||||
|
messages: OllamaMessage[],
|
||||||
|
model: string,
|
||||||
|
toolRegistry: ToolRegistry
|
||||||
|
): Promise<OllamaChatResponse> {
|
||||||
|
this.callCount++;
|
||||||
|
return this.mockResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected resolveModel(): string {
|
||||||
|
return "test-model";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OllamaBaseBackend", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-ollama-base-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns success when model responds without tool calls", async () => {
|
||||||
|
const mockResponse: OllamaChatResponse = {
|
||||||
|
choices: [{
|
||||||
|
message: {
|
||||||
|
content: '{"success": true, "output": "task completed"}',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const backend = new TestableOllamaBaseBackend(mockResponse);
|
||||||
|
const request: BackendRequest = {
|
||||||
|
persona: "executor",
|
||||||
|
workflow: "execute",
|
||||||
|
task: "Do something",
|
||||||
|
context: {
|
||||||
|
project_path: tempDir,
|
||||||
|
phase: 1,
|
||||||
|
stage: "execute",
|
||||||
|
specification: "",
|
||||||
|
config_path: "",
|
||||||
|
},
|
||||||
|
autonomy: "full",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await backend.execute(request);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.output).toContain("task completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles tool calls in response", async () => {
|
||||||
|
const writePath = path.join(tempDir, "output.txt");
|
||||||
|
const responses: OllamaChatResponse[] = [
|
||||||
|
{
|
||||||
|
choices: [{
|
||||||
|
message: {
|
||||||
|
content: "",
|
||||||
|
tool_calls: [{
|
||||||
|
function: { name: "writeFile", arguments: JSON.stringify({ path: writePath, content: "hello" }) },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
choices: [{
|
||||||
|
message: {
|
||||||
|
content: '{"success": true, "output": "file written"}',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let callIndex = 0;
|
||||||
|
class ToolCallBackend extends OllamaBaseBackend {
|
||||||
|
readonly name = "tool-call-test";
|
||||||
|
constructor() {
|
||||||
|
super(undefined);
|
||||||
|
}
|
||||||
|
async isAvailable(): Promise<boolean> { return true; }
|
||||||
|
protected async callModel(): Promise<OllamaChatResponse> {
|
||||||
|
return responses[callIndex++];
|
||||||
|
}
|
||||||
|
protected resolveModel(): string { return "test-model"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const backend = new ToolCallBackend();
|
||||||
|
const request: BackendRequest = {
|
||||||
|
persona: "executor",
|
||||||
|
workflow: "execute",
|
||||||
|
task: "Write a file",
|
||||||
|
context: {
|
||||||
|
project_path: tempDir,
|
||||||
|
phase: 1,
|
||||||
|
stage: "execute",
|
||||||
|
specification: "",
|
||||||
|
config_path: "",
|
||||||
|
},
|
||||||
|
autonomy: "full",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await backend.execute(request);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(fs.existsSync(writePath)).toBe(true);
|
||||||
|
expect(fs.readFileSync(writePath, "utf-8")).toBe("hello");
|
||||||
|
expect(result.artifacts.length).toBe(1);
|
||||||
|
expect(result.artifacts[0].path).toBe(writePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops after max tool rounds", async () => {
|
||||||
|
const alwaysToolCall: OllamaChatResponse = {
|
||||||
|
choices: [{
|
||||||
|
message: {
|
||||||
|
content: "",
|
||||||
|
tool_calls: [{
|
||||||
|
function: { name: "readFile", arguments: JSON.stringify({ path: "/etc/hostname" }) },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
class InfiniteLoopBackend extends OllamaBaseBackend {
|
||||||
|
readonly name = "infinite-loop";
|
||||||
|
private callCount = 0;
|
||||||
|
constructor() {
|
||||||
|
super(undefined);
|
||||||
|
}
|
||||||
|
async isAvailable(): Promise<boolean> { return true; }
|
||||||
|
protected async callModel(): Promise<OllamaChatResponse> {
|
||||||
|
this.callCount++;
|
||||||
|
return alwaysToolCall;
|
||||||
|
}
|
||||||
|
protected resolveModel(): string { return "test-model"; }
|
||||||
|
getCallCount() { return this.callCount; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const backend = new InfiniteLoopBackend();
|
||||||
|
const request: BackendRequest = {
|
||||||
|
persona: "executor",
|
||||||
|
workflow: "execute",
|
||||||
|
task: "Infinite loop test",
|
||||||
|
context: {
|
||||||
|
project_path: tempDir,
|
||||||
|
phase: 1,
|
||||||
|
stage: "execute",
|
||||||
|
specification: "",
|
||||||
|
config_path: "",
|
||||||
|
},
|
||||||
|
autonomy: "full",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await backend.execute(request);
|
||||||
|
expect(result.output).toContain("maximum rounds");
|
||||||
|
expect(backend.getCallCount()).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles error from callModel gracefully", async () => {
|
||||||
|
class ErrorBackend extends OllamaBaseBackend {
|
||||||
|
readonly name = "error-backend";
|
||||||
|
constructor() {
|
||||||
|
super(undefined);
|
||||||
|
}
|
||||||
|
async isAvailable(): Promise<boolean> { return true; }
|
||||||
|
protected async callModel(): Promise<OllamaChatResponse> {
|
||||||
|
throw new Error("Model connection failed");
|
||||||
|
}
|
||||||
|
protected resolveModel(): string { return "test-model"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const backend = new ErrorBackend();
|
||||||
|
const request: BackendRequest = {
|
||||||
|
persona: "executor",
|
||||||
|
workflow: "execute",
|
||||||
|
task: "Fail test",
|
||||||
|
context: {
|
||||||
|
project_path: tempDir,
|
||||||
|
phase: 1,
|
||||||
|
stage: "execute",
|
||||||
|
specification: "",
|
||||||
|
config_path: "",
|
||||||
|
},
|
||||||
|
autonomy: "full",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await backend.execute(request);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain("Backend execution failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("modelProfileToModel selects smallest for speed", () => {
|
||||||
|
const backend = new TestableOllamaBaseBackend({} as OllamaChatResponse);
|
||||||
|
const models = ["llama3.1:70b", "llama3.1:8b", "llama3.1"];
|
||||||
|
const selected = (backend as unknown as { modelProfileToModel: (p: string, m: string[]) => string }).modelProfileToModel("speed", models);
|
||||||
|
expect(selected).toBe("llama3.1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import * as os from "node:os";
|
||||||
|
import { OllamaCloudBackend } from "../backends/ollama-cloud.js";
|
||||||
|
|
||||||
|
describe("OllamaCloudBackend Retry/Rate-Limit", () => {
|
||||||
|
describe("configuration", () => {
|
||||||
|
it("uses default config when none provided", () => {
|
||||||
|
const backend = new OllamaCloudBackend();
|
||||||
|
expect(backend.name).toBe("ollama-cloud");
|
||||||
|
expect(backend.type).toBe("llm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts custom config", () => {
|
||||||
|
const backend = new OllamaCloudBackend({
|
||||||
|
base_url: "https://custom.api.com",
|
||||||
|
api_key_env: "MY_API_KEY",
|
||||||
|
model_profile: "quality",
|
||||||
|
timeout_ms: 30000,
|
||||||
|
});
|
||||||
|
expect(backend).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAvailable", () => {
|
||||||
|
it("returns false when base_url is empty", async () => {
|
||||||
|
const backend = new OllamaCloudBackend({
|
||||||
|
base_url: "",
|
||||||
|
api_key_env: "KEY",
|
||||||
|
model_profile: "quality",
|
||||||
|
});
|
||||||
|
expect(await backend.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no API key in environment", async () => {
|
||||||
|
const backend = new OllamaCloudBackend({
|
||||||
|
base_url: "https://api.example.com",
|
||||||
|
api_key_env: "NONEXISTENT_API_KEY_VAR_98765",
|
||||||
|
model_profile: "quality",
|
||||||
|
timeout_ms: 5000,
|
||||||
|
});
|
||||||
|
expect(await backend.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for unreachable endpoint", async () => {
|
||||||
|
process.env.TEST_OLLAMA_CLOUD_KEY = "test-key";
|
||||||
|
const backend = new OllamaCloudBackend({
|
||||||
|
base_url: "http://localhost:1",
|
||||||
|
api_key_env: "TEST_OLLAMA_CLOUD_KEY",
|
||||||
|
model_profile: "quality",
|
||||||
|
timeout_ms: 5000,
|
||||||
|
});
|
||||||
|
expect(await backend.isAvailable()).toBe(false);
|
||||||
|
delete process.env.TEST_OLLAMA_CLOUD_KEY;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("retry behavior", () => {
|
||||||
|
it("MAX_RETRIES is 3", () => {
|
||||||
|
const source = OllamaCloudBackend.toString();
|
||||||
|
expect(source).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BASE_BACKOFF_MS is 1000", () => {
|
||||||
|
const source = OllamaCloudBackend.toString();
|
||||||
|
expect(source).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authentication", () => {
|
||||||
|
it("uses API key from environment variable", () => {
|
||||||
|
process.env.TEST_CI_CLOUD_KEY = "sk-test-key-123";
|
||||||
|
const backend = new OllamaCloudBackend({
|
||||||
|
base_url: "https://api.example.com",
|
||||||
|
api_key_env: "TEST_CI_CLOUD_KEY",
|
||||||
|
model_profile: "quality",
|
||||||
|
});
|
||||||
|
expect(backend).toBeDefined();
|
||||||
|
delete process.env.TEST_CI_CLOUD_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when API key env var is not set", async () => {
|
||||||
|
const backend = new OllamaCloudBackend({
|
||||||
|
base_url: "https://api.example.com",
|
||||||
|
api_key_env: "DEFINITELY_NOT_SET_99999",
|
||||||
|
model_profile: "quality",
|
||||||
|
timeout_ms: 5000,
|
||||||
|
});
|
||||||
|
expect(await backend.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import { ToolRegistry, TOOL_DEFINITIONS } from "../backends/tool-registry.js";
|
||||||
|
|
||||||
|
describe("ToolRegistry Extended", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let registry: ToolRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-tool-registry-ext-"));
|
||||||
|
registry = new ToolRegistry(tempDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readFile edge cases", () => {
|
||||||
|
it("reads empty file", () => {
|
||||||
|
const filePath = path.join(tempDir, "empty.txt");
|
||||||
|
fs.writeFileSync(filePath, "");
|
||||||
|
const result = registry.execute({ name: "readFile", arguments: { path: filePath } });
|
||||||
|
expect(result.content).toBe("");
|
||||||
|
expect(result.isError).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads file with unicode content", () => {
|
||||||
|
const filePath = path.join(tempDir, "unicode.txt");
|
||||||
|
fs.writeFileSync(filePath, "héllo wörld 🌍");
|
||||||
|
const result = registry.execute({ name: "readFile", arguments: { path: filePath } });
|
||||||
|
expect(result.content).toBe("héllo wörld 🌍");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unreadable file gracefully", () => {
|
||||||
|
if (process.getuid?.() === 0) return;
|
||||||
|
const filePath = path.join(tempDir, "unreadable.txt");
|
||||||
|
fs.writeFileSync(filePath, "data");
|
||||||
|
fs.chmodSync(filePath, 0o000);
|
||||||
|
const result = registry.execute({ name: "readFile", arguments: { path: filePath } });
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
fs.chmodSync(filePath, 0o644);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("writeFile edge cases", () => {
|
||||||
|
it("overwrites existing file", () => {
|
||||||
|
const filePath = path.join(tempDir, "overwrite.txt");
|
||||||
|
fs.writeFileSync(filePath, "old");
|
||||||
|
const result = registry.execute({ name: "writeFile", arguments: { path: filePath, content: "new" } });
|
||||||
|
expect(result.isError).toBeFalsy();
|
||||||
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates nested directories", () => {
|
||||||
|
const filePath = path.join(tempDir, "a", "b", "c", "deep.txt");
|
||||||
|
const result = registry.execute({ name: "writeFile", arguments: { path: filePath, content: "deep" } });
|
||||||
|
expect(result.isError).toBeFalsy();
|
||||||
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("deep");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editFile edge cases", () => {
|
||||||
|
it("replaces only first occurrence", () => {
|
||||||
|
const filePath = path.join(tempDir, "multi.txt");
|
||||||
|
fs.writeFileSync(filePath, "aaa bbb aaa");
|
||||||
|
const result = registry.execute({ name: "editFile", arguments: { path: filePath, old: "aaa", new: "zzz" } });
|
||||||
|
expect(result.isError).toBeFalsy();
|
||||||
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("zzz bbb aaa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty old string", () => {
|
||||||
|
const filePath = path.join(tempDir, "empty-old.txt");
|
||||||
|
fs.writeFileSync(filePath, "hello");
|
||||||
|
const result = registry.execute({ name: "editFile", arguments: { path: filePath, old: "", new: "X" } });
|
||||||
|
expect(fs.readFileSync(filePath, "utf-8")).toContain("X");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runBash edge cases", () => {
|
||||||
|
it("respects cwd argument", () => {
|
||||||
|
const subDir = path.join(tempDir, "subdir");
|
||||||
|
fs.mkdirSync(subDir);
|
||||||
|
const result = registry.execute({ name: "runBash", arguments: { command: "pwd", cwd: subDir } });
|
||||||
|
expect(result.content).toContain("subdir");
|
||||||
|
expect(result.isError).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects timeout argument", () => {
|
||||||
|
const result = registry.execute({ name: "runBash", arguments: { command: "sleep 100", timeout: 500 } });
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures stderr in error output", () => {
|
||||||
|
const result = registry.execute({ name: "runBash", arguments: { command: "echo error >&2 && exit 1" } });
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect(result.content).toContain("error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("glob edge cases", () => {
|
||||||
|
it("finds files in subdirectories", () => {
|
||||||
|
const subDir = path.join(tempDir, "src");
|
||||||
|
fs.mkdirSync(subDir);
|
||||||
|
fs.writeFileSync(path.join(subDir, "app.ts"), "");
|
||||||
|
fs.writeFileSync(path.join(subDir, "util.ts"), "");
|
||||||
|
const result = registry.execute({ name: "glob", arguments: { pattern: "**/*.ts" } });
|
||||||
|
const matches = JSON.parse(result.content);
|
||||||
|
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for no matches", () => {
|
||||||
|
const result = registry.execute({ name: "glob", arguments: { pattern: "*.xyz" } });
|
||||||
|
const matches = JSON.parse(result.content);
|
||||||
|
expect(matches).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("grep edge cases", () => {
|
||||||
|
it("supports include pattern filter", () => {
|
||||||
|
fs.writeFileSync(path.join(tempDir, "app.ts"), "const x = 1;\n");
|
||||||
|
fs.writeFileSync(path.join(tempDir, "app.js"), "const x = 1;\n");
|
||||||
|
const result = registry.execute({ name: "grep", arguments: { pattern: "const", include: "*.ts" } });
|
||||||
|
const matches = JSON.parse(result.content);
|
||||||
|
expect(matches.every((m: { file: string }) => m.file.endsWith(".ts"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty for no matches", () => {
|
||||||
|
fs.writeFileSync(path.join(tempDir, "app.ts"), "nothing interesting\n");
|
||||||
|
const result = registry.execute({ name: "grep", arguments: { pattern: "NONEXISTENT_PATTERN_XYZ", include: "*.ts" } });
|
||||||
|
const matches = JSON.parse(result.content);
|
||||||
|
expect(matches).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
import { CIConfig } from "../types/config.js";
|
import { CIConfig } from "../types/config.js";
|
||||||
|
|
||||||
export interface RetryConfig {
|
export interface RetryConfig {
|
||||||
@@ -67,12 +68,39 @@ export class ErrorRecovery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async rollback(phase: number, reason: string): Promise<RecoveryResult> {
|
async rollback(phase: number, reason: string): Promise<RecoveryResult> {
|
||||||
|
try {
|
||||||
|
const phaseBranch = `phase/${String(phase).padStart(2, "0")}`;
|
||||||
|
const branches = this.git("branch --list");
|
||||||
|
const branchExists = branches.split("\n").some((b) => b.trim().replace(/^\*?\s+/, "") === phaseBranch);
|
||||||
|
|
||||||
|
if (branchExists) {
|
||||||
|
const currentBranch = this.git("rev-parse --abbrev-ref HEAD");
|
||||||
|
if (currentBranch === phaseBranch) {
|
||||||
|
this.git("checkout main");
|
||||||
|
}
|
||||||
|
this.git(`branch -D ${phaseBranch}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = `v0.5.${phase}`;
|
||||||
|
const tags = this.git("tag -l").split("\n").map((t) => t.trim());
|
||||||
|
if (tags.includes(tag)) {
|
||||||
|
this.git(`tag -d ${tag}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
recovered: true,
|
recovered: true,
|
||||||
strategy: "rollback",
|
strategy: "rollback",
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
message: `Rolled back phase ${phase}: ${reason}`,
|
message: `Rolled back phase ${phase}: ${reason}. Branch ${branchExists ? `${phaseBranch} deleted` : "not found"}. Tag ${tags.includes(tag) ? `${tag} deleted` : "not found"}.`,
|
||||||
};
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
recovered: false,
|
||||||
|
strategy: "rollback",
|
||||||
|
attempts: 1,
|
||||||
|
message: `Rollback failed for phase ${phase}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
canAutoDebug(error: string, confidence: number): boolean {
|
canAutoDebug(error: string, confidence: number): boolean {
|
||||||
@@ -86,4 +114,16 @@ export class ErrorRecovery {
|
|||||||
getMaxRevisions(): number {
|
getMaxRevisions(): number {
|
||||||
return this.config.autonomy.max_revision_iterations;
|
return this.config.autonomy.max_revision_iterations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private git(args: string): string {
|
||||||
|
try {
|
||||||
|
return execSync(`git ${args}`, {
|
||||||
|
cwd: this.projectPath,
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
}).trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -49,10 +49,33 @@ describe("BehavioralVerification", () => {
|
|||||||
expect(testFilesCheck?.status).toBe("pass");
|
expect(testFilesCheck?.status).toBe("pass");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes with specification and requirements", async () => {
|
it("passes with REQUIREMENTS.md", async () => {
|
||||||
const ciDir = path.join(tempDir, ".ci");
|
const ciDir = path.join(tempDir, ".ci");
|
||||||
fs.mkdirSync(ciDir, { recursive: true });
|
fs.mkdirSync(ciDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(ciDir, "specification.md"), "# Test\n## Objective\nBuild it\n\n## Requirements\n- Must have auth\n- Shall support CRUD\n");
|
fs.writeFileSync(path.join(ciDir, "REQUIREMENTS.md"), "# Requirements\n\n| REQ-ID | Requirement | Priority | Phase | Status |\n|--------|-------------|----------|-------|--------|\n| REQ-01 | Must have auth | P0 | 1 | pending |\n");
|
||||||
|
|
||||||
|
const verifier = new BehavioralVerification();
|
||||||
|
const result = await verifier.verify(tempDir, 1);
|
||||||
|
|
||||||
|
const specCheck = result.checks.find((c) => c.name === "Specification requirements traceable");
|
||||||
|
expect(specCheck?.status).toBe("pass");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when no REQUIREMENTS.md or PROJECT.md", async () => {
|
||||||
|
const ciDir = path.join(tempDir, ".ci");
|
||||||
|
fs.mkdirSync(ciDir, { recursive: true });
|
||||||
|
|
||||||
|
const verifier = new BehavioralVerification();
|
||||||
|
const result = await verifier.verify(tempDir, 1);
|
||||||
|
|
||||||
|
const specCheck = result.checks.find((c) => c.name === "Specification requirements traceable");
|
||||||
|
expect(specCheck?.status).toBe("skipped");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes with PROJECT.md when no REQUIREMENTS.md", async () => {
|
||||||
|
const ciDir = path.join(tempDir, ".ci");
|
||||||
|
fs.mkdirSync(ciDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(ciDir, "PROJECT.md"), "# Test\n\n## What This Is\nBuild it\n\n## Requirements\n\n### Active\n\n- [ ] Must have auth\n- [ ] Shall support CRUD\n");
|
||||||
|
|
||||||
const verifier = new BehavioralVerification();
|
const verifier = new BehavioralVerification();
|
||||||
const result = await verifier.verify(tempDir, 1);
|
const result = await verifier.verify(tempDir, 1);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
|
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
|
||||||
|
|
||||||
const TEST_FRAMEWORK_PATTERNS = [
|
const TEST_FRAMEWORK_PATTERNS = [
|
||||||
@@ -106,15 +107,59 @@ export class BehavioralVerification extends VerificationLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private checkSpecificationRequirements(projectPath: string): VerificationCheck {
|
private checkSpecificationRequirements(projectPath: string): VerificationCheck {
|
||||||
const specPath = path.join(projectPath, ".ci", "specification.md");
|
const reqPath = path.join(projectPath, ".ci", "REQUIREMENTS.md");
|
||||||
|
const projectPath_md = path.join(projectPath, ".ci", "PROJECT.md");
|
||||||
|
|
||||||
|
const specPath = reqPath;
|
||||||
if (!fs.existsSync(specPath)) {
|
if (!fs.existsSync(specPath)) {
|
||||||
|
const altPath = projectPath_md;
|
||||||
|
if (!fs.existsSync(altPath)) {
|
||||||
return this.check(
|
return this.check(
|
||||||
"Specification requirements traceable",
|
"Specification requirements traceable",
|
||||||
"skipped",
|
"skipped",
|
||||||
"No specification file found"
|
"No REQUIREMENTS.md or PROJECT.md found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.checkFromProjectMd(altPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(specPath, "utf-8");
|
||||||
|
const requirements = content
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => /^\|.*\|.*\|.*\|/.test(line) && !line.includes("REQ-ID") && !line.includes("---"))
|
||||||
|
.map((line) => {
|
||||||
|
const cols = line.split("|").map((c) => c.trim()).filter(Boolean);
|
||||||
|
return cols.length >= 2 ? cols[1] : "";
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (requirements.length === 0) {
|
||||||
|
const listRequirements = content
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim().startsWith("- "))
|
||||||
|
.map((line) => line.trim().slice(2));
|
||||||
|
if (listRequirements.length === 0) {
|
||||||
|
return this.check(
|
||||||
|
"Specification requirements traceable",
|
||||||
|
"warning",
|
||||||
|
"No requirements found in REQUIREMENTS.md"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.check(
|
||||||
|
"Specification requirements traceable",
|
||||||
|
"pass",
|
||||||
|
`Found ${listRequirements.length} requirement(s)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.check(
|
||||||
|
"Specification requirements traceable",
|
||||||
|
"pass",
|
||||||
|
`Found ${requirements.length} requirement(s) in REQUIREMENTS.md`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkFromProjectMd(specPath: string): VerificationCheck {
|
||||||
const content = fs.readFileSync(specPath, "utf-8");
|
const content = fs.readFileSync(specPath, "utf-8");
|
||||||
const requirements = content
|
const requirements = content
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -129,7 +174,7 @@ export class BehavioralVerification extends VerificationLayer {
|
|||||||
return this.check(
|
return this.check(
|
||||||
"Specification requirements traceable",
|
"Specification requirements traceable",
|
||||||
"warning",
|
"warning",
|
||||||
"No requirements found in specification"
|
"No requirements found in PROJECT.md"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user