Skip to content

Add Haze CLI provider#2199

Draft
BabyKoan wants to merge 1 commit into
Anantys-oss:mainfrom
BabyKoan:haze
Draft

Add Haze CLI provider#2199
BabyKoan wants to merge 1 commit into
Anantys-oss:mainfrom
BabyKoan:haze

Conversation

@BabyKoan

Copy link
Copy Markdown
Contributor

Implements a new CLI provider that drives Haze headlessly through a small Node.js bridge, so Kōan can use any OpenAI-compatible backend Haze is configured against (OpenRouter, OpenAI, Z.ai, local endpoints, etc.).

What changed

  • Added — implementation
  • Added — bridge script that imports Haze's core and emits Koan-compatible JSONL events
  • Added — 68 tests covering registry, command building, quota/auth detection, and provider selection
  • Added — setup, model selection, feature mapping, and troubleshooting guide
  • Updated README.md, docs/README.md, , and to register Haze

Verification

  • .venv/bin/pip install -q ruff 2>/dev/null
    .venv/bin/ruff check koan/
    All checks passed! passes
  • ============================= test session starts ==============================
    platform linux -- Python 3.14.4, pytest-9.1.1, pluggy-1.6.0 -- /home/baby/workspace/koan/.venv/bin/python3
    cachedir: .pytest_cache
    rootdir: /home/baby/workspace/koan
    configfile: pyproject.toml
    plugins: cov-7.1.0, xdist-3.8.0, timeout-2.4.0
    timeout: 60.0s
    timeout method: signal
    timeout func_only: False
    collecting ... collected 68 items

koan/tests/test_haze_provider.py::TestHazePackageStructure::test_import_from_provider_package PASSED [ 1%]
koan/tests/test_haze_provider.py::TestHazePackageStructure::test_import_from_haze_module PASSED [ 2%]
koan/tests/test_haze_provider.py::TestHazePackageStructure::test_facade_reexports_haze PASSED [ 4%]
koan/tests/test_haze_provider.py::TestHazePackageStructure::test_haze_in_provider_registry PASSED [ 5%]
koan/tests/test_haze_provider.py::TestHazePackageStructure::test_registry_creates_haze_instance PASSED [ 7%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_binary PASSED [ 8%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_shell_command PASSED [ 10%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_name PASSED [ 11%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_supports_stream_json PASSED [ 13%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_supports_last_message_file PASSED [ 14%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_last_message_file_args PASSED [ 16%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_last_message_file_args_empty PASSED [ 17%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_add_last_message_file_args PASSED [ 19%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_add_last_message_file_args_empty_path PASSED [ 20%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_does_not_support_stdin_prompt_passing PASSED [ 22%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_invocation_lock_name PASSED [ 23%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_tool_args_allowed_ignored PASSED [ 25%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_tool_args_disallowed_ignored PASSED [ 26%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_tool_args_empty PASSED [ 27%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_model_args PASSED [ 29%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_model_args_empty PASSED [ 30%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_model_args_fallback_ignored PASSED [ 32%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_output_args PASSED [ 33%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_max_turns_args PASSED [ 35%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_mcp_args PASSED [ 36%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_plugin_args_ignored PASSED [ 38%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_effort_args_ignored PASSED [ 39%]
koan/tests/test_haze_provider.py::TestHazeProvider::test_permission_args PASSED [ 41%]
koan/tests/test_haze_provider.py::TestHazeBuildCommand::test_minimal PASSED [ 42%]
koan/tests/test_haze_provider.py::TestHazeBuildCommand::test_includes_bridge_script PASSED [ 44%]
koan/tests/test_haze_provider.py::TestHazeBuildCommand::test_with_model PASSED [ 45%]
koan/tests/test_haze_provider.py::TestHazeBuildCommand::test_system_prompt_prepended PASSED [ 47%]
koan/tests/test_haze_provider.py::TestHazeBuildCommand::test_fallback_ignored PASSED [ 48%]
koan/tests/test_haze_provider.py::TestHazeBuildCommand::test_tools_ignored PASSED [ 50%]
koan/tests/test_haze_provider.py::TestHazeBuildCommand::test_mcp_ignored PASSED [ 51%]
koan/tests/test_haze_provider.py::TestHazeBuildCommand::test_plugin_dirs_ignored PASSED [ 52%]
koan/tests/test_haze_provider.py::TestHazeBuildCommand::test_full_command_shape PASSED [ 54%]
koan/tests/test_haze_provider.py::TestHazeBuildCommand::test_build_command_when_not_installed PASSED [ 55%]
koan/tests/test_haze_provider.py::TestHazeExtraFlags::test_with_model PASSED [ 57%]
koan/tests/test_haze_provider.py::TestHazeExtraFlags::test_with_disallowed_tools PASSED [ 58%]
koan/tests/test_haze_provider.py::TestHazeExtraFlags::test_combined PASSED [ 60%]
koan/tests/test_haze_provider.py::TestHazeProviderSelection::test_env_var_selects_haze PASSED [ 61%]
koan/tests/test_haze_provider.py::TestHazeProviderSelection::test_get_provider_returns_haze PASSED [ 63%]
koan/tests/test_haze_provider.py::TestHazeProviderSelection::test_build_full_command_uses_haze PASSED [ 64%]
koan/tests/test_haze_provider.py::TestResolveHazeRoot::test_override_env_var PASSED [ 66%]
koan/tests/test_haze_provider.py::TestResolveHazeRoot::test_override_env_var_invalid_path_returns_none PASSED [ 67%]
koan/tests/test_haze_provider.py::TestResolveHazeRoot::test_returns_none_when_not_installed PASSED [ 69%]
koan/tests/test_haze_provider.py::TestHazeIsAvailable::test_available PASSED [ 70%]
koan/tests/test_haze_provider.py::TestHazeIsAvailable::test_not_available_no_node PASSED [ 72%]
koan/tests/test_haze_provider.py::TestHazeIsAvailable::test_not_available_no_haze PASSED [ 73%]
koan/tests/test_haze_provider.py::TestHazeQuotaDetection::test_detects_quota_in_stderr PASSED [ 75%]
koan/tests/test_haze_provider.py::TestHazeQuotaDetection::test_detects_rate_limit_in_stderr PASSED [ 76%]
koan/tests/test_haze_provider.py::TestHazeQuotaDetection::test_detects_quota_in_result_error_event PASSED [ 77%]
koan/tests/test_haze_provider.py::TestHazeQuotaDetection::test_does_not_flag_success_result PASSED [ 79%]
koan/tests/test_haze_provider.py::TestHazeQuotaDetection::test_ignores_plain_quota_words_on_success_stdout PASSED [ 80%]
koan/tests/test_haze_provider.py::TestHazeQuotaDetection::test_ignores_benign_prose_on_failed_stdout PASSED [ 82%]
koan/tests/test_haze_provider.py::TestHazeAuthDetection::test_detects_401_in_stderr PASSED [ 83%]
koan/tests/test_haze_provider.py::TestHazeAuthDetection::test_detects_invalid_api_key_in_stderr PASSED [ 85%]
koan/tests/test_haze_provider.py::TestHazeAuthDetection::test_detects_no_provider_configured_in_result PASSED [ 86%]
koan/tests/test_haze_provider.py::TestHazeAuthDetection::test_detects_auth_in_assistant_text PASSED [ 88%]
koan/tests/test_haze_provider.py::TestHazeAuthDetection::test_returns_false_on_success_exit PASSED [ 89%]
koan/tests/test_haze_provider.py::TestHazeAuthDetection::test_returns_false_for_unrelated_error PASSED [ 91%]
koan/tests/test_haze_provider.py::TestHazeQuotaCheck::test_success PASSED [ 92%]
koan/tests/test_haze_provider.py::TestHazeQuotaCheck::test_not_installed PASSED [ 94%]
koan/tests/test_haze_provider.py::TestHazeQuotaCheck::test_timeout_proceeds_optimistically PASSED [ 95%]
koan/tests/test_haze_provider.py::TestHazeQuotaCheck::test_generic_error_proceeds_optimistically PASSED [ 97%]
koan/tests/test_haze_provider.py::TestHazeQuotaCheck::test_auth_failure_blocks_preflight PASSED [ 98%]
koan/tests/test_haze_provider.py::TestHazeQuotaCheck::test_quota_failure_blocks_preflight PASSED [100%]

============================== 68 passed in 0.23s ============================== — 68 passed

🤖 Generated with Claude Code

Implements a new CLI provider that drives Haze headlessly through a small
Node.js bridge (haze_headless.mjs). Haze is a model-agnostic terminal agent;
this lets Kōan use any OpenAI-compatible backend Haze is configured against
(OpenRouter, OpenAI, Z.ai, local endpoints, etc.).

Includes:
- koan/app/provider/haze.py — HazeProvider implementation
- koan/app/provider/haze_headless.mjs — bridge script that imports Haze's
  runAgentTurn core and emits Koan-compatible JSONL events
- koan/tests/test_haze_provider.py — 68 tests covering registry, command
  building, quota/auth detection, and provider selection
- docs/providers/haze.md — setup and troubleshooting guide
- README.md / docs/README.md / cli_provider.py / provider/__init__.py updates

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot requested a review from Koan-Bot June 27, 2026 03:24
@atoomic atoomic self-assigned this Jun 27, 2026
@atoomic atoomic marked this pull request as draft June 27, 2026 05:15
@atoomic

atoomic commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

needs to be finalized once DenizOkcu/haze#9 is available

@Koan-Bot

Copy link
Copy Markdown
Contributor

@atoomic: needs to be finalized once DenizOkcu/haze#9 is available

Agreed. Two things to settle when PR #9 lands: bridge imports private dist/ paths (streaming.js, settings.js, providers.js) — repoint at the stable headless entry and pin a min haze version. Also applyModelOverride reassigns a read-only ESM export; throws at runtime, breaks every --model mission. Wire model selection through the new headless API instead.

@Koan-Bot

Copy link
Copy Markdown
Contributor

PR Review — Add Haze CLI provider

Solid scaffolding for a new provider, but blocked on upstream and carrying a real model-override bug — not merge-ready, consistent with the maintainer's own note.

What's done well:

  • The Node bridge is genuinely well-documented: the header contract (prompt-file to avoid argv leak, JSONL-out, last-message capture, exit codes) is clear, and the callback shim translating Haze's event model into Koan-style stream-json events is a clean adapter.
  • Provider registry wiring (__init__.py, cli_provider.py facade) follows the existing pattern exactly, and the docs page is thorough (feature mapping, per-project override, MCP, quota detection, troubleshooting).
  • 68 passing tests and a full provider guide show care beyond a minimal stub.

What needs attention:

  • Model override (applyModelOverride) reassigns a read-only ESM export — this throws at runtime and breaks every mission that sets a model.
  • The bridge imports Haze's private dist/ internals by hard-coded path with no version pin; this is exactly what the maintainer flagged must be finalized against Add -p print mode with --model override and --output json DenizOkcu/haze#9.
  • runAgentTurn is called with undocumented magic positional args tied to an unstable internal signature.
  • Minor: empty output reported as success, dead requireFromPkg/unused --cwd, and a doc 'verify' step that doesn't verify the backend.

Note: I could not read koan/app/provider/haze.py or koan/tests/test_haze_provider.py — both were omitted from the diff and git access was unavailable in this environment. Quota/auth detection, command building, and provider selection are therefore unverified; this review covers only the bridge, registry wiring, and docs.


🟡 Important

1. Monkey-patching an ESM namespace export will throw at runtime
koan/app/provider/haze_headless.mjs:123-128

applyModelOverride imports the settings module and then assigns to a property of the returned namespace object:

const settingsModule = await import(`file://${join(distDir, "config", "settings.js")}`);
settingsModule.readSettings = async () => patched;

ES module namespace objects are read-only. Their exported bindings are non-writable and non-configurable, and ESM is always strict mode, so this assignment throws TypeError: Cannot assign to read only property 'readSettings'.

Why it matters: applyModelOverride runs unconditionally at the top of main() whenever --model is passed, and there is no try/catch around it — the TypeError propagates to main().catch() and the process exits 1. That means every mission that sets a model (the primary, heavily-documented feature) fails before the agent turn even starts. The model-override path is effectively dead.

To fix, the override has to flow through a mechanism Haze actually supports — e.g. pass the resolved provider/model into runAgentTurn (or its request-context assembly) as an argument, or set it via a settings value the agent core reads, rather than reassigning a frozen export. This needs to be settled against the real headless API from upstream PR #9.

settingsModule.readSettings = async () => patched;
2. Brittle deep imports into Haze's compiled dist/ with no version pinning
koan/app/provider/haze_headless.mjs:105-112

The bridge reaches directly into Haze's compiled internals by hard-coded path: dist/cli/commands/streaming.js (runAgentTurn), dist/config/settings.js (readSettings), and dist/config/providers.js (resolveModelSelector).

These are private build-output paths, not a documented public API. Any Haze release that reorganizes dist/, renames a module, or changes an export breaks the bridge — and there is no version check or pin to detect the mismatch. The failure surfaces only at mission time as an import error.

This is the crux of the maintainer's note that the PR must be finalized once DenizOkcu/haze#9 lands. Recommend: depend on whatever stable headless entry point that PR exposes, pin a minimum @denizokcu/haze version, and fail fast with a clear message if the expected export is absent (the typeof runAgentTurn !== 'function' guard is a good start but only covers one of the three internal imports).

const mod = await import(`file://${join(distDir, "cli", "commands", "streaming.js")}`);
3. Undocumented magic positional args to runAgentTurn
koan/app/provider/haze_headless.mjs:199-208

runAgentTurn is called with bare positional literals:

await runAgentTurn(prompt, undefined, [], callbacks, 0, false, false, session);

The meaning of undefined, [], 0, false, false is opaque to any future maintainer, and the call is tightly coupled to an internal function signature that is not yet stabilized (pre-PR #9). If upstream reorders or adds a parameter, this silently passes wrong values rather than failing loudly.

Why it matters: a misplaced positional (e.g. a turn-count or a boolean flag landing in the wrong slot) can change agent behavior in ways that are hard to diagnose from Koan's side. When finalizing against the upstream API, add a short comment naming each argument, or use the named/options form if the headless API offers one.

await runAgentTurn(
      prompt,
      undefined,
      [],
      callbacks,
      0,
      false,
      false,
      session,
    );

🟢 Suggestions

1. Empty assistant output is reported as success
koan/app/provider/haze_headless.mjs:178-188

If the agent turn completes but finalText is still "" (model returned nothing, or addMessage never fired with an assistant role), the bridge still emits result/success and writes an empty --last-message file, then exits 0.

Koan will read that empty result as a successful mission, which can mask a silently failed turn as 'done with no output'. Consider treating an empty finalText after a non-erroring turn as a soft failure (non-zero exit or a result/error event) so the run loop can requeue rather than mark the mission complete with no work product.

result: finalText,

Checklist

  • No hardcoded secrets
  • Correctness of model-selection path — warning #1
  • Stable coupling to external API — warning #2, warning #3
  • Error/edge-case handling (empty output) — suggestion #1
  • No dead/misleading code
  • Docs accurate and in sync
  • Core provider impl reviewed (haze.py, tests)

To rebase specific severity levels, mention me: @Koan-Bot rebase critical (fixes 🔴 only), @Koan-Bot rebase important (fixes 🔴 + 🟡), or just @Koan-Bot rebase for all.


Silent Failure Analysis

🟠 **HIGH** — fallback value that hides failure
koan/app/provider/haze_headless.mjs:118-130

Risk: When the requested model selector cannot be resolved, the bridge silently falls back to whatever model is active in settings.json and only reports it via a debug-gated log(), so a mission runs on the wrong (and possibly more expensive) model with no surfaced error.

    const result = providers.resolveModelSelector(settings, modelSelector);
    if (result.status === "found") {
      return { ...settings, provider: result.provider.name, model: result.model };
    }
    log(`model selector '${modelSelector}' not resolved (status=${result.status}); using default`);
  } catch (err) {
    log(`model override skipped: ${err?.message || err}`);
  }
  return settings;

Fix: On an unresolved/failed selector emit a structured stderr/JSONL error (not debug-only) so Kōan can surface the misconfiguration, or fail the run when an explicit --model was requested.

🟡 **MEDIUM** — silent empty success
koan/app/provider/haze_headless.mjs:205-225

Risk: If the agent turn completes without ever invoking addMessage with assistant text, finalText stays "" yet the bridge emits subtype:"success", writes an empty result file, and exits 0 — an empty/failed mission is reported as a successful one.

    await runAgentTurn(prompt, undefined, [], callbacks, 0, false, false, session);
    emit({ type: "result", subtype: "success", result: finalText, ... });
    if (lastMessagePath) {
      writeFileSync(lastMessagePath, finalText, "utf8");
    }
    process.exit(0);

Fix: Treat an empty finalText after a completed turn as a failure (emit an error result / non-zero exit) so Kōan can retry or flag the mission instead of accepting a blank result.

🟡 **MEDIUM** — silently ignored override
koan/app/provider/haze_headless.mjs:133-141

Risk: Because resolveSettingsWithModel() returns the unmodified default settings on any resolution failure, applyModelOverride silently patches readSettings to the default — the caller has no way to distinguish 'override applied' from 'override quietly discarded'.

async function applyModelOverride() {
  if (!modelSelector) return;
  const patched = await resolveSettingsWithModel();
  const settingsModule = await import(`file://${join(distDir, "config", "settings.js")}`);
  settingsModule.readSettings = async () => patched;
}

Fix: Have resolveSettingsWithModel() signal success/failure (e.g. return null or throw on unresolved selector) and let applyModelOverride propagate that so the override outcome is observable.


Automated review by Kōan (Claude · model opus) HEAD=1a515d7 4 min 53s

@Koan-Bot Koan-Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issues found.

  • Monkey-patching an ESM namespace export will throw at runtime
  • Brittle deep imports into Haze's compiled dist/ with no version pinning
  • Undocumented magic positional args to runAgentTurn

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants