From 5253fe3aff3fbde37e22b7798b6121dcdb3f7e64 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 08:32:51 +0100 Subject: [PATCH] feat(opencode): the OpenCode harness, checked against its goldens The tool source generated from the schema, the home with its view, the shared and per-process environments, the permission rules, the commands, the warm-up under the supervisor, the event stream and the classification, its patterns as hand-written scanners. The goldens record the answer for each, a warm-up against a fake opencode included. A timed-out supervised command now gets SIGTERM, so the supervisor takes its tree down (SIGKILL 10 s later); any other child gets SIGKILL. :Json gains DumpCompact. Co-Authored-By: Claude Opus 5.5 --- implementations/Crafter.Review-Mcp.cpp | 2 +- implementations/Crafter.Review-OpenCode.cpp | 359 ++++++ implementations/Crafter.Review-Process.cpp | 11 +- interfaces/Crafter.Review-Json.cppm | 6 + interfaces/Crafter.Review-OpenCode.cppm | 78 ++ interfaces/Crafter.Review-Process.cppm | 4 +- interfaces/Crafter.Review.cppm | 1 + project.cpp | 9 +- tests/Goldens/fixture/pipeline.json | 1254 +++++++++++++++++++ tests/OpenCode/main.cpp | 229 ++++ 10 files changed, 1946 insertions(+), 7 deletions(-) create mode 100644 implementations/Crafter.Review-OpenCode.cpp create mode 100644 interfaces/Crafter.Review-OpenCode.cppm create mode 100644 tests/OpenCode/main.cpp diff --git a/implementations/Crafter.Review-Mcp.cpp b/implementations/Crafter.Review-Mcp.cpp index 8a39501..b7e6412 100644 --- a/implementations/Crafter.Review-Mcp.cpp +++ b/implementations/Crafter.Review-Mcp.cpp @@ -33,7 +33,7 @@ namespace Crafter::Review::Mcp { } std::string ConfigJson(const Json& config) { - return config.dump(-1, ' ', true, Json::error_handler_t::replace); + return DumpCompact(config); } fs::path OutputDir(const fs::path& scratch, std::string_view label) { diff --git a/implementations/Crafter.Review-OpenCode.cpp b/implementations/Crafter.Review-OpenCode.cpp new file mode 100644 index 0000000..2a320e0 --- /dev/null +++ b/implementations/Crafter.Review-OpenCode.cpp @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Crafter.Review:OpenCode_impl; +import :OpenCode; +import :Json; +import :Text; +import :Platform; +import :Process; +import :Schema; +import std; + +namespace fs = std::filesystem; + +namespace Crafter::Review::OpenCode { + namespace { + // A JSON string literal: the descriptions and enum values go into the + // TypeScript as one. + std::string Literal(std::string_view text) { + return DumpSpaced(Json(std::string(text))); + } + + template + std::string Enum(const std::array& values) { + std::vector parts; + for (const std::string_view v : values) parts.push_back(Literal(v)); + return std::format("z.enum([{}])", Dyn::Join(parts, ", ")); + } + + std::string Described(const Json& schema, std::string_view property) { + return Literal(schema.at("properties").at(property).at("description").get()); + } + + // The literal at `at`, its letters matched as re.IGNORECASE folds + // them and everything else as it is. + bool LiteralAt(const std::u32string& t, std::size_t at, std::string_view literal) { + if (at + literal.size() > t.size()) return false; + for (std::size_t i = 0; i < literal.size(); ++i) { + const char c = literal[i]; + const bool letter = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + if (letter ? Dyn::IgnoreCaseLetter(t[at + i]) != static_cast(c | 0x20) : t[at + i] != static_cast(static_cast(c))) return false; + } + return true; + } + + bool Contains(const std::u32string& t, std::string_view literal) { + for (std::size_t at = 0; at + literal.size() <= t.size(); ++at) { + if (LiteralAt(t, at, literal)) return true; + } + return false; + } + + // \b\b + bool Number(const std::u32string& t, std::string_view digits) { + for (std::size_t at = 0; at + digits.size() <= t.size(); ++at) { + if (LiteralAt(t, at, digits) && (at == 0 || !Dyn::IsWord(t[at - 1])) && (at + digits.size() == t.size() || !Dyn::IsWord(t[at + digits.size()]))) return true; + } + return false; + } + + // r'\b429\b|rate[ -]limit|temporarily rate-limited|engine_overloaded', re.I + bool LimitText(const std::u32string& t) { + return Number(t, "429") || Contains(t, "rate limit") || Contains(t, "rate-limit") || Contains(t, "temporarily rate-limited") || Contains(t, "engine_overloaded"); + } + + // r'\b402\b|insufficient credits|requires more credits|credit balance', re.I + bool CreditText(const std::u32string& t) { + return Number(t, "402") || Contains(t, "insufficient credits") || Contains(t, "requires more credits") || Contains(t, "credit balance"); + } + + // r'\b401\b|\b403\b|invalid.*(api key|token)|unauthori[sz]ed|No auth credentials', re.I + bool AuthText(const std::u32string& t) { + if (Number(t, "401") || Number(t, "403") || Contains(t, "unauthorised") || Contains(t, "unauthorized") || Contains(t, "no auth credentials")) return true; + for (std::size_t at = 0; at + 7 <= t.size(); ++at) { + if (!LiteralAt(t, at, "invalid")) continue; + // `.*` runs to the end of the line. + for (std::size_t j = at + 7; j < t.size(); ++j) { + if (LiteralAt(t, j, "api key") || LiteralAt(t, j, "token")) return true; + if (t[j] == U'\n') break; + } + } + return false; + } + + // `x.get(k, 0) or 0` + Json Count(const Json& value) { + return Dyn::Truthy(value) ? value : Json(0); + } + + // The value as a float, 0 when it is falsy. + double Num(const Json& value) { + if (!Dyn::Truthy(value)) return 0.0; + if (value.is_number()) return value.get(); + if (value.is_boolean()) return 1.0; + if (value.is_string()) { + if (const std::optional parsed = Dyn::ParseFloat(value.get_ref())) return *parsed; + throw Dyn::Error("ValueError", Dyn::FloatError(value.get_ref())); + } + throw Dyn::Error("TypeError", std::format("float() argument must be a string or a real number, not '{}'", Dyn::TypeName(value))); + } + + // The text a JSON object key is written as. + std::string KeyText(const Json& key) { + if (key.is_string()) return key.get(); + return DumpCompact(key); + } + } + + Json Routing() { + return {{"zdr", true}, {"data_collection", "deny"}, {"quantizations", {"fp8", "fp16", "bf16", "fp32"}}, {"sort", "price"}}; + } + + std::string DefaultBin() { + const std::optional configured = Platform::GetEnv("REVIEW_GATES_OPENCODE_BIN"); + return configured && !configured->empty() ? *configured : std::string("opencode"); + } + + std::pair ModelId(std::string_view model) { + const std::size_t slash = model.find('/'); + if (slash != std::string_view::npos && model.substr(0, slash) == "openrouter") return {std::string(model.substr(0, slash)), std::string(model.substr(slash + 1))}; + return {"openrouter", std::string(model)}; + } + + std::string ToolSource() { + const Json finding = Schema::Finding(); + const Json note = Schema::Note(); + const Json coverage = Schema::Coverage(); + std::string out = "import { tool } from \"@opencode-ai/plugin\"\nimport * as fs from \"node:fs\"\nconst z = tool.schema\nconst Finding = z.object({\n"; + out += std::format(" finding: z.string().describe({}),\n", Described(finding, "finding")); + out += std::format(" evidence: z.string().describe({}),\n", Described(finding, "evidence")); + out += " proposedAction: z.string(),\n"; + out += std::format(" items: z.array(z.string()).describe({}),\n", Described(finding, "items")); + out += std::format(" dischargedBy: z.string().optional().describe({}),\n", Described(finding, "dischargedBy")); + out += std::format(" contests: z.object({{ id: z.string(), why: z.string().describe({}) }}).strict().optional(),\n", Described(finding.at("properties").at("contests"), "why")); + out += "}).strict()\nconst Note = z.object({\n"; + out += std::format(" kind: {},\n", Enum(Schema::NoteKinds)); + out += " note: z.string(), evidence: z.string(),\n"; + out += std::format(" action: z.string().describe({}),\n", Described(note, "action")); + out += std::format(" id: z.string().optional().describe({}),\n", Described(note, "id")); + out += " items: z.array(z.string()).optional(),\n}).strict()\nconst Coverage = z.object({\n"; + out += std::format(" item: z.string().describe({}),\n", Described(coverage, "item")); + out += std::format(" verdict: {},\n", Enum(Schema::CoverageVerdicts)); + out += std::format(" basis: z.string().describe({}),\n", Described(coverage, "basis")); + out += "}).strict()\nexport default tool({\n"; + out += " description: \"Return the structured output your instructions describe: the verdict, findings, notes and coverage of this pass. Call it exactly once, as the last action of " + "the pass, with the complete result; nothing you write as text counts.\",\n"; + out += " args: {\n"; + out += std::format(" verdict: {}.describe({}),\n", Enum(Schema::VerdictEnum), Described(Schema::BlockSchema(), "verdict")); + out += " findings: z.array(Finding),\n notes: z.array(Note),\n"; + out += " coverage: z.array(Coverage).describe(\"block pass: exactly one entry, the unit this pass covers; whole pass: one entry per unit you were asked to cover\"),\n"; + out += std::format(" summary: z.string().optional().describe({}),\n", Described(Schema::WholeSchema(), "summary")); + out += " },\n async execute(args) {\n"; + out += std::format(" const p = process.env.{}\n", SoEnv); + out += " if (p) fs.writeFileSync(p, JSON.stringify(args))\n return \"Structured output provided successfully\"\n },\n})\n"; + return out; + } + + fs::path PrepareHome(const fs::path& scratch, const fs::path& repo, std::span models, const Json& routing) { + const fs::path home = scratch / "opencode"; + const fs::path configDir = home / "xdg" / "opencode"; + Platform::MakeDirs(configDir / "tools", 0700); + for (const std::string_view d : {"cache", "state", "data", "view"}) Platform::MakeDirs(home / d, 0700); + const fs::path view = home / "view"; + std::vector names; + for (const fs::directory_entry& entry : fs::directory_iterator(repo)) names.push_back(entry.path().filename().string()); + std::ranges::sort(names); + for (const std::string& name : names) { + const fs::path link = view / name; + if (std::ranges::contains(ViewSkip, name) || fs::is_symlink(fs::symlink_status(link))) continue; + fs::create_symlink(repo / name, link); + } + Json provider = Json::object(); + for (const std::string& m : models) { + const auto [prov, id] = ModelId(m); + if (!provider.contains(prov)) provider[prov] = {{"models", Json::object()}}; + provider[prov]["models"][id] = {{"options", {{"provider", routing}}}}; + } + const Json config = {{"$schema", "https://opencode.ai/config.json"}, {"autoupdate", false}, {"share", "disabled"}, {"provider", provider}}; + std::ofstream(configDir / "opencode.json", std::ios::binary | std::ios::trunc) << DumpFile(config); + std::ofstream(configDir / "tools" / std::format("{}.ts", Tool), std::ios::binary | std::ios::trunc) << ToolSource(); + return home; + } + + std::map BaseEnv(const fs::path& home, std::string_view key, const std::optional>& base) { + std::map env; + for (const auto& [name, value] : base ? *base : Platform::Environment()) { + if (name.starts_with("ANTHROPIC_") || name.starts_with("CLAUDE_") || name.starts_with("OPENCODE_") || name.starts_with("XDG_") || name == "OPENROUTER_API_KEY") continue; + env[name] = value; + } + env["XDG_CONFIG_HOME"] = (home / "xdg").string(); + env["XDG_CACHE_HOME"] = (home / "cache").string(); + env["XDG_STATE_HOME"] = (home / "state").string(); + env["XDG_DATA_HOME"] = (home / "data").string(); + env["OPENROUTER_API_KEY"] = std::string(key); + env["OPENCODE_DISABLE_AUTOUPDATE"] = "1"; + return env; + } + + Json Permissions(std::span allowList) { + Json bash = {{"*", "deny"}}; + Json perm = {{"*", "deny"}, {std::string(Tool), "allow"}, {"read", "deny"}, {"glob", "deny"}, {"grep", "deny"}, {"list", "allow"}, {"external_directory", "allow"}, {"bash", nullptr}}; + for (const std::string& a : allowList) { + // re.match(r'^Bash\((.+):\*\)$', a): `.` stops at a line break, + // `$` also matches before a final one. + const std::string_view body = a.ends_with('\n') ? std::string_view(a).substr(0, a.size() - 1) : std::string_view(a); + if (body.starts_with("Bash(") && body.ends_with(":*)") && body.size() > 8 && !body.substr(5, body.size() - 8).contains('\n')) { + bash[std::format("{}*", body.substr(5, body.size() - 8))] = "allow"; + } else if (a == "Read" || a == "Glob" || a == "Grep") { + perm[Dyn::Lower(a)] = "allow"; + } else if (a.starts_with("Read(")) { + perm["read"] = "allow"; + } + } + perm["bash"] = std::move(bash); + return perm; + } + + std::map ChildEnv(const std::map& base, const fs::path& systemPath, const fs::path& soPath, std::span allowList, + const fs::path& dataDir) { + std::map env = base; + const Json agent = {{"mode", "primary"}, {"description", "review lens"}, {"prompt", std::format("{{file:{}}}", Dyn::AbsPath(systemPath.string()))}, {"permission", Permissions(allowList)}}; + env["OPENCODE_CONFIG_CONTENT"] = DumpCompact({{"agent", {{std::string(Agent), agent}}}}); + env[std::string(SoEnv)] = soPath.string(); + Platform::MakeDirs(dataDir, 0700); + env["XDG_DATA_HOME"] = dataDir.string(); + return env; + } + + std::vector Cmd(std::string_view bin, std::string_view model, const fs::path& view, std::string_view title, std::string_view variant, std::string_view prompt) { + std::vector argv = {std::string(bin), "run", "--format", "json", "--model", std::string(model), "--agent", std::string(Agent), "--dir", view.string(), "--title", Dyn::Slice(title, 0, 80)}; + if (!variant.empty()) { + argv.emplace_back("--variant"); + argv.emplace_back(variant); + } + argv.emplace_back("--"); + argv.emplace_back(prompt); + return argv; + } + + std::pair Warm(const std::string& bin, const fs::path& home, const std::map& env, std::string_view model, + const std::function& log, std::int64_t timeoutS) { + std::map e = env; + const fs::path data = home / "data" / "warm"; + e["XDG_DATA_HOME"] = data.string(); + fs::create_directories(data); + const fs::path view = home / "view"; + const std::vector argv = {bin, "run", "--format", "json", "--model", std::string(model), "--dir", view.string(), "--title", "warm-up", "--", "Reply with the single word ok."}; + // Under the supervisor: what the warm-up starts goes with it, a + // timeout included. + const Process::Captured run = Process::Run(Process::Supervised(argv), view, std::chrono::seconds(timeoutS), {}, e); + if (run.timedOut) return {false, std::format("warm-up timed out after {} s", timeoutS)}; + const std::int32_t code = Process::SupervisedReturnCode(run.returnCode); + const Json parsed = ParseEvents(Dyn::UniversalNewlines(Dyn::DecodeReplace(run.out))); + const Json& errors = parsed.at("errors"); + const bool ok = code == 0 && parsed.at("turns").get() > 0 && errors.empty(); + Json firstErrors = Json::array(); + for (std::size_t i = 0; i < std::min(2, errors.size()); ++i) firstErrors.push_back(errors.at(i)); + log(std::format("opencode: warm-up {} ({} step(s), cost ${:.4f}, exit {}{})", ok ? "ok" : "FAILED", parsed.at("turns").get(), Num(parsed.at("costUsd")), code, + errors.empty() ? std::string() : std::format(", errors {}", Dyn::ReprValue(firstErrors)))); + return {ok, Dyn::Slice(Dyn::UniversalNewlines(Dyn::DecodeReplace(run.err)), -800)}; + } + + Json ParseEvents(std::string_view text, const std::optional& soPath) { + Json usage = {{"input", 0}, {"cacheCreation", 0}, {"cacheRead", 0}, {"output", 0}, {"thinking", 0}}; + std::int64_t turns = 0; + double cost = 0.0; + Json toolCalls = Json::object(); + std::int64_t toolErrors = 0; + Json errors = Json::array(); + Json structured; + Json sessionId; + Json first; + Json stop; + for (const std::string& raw : Dyn::SplitLines(text)) { + const std::string line = Dyn::Strip(raw); + if (!line.starts_with('{') || Dyn::JsonDecodeError(line)) continue; + Json o; + try { + o = ParseLoose(line); + } catch (const std::exception&) { + // NaN or Infinity, which nlohmann refuses (AGENTS.md): the line + // is skipped. + continue; + } + const Json& type = Dyn::Get(o, "type"); + const Json& rawPart = Dyn::Get(o, "part"); + const Json& part = rawPart.is_object() ? rawPart : Dyn::EmptyDict(); + if (!Dyn::Truthy(sessionId)) sessionId = Dyn::Get(o, "sessionID"); + if (type == "step_finish") { + const Json& tokens = Dyn::Or(Dyn::Get(part, "tokens"), Dyn::EmptyDict()); + const Json& cache = Dyn::Or(Dyn::Get(tokens, "cache"), Dyn::EmptyDict()); + ++turns; + usage["input"] = Dyn::Add(usage.at("input"), Count(Dyn::Get(tokens, "input"))); + usage["output"] = Dyn::Add(usage.at("output"), Count(Dyn::Get(tokens, "output"))); + usage["thinking"] = Dyn::Add(usage.at("thinking"), Count(Dyn::Get(tokens, "reasoning"))); + usage["cacheRead"] = Dyn::Add(usage.at("cacheRead"), Count(Dyn::Get(cache, "read"))); + usage["cacheCreation"] = Dyn::Add(usage.at("cacheCreation"), Count(Dyn::Get(cache, "write"))); + cost += Num(Dyn::Get(part, "cost")); + stop = Dyn::Get(part, "reason"); + if (first.is_null()) { + first = {{"input_tokens", Count(Dyn::Get(tokens, "input"))}, + {"cache_creation_input_tokens", Count(Dyn::Get(cache, "write"))}, + {"cache_read_input_tokens", Count(Dyn::Get(cache, "read"))}, + {"output_tokens", Count(Dyn::Get(tokens, "output"))}}; + } + } else if (Dyn::Get(part, "type") == "tool") { + const Json& name = Dyn::Get(part, "tool"); + const Json& state = Dyn::Or(Dyn::Get(part, "state"), Dyn::EmptyDict()); + const std::string key = KeyText(name); + toolCalls[key] = Dyn::Add(Dyn::Has(toolCalls, key) ? toolCalls.at(key) : Json(0), Json(1)); + if (Dyn::Get(state, "status") == "error") ++toolErrors; + if (name.is_string() && name.get_ref() == Tool && Dyn::Get(state, "status") == "completed" && Dyn::Get(state, "input").is_object()) structured = state.at("input"); + } else if (type == "error") { + const Json& error = Dyn::Get(o, "error"); + errors.push_back(Dyn::Slice(DumpSpaced(Dyn::Truthy(error) ? error : o), 0, 300)); + } + } + if (structured.is_null() && soPath && !soPath->empty() && fs::exists(*soPath)) { + try { + structured = Dyn::LoadJsonFile(*soPath); + } catch (const std::exception&) { + structured = nullptr; + } + } + // Reasoning tokens are output tokens OpenCode counts apart; the claude + // harness counts them inside output. + usage["output"] = Dyn::Add(usage.at("output"), usage.at("thinking")); + const bool result = structured.is_object() && Dyn::Get(structured, "findings").is_array(); + return { + {"usage", usage}, + {"turns", turns}, + {"costUsd", cost != 0 ? Json(cost) : Json()}, + {"toolCalls", toolCalls}, + {"toolErrors", toolErrors}, + {"errors", errors}, + {"structured", result ? structured : Json()}, + {"sessionId", sessionId}, + {"firstTurn", first}, + {"stop", stop}, + }; + } + + std::pair Classify(const Json& parsed, std::int64_t exitCode, std::string_view stderrText, bool timedOut) { + if (timedOut || exitCode == 124) return {"timeout", "process exceeded its time budget"}; + const Json& errors = Dyn::At(parsed, "errors"); + std::vector texts; + for (const Json& e : errors) texts.push_back(Dyn::Str(e)); + const std::string text = std::format("{} {}", Dyn::Join(texts, " "), Dyn::Slice(stderrText, -2000)); + if (!Dyn::At(parsed, "structured").is_null()) return {"ok", ""}; + const std::u32string t = Dyn::Decode(text); + const std::string reason = Dyn::Slice(Dyn::Strip(text), 0, 200); + if (CreditText(t)) return {"credits", reason}; + if (AuthText(t)) return {"auth", reason}; + if (LimitText(t)) return {"limit", reason}; + if (!errors.empty()) return {"error", std::format("opencode error: {}", Dyn::Slice(Dyn::Str(errors.at(0)), 0, 200))}; + if (Dyn::At(parsed, "turns") == 0) return {"error", std::format("no step finished (exit {}): {}", exitCode, Dyn::Slice(Dyn::Strip(stderrText), -200))}; + return {"error", std::format("no {} call in the process ({} steps, stop {})", Tool, Dyn::Str(parsed.at("turns")), Dyn::Str(Dyn::Get(parsed, "stop")))}; + } +} diff --git a/implementations/Crafter.Review-Process.cpp b/implementations/Crafter.Review-Process.cpp index bff1a1a..219d7ed 100644 --- a/implementations/Crafter.Review-Process.cpp +++ b/implementations/Crafter.Review-Process.cpp @@ -324,7 +324,9 @@ namespace Crafter::Review::Process { if (deadline) { const auto left = std::chrono::duration_cast(*deadline - std::chrono::steady_clock::now()).count(); if (left <= 0) { - Signal(child.pid, SIGKILL); + // A supervisor takes the whole tree down on SIGTERM; any + // other child gets SIGKILL. + Signal(child.pid, argv.size() > 1 && argv[1] == SuperviseVerb ? SIGTERM : SIGKILL); captured.timedOut = true; break; } @@ -360,7 +362,12 @@ namespace Crafter::Review::Process { for (const std::int32_t fd : { child.input, child.output, child.error }) { if (fd >= 0) close(fd); } - captured.returnCode = Wait(child.pid).value_or(-1); + std::optional code = Wait(child.pid, captured.timedOut ? std::optional(std::chrono::milliseconds(10000)) : std::nullopt); + if (!code) { + Signal(child.pid, SIGKILL); + code = Wait(child.pid); + } + captured.returnCode = code.value_or(-1); return captured; } diff --git a/interfaces/Crafter.Review-Json.cppm b/interfaces/Crafter.Review-Json.cppm index 97b5bac..fbba4e4 100644 --- a/interfaces/Crafter.Review-Json.cppm +++ b/interfaces/Crafter.Review-Json.cppm @@ -26,6 +26,12 @@ export namespace Crafter::Review { return value.dump(1, ' ', true, Json::error_handler_t::replace); } + // No whitespace, ASCII only, the keys in their order. What the MCP config + // and OpenCode's inline agent config go to their programs as. + inline std::string DumpCompact(const Json& value) { + return value.dump(-1, ' ', true, Json::error_handler_t::replace); + } + // Keys in code-point order, no whitespace, ASCII only. The // structured-output schema goes to claude in this form and its bytes are // part of the prompt cache, so they must not move by a byte (the goldens diff --git a/interfaces/Crafter.Review-OpenCode.cppm b/interfaces/Crafter.Review-OpenCode.cppm new file mode 100644 index 0000000..4e3a4ec --- /dev/null +++ b/interfaces/Crafter.Review-OpenCode.cppm @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +export module Crafter.Review:OpenCode; +import std; +import :Json; + +// The OpenCode harness (2026-10-12): a lens pass as an `opencode run` +// process instead of `claude --print`, for models that are not Anthropic's - +// the lane for the hours when every Anthropic seat is out. A lens whose +// environment needs an MCP server never runs here. The pass keeps its +// prompts byte for byte: the appended system prompt becomes the agent's +// prompt (`{file:...}` in an inline config), the stdin user message its +// positional message, the read-only tool allowlist its permission rules, and +// the structured output a custom tool with the same schema that writes the +// result to a file the driver reads. +// +// OpenCode runs in a HOME of its own under the scratch dir: config, the custom +// tool and the SDK packages it installs on first use (warmed once before the +// pool starts), one data dir per process (its sqlite session store is never +// shared), and a view directory as the project - symlinks to the repository's +// top-level entries, so no instruction file of the repository root reaches the +// model. +export namespace Crafter::Review::OpenCode { + // The view links every other top-level entry of the consumer's checkout. + constexpr std::array ViewSkip = {".git", "node_modules"}; + constexpr std::string_view Agent = "lens"; + constexpr std::string_view Tool = "StructuredOutput"; + constexpr std::string_view SoEnv = "REVIEW_GATES_STRUCTURED_OUT"; + // The model's reasoning variants are low/medium/high (OpenRouter + // `reasoning.effort`). + constexpr std::string_view DefaultVariant = "high"; + // Provider routing for OpenRouter models (Jorijn, 2026-10-12): zero data + // retention only, fp8 the lowest precision, cheapest first. + Json Routing(); + + // REVIEW_GATES_OPENCODE_BIN, else `opencode` on PATH. + std::string DefaultBin(); + // `openrouter/xiaomi/mimo-v2.6-pro` -> (openrouter, xiaomi/mimo-v2.6-pro); a + // bare id is OpenRouter's. + std::pair ModelId(std::string_view model); + // StructuredOutput.ts: the lens result schema as the tool's zod arguments + // (one tool for block and whole passes: the whole-only fields optional). + std::string ToolSource(); + // /opencode: the XDG home (config + tool), cache and state dirs, + // the view dir. Idempotent. + std::filesystem::path PrepareHome(const std::filesystem::path& scratch, const std::filesystem::path& repo, std::span models, const Json& routing = Routing()); + // The environment every OpenCode process of the run shares: its own XDG + // home, the OpenRouter key, no Anthropic credential or endpoint, no + // auto-update. nullopt: this process's environment. + std::map BaseEnv(const std::filesystem::path& home, std::string_view key, + const std::optional>& base = std::nullopt); + // The claude harness's --allowedTools entries as OpenCode permission rules: + // Read/Glob/Grep allowed, every `Bash(:*)` the bash pattern + // `*`, everything else denied (the last matching rule wins, so the + // catch-all comes first). + Json Permissions(std::span allowList); + // One process: the agent (its prompt the pass's system prompt file), its + // permissions, the structured-output file, a data dir of its own. + std::map ChildEnv(const std::map& base, const std::filesystem::path& systemPath, const std::filesystem::path& soPath, + std::span allowList, const std::filesystem::path& dataDir); + // An empty variant passes none. + std::vector Cmd(std::string_view bin, std::string_view model, const std::filesystem::path& view, std::string_view title, std::string_view variant = {}, + std::string_view prompt = {}); + // One short run before the pool: OpenCode installs the provider SDK into + // the config dir on first use (minutes; a wave of concurrent first runs + // would all do it). (ok, the tail of its stderr or why it failed). + std::pair Warm(const std::string& bin, const std::filesystem::path& home, const std::map& env, std::string_view model, + const std::function& log, std::int64_t timeoutS = 600); + // The `--format json` event stream as Stream::ParseStream gives the claude + // one: usage totals, steps as turns, tool calls and errors, OpenCode's own + // cost, and the structured output - the last StructuredOutput call's + // arguments, the file it wrote the fallback. + Json ParseEvents(std::string_view text, const std::optional& soPath = std::nullopt); + // (status, reason) as Stream::Classify: ok | limit | credits | auth | + // timeout | error. + std::pair Classify(const Json& parsed, std::int64_t exitCode, std::string_view stderrText, bool timedOut = false); +} diff --git a/interfaces/Crafter.Review-Process.cppm b/interfaces/Crafter.Review-Process.cppm index 28a5c73..6ca5c5e 100644 --- a/interfaces/Crafter.Review-Process.cppm +++ b/interfaces/Crafter.Review-Process.cppm @@ -81,7 +81,9 @@ export namespace Crafter::Review::Process { // A command run to its end with both outputs captured: stdin from // `input`, or /dev/null; both outputs read as they come, so neither can // fill its pipe and stall the child. With a timeout the child is killed - // when it runs out, and timedOut is set. + // when it runs out, and timedOut is set; a supervised command (Supervised) + // gets SIGTERM, so the supervisor takes its tree down, and SIGKILL 10 s + // later if it is still there. struct Captured { std::int32_t returnCode = -1; std::string out; diff --git a/interfaces/Crafter.Review.cppm b/interfaces/Crafter.Review.cppm index d9031ba..75bb961 100644 --- a/interfaces/Crafter.Review.cppm +++ b/interfaces/Crafter.Review.cppm @@ -28,5 +28,6 @@ export import :Memory; export import :Mcp; export import :Seats; export import :SeatPool; +export import :OpenCode; export import :Freeze; export import :Driver; diff --git a/project.cpp b/project.cpp index 26905cd..be8527d 100644 --- a/project.cpp +++ b/project.cpp @@ -13,7 +13,7 @@ using namespace Crafter; namespace { // The umbrella, then the partitions in import order: each after the ones // it imports, which is also the order the module map in AGENTS.md uses. - constexpr std::array Interfaces = { + constexpr std::array Interfaces = { "interfaces/Crafter.Review", "interfaces/Crafter.Review-Unicode", "interfaces/Crafter.Review-Json", @@ -40,10 +40,11 @@ namespace { "interfaces/Crafter.Review-Mcp", "interfaces/Crafter.Review-Seats", "interfaces/Crafter.Review-SeatPool", + "interfaces/Crafter.Review-OpenCode", "interfaces/Crafter.Review-Freeze", "interfaces/Crafter.Review-Driver", }; - constexpr std::array Implementations = { + constexpr std::array Implementations = { "implementations/Crafter.Review-Text", "implementations/Crafter.Review-Crypto", "implementations/Crafter.Review-Platform", @@ -67,6 +68,7 @@ namespace { "implementations/Crafter.Review-Mcp", "implementations/Crafter.Review-Seats", "implementations/Crafter.Review-SeatPool", + "implementations/Crafter.Review-OpenCode", "implementations/Crafter.Review-Freeze", "implementations/Crafter.Review-Driver", }; @@ -78,7 +80,7 @@ namespace { // One per directory under tests/, each an ordinary main() that links the // library: exit 0 passes, nonzero fails, 77 skips. - constexpr std::array Tests = { + constexpr std::array Tests = { "Crypto", "Text", "Platform", @@ -96,6 +98,7 @@ namespace { "Seats", "SeatPool", "Drivers", + "OpenCode", }; // The version the binary reports: CI sets it from the tag, a checkout diff --git a/tests/Goldens/fixture/pipeline.json b/tests/Goldens/fixture/pipeline.json index e3d9ea4..50e3e47 100644 --- a/tests/Goldens/fixture/pipeline.json +++ b/tests/Goldens/fixture/pipeline.json @@ -28079,5 +28079,1259 @@ ] ] ] + }, + "opencode": { + "toolSource": "import { tool } from \"@opencode-ai/plugin\"\nimport * as fs from \"node:fs\"\nconst z = tool.schema\nconst Finding = z.object({\n finding: z.string().describe(\"the defect - by your lens's charter question (objective wrongness)\"),\n evidence: z.string().describe(\"file:line, a verbatim quote, the steps taken in the application and what was observed (a screenshot or snapshot path) - no vibes\"),\n proposedAction: z.string(),\n items: z.array(z.string()).describe(\"the INVENTORY unit ids this finding concerns, copied exactly; a finding that spans units lists them all\"),\n dischargedBy: z.string().optional().describe(\"id of a disposition listed under DISPOSITIONS IN FORCE that discharges this finding (you verified its grounds); omit when none applies\"),\n contests: z.object({ id: z.string(), why: z.string().describe(\"NEW evidence the disposition's recorded grounds do not survive\") }).strict().optional(),\n}).strict()\nconst Note = z.object({\n kind: z.enum([\"reserve-answer\", \"invariant\", \"pre-existing\", \"residual-risk\", \"hold\", \"info\", \"immaterial\", \"send-time-check\", \"refutation-verified\", \"unverified\"]),\n note: z.string(), evidence: z.string(),\n action: z.string().describe(\"what the orchestrator does with it (bank / record in the manifest / check at push time / nothing)\"),\n id: z.string().optional().describe(\"refutation-verified only: the disposition id\"),\n items: z.array(z.string()).optional(),\n}).strict()\nconst Coverage = z.object({\n item: z.string().describe(\"an INVENTORY unit id, verbatim\"),\n verdict: z.enum([\"clear\", \"defect\", \"immaterial\", \"n/a\"]),\n basis: z.string().describe(\"what this verdict rests on: the tree ref, the command run, the steps taken in the application and the evidence file - or the literal string \\\"no record\\\"\"),\n}).strict()\nexport default tool({\n description: \"Return the structured output your instructions describe: the verdict, findings, notes and coverage of this pass. Call it exactly once, as the last action of the pass, with the complete result; nothing you write as text counts.\",\n args: {\n verdict: z.enum([\"FIX FIRST\", \"SEND-READY\"]).describe(\"FIX FIRST iff any finding is undischarged - the pipeline derives the same and fails closed on disagreement\"),\n findings: z.array(Finding),\n notes: z.array(Note),\n coverage: z.array(Coverage).describe(\"block pass: exactly one entry, the unit this pass covers; whole pass: one entry per unit you were asked to cover\"),\n summary: z.string().optional().describe(\"5-10 sentences: the range as you judged it and the constraints the block passes must respect (the functional lens: how to reach each touched screen, the login used, what the block passes must not touch)\"),\n },\n async execute(args) {\n const p = process.env.REVIEW_GATES_STRUCTURED_OUT\n if (p) fs.writeFileSync(p, JSON.stringify(args))\n return \"Structured output provided successfully\"\n },\n})\n", + "routing": { + "zdr": true, + "data_collection": "deny", + "quantizations": [ + "fp8", + "fp16", + "bf16", + "fp32" + ], + "sort": "price" + }, + "modelId": [ + [ + "openrouter/xiaomi/mimo-v2.6-pro", + [ + "openrouter", + "xiaomi/mimo-v2.6-pro" + ] + ], + [ + "xiaomi/mimo-v2.6-pro", + [ + "openrouter", + "xiaomi/mimo-v2.6-pro" + ] + ], + [ + "anthropic/claude", + [ + "openrouter", + "anthropic/claude" + ] + ], + [ + "openrouter", + [ + "openrouter", + "openrouter" + ] + ], + [ + "openrouter/", + [ + "openrouter", + "" + ] + ], + [ + "x", + [ + "openrouter", + "x" + ] + ] + ], + "permissions": [ + [ + [ + "Read", + "Glob", + "Grep", + "Bash(cat:*)", + "Bash(git -C . diff:*)", + "Bash(python3 -:*)" + ], + { + "*": "deny", + "StructuredOutput": "allow", + "read": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "external_directory": "allow", + "bash": { + "*": "deny", + "cat*": "allow", + "git -C . diff*": "allow", + "python3 -*": "allow" + } + } + ], + [ + [ + "Read(/abs/**)", + "Bash(a:*):*)", + "Bash(:*)", + "Bash(x:*)\n", + "Bash(y\nz:*)", + "Bash(cat:*)", + "Edit", + "mcp__playwright__browser_snapshot", + "Grep", + "bash(q:*)" + ], + { + "*": "deny", + "StructuredOutput": "allow", + "read": "allow", + "glob": "deny", + "grep": "allow", + "list": "allow", + "external_directory": "allow", + "bash": { + "*": "deny", + "a:*)*": "allow", + "x*": "allow", + "cat*": "allow" + } + } + ], + [ + [], + { + "*": "deny", + "StructuredOutput": "allow", + "read": "deny", + "glob": "deny", + "grep": "deny", + "list": "allow", + "external_directory": "allow", + "bash": { + "*": "deny" + } + } + ] + ], + "agentConfig": [ + [ + [ + "Read", + "Glob", + "Grep", + "Bash(cat:*)", + "Bash(git -C . diff:*)", + "Bash(python3 -:*)" + ], + "{\"agent\":{\"lens\":{\"mode\":\"primary\",\"description\":\"review lens\",\"prompt\":\"{file:/abs/system \\u00e9.md}\",\"permission\":{\"*\":\"deny\",\"StructuredOutput\":\"allow\",\"read\":\"allow\",\"glob\":\"allow\",\"grep\":\"allow\",\"list\":\"allow\",\"external_directory\":\"allow\",\"bash\":{\"*\":\"deny\",\"cat*\":\"allow\",\"git -C . diff*\":\"allow\",\"python3 -*\":\"allow\"}}}}}" + ], + [ + [ + "Read(/abs/**)", + "Bash(a:*):*)", + "Bash(:*)", + "Bash(x:*)\n", + "Bash(y\nz:*)", + "Bash(cat:*)", + "Edit", + "mcp__playwright__browser_snapshot", + "Grep", + "bash(q:*)" + ], + "{\"agent\":{\"lens\":{\"mode\":\"primary\",\"description\":\"review lens\",\"prompt\":\"{file:/abs/system \\u00e9.md}\",\"permission\":{\"*\":\"deny\",\"StructuredOutput\":\"allow\",\"read\":\"allow\",\"glob\":\"deny\",\"grep\":\"allow\",\"list\":\"allow\",\"external_directory\":\"allow\",\"bash\":{\"*\":\"deny\",\"a:*)*\":\"allow\",\"x*\":\"allow\",\"cat*\":\"allow\"}}}}}" + ], + [ + [], + "{\"agent\":{\"lens\":{\"mode\":\"primary\",\"description\":\"review lens\",\"prompt\":\"{file:/abs/system \\u00e9.md}\",\"permission\":{\"*\":\"deny\",\"StructuredOutput\":\"allow\",\"read\":\"deny\",\"glob\":\"deny\",\"grep\":\"deny\",\"list\":\"allow\",\"external_directory\":\"allow\",\"bash\":{\"*\":\"deny\"}}}}}" + ] + ], + "cmd": [ + [ + [ + "opencode", + "openrouter/x/y", + "/v", + "tttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "high", + "the prompt" + ], + [ + "opencode", + "run", + "--format", + "json", + "--model", + "openrouter/x/y", + "--agent", + "lens", + "--dir", + "/v", + "--title", + "tttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "--variant", + "high", + "--", + "the prompt" + ] + ], + [ + [ + "/bin/oc", + "m", + "/v", + "title", + null, + "" + ], + [ + "/bin/oc", + "run", + "--format", + "json", + "--model", + "m", + "--agent", + "lens", + "--dir", + "/v", + "--title", + "title", + "--", + "" + ] + ], + [ + [ + "oc", + "m", + "/v", + "\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9", + "", + "p" + ], + [ + "oc", + "run", + "--format", + "json", + "--model", + "m", + "--agent", + "lens", + "--dir", + "/v", + "--title", + "\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9", + "--", + "p" + ] + ] + ], + "baseEnv": [ + { + "PATH": "/bin", + "ANTHROPIC_API_KEY": "x", + "CLAUDE_CODE_OAUTH_TOKEN": "y", + "OPENCODE_X": "z", + "XDG_CONFIG_HOME": "w", + "OPENROUTER_API_KEY": "old", + "HOME": "/home/u", + "ANTHROPICX": "keep" + }, + { + "PATH": "/bin", + "HOME": "/home/u", + "ANTHROPICX": "keep", + "XDG_CONFIG_HOME": "/h/xdg", + "XDG_CACHE_HOME": "/h/cache", + "XDG_STATE_HOME": "/h/state", + "XDG_DATA_HOME": "/h/data", + "OPENROUTER_API_KEY": "sk-or-key", + "OPENCODE_DISABLE_AUTOUPDATE": "1" + } + ], + "home": { + "models": [ + "openrouter/x/y", + "xiaomi/mimo-v2.6-pro", + "openrouter/a/b", + "other/m" + ], + "home": "/sc/opencode", + "view": [ + [ + ".env", + "/repo/.env" + ], + [ + "Z", + "/repo/Z" + ], + [ + "a", + "/repo/a" + ], + [ + "b", + "/repo/b" + ], + [ + "package.json", + "/repo/package.json" + ], + [ + "src", + "/repo/src" + ] + ], + "firstConfig": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"autoupdate\": false,\n \"share\": \"disabled\",\n \"provider\": {\n \"openrouter\": {\n \"models\": {\n \"x/y\": {\n \"options\": {\n \"provider\": {\n \"zdr\": true,\n \"data_collection\": \"deny\",\n \"quantizations\": [\n \"fp8\",\n \"fp16\",\n \"bf16\",\n \"fp32\"\n ],\n \"sort\": \"price\"\n }\n }\n },\n \"xiaomi/mimo-v2.6-pro\": {\n \"options\": {\n \"provider\": {\n \"zdr\": true,\n \"data_collection\": \"deny\",\n \"quantizations\": [\n \"fp8\",\n \"fp16\",\n \"bf16\",\n \"fp32\"\n ],\n \"sort\": \"price\"\n }\n }\n },\n \"a/b\": {\n \"options\": {\n \"provider\": {\n \"zdr\": true,\n \"data_collection\": \"deny\",\n \"quantizations\": [\n \"fp8\",\n \"fp16\",\n \"bf16\",\n \"fp32\"\n ],\n \"sort\": \"price\"\n }\n }\n },\n \"other/m\": {\n \"options\": {\n \"provider\": {\n \"zdr\": true,\n \"data_collection\": \"deny\",\n \"quantizations\": [\n \"fp8\",\n \"fp16\",\n \"bf16\",\n \"fp32\"\n ],\n \"sort\": \"price\"\n }\n }\n }\n }\n }\n }\n}", + "config": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"autoupdate\": false,\n \"share\": \"disabled\",\n \"provider\": {\n \"openrouter\": {\n \"models\": {\n \"x/y\": {\n \"options\": {\n \"provider\": {\n \"zdr\": true,\n \"data_collection\": \"deny\",\n \"quantizations\": [\n \"fp8\",\n \"fp16\",\n \"bf16\",\n \"fp32\"\n ],\n \"sort\": \"price\"\n }\n }\n }\n }\n }\n }\n}", + "tool": true, + "modes": { + "xdg": "0o755", + "xdg/opencode": "0o755", + "xdg/opencode/tools": "0o700", + "cache": "0o700", + "state": "0o700", + "data": "0o700", + "view": "0o700" + } + }, + "events": [ + [ + "{\"type\": \"step_start\", \"sessionID\": \"ses_1\", \"part\": {}}\n{\"type\": \"tool_use\", \"part\": {\"type\": \"tool\", \"tool\": \"StructuredOutput\", \"state\": {\"status\": \"completed\", \"input\": {\"verdict\": \"SEND-READY\", \"findings\": [], \"notes\": [], \"coverage\": [{\"item\": \"u\", \"verdict\": \"clear\", \"basis\": \"b\"}]}}}}\n{\"type\": \"step_finish\", \"part\": {\"type\": \"step-finish\", \"reason\": \"stop\", \"tokens\": {\"input\": 20, \"output\": 4, \"reasoning\": 1, \"cache\": {\"read\": 150, \"write\": 0}}, \"cost\": 0.0005}}", + { + "usage": { + "input": 20, + "cacheCreation": 0, + "cacheRead": 150, + "output": 5, + "thinking": 1 + }, + "turns": 1, + "costUsd": 0.0005, + "toolCalls": { + "StructuredOutput": 1 + }, + "toolErrors": 0, + "errors": [], + "structured": { + "verdict": "SEND-READY", + "findings": [], + "notes": [], + "coverage": [ + { + "item": "u", + "verdict": "clear", + "basis": "b" + } + ] + }, + "sessionId": "ses_1", + "firstTurn": { + "input_tokens": 20, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 150, + "output_tokens": 4 + }, + "stop": "stop" + } + ], + [ + "not json\n{broken\n {\"type\": \"step_start\", \"part\": {}}\n{\"type\": \"step_finish\", \"sessionID\": \"ses_A\", \"part\": {\"tokens\": {\"input\": 5, \"output\": 2, \"cache\": {\"write\": 3}}, \"cost\": 0.25, \"reason\": \"tool-calls\"}}\n{\"type\": \"tool_use\", \"sessionID\": \"ses_B\", \"part\": {\"type\": \"tool\", \"tool\": \"read\", \"state\": {\"status\": \"error\", \"error\": \"x\"}}}\n{\"type\": \"tool_use\", \"part\": {\"type\": \"tool\", \"tool\": \"read\", \"state\": {\"status\": \"completed\"}}}\n{\"type\": \"tool_use\", \"part\": {\"type\": \"tool\", \"tool\": \"StructuredOutput\", \"state\": {\"status\": \"completed\", \"input\": {\"verdict\": \"X\", \"findings\": \"not a list\"}}}}\n{\"type\": \"error\", \"error\": {\"name\": \"APIError\", \"data\": {\"message\": \"Rate limit exceeded: free-models-per-day. \\u00e9\"}}}\n{\"type\": \"error\"}\n{\"type\": \"step_finish\", \"part\": {\"tokens\": {\"input\": 1.5, \"reasoning\": 7}, \"cost\": null, \"reason\": \"stop\"}}", + { + "usage": { + "input": 6.5, + "cacheCreation": 3, + "cacheRead": 0, + "output": 9, + "thinking": 7 + }, + "turns": 2, + "costUsd": 0.25, + "toolCalls": { + "read": 2, + "StructuredOutput": 1 + }, + "toolErrors": 1, + "errors": [ + "{\"name\": \"APIError\", \"data\": {\"message\": \"Rate limit exceeded: free-models-per-day. \\u00e9\"}}", + "{\"type\": \"error\"}" + ], + "structured": null, + "sessionId": "ses_A", + "firstTurn": { + "input_tokens": 5, + "cache_creation_input_tokens": 3, + "cache_read_input_tokens": 0, + "output_tokens": 2 + }, + "stop": "stop" + } + ], + [ + "", + { + "usage": { + "input": 0, + "cacheCreation": 0, + "cacheRead": 0, + "output": 0, + "thinking": 0 + }, + "turns": 0, + "costUsd": null, + "toolCalls": {}, + "toolErrors": 0, + "errors": [], + "structured": null, + "sessionId": null, + "firstTurn": null, + "stop": null + } + ], + [ + "{\"type\": \"step_finish\", \"part\": \"not a dict\"}\r\n{\"type\": \"step_finish\", \"part\": {}}\u2028{\"type\": \"error\", \"error\": \"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}", + { + "usage": { + "input": 0, + "cacheCreation": 0, + "cacheRead": 0, + "output": 0, + "thinking": 0 + }, + "turns": 2, + "costUsd": null, + "toolCalls": {}, + "toolErrors": 0, + "errors": [ + "\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ], + "structured": null, + "sessionId": null, + "firstTurn": { + "input_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0 + }, + "stop": null + } + ], + [ + "{\"type\": \"tool_use\", \"part\": {\"type\": \"tool\", \"state\": {\"status\": \"error\"}}}", + { + "usage": { + "input": 0, + "cacheCreation": 0, + "cacheRead": 0, + "output": 0, + "thinking": 0 + }, + "turns": 0, + "costUsd": null, + "toolCalls": { + "null": 1 + }, + "toolErrors": 1, + "errors": [], + "structured": null, + "sessionId": null, + "firstTurn": null, + "stop": null + } + ] + ], + "classify": [ + [ + [ + { + "usage": { + "input": 20, + "cacheCreation": 0, + "cacheRead": 150, + "output": 5, + "thinking": 1 + }, + "turns": 1, + "costUsd": 0.0005, + "toolCalls": { + "StructuredOutput": 1 + }, + "toolErrors": 0, + "errors": [], + "structured": { + "verdict": "SEND-READY", + "findings": [], + "notes": [], + "coverage": [ + { + "item": "u", + "verdict": "clear", + "basis": "b" + } + ] + }, + "sessionId": "ses_1", + "firstTurn": { + "input_tokens": 20, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 150, + "output_tokens": 4 + }, + "stop": "stop" + }, + 0, + "", + false + ], + [ + "ok", + "" + ] + ], + [ + [ + { + "usage": { + "input": 20, + "cacheCreation": 0, + "cacheRead": 150, + "output": 5, + "thinking": 1 + }, + "turns": 1, + "costUsd": 0.0005, + "toolCalls": { + "StructuredOutput": 1 + }, + "toolErrors": 0, + "errors": [], + "structured": { + "verdict": "SEND-READY", + "findings": [], + "notes": [], + "coverage": [ + { + "item": "u", + "verdict": "clear", + "basis": "b" + } + ] + }, + "sessionId": "ses_1", + "firstTurn": { + "input_tokens": 20, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 150, + "output_tokens": 4 + }, + "stop": "stop" + }, + 124, + "", + false + ], + [ + "timeout", + "process exceeded its time budget" + ] + ], + [ + [ + { + "usage": { + "input": 20, + "cacheCreation": 0, + "cacheRead": 150, + "output": 5, + "thinking": 1 + }, + "turns": 1, + "costUsd": 0.0005, + "toolCalls": { + "StructuredOutput": 1 + }, + "toolErrors": 0, + "errors": [], + "structured": { + "verdict": "SEND-READY", + "findings": [], + "notes": [], + "coverage": [ + { + "item": "u", + "verdict": "clear", + "basis": "b" + } + ] + }, + "sessionId": "ses_1", + "firstTurn": { + "input_tokens": 20, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 150, + "output_tokens": 4 + }, + "stop": "stop" + }, + 0, + "", + true + ], + [ + "timeout", + "process exceeded its time budget" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 124, + "x", + false + ], + [ + "timeout", + "process exceeded its time budget" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "HTTP 402 Payment Required", + false + ], + [ + "credits", + "HTTP 402 Payment Required" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "error 4020 x", + false + ], + [ + "error", + "no StructuredOutput call in the process (1 steps, stop stop)" + ] + ], + [ + [ + { + "structured": null, + "errors": [ + "{\"message\": \"insufficient CREDITS\"}" + ], + "turns": 1, + "stop": "stop" + }, + 1, + "", + false + ], + [ + "credits", + "{\"message\": \"insufficient CREDITS\"}" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "Requires more credits", + false + ], + [ + "credits", + "Requires more credits" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "your credit balance is low", + false + ], + [ + "credits", + "your credit balance is low" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "insufficient credit", + false + ], + [ + "error", + "no StructuredOutput call in the process (1 steps, stop stop)" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "Invalid API key provided", + false + ], + [ + "auth", + "Invalid API key provided" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "invalid\ntoken", + false + ], + [ + "error", + "no StructuredOutput call in the process (1 steps, stop stop)" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "invalid bearer to\u212aen", + false + ], + [ + "auth", + "invalid bearer to\u212aen" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "\u0130nvalid token", + false + ], + [ + "auth", + "\u0130nvalid token" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "Unauthorized", + false + ], + [ + "auth", + "Unauthorized" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "unauthorised", + false + ], + [ + "auth", + "unauthorised" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "unauthori\u017fed", + false + ], + [ + "auth", + "unauthori\u017fed" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "No auth credentials found", + false + ], + [ + "auth", + "No auth credentials found" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "NO AUTH CREDENTIALS", + false + ], + [ + "auth", + "NO AUTH CREDENTIALS" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "status 401", + false + ], + [ + "auth", + "status 401" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "x403y", + false + ], + [ + "error", + "no StructuredOutput call in the process (1 steps, stop stop)" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "(403)", + false + ], + [ + "auth", + "(403)" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "429 Too Many Requests", + false + ], + [ + "limit", + "429 Too Many Requests" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "a4290", + false + ], + [ + "error", + "no StructuredOutput call in the process (1 steps, stop stop)" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "Rate-limit hit", + false + ], + [ + "limit", + "Rate-limit hit" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "rate limit", + false + ], + [ + "limit", + "rate limit" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "ratelimit", + false + ], + [ + "error", + "no StructuredOutput call in the process (1 steps, stop stop)" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "temporarily rate-limited upstream", + false + ], + [ + "limit", + "temporarily rate-limited upstream" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "ENGINE_OVERLOADED", + false + ], + [ + "limit", + "ENGINE_OVERLOADED" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "\u00e9429\u00e9", + false + ], + [ + "error", + "no StructuredOutput call in the process (1 steps, stop stop)" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "_429", + false + ], + [ + "error", + "no StructuredOutput call in the process (1 steps, stop stop)" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 1, + "stop": "stop" + }, + 1, + "\u0664\u0662\u0669", + false + ], + [ + "error", + "no StructuredOutput call in the process (1 steps, stop stop)" + ] + ], + [ + [ + { + "structured": null, + "errors": [ + "{\"x\": 1}" + ], + "turns": 1, + "stop": "stop" + }, + 1, + "", + false + ], + [ + "error", + "opencode error: {\"x\": 1}" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 0, + "stop": "stop" + }, + 3, + " boom \n", + false + ], + [ + "error", + "no step finished (exit 3): boom" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 0, + "stop": "stop" + }, + -9, + "", + false + ], + [ + "error", + "no step finished (exit -9): " + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 2, + "stop": null + }, + 0, + "", + false + ], + [ + "error", + "no StructuredOutput call in the process (2 steps, stop None)" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 2, + "stop": "length" + }, + 0, + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 402", + false + ], + [ + "credits", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + ], + [ + [ + { + "structured": null, + "errors": [], + "turns": 2, + "stop": "length" + }, + 0, + "402 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + false + ], + [ + "error", + "no StructuredOutput call in the process (2 steps, stop length)" + ] + ], + [ + [ + { + "structured": null, + "errors": [ + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ], + "turns": 1, + "stop": "stop" + }, + 1, + "", + false + ], + [ + "error", + "opencode error: eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ] + ], + [ + [ + { + "structured": null, + "errors": [ + "429" + ], + "turns": 1, + "stop": "stop" + }, + 1, + " ", + false + ], + [ + "limit", + "429" + ] + ] + ], + "fake": "#!/bin/sh\nprintf \"%s\\n\" \"$@\" > \"$XDG_DATA_HOME/args\"\necho \"warning: x\" >&2\necho '{\"type\":\"step_finish\",\"part\":{\"tokens\":{\"input\":3,\"output\":1},\"cost\":0.00012}}'\necho '{\"type\":\"error\",\"error\":{\"message\":\"m1\"}}'\necho '{\"type\":\"error\",\"error\":{\"message\":\"m2\"}}'\necho '{\"type\":\"error\",\"error\":{\"message\":\"m3\"}}'\nexit 3\n", + "warm": { + "result": [ + false, + "warning: x\n" + ], + "log": [ + "opencode: warm-up FAILED (1 step(s), cost $0.0001, exit 3, errors ['{\"message\": \"m1\"}', '{\"message\": \"m2\"}'])" + ], + "args": "run\n--format\njson\n--model\nopenrouter/x/y\n--dir\n/sc/opencode/view\n--title\nwarm-up\n--\nReply with the single word ok.\n", + "timedOut": [ + false, + "warm-up timed out after 1 s" + ], + "timedLog": [] + } } } diff --git a/tests/OpenCode/main.cpp b/tests/OpenCode/main.cpp new file mode 100644 index 0000000..2fb038f --- /dev/null +++ b/tests/OpenCode/main.cpp @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The OpenCode harness, against the goldens (tests/Goldens/fixture/ +// pipeline.json, under `opencode`): the tool source, the permission rules, +// the agent config, the commands, the environment, the home, the event +// parsing, the classification, and a warm-up against a fake opencode. The +// test binary is its own supervisor (`__supervise`), as the driver is. + +#include +#include +import std; +import Crafter.Review; +namespace fs = std::filesystem; +using namespace Crafter::Review; + +namespace { + std::int32_t Failures = 0; + + void Check(std::string_view name, bool cond, std::string_view detail = {}) { + std::println("{}{}{}", cond ? " ok " : " FAIL ", name, !cond && !detail.empty() ? std::format(" ({})", detail) : std::string()); + if (!cond) ++Failures; + } + + void Write(const fs::path& path, std::string_view text) { + fs::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary) << text; + } + + std::string Read(const fs::path& path) { + return Platform::ReadFile(path).value_or(""); + } + + std::vector Strings(const Json& array) { + std::vector out; + for (const Json& item : array) out.push_back(item.get()); + return out; + } + + std::vector Names(const fs::path& dir) { + std::vector names; + for (const fs::directory_entry& entry : fs::directory_iterator(dir)) names.push_back(entry.path().filename().string()); + std::ranges::sort(names); + return names; + } + + // What the generator's temp dir is in the goldens. + std::string Tidy(std::string text, const fs::path& td) { + return Dyn::Replace(text, td.string(), ""); + } + + std::int32_t Run() { + std::println("opencode harness + OpenRouter seats: loader, environments, permissions, event parsing, the tool schema"); + Platform::SetMode(Platform::Mode::Native); + // The directory modes below are the generator's, made under 022. + umask(022); + const fs::path td = fs::temp_directory_path() / std::format("crafter-review-opencode-{}", Platform::PublishedPid()); + fs::remove_all(td); + fs::create_directories(td); + + const fs::path envFile = td / ".env"; + Write(envFile, "CLAUDE_CODE_OAUTH_TOKEN1=tok\nOPENROUTER_KEY=sk-or-one\n"); + auto ids = [](const std::vector& seats) { + std::vector out; + for (const Json& s : seats) out.push_back(s.at("id").get()); + return out; + }; + Check("OPENROUTER_KEY is a seat only on request", + ids(SeatPool::LoadSeats({}, envFile, "")) == std::vector{"1"} && ids(SeatPool::LoadSeats({}, envFile, "openrouter")) == std::vector{"or1"}); + try { + SeatPool::LoadSeats({}, envFile, "or1,1"); + Check("an OpenRouter seat never shares a pool with Anthropic seats", false); + } catch (const std::invalid_argument& e) { + Check("an OpenRouter seat never shares a pool with Anthropic seats", std::string_view(e.what()).contains("cannot share"), e.what()); + } + const std::vector allow = {"Read", "Glob", "Grep", "Bash(cat:*)", "Bash(git -C . diff:*)", "Bash(python3 -:*)"}; + const Json p = OpenCode::Permissions(allow); + Check("allowlist -> permission rules", p.at("read") == "allow" && p.at("StructuredOutput") == "allow" && p.at("bash").at("*") == "deny" && p.at("bash").at("git -C . diff*") == "allow"); + const std::vector ev = { + {{"type", "step_start"}, {"sessionID", "ses_1"}, {"part", Json::object()}}, + {{"type", "tool_use"}, + {"part", + {{"type", "tool"}, + {"tool", "StructuredOutput"}, + {"state", {{"status", "completed"}, {"input", {{"verdict", "SEND-READY"}, {"findings", Json::array()}, {"notes", Json::array()}, {"coverage", {{{"item", "u"}, {"verdict", "clear"}, {"basis", "b"}}}}}}}}}}}, + {{"type", "step_finish"}, {"part", {{"type", "step-finish"}, {"reason", "stop"}, {"tokens", {{"input", 20}, {"output", 4}, {"reasoning", 1}, {"cache", {{"read", 150}, {"write", 0}}}}}, {"cost", 0.0005}}}}, + }; + std::vector lines; + for (const Json& e : ev) lines.push_back(DumpSpaced(e)); + const Json ps = OpenCode::ParseEvents(Dyn::Join(lines, "\n")); + Check("events -> the structured output, usage, classify ok", + ps.at("structured").at("coverage").at(0).at("item") == "u" && ps.at("usage").at("output") == 5 && OpenCode::Classify(ps, 0, "") == std::pair{"ok", ""}); + const std::string src = OpenCode::ToolSource(); + Check("the tool schema carries the note kinds, the coverage vocabulary and the verdicts of the schema; no persona field", + std::ranges::all_of(std::array{"reserve-answer", "unverified", "FIX FIRST", "refutation-verified", "n/a"}, + [&](std::string_view k) { return src.contains(DumpSpaced(Json(std::string(k)))); }) + && !src.contains("notMine") && !src.contains("wouldComment")); + const fs::path repo = td / "repo"; + fs::create_directories(repo / "src"); + fs::create_directories(repo / ".git"); + fs::create_directories(repo / "node_modules"); + Write(repo / "package.json", "{}"); + const std::vector one = {"openrouter/x/y"}; + const fs::path home = OpenCode::PrepareHome(td / "sc", repo, one); + Check("prepare_home: the view links every top-level entry but .git and node_modules", + Names(home / "view") == std::vector{"package.json", "src"} && fs::is_symlink(fs::symlink_status(home / "view" / "src"))); + + // ---- against the goldens --------------------------------------------------------------- + const Json g = Dyn::LoadJsonFile(fs::current_path() / "tests" / "Goldens" / "fixture" / "pipeline.json").at("opencode"); + Check("goldens: the tool source, byte for byte", src == g.at("toolSource").get()); + Check("goldens: the provider routing", DumpFile(OpenCode::Routing()) == DumpFile(g.at("routing"))); + std::vector missed; + for (const Json& entry : g.at("modelId")) { + const auto [prov, id] = OpenCode::ModelId(entry.at(0).get()); + if (Json::array({prov, id}) != entry.at(1)) missed.push_back(entry.at(0).get()); + } + Check("goldens: model ids", missed.empty(), Dyn::Join(missed, ", ")); + missed.clear(); + std::size_t index = 0; + for (const Json& entry : g.at("permissions")) { + const std::vector list = Strings(entry.at(0)); + if (DumpFile(OpenCode::Permissions(list)) != DumpFile(entry.at(1))) missed.push_back(DumpCompact(entry.at(0))); + const std::map child = OpenCode::ChildEnv({{"PATH", "/bin"}}, "/abs/system é.md", "/abs/so.json", list, td / "data" / std::to_string(index)); + if (child.at("OPENCODE_CONFIG_CONTENT") != g.at("agentConfig").at(index).at(1).get()) missed.push_back(std::format("agent config {}", std::to_string(index))); + if (child.at("REVIEW_GATES_STRUCTURED_OUT") != "/abs/so.json" || child.at("XDG_DATA_HOME") != (td / "data" / std::to_string(index)).string()) missed.push_back(std::format("child env {}", std::to_string(index))); + ++index; + } + Check("goldens: permission rules and the inline agent config, key for key", missed.empty(), Dyn::Join(missed, "; ")); + missed.clear(); + for (const Json& entry : g.at("cmd")) { + const Json& a = entry.at(0); + const std::vector got = OpenCode::Cmd(a.at(0).get(), a.at(1).get(), a.at(2).get(), a.at(3).get(), + a.at(4).is_null() ? std::string() : a.at(4).get(), a.at(5).get()); + if (got != Strings(entry.at(1))) missed.push_back(DumpCompact(a)); + } + Check("goldens: the opencode command lines", missed.empty(), Dyn::Join(missed, "; ")); + std::map baseIn; + for (auto it = g.at("baseEnv").at(0).begin(); it != g.at("baseEnv").at(0).end(); ++it) baseIn[it.key()] = it.value().get(); + std::map baseWant; + for (auto it = g.at("baseEnv").at(1).begin(); it != g.at("baseEnv").at(1).end(); ++it) baseWant[it.key()] = it.value().get(); + Check("goldens: the shared environment - its own XDG home and key, no Anthropic or OpenCode variable", OpenCode::BaseEnv("/h", "sk-or-key", baseIn) == baseWant); + + // The generator's temp dir, which the goldens call . + const fs::path gd = td / "g"; + const fs::path repo2 = gd / "repo"; + for (const std::string_view d : {".git", "node_modules", "src", "b", "a"}) fs::create_directories(repo2 / d); + for (const std::string_view name : {"package.json", ".env", "Z"}) Write(repo2 / name, "{}"); + const Json& h = g.at("home"); + const fs::path home2 = OpenCode::PrepareHome(gd / "sc", repo2, Strings(h.at("models"))); + const fs::path config = home2 / "xdg" / "opencode" / "opencode.json"; + const std::string firstConfig = Read(config); + OpenCode::PrepareHome(gd / "sc", repo2, one); + Check("goldens: prepare_home's config, byte for byte, and rewritten by the next call", firstConfig == h.at("firstConfig").get() && Read(config) == h.at("config").get()); + std::vector view; + for (const std::string& name : Names(home2 / "view")) view.push_back(std::format("{} -> {}", name, Tidy(fs::read_symlink(home2 / "view" / name).string(), gd))); + std::vector viewWant; + for (const Json& entry : h.at("view")) viewWant.push_back(std::format("{} -> {}", entry.at(0).get(), entry.at(1).get())); + Check("goldens: the view's links, sorted, dot files included, .git and node_modules left out; idempotent", view == viewWant && Tidy(home2.string(), gd) == h.at("home").get(), + Dyn::Join(view, "; ")); + missed.clear(); + for (auto it = h.at("modes").begin(); it != h.at("modes").end(); ++it) { + const auto mode = static_cast(fs::status(home2 / it.key()).permissions()) & 0777; + if (std::format("0o{:o}", mode) != it.value().get()) missed.push_back(std::format("{} 0o{:o}", it.key(), mode)); + } + Check("goldens: os.makedirs' modes - the last directory 0700, its parents as mkdir makes them", missed.empty() && Read(home2 / "xdg" / "opencode" / "tools" / "StructuredOutput.ts") == src, + Dyn::Join(missed, ", ")); + + missed.clear(); + for (const Json& entry : g.at("events")) { + if (DumpFile(OpenCode::ParseEvents(entry.at(0).get())) != DumpFile(entry.at(1))) missed.push_back(Dyn::Repr(Dyn::Slice(entry.at(0).get(), 0, 60))); + } + Check("goldens: the event stream - steps, tokens, tool calls and errors, the session, the first turn", missed.empty(), Dyn::Join(missed, "; ")); + missed.clear(); + for (const Json& entry : g.at("classify")) { + const Json& c = entry.at(0); + const auto [status, reason] = OpenCode::Classify(c.at(0), c.at(1).get(), c.at(2).get(), c.at(3).get()); + if (Json::array({status, reason}) != entry.at(1)) missed.push_back(std::format("{} -> {} {}", Dyn::Repr(Dyn::Slice(c.at(2).get(), 0, 40)), status, Dyn::Repr(Dyn::Slice(reason, 0, 60)))); + } + Check("goldens: classify - the time budget, then credits, auth and limits as the regexes read them (re.I, \\b), then why nothing came back", missed.empty(), Dyn::Join(missed, "; ")); + + const Json& w = g.at("warm"); + const fs::path fake = td / "opencode"; + Write(fake, g.at("fake").get()); + fs::permissions(fake, fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec | fs::perms::others_read | fs::perms::others_exec); + const std::map env = OpenCode::BaseEnv(home2, "sk-or", std::map{{"PATH", "/usr/bin:/bin"}}); + std::vector log; + const auto warmed = OpenCode::Warm(fake.string(), home2, env, "openrouter/x/y", [&](const std::string& line) { log.push_back(line); }); + const std::string args = Tidy(Read(home2 / "data" / "warm" / "args"), gd); + Check("goldens: a warm-up that fails - its log line, the tail of its stderr, the command it ran", + warmed == std::pair{w.at("result").at(0).get(), w.at("result").at(1).get()} && log == Strings(w.at("log")) && args == w.at("args").get(), + std::format("{} | {}", Dyn::Join(log, " | "), Dyn::Repr(args))); + const fs::path slow = td / "slow-opencode"; + Write(slow, "#!/bin/sh\necho $$ > \"$XDG_DATA_HOME/pid\"\nexec sleep 30\n"); + fs::permissions(slow, fs::perms::owner_all); + log.clear(); + const auto timed = OpenCode::Warm(slow.string(), home2, env, "openrouter/x/y", [&](const std::string& line) { log.push_back(line); }, 1); + const std::optional sleeper = Dyn::ParseInt(Dyn::Strip(Read(home2 / "data" / "warm" / "pid"))); + Check("goldens: a warm-up out of time says so and logs nothing", + timed == std::pair{w.at("timedOut").at(0).get(), w.at("timedOut").at(1).get()} && log.empty() && w.at("timedLog").empty()); + Check("...and leaves no process behind: the supervisor takes it down", sleeper && !Platform::ProcStart(*sleeper)); + + // ---- beyond the goldens ----------------------------------------------------------------- + const fs::path so = td / "so.json"; + Write(so, R"({"verdict": "SEND-READY", "findings": [], "notes": [], "coverage": []})"); + const fs::path bad = td / "bad.json"; + Write(bad, R"({"verdict": "SEND-READY", "findings": "none"})"); + const fs::path garbage = td / "garbage.json"; + Write(garbage, "{"); + const std::string noTool = DumpSpaced({{"type", "step_finish"}, {"part", {{"reason", "stop"}}}}); + Check("the structured-output file stands in when the stream carries no call: one with a findings list, nothing else", + OpenCode::ParseEvents(noTool, so).at("structured").at("verdict") == "SEND-READY" && OpenCode::ParseEvents(noTool, bad).at("structured").is_null() + && OpenCode::ParseEvents(noTool, garbage).at("structured").is_null() && OpenCode::ParseEvents(noTool, td / "missing.json").at("structured").is_null() + && OpenCode::ParseEvents(noTool).at("structured").is_null()); + + fs::remove_all(td); + return Failures == 0 ? 0 : 1; + } +} + +int main(int argc, char** argv) { + const std::vector args(argv, argv + argc); + if (args.size() > 1 && args[1] == Process::SuperviseVerb) return Process::SuperviseMain(std::span(args).subspan(2)); + try { + return Run(); + } catch (const std::exception& error) { + std::println(std::cerr, "opencode: {}", error.what()); + return 1; + } +}