diff --git a/Sources/Core/Infrastructure/OllamaClient.swift b/Sources/Core/Infrastructure/OllamaClient.swift index 6196730..c63ded9 100644 --- a/Sources/Core/Infrastructure/OllamaClient.swift +++ b/Sources/Core/Infrastructure/OllamaClient.swift @@ -4,9 +4,9 @@ import Foundation public struct OllamaClient: Client { - private let model = "llama3.2" + public let model: String private let url = "http://localhost:11434/api/chat" - public init() {} + public init(model: String) {self.model = model} public func send(messages: [Message]) async throws -> String { let url = URL(string: url)! var request = URLRequest(url: url) diff --git a/Sources/Core/Main/Client.swift b/Sources/Core/Main/Client.swift index cd923f8..b8ad2bc 100644 --- a/Sources/Core/Main/Client.swift +++ b/Sources/Core/Main/Client.swift @@ -3,5 +3,6 @@ public protocol Client { typealias Message = [String: String] + var model: String {get} func send(messages: [Message]) async throws -> String } diff --git a/Sources/Core/Main/Coordinator.swift b/Sources/Core/Main/Coordinator.swift index 988c927..98627e8 100644 --- a/Sources/Core/Main/Coordinator.swift +++ b/Sources/Core/Main/Coordinator.swift @@ -12,10 +12,10 @@ public class Coordinator { private let persistor: Persistor private let iterator = Iterator() public init( - reader: FileReader, + reader: FileReader = FileManager.default, client: Client, runner: Runner, - persistor: Persistor + persistor: Persistor = FilePersistor() ) { self.reader = reader self.client = client @@ -26,16 +26,23 @@ public class Coordinator { @discardableResult public func generateAndSaveCode(systemPrompt: String, specsFileURL: URL, outputFileURL: URL, maxIterationCount: Int = 1) async throws -> Output { let specs = try reader.read(specsFileURL) + let output = try await generateCode( + systemPrompt: systemPrompt, + specs: specs, + maxIterationCount: maxIterationCount + ) + try persistor.persist(output.generatedCode, outputURL: outputFileURL) + return output + } + + public func generateCode(systemPrompt: String, specs: String, maxIterationCount: Int = 1) async throws -> Output { var previousOutput: Output? - let output = try await iterator.iterate( + return try await iterator.iterate( nTimes: maxIterationCount, until: { previousOutput = $0 ; return isSuccess($0) } ) { try await self.generateCode(systemPrompt: systemPrompt, from: specs, previous: previousOutput) } - - try persistor.persist(output.generatedCode, outputURL: outputFileURL) - return output } private func generateCode(systemPrompt: String, from specs: String, previous: Output?) async throws -> Output { diff --git a/Sources/tddbuddy/TddBuddy.swift b/Sources/tddbuddy/TddBuddy.swift index 24ac41f..2274706 100644 --- a/Sources/tddbuddy/TddBuddy.swift +++ b/Sources/tddbuddy/TddBuddy.swift @@ -18,7 +18,7 @@ struct TDDBuddy: AsyncParsableCommand { var iterations: Int = 5 func run() async throws { - let client = OllamaClient() + let client = OllamaClient(model: "llama3.2") let runner = LoggerDecorator(SwiftRunner()) let persistor = LoggerDecorator(FilePersistor()) diff --git a/Tests/CoreE2ETests/IntegrationTests.swift b/Tests/CoreE2ETests/IntegrationTests.swift index 236a7de..1697793 100644 --- a/Tests/CoreE2ETests/IntegrationTests.swift +++ b/Tests/CoreE2ETests/IntegrationTests.swift @@ -21,7 +21,7 @@ class IntegrationTests: XCTestCase { If your code fails to compile, the user will provide the error output for you to make adjustments. """ let reader = FileManager.default - let client = OllamaClient() + let client = OllamaClient(model: "llama3.2") let runner = LoggerDecorator(SwiftRunner()) let persistor = LoggerDecorator(FilePersistor()) let sut = Coordinator( @@ -32,7 +32,7 @@ class IntegrationTests: XCTestCase { ) let adderSpecs = specsURL("adder.swift.txt") let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("adder.swift.txt") - let output = try await sut.generateAndSaveCode(systemPrompt: systemPrompt, specsFileURL: adderSpecs, outputFileURL: tmpURL, maxIterationCount: 5) + let output = try await sut.generateAndSaveCode(systemPrompt: systemPrompt, specsFileURL: adderSpecs, outputFileURL: tmpURL, maxIterationCount: 2) XCTAssertFalse(output.generatedCode.isEmpty) XCTAssertEqual(output.procesOutput.exitCode, 0) diff --git a/Tests/CoreE2ETests/OllamaClientTests.swift b/Tests/CoreE2ETests/OllamaClientTests.swift index 08065ba..a4d0545 100644 --- a/Tests/CoreE2ETests/OllamaClientTests.swift +++ b/Tests/CoreE2ETests/OllamaClientTests.swift @@ -7,7 +7,7 @@ import Core class OllamaClientTests: XCTestCase { func test_send_withRunningOllamaServer_returnsContent() async throws { - let sut = OllamaClient() + let sut = OllamaClient(model: "llama3.2") let response = try await sut.send(messages: [["role": "user", "content": "hello"]]) XCTAssert(!response.isEmpty) } diff --git a/Tests/CoreTests/DataGathering.swift b/Tests/CoreTests/DataGathering.swift new file mode 100644 index 0000000..5f8a0e3 --- /dev/null +++ b/Tests/CoreTests/DataGathering.swift @@ -0,0 +1,196 @@ +// © 2025 Cristian Felipe Patiño Rojas. Created on 28/5/25. + +import XCTest +import Core + +class DataGatheringTests: XCTestCase { + final class IterationRecorder { + private var date: Date? + private var code: String? + private let currentDate: () -> Date + var iterations: [DataGatheringTests.Iteration] = [] + + init(currentDate: @escaping () -> Date) { + self.currentDate = currentDate + } + + func recordGeneratedCode(_ code: String) { + self.code = code + self.date = currentDate() + } + + func recordOutput(_ output: Runner.ProcessOutput) { + guard let date = date, let code = code else { + print("⚠️ Skipped iteration: incomplete data") + return + } + iterations.append( + .init( + startDate: date, + generatedCode: code, + exitCode: output.exitCode, + stdErr: output.stderr, + stdOut: output.stdout + ) + ) + self.code = nil + self.date = nil + } + } + + struct GatheredData: Equatable { + let testId: String + let modelName: String + let executions: [Execution] + } + + struct Execution: Equatable { + let startDate: Date + let iterations: [Iteration] + } + + struct Iteration: Equatable { + let startDate: Date + let generatedCode: String + let exitCode: Int + let stdErr: String? + let stdOut: String? + } + + struct Specs { + let id: String + let content: String + } + + class DataGatherer { + let client: Client + let runner: Runner + let currentDate: () -> Date + + init(client: Client, runner: Runner, currentDate: @escaping () -> Date = Date.init) { + self.client = client + self.runner = runner + self.currentDate = currentDate + } + + func gatherData(systemPrompt: String, specs: Specs, executionCount: Int, iterationCount: Int) async throws -> GatheredData { + + var executions = [Execution]() + + let currentDate = currentDate + for _ in (1...executionCount) { + let initialTimestamp = currentDate() + let recorder = IterationRecorder(currentDate: currentDate) + let clientSpy = ClientSpy(client: client, onResult: recorder.recordGeneratedCode) + let runnerSpy = RunnerSpy(runner: runner, onResult: recorder.recordOutput) + let coordinator = Coordinator(client: clientSpy, runner: runnerSpy) + let _ = try await coordinator.generateCode( + systemPrompt: systemPrompt, + specs: specs.content, + maxIterationCount: iterationCount + ) + executions.append( + Execution( + startDate: initialTimestamp, + iterations: recorder.iterations + ) + ) + } + + return GatheredData(testId: specs.id, modelName: client.model, executions: executions) + } + + class ClientSpy: Client { + let client: Client + + init(client: Client, onResult: @escaping (String) -> Void) { + self.client = client + self.onResponse = onResult + } + var model: String {client.model} + let onResponse: (String) -> Void + func send(messages: [Message]) async throws -> String { + let response = try await client.send(messages: messages) + onResponse(response) + return response + } + } + + class RunnerSpy: Runner { + let runner: Runner + + init(runner: Runner, onResult: @escaping (ProcessOutput) -> Void) { + self.runner = runner + self.onProcessOutput = onResult + } + let onProcessOutput: (ProcessOutput) -> Void + func run(_ code: String) throws -> ProcessOutput { + let output = try runner.run(code) + onProcessOutput(output) + return output + } + } + + } + + func test_gatherData_recordsExpectedData() async throws { + struct ClientStub: Client { + let model: String + let result: String + func send(messages: [Message]) async throws -> String { + result + } + } + + struct RunnerStub: Runner { + let result: ProcessOutput + func run(_ code: String) throws -> ProcessOutput { + return result + } + } + + let executionCount = 5 + let iterationCount = 5 + let timestamp = Date() + let client = ClientStub(model: "fake model", result: "any generated code") + let runner = RunnerStub(result: (stdout: "", stderr: "any stderr", exitCode: 1)) + let sut = DataGatherer(client: client, runner: runner, currentDate: { timestamp }) + let specs = Specs( + id: "adder_spec", + content: """ + func test_adder() { + let sut = Adder(1,3) + assert(sut.result == 4) + } + test_adder() + """ + ) + + let data = try await sut.gatherData( + systemPrompt: "any system prompt", + specs: specs, + executionCount: executionCount, + iterationCount: iterationCount + ) + + let iteration = Iteration( + startDate: timestamp, + generatedCode: "any generated code", + exitCode: 1, + stdErr: "any stderr", + stdOut: "" + ) + + let iterations = Array(repeating: iteration, count: iterationCount) + let execution = Execution(startDate: timestamp, iterations: iterations) + let executions = Array(repeating: execution, count: executionCount) + + let expectedData = GatheredData( + testId: "adder_spec", + modelName: "fake model", + executions: executions + ) + + XCTAssertEqual(data, expectedData) + } +} diff --git a/Tests/CoreTests/UseCases/Helpers/CoordinatorTests+Mocks.swift b/Tests/CoreTests/UseCases/Helpers/CoordinatorTests+Mocks.swift index 25d7bae..3ec165d 100644 --- a/Tests/CoreTests/UseCases/Helpers/CoordinatorTests+Mocks.swift +++ b/Tests/CoreTests/UseCases/Helpers/CoordinatorTests+Mocks.swift @@ -41,6 +41,7 @@ extension CoordinatorTests { struct ClientStub: Client { let result: Result + var model: String { "any model" } func send(messages: [Message]) async throws -> String { try result.get() } @@ -57,6 +58,7 @@ extension CoordinatorTests { } struct ClientDummy: Client { + var model: String {"any model"} func send(messages: [Message]) async throws -> String { "" } @@ -79,6 +81,7 @@ extension CoordinatorTests { extension CoordinatorTests { class ClientSpy: Client { var messages = [[Message]]() + var model: String {"any model"} func send(messages: [Message]) async throws -> String { self.messages.append(messages) return "any generated code" diff --git a/todo.taskpaper b/todo.taskpaper index caf672a..68786cc 100644 --- a/todo.taskpaper +++ b/todo.taskpaper @@ -2,3 +2,4 @@ - Test CLI through `TDDBuddy.main() ` testing. - Allow persisting attempts with logs. - Add more LLM Clients and allow customization from command line. +- Add memory leak tracking