// // MockProviderClient.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // A scripted ProviderClient behind the hidden `--run-mock` CLI flag: drives // the AgentLoop end-to-end WITHOUT network or keys (CI smoke test). Emits // three canned turns exactly like a real provider stream would — // (1) update_plan draft + a real bash tool call, (2) update_plan marking the // work done, (3) a final answer with no tool calls — proving plan // interception, the policy gate, real process execution, live output // streaming, transcript persistence, and endTurn termination. // import Foundation struct MockProviderClient: ProviderClient { var providerID: ProviderID { .custom } /// The catalog-shaped model the mock run uses (tools-capable ⇒ agent-capable). static let model = AIModel( id: "mock-agent-model", provider: .custom, displayName: "Mock Agent Model", contextWindow: 128_000, maxOutputTokens: 8_192, capabilities: ModelCapabilities(tools: true), pricing: nil, parameterSupport: ParameterSupport() ) /// The command the scripted bash call executes. `ZYQUO_MOCK_BASH` /// overrides it so CI can also exercise the policy gate's ask/deny paths /// (e.g. a destructive payload auto-denied under --yes). static var bashCommand: String { ProcessInfo.processInfo.environment["ZYQUO_MOCK_BASH"] ?? "echo hello && mkdir demo && ls" } func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream { // Which scripted turn: count the tool-result messages already threaded. let toolResultTurns = request.messages.filter { ($0.toolResults?.isEmpty == false) }.count return AsyncThrowingStream { continuation in switch toolResultTurns { case 0: Self.emitFirstTurn(into: continuation) case 1: Self.emitSecondTurn(into: continuation) default: Self.emitFinalTurn(into: continuation) } continuation.finish() } } /// Turn 1: think, draft the plan (update_plan), and issue the bash call. private static func emitFirstTurn(into continuation: AsyncThrowingStream.Continuation) { for fragment in ["I'll draft a plan, ", "then run the command ", "and verify the result."] { continuation.yield(.textDelta(fragment)) } let planArguments = #"{"items":[{"title":"Run the demo command","status":"active"},{"title":"Verify the demo folder exists","status":"pending"}]}"# continuation.yield(.toolCallStarted(index: 0, id: "mock_call_plan_1", name: Planner.toolName)) for fragment in Self.split(planArguments) { continuation.yield(.toolCallArgumentsDelta(index: 0, delta: fragment)) } let bashArguments = JSONValue.object([ "command": .string(Self.bashCommand), "explanation": .string("Print a greeting, create the demo folder, and list the workspace."), ]).jsonString continuation.yield(.toolCallStarted(index: 1, id: "mock_call_bash_1", name: "bash")) for fragment in Self.split(bashArguments) { continuation.yield(.toolCallArgumentsDelta(index: 1, delta: fragment)) } continuation.yield(.toolCalls([ ToolCall(id: "mock_call_plan_1", name: Planner.toolName, argumentsJSON: planArguments), ToolCall(id: "mock_call_bash_1", name: "bash", argumentsJSON: bashArguments), ])) continuation.yield(.usage(TokenUsage(inputTokens: 850, outputTokens: 120))) continuation.yield(.finished(reason: "tool_use", stop: .toolUse)) } /// Turn 2: observe the result and update the plan to done. private static func emitSecondTurn(into continuation: AsyncThrowingStream.Continuation) { continuation.yield(.textDelta("The command succeeded and `demo` appears in the listing — marking the plan done.")) let planArguments = #"{"items":[{"title":"Run the demo command","status":"done"},{"title":"Verify the demo folder exists","status":"done","note":"demo/ present in ls output"}]}"# continuation.yield(.toolCallStarted(index: 0, id: "mock_call_plan_2", name: Planner.toolName)) for fragment in Self.split(planArguments) { continuation.yield(.toolCallArgumentsDelta(index: 0, delta: fragment)) } continuation.yield(.toolCalls([ ToolCall(id: "mock_call_plan_2", name: Planner.toolName, argumentsJSON: planArguments), ])) continuation.yield(.usage(TokenUsage(inputTokens: 1_100, outputTokens: 90))) continuation.yield(.finished(reason: "tool_use", stop: .toolUse)) } /// Turn 3: the final answer — no tool calls ⇒ the loop's done-signal. private static func emitFinalTurn(into continuation: AsyncThrowingStream.Continuation) { for fragment in [ "Done. I ran `", Self.bashCommand, "` in the workspace: ", "it printed `hello`, created the `demo/` folder, ", "and the listing confirms `demo` exists alongside MEMORY.md.", ] { continuation.yield(.textDelta(fragment)) } continuation.yield(.usage(TokenUsage(inputTokens: 1_300, outputTokens: 60))) continuation.yield(.finished(reason: "end_turn", stop: .endTurn)) } /// Splits a JSON string into small fragments the way providers stream /// partial tool-call arguments (chunks don't respect JSON boundaries). private static func split(_ text: String, chunk: Int = 18) -> [String] { var fragments: [String] = [] var remaining = Substring(text) while !remaining.isEmpty { fragments.append(String(remaining.prefix(chunk))) remaining = remaining.dropFirst(chunk) } return fragments } /// Non-streaming completion (used by MemoryManager summarization — /// exercised only if a mock run ever compacts). func complete(_ request: ChatRequest, apiKey: String) async throws -> Message { Message( role: .assistant, text: "Sub-tasks completed:\n- (mock summary)\nKey facts, paths & decisions:\n- (mock)\nOpen issues:\n- none", modelID: Self.model.id, provider: .custom ) } func listModelIDs(apiKey: String) async throws -> [String] { [Self.model.id] } }