spb/zyquo-agent Public MIT
The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.
Swift 94.7%
Shell 4.1%
Python 0.7%
Makefile 0.5%
1//2// MockProviderClient.swift3// Zyquo Agent4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// A scripted ProviderClient behind the hidden `--run-mock` CLI flag: drives9// the AgentLoop end-to-end WITHOUT network or keys (CI smoke test). Emits10// three canned turns exactly like a real provider stream would —11// (1) update_plan draft + a real bash tool call, (2) update_plan marking the12// work done, (3) a final answer with no tool calls — proving plan13// interception, the policy gate, real process execution, live output14// streaming, transcript persistence, and endTurn termination.15//1617import Foundation1819struct MockProviderClient: ProviderClient {20 var providerID: ProviderID { .custom }2122 /// The catalog-shaped model the mock run uses (tools-capable ⇒ agent-capable).23 static let model = AIModel(24 id: "mock-agent-model",25 provider: .custom,26 displayName: "Mock Agent Model",27 contextWindow: 128_000,28 maxOutputTokens: 8_192,29 capabilities: ModelCapabilities(tools: true),30 pricing: nil,31 parameterSupport: ParameterSupport()32 )3334 /// The command the scripted bash call executes. `ZYQUO_MOCK_BASH`35 /// overrides it so CI can also exercise the policy gate's ask/deny paths36 /// (e.g. a destructive payload auto-denied under --yes).37 static var bashCommand: String {38 ProcessInfo.processInfo.environment["ZYQUO_MOCK_BASH"] ?? "echo hello && mkdir demo && ls"39 }4041 func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> {42 // Which scripted turn: count the tool-result messages already threaded.43 let toolResultTurns = request.messages.filter { ($0.toolResults?.isEmpty == false) }.count44 return AsyncThrowingStream { continuation in45 switch toolResultTurns {46 case 0:47 Self.emitFirstTurn(into: continuation)48 case 1:49 Self.emitSecondTurn(into: continuation)50 default:51 Self.emitFinalTurn(into: continuation)52 }53 continuation.finish()54 }55 }5657 /// Turn 1: think, draft the plan (update_plan), and issue the bash call.58 private static func emitFirstTurn(into continuation: AsyncThrowingStream<ChatEvent, Error>.Continuation) {59 for fragment in ["I'll draft a plan, ", "then run the command ", "and verify the result."] {60 continuation.yield(.textDelta(fragment))61 }6263 let planArguments = #"{"items":[{"title":"Run the demo command","status":"active"},{"title":"Verify the demo folder exists","status":"pending"}]}"#64 continuation.yield(.toolCallStarted(index: 0, id: "mock_call_plan_1", name: Planner.toolName))65 for fragment in Self.split(planArguments) {66 continuation.yield(.toolCallArgumentsDelta(index: 0, delta: fragment))67 }6869 let bashArguments = JSONValue.object([70 "command": .string(Self.bashCommand),71 "explanation": .string("Print a greeting, create the demo folder, and list the workspace."),72 ]).jsonString73 continuation.yield(.toolCallStarted(index: 1, id: "mock_call_bash_1", name: "bash"))74 for fragment in Self.split(bashArguments) {75 continuation.yield(.toolCallArgumentsDelta(index: 1, delta: fragment))76 }7778 continuation.yield(.toolCalls([79 ToolCall(id: "mock_call_plan_1", name: Planner.toolName, argumentsJSON: planArguments),80 ToolCall(id: "mock_call_bash_1", name: "bash", argumentsJSON: bashArguments),81 ]))82 continuation.yield(.usage(TokenUsage(inputTokens: 850, outputTokens: 120)))83 continuation.yield(.finished(reason: "tool_use", stop: .toolUse))84 }8586 /// Turn 2: observe the result and update the plan to done.87 private static func emitSecondTurn(into continuation: AsyncThrowingStream<ChatEvent, Error>.Continuation) {88 continuation.yield(.textDelta("The command succeeded and `demo` appears in the listing — marking the plan done."))89 let planArguments = #"{"items":[{"title":"Run the demo command","status":"done"},{"title":"Verify the demo folder exists","status":"done","note":"demo/ present in ls output"}]}"#90 continuation.yield(.toolCallStarted(index: 0, id: "mock_call_plan_2", name: Planner.toolName))91 for fragment in Self.split(planArguments) {92 continuation.yield(.toolCallArgumentsDelta(index: 0, delta: fragment))93 }94 continuation.yield(.toolCalls([95 ToolCall(id: "mock_call_plan_2", name: Planner.toolName, argumentsJSON: planArguments),96 ]))97 continuation.yield(.usage(TokenUsage(inputTokens: 1_100, outputTokens: 90)))98 continuation.yield(.finished(reason: "tool_use", stop: .toolUse))99 }100101 /// Turn 3: the final answer — no tool calls ⇒ the loop's done-signal.102 private static func emitFinalTurn(into continuation: AsyncThrowingStream<ChatEvent, Error>.Continuation) {103 for fragment in [104 "Done. I ran `", Self.bashCommand, "` in the workspace: ",105 "it printed `hello`, created the `demo/` folder, ",106 "and the listing confirms `demo` exists alongside MEMORY.md.",107 ] {108 continuation.yield(.textDelta(fragment))109 }110 continuation.yield(.usage(TokenUsage(inputTokens: 1_300, outputTokens: 60)))111 continuation.yield(.finished(reason: "end_turn", stop: .endTurn))112 }113114 /// Splits a JSON string into small fragments the way providers stream115 /// partial tool-call arguments (chunks don't respect JSON boundaries).116 private static func split(_ text: String, chunk: Int = 18) -> [String] {117 var fragments: [String] = []118 var remaining = Substring(text)119 while !remaining.isEmpty {120 fragments.append(String(remaining.prefix(chunk)))121 remaining = remaining.dropFirst(chunk)122 }123 return fragments124 }125126 /// Non-streaming completion (used by MemoryManager summarization —127 /// exercised only if a mock run ever compacts).128 func complete(_ request: ChatRequest, apiKey: String) async throws -> Message {129 Message(130 role: .assistant,131 text: "Sub-tasks completed:\n- (mock summary)\nKey facts, paths & decisions:\n- (mock)\nOpen issues:\n- none",132 modelID: Self.model.id,133 provider: .custom134 )135 }136137 func listModelIDs(apiKey: String) async throws -> [String] {138 [Self.model.id]139 }140}141