# Qwen Integration Follow-ups Tracks pending work and known limitations for the Qwen Code harness (`harness: qwen`, driving `qwen --acp`). ## What works today - `omnigent run --harness qwen` / `executor.harness: qwen` (alias `qwen-code`). - ACP executor: streaming turns, system-prompt folding, session-not-found reset, missing-binary handling. - **Permission gating** (`session/request_permission`): routed through Omnigent's TOOL_CALL policy + human-consent elicitation (`_decide_permission`), mirroring claude-sdk — a hard policy DENY rejects, otherwise the user is asked; default-deny on policy-ASK with no handler. Standalone/test use (no bridges wired) falls back to allow. - `omnigent setup` → **Qwen Code** row: installs the CLI and guides auth (env vars or interactive `/auth`). - Auth via the CLI's own ambient credentials (see Auth model below). - **Provider / gateway routing (clean env).** A spec `auth:` / `providers:` entry is translated to `HARNESS_QWEN_GATEWAY_*` vars and the executor exports `OPENAI_BASE_URL` / `OPENAI_API_KEY` (from the gateway's bearer-token command, run once at session start) / `OPENAI_MODEL` into the `qwen --acp` subprocess. Verified end-to-end against an OpenAI-compatible endpoint. **Caveat:** this is authoritative only when qwen has no conflicting ambient `~/.qwen/settings.json` — see Pending work for the precedence limitation. - **Cost / token tracking.** Per-turn token usage is parsed from qwen's ACP stream and emitted on `TurnComplete.usage` (and fed to the cost observer). qwen rides usage out-of-band on an `agent_message_chunk` whose text is empty and whose `_meta.usage` carries `{inputTokens, outputTokens, totalTokens, thoughtTokens, cachedReadTokens}` (qwen-code `emitUsageMetadata`). The executor sums these across a turn's internal model calls and splits `cachedReadTokens` out of `input_tokens` (qwen's `inputTokens` is cache- inclusive; cost wants the non-cached portion) — see `_accumulate_usage`. Verified end-to-end against a live `qwen --acp` turn. - **Context status.** The UI context meter shows used/total for qwen. The numerator (per-turn context consumed) comes from `_meta.usage.totalTokens` via cost/token tracking above; the denominator (the model's context-window *limit*) comes from a curated Qwen lookup in `get_model_context_window` (`_QWEN_CONTEXT_WINDOWS`) — qwen models are absent from litellm and the MLflow catalog, so without it they fell back to the wrong 128K default (qwen3-coder-plus is 1M). A spec's `executor.context_window` still overrides; unrecognized qwen models keep the 128K fallback. - **In-session model selection (`/model`).** Switching models mid-session works. The model is fixed in the `qwen --acp` subprocess env (`HARNESS_QWEN_MODEL`) at spawn, so on a `/model` change the runner's `HarnessProcessManager` respawns the harness with the new value — a fresh `QwenExecutor` then opens a new `session/new` carrying the new model. Context survives the respawn because the first turn of the new session replays the prior conversation (see History replay below). - **History replay on a fresh ACP session.** When the `qwen --acp` subprocess is (re)spawned — first turn, a `/model` switch, or a `Session not found` reset — qwen holds none of the earlier conversation (it lived in the dead process). `run_turn` normally sends only the latest user turn, so the first turn of any fresh session folds the prior transcript into the prompt as a labeled `Conversation so far:` block (`_history_prefix`), mirroring `ClaudeSDKExecutor._build_prompt`. Keeps a mid-conversation model switch from dropping the thread. (Same fix applied to the goose ACP harness.) - **OS sandbox.** When the spec's `os_env.sandbox` is not `none`, the whole `qwen` process tree is wrapped in the platform sandbox (bwrap / seatbelt) at spawn (`_sandbox_launch_path`), confining qwen's own file/shell tools to the spec's read/write roots — an OS-level guarantee independent of the per-tool permission gate. - **File I/O delegation (`fs/*`).** When an `os_env` is configured, the executor advertises `clientCapabilities.fs` in `initialize`, so qwen routes its file reads/writes back to us as `fs/read_text_file` / `fs/write_text_file` requests (qwen's `AcpFileSystemService` swaps in only when the capability is set). The handlers execute the I/O through the Omnigent `OSEnvironment`, so the spec's sandbox read/write roots are enforced at the Python layer — and the bytes flow through Omnigent rather than qwen touching disk directly. Disabled (qwen uses its own tools) when there's no `os_env` or it's a `fork` env (a forked tree's path would diverge from the qwen subprocess cwd). Binary/non-UTF-8 reads are refused; missing-file reads map to qwen's ENOENT code. (Same fix applied to the goose ACP harness.) See the Pending item below for what's still out of scope (event recording / TOOL_RESULT-phase content policy). ## Pending work Functionality not yet supported, by priority. (How to build each lives in code comments; this is the *what*, not the *how*.) ### High - [x] **Native TUI variant (`qwen-native` / `native-qwen`).** Implemented — the live `qwen` TUI runs in a runner-owned tmux pane embedded in the web UI, driven by `omnigent qwen`. Unlike the goose/cursor `tmux send-keys` native harnesses, it uses qwen's built-in remote-control protocol: web-UI turns are appended to qwen's `--input-file` (a `{"type":"submit"}` line, routed through the same `submitQuery` path the keyboard uses, so it renders in the TUI), and the transcript is mirrored back by tailing qwen's structured `--json-file` event stream (Anthropic stream-json shape). Interrupt/stop still go through the pane (`Escape` / kill) since the input-file watcher has no interrupt command. See `docs/QWEN_NATIVE_DESIGN.md`. **Still a follow-up (PR2):** usage parsing from the `result`/`assistant` events is not yet emitted on `TurnComplete.usage` (see the status-line item below for the model/ring/cost consequences). - [x] **Tool-approval elicitation card (TUI → web).** Implemented — qwen's in-terminal tool-approval prompt now also renders as an approval card in the web chat, and answering either surface resolves the other. qwen emits a structured `{"type":"control_request","request":{"subtype":"can_use_tool", "tool_name","tool_use_id","input"},"request_id"}` on `--json-file` **and** accepts a `{"type":"confirmation_response","request_id","allowed"}` on `--input-file`, *coexisting* with its own TUI prompt (whichever answers first wins; qwen's `dual-output.md` confirms `control_request` is emitted whenever a tool needs approval — the earlier "default mode doesn't emit these" note was wrong). - **Mirror:** `omnigent/qwen_native_permissions.py` — `supervise_qwen_approval_mirror` tails the *same* `--json-file` the transcript forwarder reads (seeded at EOF so only new prompts park), POSTs each `can_use_tool` to the server's `qwen-permission-request` hook, and on the web verdict writes `confirmation_response` to the input file (no keystrokes). It's the structured analog of cursor-native's pane-scraping mirror. Wired alongside the forwarder under one supervised task in `runner/app.py::_auto_create_qwen_terminal` (`_supervise_qwen_native_bridges`). - **Server hook:** `POST /v1/sessions/{id}/hooks/qwen-permission-request` (`qwen_permission_request_hook`, modeled on the cursor hook) publishes the standard `response.elicitation_request` (`policy_name=qwen_native_permission`, `phase=pre_tool_use`) and parks via `_publish_and_wait_for_harness_elicitation`. This always surfaces a card whenever the TUI prompts — the explicit goal — rather than routing through `/policies/evaluate` (which would auto-resolve and skip the card when no TOOL_CALL policy matches qwen's tool names). - **Loser release:** qwen emits a `control_response` for a `request_id` whether the TUI or an external `confirmation_response` answered. The mirror watches for it: if it lands while the web card is still parked (TUI answered first), it POSTs `external_elicitation_resolved` to clear the card and skips the stale `confirmation_response`; if the card answered first, the task is already done and the `control_response` just cleans up. Still worth a live E2E to confirm timing under a real `qwen --acp` turn. - [ ] **Composer status line: real model + context ring (Web UI).** For native-qwen the composer's model/effort chip is currently **hidden** (web UI flag `nativeVendorOwnsModel` in `chatStore.sessionBindingPatch` → `ComposerStatusLine` in `web/src/pages/ChatPage.tsx`). It was showing the bound spec's *default* model (`claude-sonnet-4-6`) because the qwen-native-ui spec sets no model and qwen picks its model inside the vendor TUI (OpenAI-compat env / qwen's own `/model`), so Omnigent's `llmModel` was a misleading default. Hiding it is the interim; the real fix is to **surface qwen's actual model** (and effort/approval-mode if meaningful). The data is already on qwen's `--json-file` stream — `assistant` message events carry `message.model` (e.g. `openai/gpt-oss-120b:free`) and the `system/session_start` event carries model metadata. The forwarder (`omnigent/qwen_native_forwarder.py`) could parse it and report it onto the session so the chip reflects qwen's reality. - **Context ring + cost tracking also missing**, same root cause: native-qwen doesn't yet parse/forward token usage, so `tokensUsed` / `contextWindow` stay null (the ring renders only when `contextWindow > 0 && tokensUsed != null`) and the session cost stays $0 (cost is derived from per-turn usage × model price). The usage *is* on the stream, though — verified live (`qwen` v0.18.2): each turn's final `assistant` event carries `message.usage` (`{input_tokens, output_tokens, cache_read_input_tokens, total_tokens}`), so the forwarder could parse it and POST `external_session_usage`. The ACP `qwen` harness already does this — see "Cost / token tracking" in *What works today* (`_accumulate_usage`); native-qwen needs the equivalent off the `--json-file` stream. Parse `result.usage` (`input_tokens` / `output_tokens` / `cache_read_input_tokens` / `total_tokens`) in `omnigent/qwen_native_forwarder.py`, split `cache_read_input_tokens` out of `input_tokens` (qwen's `input_tokens` is cache-inclusive; cost wants the non-cached portion), and report it onto the session so the cost observer + context ring pick it up; the context-window *limit* comes from the curated `_QWEN_CONTEXT_WINDOWS` lookup. One usage-parsing change feeds the model chip, the ring, and cost together. - [x] **Restore qwen's TUI history on resume.** `omni qwen --resume ` used to relaunch a **blank** `qwen` TUI (only the web chat kept history, via the forwarder). Fixed, using the **same `external_session_id` convention as claude-/codex-/pi-native** (so it's consistent and fork-capable): `_auto_create_qwen_terminal` persists the qwen session id on the Omnigent session (`_persist_qwen_external_session_id` → `PATCH /v1/sessions/{id}`), reads it back from the snapshot (`launch_config.external_session_id`) on the next launch, and it's stamped as `omnigent.fork.source_external_session_id` for fork history carry-over. qwen is cleaner than claude/codex here — it lets us *assign* the id via `--session-id`, so we mint a deterministic one (`qwen_session_id_for_conversation`, UUIDv5 of the `conv_id`) up front instead of capturing a vendor-generated id, and a failed persist self-heals (the id is recomputable). Launch is fresh `--session-id ` the first time, `--resume ` once qwen has an on-disk recording — the recording check (`qwen_session_recording_exists`, scoped to the launch workspace's qwen project slug at `~/.qwen/projects//chats/.jsonl`) is the `--resume` guard, since `--resume` on an id not recorded *under that cwd* shows qwen's blocking "No saved session found" screen — qwen resolves `--resume` per-project, so the check must be workspace-scoped, not a cross-project glob (also keeps never-messaged / pre-convention sessions on the clean fresh path). **No forwarder change needed:** verified that on `--resume` qwen restores history into the TUI from its own checkpoint and emits *only new* events to `--json-file`, so the transcript is never re-mirrored — qwen sidesteps the double-mirror problem that forced goose-native to start fresh. - [x] **Carry history into qwen on fork / switch-agent (incl. cross-harness).** Forking a session — or switching its agent — into qwen-native now seeds the new qwen session with the prior conversation, the same way claude-/codex-/pi-native do. qwen-native is registered in `_FORK_HISTORY_NATIVE_HARNESSES` (`server/routes/sessions.py`), so both the fork and switch-agent routes stamp `omnigent.fork.carry_history` and clear `external_session_id` on the clone. On the clone's first launch, `_auto_create_qwen_terminal` calls `_build_qwen_fork_recording`, which fetches the clone's copied Omnigent items (`fetch_all_session_items_for_pi_resume` — harness-neutral) and rebuilds qwen's on-disk recording via `qwen_session_records_from_session_items` + `write_qwen_session_recording`, then forces `--resume`. Because it rebuilds from Omnigent items (not the source's vendor transcript), it works **cross-harness** (claude/pi/codex → qwen). **Key on-disk-format finding:** qwen resolves `--resume ` from *three* files, not the `.jsonl` alone — it also needs `chats/.runtime.json` (session index entry) and the project `meta.json`; a bare recording yields the blocking "No saved session found" screen (verified on v0.18.2). The synthesized recording emits only `user`/`assistant` message records (the `system` snapshot records qwen writes live are optional for resume); tool calls are dropped (text turns carry the context). The rebuild is gated on a NULL `external_session_id` so it runs only on the first launch — once the minted id is persisted, later relaunches take the normal resume path and never clobber qwen's live recording (which by then holds post-fork turns). The minted id is the clone's own deterministic `qwen_session_id_for_conversation`, so the resume path recomputes it. Mirrors pi-native's fork rebuild (`_resolve_pi_external_session_id` case 2). ### Medium - [x] **Compaction via `/compact` (web → TUI), with spinner + divider.** Implemented, mirroring cursor-native PR #1259 — the web composer's `/compact` now drives qwen's `/compress` in the TUI, with a "Compacting conversation…" spinner that resolves to the "Conversation compacted" divider when qwen actually finishes. Works for both explicit `/compact` and auto-compaction. - **Server (existing, harness-agnostic):** `/compact` → forwards `{"type": "compact"}` to the bound runner; a 200 means the control was handled in the terminal (server skips its own AP-side compaction, which 400s on the LLM-less native pseudo-agent). - **Runner (`_handle_qwen_native_compact`):** publishes `response.compaction.in_progress` (raises the spinner), submits `/compress` via the **input file** (`submit_user_message`), returns 200; on failure publishes `response.compaction.failed` (dismisses the spinner) + 503. Unlike cursor's bracketed-paste, qwen's input-file `submit` routes through `RemoteInputWatcher` → `submitQuery` (the keyboard's own path), which processes the slash command directly — no autocomplete-dropdown trap, and no `/compress` user bubble on the stream (verified live, `qwen` v0.18.2). - **Completion signal — the chat recording, not the stream.** qwen emits **no** compression event on the `--json-file` stream (`session_start`'s `supported_events` omits it; the green "compressed from…" TUI line is an internal `addItem`, never streamed). But it writes a `{"type":"system", "subtype":"chat_compression","systemPayload":{"info":{originalTokenCount, newTokenCount,compressionStatus}}}` record to its on-disk recording (`~/.qwen/projects//chats/.jsonl`) the instant compression finishes. `supervise_qwen_compaction_mirror` tails that recording (seeded at EOF so a resumed session's prior records don't re-fire) and POSTs `external_compaction_status` — `completed` on `compressionStatus == 1`, `failed` on the `COMPRESSION_FAILED_*` codes (2/3) — which the server republishes as `response.compaction.completed/failed`. - **Note on the ACP `qwen` harness:** the in-process executor compresses internally over ACP and is opaque to us (same boundary as the LLM-phase policy exclusion below), so this item is **native-qwen only**. - **Follow-up:** the context ring won't shrink after compaction until usage is forwarded as `external_session_usage` (see the "Composer status line" item) — the recording's `newTokenCount` could feed that. - [ ] **Provider routing: settings.json precedence + token refresh.** The base injection now works (see What works today), but two gaps remain before it's robust on a developer machine: - **Ambient settings win.** qwen prefers a user-level `~/.qwen/settings.json` (`security.auth.selectedType` + `modelProviders`) over the injected `OPENAI_*` env vars, so on a host where someone ran `qwen /auth`, the spec's gateway is silently ignored. qwen exposes no config-dir flag, so making the gateway authoritative needs HOME / config-dir isolation for the subprocess. - **No token refresh.** The bearer token is snapshotted once at session start; qwen has no refresh hook, so a short-lived rotating token (Databricks gateway) can expire over a long session. Static keys / stable gateways are unaffected. - [ ] **Databricks path.** Verify the `databricks-*` profile route end-to-end (the env plumbing exists; only the OpenAI-compatible gateway has been tested). The profile route derives the base URL + auth from **ucode state**, so it depends on ucode provisioning a `qwen` agent for the workspace. To test: - *Quick (no ucode):* point a gateway straight at Databricks' OpenAI-compatible serving endpoint — `gateway_base_url = https:///serving-endpoints`, `gateway_auth_command = databricks auth token --profile

--output json | jq -r .access_token`, `model = ` — run a turn from a **clean `HOME`** (so `~/.qwen/settings.json` can't take precedence). - *Full route:* spec with `executor.profile: ` (or a `databricks-*` model), then `omni run`; confirm the runner log's `qwen gateway routing:` line shows the Databricks base URL + profile. - [x] **Omnigent tools.** Qwen-native now exposes the shared Omnigent MCP relay (`omnigent.claude_native_bridge serve-mcp`, `mcpServers.omnigent`, `trust: true`) to qwen via the `--mcp-config ` launch flag (the claude-native model). qwen connects to it on boot, `/mcp` lists it, and the model can call Omnigent's builtin tools (`sys_*`, `load_skill`, `web_fetch`, …). The config lives in the per-session bridge dir, **not** the workspace, so we drop no file in the user's repo, concurrent same-workspace sessions can't collide, and CLI-provided servers are ungated (no "Untrusted MCP server" prompt → no pre-approval step). The token + config are written by `qwen_native_bridge.write_mcp_config`; the live tool surface is advertised by the `tool_relay.json` that `ensure_comment_relay` writes. The `bridge.json` bearer token is written through `_ensure_secure_bridge_dir` (the same owner-only ancestor validation the shared relay applies to token-bearing trees). Permission gating on qwen's *own* tool calls already works. - [ ] **File I/O recording / content policy.** Omnigent now *executes* delegated file reads/writes through the `OSEnvironment` (see "File I/O delegation" in What works today), so the bytes flow through Omnigent and the sandbox roots are enforced. Still missing on top of that: (a) emitting the I/O into Omnigent's event stream (ToolCall-style records) so it shows in history, and (b) running TOOL_RESULT-phase content policy on the read/written content. Both build on the `_handle_fs_read` / `_handle_fs_write` handlers — the byte-level hook now exists; this is wiring the recording/policy layers onto it. > LLM-phase policy (`PHASE_LLM_REQUEST` / `PHASE_LLM_RESPONSE`) is intentionally > out of scope: qwen's model calls happen internally over ACP and are opaque to > us. Only tool-call-phase policy is feasible, and it is wired. ### Low - [ ] **More attachment types.** Text files and images now reach the agent; still unsupported are binary documents (PDF, etc.) and audio input. - [ ] **Session resilience:** cancel a turn mid-flight, recover when the `qwen` subprocess crashes, and resume a session across separate runs. - *Done in this pass:* dead qwen-native terminals now recreate on attach instead of failing 4404, so the embedded pane recovers after a crash or deferred-start failure. - [ ] **Vision/audio quality** depends on the model: text-only routes (e.g. `qwen3-coder:free`) can't see forwarded images. Worth surfacing model capability to users picking an agent. ## Known limitations & behavior ### Model capability vs. file attachments Tool-calling reliability depends on the model. Weak/free routes (notably `qwen/qwen3-coder:free`) **lose the tool-calling thread when a message carries a file attachment**: instead of emitting a structured tool call (which would reach our `session/request_permission` gate), they narrate the shell command as prose (e.g. printing `Command: rm …` as text). The omni run is deterministic about this — every `input_file` turn skips policy/elicitation; every text-only turn reaches them. `qwen3-coder-plus` keeps tool-calling across the same prompts. Mitigation: `_text_from_blocks` fences inlined file content with a labeled `--- attached file: ---` header/footer so the model reads it as an attachment, not instructions (bare-appending raw content reproduced the prose-narration leak even on `:free`). This reduces but does not eliminate the fragility — for reliable tool use with attachments, prefer a stronger model. ### Auth model Qwen has **no CLI login** — its `auth` subcommand was removed (`qwen login` doesn't exist; `qwen auth status` prints "removed" and exits 0). Auth is: - **Headless / ACP:** env vars — `OPENAI_API_KEY` + `OPENAI_BASE_URL` + `OPENAI_MODEL`, or `BAILIAN_CODING_PLAN_API_KEY`, or `OPENROUTER_API_KEY`. - **Interactive:** run `qwen` and use `/auth` (API key or Alibaba Cloud Coding Plan), persisted under `~/.qwen/`. Qwen OAuth was discontinued 2026-04-15; the installed CLI may still mention it (version skew), but the service is gone. The `HarnessInstallSpec` deliberately leaves `login_args` / `logout_args` / `status_args` unset so `harness_cli_logged_in/login/logout` stay no-ops for qwen. ### ACP constraints - Qwen runs its own tools internally (not yet bridged — see Pending work). - Qwen assigns its own `sessionId`; ours is a hint. - ACP has no system-prompt field, so the spec `prompt:` is folded into the first user turn. - Server-initiated requests are dispatched by method: `request_permission` goes through the policy + elicitation gate (see What works today); everything else (including `fs/*`) → JSON-RPC method-not-found. We do **not** advertise `clientCapabilities.fs` in `initialize`, so qwen never delegates file ops to us — it uses its own file tools. (fs delegation handlers were removed as dead code; re-add them with the capability — see Pending work.) ## Reference ### ACP session lifecycle (`qwen --acp`, JSON-RPC over NDJSON) 1. `initialize` — capability handshake (once per subprocess). 2. `session/new { cwd, mcpServers }` — server returns its own `sessionId`. 3. `session/prompt { sessionId, prompt }` — streaming `session/update` notifications flow back; the final response resolves the request. 4. The subprocess is kept alive across turns (no per-turn respawn). ### Model override Spec model → provider default → catalog default; `/model` overrides via `HARNESS_QWEN_MODEL`. ### Env vars consumed by the harness wrap `HARNESS_QWEN_MODEL`, `HARNESS_QWEN_CWD`, `HARNESS_QWEN_PATH`, `HARNESS_QWEN_OS_ENV`. (Gateway/Databricks vars are computed but not yet consumed — see Pending work. No skills-bridge vars are emitted.)