feat(qwen-native): carry conversation history on fork / switch-agent (#1576)

* feat(qwen-native): carry conversation history on fork / switch-agent

Forking a session (or switching its agent) into qwen-native now seeds the
new qwen session with the prior conversation — including cross-harness
(claude/codex/pi -> qwen), matching claude-/codex-/pi-native.

- qwen_native_bridge: synthesize qwen's on-disk chat recording from the
  copied Omnigent items (qwen_session_records_from_session_items) plus the
  runtime.json + meta.json discovery sidecars qwen's --resume requires
  (write_qwen_session_recording). A bare .jsonl yields qwen's blocking
  "No saved session found" screen; only user/assistant message records are
  emitted (system snapshot records are optional for resume), verified
  loadable on qwen v0.18.2.
- runner/app: on a forked clone's first launch, _build_qwen_fork_recording
  rebuilds the recording under the clone's deterministic id and forces
  --resume. Gated on a NULL external_session_id so later relaunches take the
  normal resume path and never clobber qwen's live recording (which by then
  holds post-fork turns). Mirrors pi-native's fork rebuild.
- server/routes/sessions: register qwen-native in
  _FORK_HISTORY_NATIVE_HARNESSES so both fork and switch-agent stamp the
  carry-history directive and clear external_session_id.
- web/forkHarness: add qwen-native/native-qwen to isNativeHarness so Qwen
  Code is offered in the fork/switch-agent picker.

Tests: unit coverage for the record conversion + recording write (incl. an
opt-in real `qwen --resume` loadability check), the runner fork-recording
builder, and the fork/switch-agent route carry-history gating; frontend
picker-gating cases.

Co-authored-by: Isaac

* fix(qwen-native): address Polly review on fork history rebuild

- qwen_session_records_from_session_items: drop a trailing unanswered user
  prompt so a cancelled turn from a qwen-native SOURCE isn't restored. The
  response-group skip only catches sources that tag the interrupted assistant
  and share a response_id across the turn (claude/codex/pi); qwen's forwarder
  stamps a distinct per-event response_id (qwen:<uuid>) and never sets
  interrupted, so a cancelled qwen turn left its user prompt dangling.
- provider_config: key qwen-native / native-qwen in _HARNESS_FAMILY
  (OPENAI_FAMILY), mirroring codex-native, so a same-agent qwen->qwen
  fork/switch is recognized as same-family and keeps its model settings
  instead of silently resetting them.
- Tests: trailing-user-drop cases; qwen-native provider-family cases;
  correct the fork-test comment (the case is cross-family anthropic->openai,
  not "no family").

Co-authored-by: Isaac

* fix(qwen-native): harden fork recording write + idempotent rebuild

Address Polly's second review (failure-path bugs), and shorten comments.

- write_qwen_session_recording: write all three files atomically and commit
  the .jsonl (the resume gate's key) LAST, after both sidecars. A failed
  sidecar write then leaves no .jsonl, so the launch degrades to a clean fresh
  start instead of qwen's blocking "No saved session found" screen (B1).
- _build_qwen_fork_recording: short-circuit when a recording for the clone's
  id already exists, so a relaunch after a best-effort external_session_id
  persist failure resumes qwen's live, full-fidelity recording instead of
  clobbering it with a text-only rebuild (B2).
- Tests: sidecar-failure leaves no gate .jsonl; rebuild doesn't clobber an
  existing recording.

Co-authored-by: Isaac
This commit is contained in:
Serena Ruan
2026-06-29 18:54:57 +08:00
committed by GitHub
parent f1ab7d86b6
commit 84e85346fb
12 changed files with 810 additions and 20 deletions
+26
View File
@@ -183,6 +183,32 @@ comments; this is the *what*, not the *how*.)
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 <id>` from *three* files, not the `.jsonl` alone — it also needs
`chats/<id>.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.**
+4 -1
View File
@@ -166,8 +166,11 @@ _HARNESS_FAMILY: dict[str, str] = {
# per-spawn provider override flag, so Omnigent cannot thread a generic
# provider through. Provider routing for kimi lives in ``~/.kimi/config.toml``
# and is managed out-of-band via ``kimi provider add``.
# Qwen Code uses an OpenAI-compatible provider.
# Qwen Code is OpenAI-compatible; the native TUI keys both spellings (mirroring
# codex-native) so a same-agent qwen→qwen fork/switch reads as same-family.
"qwen": OPENAI_FAMILY,
"qwen-native": OPENAI_FAMILY,
"native-qwen": OPENAI_FAMILY,
# The native agy TUI bridge authenticates via the Gemini OAuth credential
# (file-based, checked in :mod:`omnigent.onboarding.gemini_auth`) and the
# detected GEMINI_API_KEY is adopted as a ``gemini``-family key, so the
+237
View File
@@ -32,10 +32,12 @@ import json
import os
import re
import secrets
import socket
import subprocess
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -69,6 +71,12 @@ _MCP_SERVER_NAME = "omnigent"
#: ``.mcp.json`` / ``.qwen/settings.json``. The claude-native ``--mcp-config``
#: model (it writes no workspace file either).
_MCP_CONFIG_FILE = "mcp_config.json"
#: qwen version string stamped on synthesized recording records + sidecars. Must
#: be a version qwen's resume loader accepts; verified loadable on qwen v0.18.2.
_QWEN_SYNTH_VERSION = "0.18.2"
#: ``contextWindowSize`` stamped on synthesized assistant records. Informational
#: only — the live resume uses the resolved model's real window.
_QWEN_SYNTH_CONTEXT_WINDOW = 131072
def bridge_dir_for_session_id(session_id: str) -> Path:
@@ -155,6 +163,235 @@ def qwen_session_recording_exists(session_id: str, workspace: Path | str) -> boo
return False
def _qwen_iso_now() -> str:
"""Return the current UTC time as a qwen-style ISO-8601 millisecond stamp."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
def _qwen_synth_uuid(qwen_session_id: str, index: int) -> str:
"""Return a deterministic record uuid for a synthesized qwen recording.
UUIDv5 over the session id + position so re-running the fork rebuild
produces a byte-identical recording (idempotent rebuilds, stable tests)
instead of fresh random ids each launch.
"""
return str(uuid.uuid5(_QWEN_SESSION_NAMESPACE, f"{qwen_session_id}:{index}"))
def _qwen_text_from_api_content(content: object, api_type: str) -> str:
"""Concatenate the text of an Omnigent content array's blocks of *api_type*.
:param content: Omnigent content array, e.g. ``[{"type":"input_text","text":"hi"}]``.
:param api_type: Block type to include, ``"input_text"`` or ``"output_text"``.
:returns: The joined text, or ``""`` when there is none.
"""
if not isinstance(content, list):
return ""
parts: list[str] = []
for block in content:
if isinstance(block, dict) and block.get("type") == api_type:
text = block.get("text")
if isinstance(text, str) and text:
parts.append(text)
return "".join(parts)
def qwen_session_records_from_session_items(
items: list[dict[str, Any]],
*,
qwen_session_id: str,
cwd: Path | str,
model: str = "",
timestamp: str | None = None,
) -> list[dict[str, Any]]:
"""Convert Omnigent session items into qwen chat-recording JSONL records.
qwen's recording is a linked list chained by ``uuid`` / ``parentUuid``. We
emit only the ``user`` / ``assistant`` message records qwen reconstructs
history from; the ``system`` snapshot records it writes live are telemetry
and not required for ``--resume`` (verified on v0.18.2). Tool calls are
dropped — text turns carry the context a cross-harness fork needs.
Omnigent items map as:
- user ``message`` → ``{"type":"user","message":{"role":"user","parts":[{"text"}]}}``
- assistant ``message`` → ``{"type":"assistant","message":{"role":"model",...}}``
Cancelled turns aren't restored: an interrupted assistant turn and its
response group are skipped (claude/codex/pi share a ``response_id`` across
the turn), and a trailing unanswered user prompt is dropped (covers a
qwen-native source, whose per-event ``response_id`` doesn't group a turn).
:param items: Flat Omnigent item dicts in chronological order.
:param qwen_session_id: qwen session id stamped on every record.
:param cwd: Working directory stamped on records (realpath'd to match the
project slug qwen records under).
:param model: Default model id for assistant records; overridden per-item by
the item's own ``model`` when present.
:param timestamp: ISO stamp for all records; defaults to now. Pass a fixed
value for deterministic output in tests.
:returns: qwen recording record dicts in order (empty if nothing carryable).
"""
ts = timestamp or _qwen_iso_now()
cwd_str = os.path.realpath(str(cwd))
skip_response_ids = {
item.get("response_id")
for item in items
if item.get("type") == "message"
and item.get("role") == "assistant"
and item.get("interrupted") is True
and isinstance(item.get("response_id"), str)
and item.get("response_id")
}
records: list[dict[str, Any]] = []
parent_uuid: str | None = None
for index, item in enumerate(items):
if item.get("type") != "message":
continue
response_id = item.get("response_id")
if isinstance(response_id, str) and response_id in skip_response_ids:
continue
role = item.get("role")
if role == "user":
text = _qwen_text_from_api_content(item.get("content"), "input_text")
if not text:
continue
rec_uuid = _qwen_synth_uuid(qwen_session_id, index)
records.append(
{
"uuid": rec_uuid,
"parentUuid": parent_uuid,
"sessionId": qwen_session_id,
"timestamp": ts,
"type": "user",
"cwd": cwd_str,
"version": _QWEN_SYNTH_VERSION,
"message": {"role": "user", "parts": [{"text": text}]},
}
)
parent_uuid = rec_uuid
elif role == "assistant":
text = _qwen_text_from_api_content(item.get("content"), "output_text")
if not text:
continue
item_model = item.get("model")
eff_model = item_model if isinstance(item_model, str) and item_model else model
rec_uuid = _qwen_synth_uuid(qwen_session_id, index)
records.append(
{
"uuid": rec_uuid,
"parentUuid": parent_uuid,
"sessionId": qwen_session_id,
"timestamp": ts,
"type": "assistant",
"cwd": cwd_str,
"version": _QWEN_SYNTH_VERSION,
"model": eff_model,
"message": {"role": "model", "parts": [{"text": text}]},
"usageMetadata": {
"promptTokenCount": 0,
"candidatesTokenCount": 0,
"totalTokenCount": 0,
},
"contextWindowSize": _QWEN_SYNTH_CONTEXT_WINDOW,
}
)
parent_uuid = rec_uuid
# Drop a trailing unanswered user prompt (a turn cancelled before any reply).
# The response-group skip can't catch it for a qwen-native source, whose
# per-event ``response_id`` (``qwen:<uuid>``) doesn't group a turn; a
# committed transcript otherwise ends on a completed assistant turn.
while records and records[-1]["type"] == "user":
records.pop()
return records
def write_qwen_session_recording(
qwen_session_id: str,
workspace: Path | str,
records: list[dict[str, Any]],
*,
timestamp: str | None = None,
) -> Path:
"""Write a synthesized qwen chat recording (+ discovery sidecars) to disk.
qwen resolves ``--resume <id>`` from THREE files under its per-project dir,
not the ``.jsonl`` alone (verified on v0.18.2 — a bare recording lands the
user on the blocking "No saved session found" screen):
- ``chats/<id>.jsonl`` — the conversation records (*records*).
- ``chats/<id>.runtime.json`` — the session index entry ``sessions list`` /
``--resume`` read to discover the session.
- ``meta.json`` — the project-level marker (created if absent; an existing
one is left untouched so we don't reset another session's ``createdAt``).
All three are written atomically, and the ``.jsonl`` is committed LAST (after
both sidecars): :func:`qwen_session_recording_exists` keys on the ``.jsonl``,
so a failed sidecar write leaves no ``.jsonl`` and the launch degrades to a
clean fresh start, never the blocking "No saved session found" screen.
:param qwen_session_id: qwen session id (file stem + ``session_id`` field).
:param workspace: cwd qwen will resume in; its realpath drives the project slug.
:param records: qwen recording records (see
:func:`qwen_session_records_from_session_items`).
:param timestamp: ISO stamp for the project ``meta.json``; defaults to now.
:returns: The written ``chats/<id>.jsonl`` recording path.
:raises RuntimeError: If the recording cannot be written.
"""
recording = qwen_session_recording_path(qwen_session_id, workspace)
chats_dir = recording.parent
chats_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
try:
hostname = socket.gethostname() or "omnigent"
except OSError:
hostname = "omnigent"
# Sidecars first so the gate file (``.jsonl``) lands last: a sidecar failure
# then leaves no ``.jsonl`` and the resume gate cleanly picks a fresh launch.
meta_path = chats_dir.parent / "meta.json"
if not meta_path.exists():
stamp = timestamp or _qwen_iso_now()
_atomic_write_text(
meta_path, json.dumps({"version": 1, "createdAt": stamp, "updatedAt": stamp})
)
_atomic_write_text(
chats_dir / f"{qwen_session_id}.runtime.json",
json.dumps(
{
"schema_version": 1,
"pid": os.getpid(),
"session_id": qwen_session_id,
"work_dir": os.path.realpath(str(workspace)),
"hostname": hostname,
"started_at": time.time(),
"qwen_version": _QWEN_SYNTH_VERSION,
}
),
)
_atomic_write_text(
recording, "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in records)
)
return recording
def _atomic_write_text(target: Path, text: str) -> None:
"""Atomically write *text* to *target* (temp file + ``os.replace``).
:param target: Destination path.
:param text: Full file contents to write.
:raises RuntimeError: If the file cannot be written; the temp is cleaned up.
"""
tmp = target.with_suffix(target.suffix + ".tmp")
try:
tmp.write_text(text, encoding="utf-8")
os.replace(tmp, target)
except OSError as exc:
with contextlib.suppress(FileNotFoundError):
tmp.unlink()
raise RuntimeError(f"Failed to write {target}: {exc}") from exc
def input_file_path(bridge_dir: Path) -> Path:
"""Return the ``--input-file`` path qwen watches for JSONL commands."""
return bridge_dir / _INPUT_FILE
+113 -11
View File
@@ -2820,6 +2820,82 @@ async def _persist_qwen_external_session_id(
)
async def _build_qwen_fork_recording(
server_client: httpx.AsyncClient,
*,
session_id: str,
workspace: str,
) -> str | None:
"""Synthesize a qwen chat recording for a forked clone from its Omnigent items.
A forked clone has its OWN copied Omnigent items but no qwen recording yet
(``external_session_id`` is NULL on a fork). We rebuild a recording from those
items under the clone's deterministic session id so the TUI resumes with the
prior conversation. The rebuild reads harness-neutral items (not the source's
vendor transcript), so it works cross-harness (claude/pi/codex qwen).
If a recording for the clone's id already exists, return the id WITHOUT
rebuilding the rebuild is idempotent. Otherwise a relaunch after a failed
``external_session_id`` persist (best-effort; qwen has no re-capture path)
would re-enter here and overwrite qwen's live, full-fidelity recording with
a text-only rebuild.
:param server_client: Runner Omnigent server client.
:param session_id: The forked clone's Omnigent conversation id.
:param workspace: Realpath'd cwd qwen will resume in.
:returns: The qwen session id to ``--resume``, or ``None`` when there's
nothing carryable or the build fails (caller then launches fresh).
"""
from omnigent.pi_native_resume import fetch_all_session_items_for_pi_resume
from omnigent.qwen_native_bridge import (
qwen_session_id_for_conversation,
qwen_session_recording_exists,
qwen_session_records_from_session_items,
write_qwen_session_recording,
)
qwen_session_id = qwen_session_id_for_conversation(session_id)
# Already built (e.g. a relaunch after the external_session_id persist failed):
# resume the live recording, never clobber it with a fresh text-only rebuild.
if qwen_session_recording_exists(qwen_session_id, workspace):
_logger.info(
"qwen fork-rebuild: recording already present for clone %s; resuming it",
session_id,
)
return qwen_session_id
try:
items = await fetch_all_session_items_for_pi_resume(server_client, session_id)
records = qwen_session_records_from_session_items(
items,
qwen_session_id=qwen_session_id,
cwd=workspace,
)
if not records:
_logger.info(
"qwen fork-rebuild: no carryable items for clone %s; launching fresh",
session_id,
)
return None
recording = await asyncio.to_thread(
write_qwen_session_recording, qwen_session_id, workspace, records
)
except Exception: # noqa: BLE001 — best-effort; launch fresh on failure
_logger.warning(
"Could not build qwen recording from items for forked clone %s; launching fresh",
session_id,
exc_info=True,
)
return None
_logger.info(
"qwen fork-rebuild: session=%s qwen_session_id=%s recording=%s records=%d",
session_id,
qwen_session_id,
recording,
len(records),
)
return qwen_session_id
async def _auto_create_qwen_terminal(
session_id: str,
resource_registry: SessionResourceRegistry,
@@ -2898,19 +2974,45 @@ async def _auto_create_qwen_terminal(
# clean fresh launch). qwen restores history into the TUI from its own
# checkpoint and emits only NEW events to ``--json-file`` on resume (verified),
# so the forwarder never re-mirrors the prior transcript — no duplicate bubbles.
existing_session_id = launch_config.external_session_id
qwen_session_id = existing_session_id or qwen_session_id_for_conversation(session_id)
# Scope the recording check to THIS workspace's qwen project slug: qwen
# resolves ``--resume`` per-project (cwd), so a recording made under another
# workspace must not pick ``--resume`` here (→ blocking "No saved session").
if qwen_session_recording_exists(qwen_session_id, workspace):
# Forked clone carrying history into qwen: rebuild a recording from the
# clone's copied Omnigent items and force ``--resume``. Gated on a NULL
# ``external_session_id`` so it normally runs only on the FIRST launch;
# ``_build_qwen_fork_recording`` is also idempotent (resumes an existing
# recording, never clobbers it). Mirrors pi-native's fork rebuild
# (``_resolve_pi_external_session_id`` case 2).
forked_qwen_session_id: str | None = None
if (
launch_config.fork_carry_history
and not launch_config.external_session_id
and server_client is not None
):
forked_qwen_session_id = await _build_qwen_fork_recording(
server_client,
session_id=session_id,
workspace=workspace,
)
if forked_qwen_session_id is not None:
qwen_session_id = forked_qwen_session_id
resume_args = ["--resume", qwen_session_id]
else:
resume_args = ["--session-id", qwen_session_id]
if existing_session_id != qwen_session_id:
# First launch (or a prior persist that didn't land): record the id so the
# next resume reads it from the snapshot and forks can carry history.
# Record the id so the clone reflects its own qwen session and later
# relaunches resume it via the normal path instead of rebuilding.
await _persist_qwen_external_session_id(server_client, session_id, qwen_session_id)
else:
existing_session_id = launch_config.external_session_id
qwen_session_id = existing_session_id or qwen_session_id_for_conversation(session_id)
# Scope the recording check to THIS workspace's qwen project slug: qwen
# resolves ``--resume`` per-project (cwd), so a recording made under another
# workspace must not pick ``--resume`` here (→ blocking "No saved session").
if qwen_session_recording_exists(qwen_session_id, workspace):
resume_args = ["--resume", qwen_session_id]
else:
resume_args = ["--session-id", qwen_session_id]
if existing_session_id != qwen_session_id:
# First launch (or a prior persist that didn't land): record the id so the
# next resume reads it from the snapshot and forks can carry history.
await _persist_qwen_external_session_id(server_client, session_id, qwen_session_id)
# Expose Omnigent's builtin tools (sys_*, load_skill, web_fetch, …) to qwen
# via the shared MCP relay, passed through qwen's ``--mcp-config`` flag (the
# claude-native model). The config lives in the bridge dir — never the
+5
View File
@@ -9992,6 +9992,11 @@ _FORK_HISTORY_NATIVE_HARNESSES: frozenset[str] = frozenset(
"hermes-native",
"native-hermes",
"pi-native",
# qwen-native rebuilds qwen's on-disk chat recording (+ runtime/meta
# sidecars) from the copied items, so a fork carries history into the
# qwen TUI (see _build_qwen_fork_recording / write_qwen_session_recording).
# Only the canonical id is needed — "native-qwen" is aliased to it.
"qwen-native",
}
)
+5
View File
@@ -38,6 +38,11 @@ from omnigent.onboarding.provider_config import (
("native-codex", OPENAI_FAMILY),
("codex", OPENAI_FAMILY),
("openai-agents", OPENAI_FAMILY),
# Qwen Code is OpenAI-compatible; the native TUI harness keys both
# spellings so a same-agent qwen→qwen fork/switch reads same-family.
("qwen", OPENAI_FAMILY),
("qwen-native", OPENAI_FAMILY),
("native-qwen", OPENAI_FAMILY),
# An unknown harness has no family (caller falls back / shows nothing).
("some-unknown-harness", None),
],
+107 -2
View File
@@ -2,9 +2,14 @@
from __future__ import annotations
import httpx
import json
from pathlib import Path
from omnigent.runner.app import _persist_qwen_external_session_id
import httpx
import pytest
from omnigent import qwen_native_bridge as qnb
from omnigent.runner.app import _build_qwen_fork_recording, _persist_qwen_external_session_id
class _RecordingClient:
@@ -41,3 +46,103 @@ async def test_persist_external_session_id_swallows_errors() -> None:
raise httpx.ConnectError("down")
await _persist_qwen_external_session_id(_Boom(), "conv_abc", "qsid-1") # type: ignore[arg-type]
class _ItemsClient:
"""Async httpx-client stub serving one page of session items from GET /items."""
def __init__(self, items: list[dict]) -> None:
self._items = items
async def get(self, url: str, *, params: dict | None = None, **_k: object) -> httpx.Response:
body = {"data": self._items, "has_more": False}
return httpx.Response(
200,
request=httpx.Request("GET", url),
json=body,
)
async def test_build_qwen_fork_recording_writes_recording(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Isolate ~/.qwen at a temp HOME so the synthesized recording lands there.
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
workspace = tmp_path / "ws"
workspace.mkdir()
items = [
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "hello"}],
},
]
client = _ItemsClient(items)
qsid = await _build_qwen_fork_recording(
client, # type: ignore[arg-type]
session_id="conv_fork",
workspace=str(workspace),
)
# Returns the clone's deterministic id, and a resumable recording now exists.
assert qsid == qnb.qwen_session_id_for_conversation("conv_fork")
assert qnb.qwen_session_recording_exists(qsid, workspace)
recording = qnb.qwen_session_recording_path(qsid, workspace)
types = [json.loads(line)["type"] for line in recording.read_text().splitlines()]
assert types == ["user", "assistant"]
async def test_build_qwen_fork_recording_returns_none_when_no_items(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Nothing carryable → None so the caller launches fresh (no recording written).
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
workspace = tmp_path / "ws"
workspace.mkdir()
qsid = await _build_qwen_fork_recording(
_ItemsClient([]), # type: ignore[arg-type]
session_id="conv_empty",
workspace=str(workspace),
)
assert qsid is None
assert not qnb.qwen_session_recording_exists(
qnb.qwen_session_id_for_conversation("conv_empty"), workspace
)
async def test_build_qwen_fork_recording_does_not_clobber_existing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# B2: a relaunch (e.g. after a failed external_session_id persist) re-enters
# the fork path. If qwen has already built and since appended live, full-
# fidelity turns, the rebuild must NOT overwrite them — it resumes as-is.
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
workspace = tmp_path / "ws"
workspace.mkdir()
qsid = qnb.qwen_session_id_for_conversation("conv_fork")
# Simulate qwen's live recording already on disk (a richer transcript than a
# text-only rebuild would produce).
recording = qnb.qwen_session_recording_path(qsid, workspace)
recording.parent.mkdir(parents=True, exist_ok=True)
sentinel = '{"type":"assistant","message":{"role":"model","parts":[{"text":"LIVE"}]}}\n'
recording.write_text(sentinel)
returned = await _build_qwen_fork_recording(
_ItemsClient(
[
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "rebuilt"}],
}
]
), # type: ignore[arg-type]
session_id="conv_fork",
workspace=str(workspace),
)
# Returns the id to resume, and the live recording is untouched.
assert returned == qsid
assert recording.read_text() == sentinel
+14
View File
@@ -830,6 +830,20 @@ async def test_fork_switch_404_unknown_target() -> None:
False,
{"omnigent.ui": "terminal", "omnigent.wrapper": "pi-native-ui"},
),
# qwen-native CAN carry fork history: the runner rebuilds qwen's on-disk
# chat recording (+ runtime/meta sidecars) from the copied Omnigent items
# (see write_qwen_session_recording). Cross-family here (claude SDK source
# is anthropic, qwen is openai-family), so model settings reset and the
# source's native session id is NOT stamped — same shape as the pi-native
# cross-family case.
(
"claude_sdk",
"qwen-native",
False,
True,
False,
{"omnigent.ui": "terminal", "omnigent.wrapper": "qwen-native-ui"},
),
# native → SDK, same family: model carries, but an SDK target
# replays the transcript itself so no native-rebuild marker is set.
# The clone drops terminal-first mode (chat) — the bug this fixes.
@@ -321,6 +321,7 @@ _BUILTIN_CLAUDE = _agent("ag_builtin_claude", "claude-native-ui", "bundle/claude
_BUILTIN_CODEX = _agent("ag_builtin_codex", "codex-native-ui", "bundle/codex", None)
_BUILTIN_CURSOR = _agent("ag_builtin_cursor", "cursor-native-ui", "bundle/cursor", None)
_BUILTIN_PI = _agent("ag_builtin_pi", "pi-native-ui", "bundle/pi", None)
_BUILTIN_QWEN = _agent("ag_builtin_qwen", "qwen-native-ui", "bundle/qwen", None)
# ── Tests ────────────────────────────────────────────────────────
@@ -432,6 +433,14 @@ async def test_switch_cross_family_resets_model_but_carries_history(
{"omnigent.ui": "terminal", "omnigent.wrapper": "pi-native-ui"},
True,
),
# qwen-native rebuilds qwen's on-disk chat recording from the copied
# items → carry history (parity with claude/codex/pi native).
(
_BUILTIN_QWEN,
"qwen-native",
{"omnigent.ui": "terminal", "omnigent.wrapper": "qwen-native-ui"},
True,
),
],
)
@pytest.mark.asyncio
+269
View File
@@ -0,0 +1,269 @@
"""Tests for qwen-native fork/resume recording synthesis.
Covers converting Omnigent items into qwen chat-recording records and writing
the recording + discovery sidecars (``runtime.json`` / ``meta.json``) that qwen
needs to resolve ``--resume``. An optional, opt-in end-to-end test confirms a
real ``qwen --resume`` loads the synthesized recording.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import uuid
from pathlib import Path
import pytest
from omnigent import qwen_native_bridge as qnb
def _user_item(text: str, *, response_id: str | None = None) -> dict:
item: dict = {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": text}],
}
if response_id is not None:
item["response_id"] = response_id
return item
def _assistant_item(
text: str,
*,
model: str | None = None,
interrupted: bool = False,
response_id: str | None = None,
) -> dict:
item: dict = {
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": text}],
}
if model is not None:
item["model"] = model
if interrupted:
item["interrupted"] = True
if response_id is not None:
item["response_id"] = response_id
return item
def test_records_map_user_and_assistant_turns(tmp_path: Path) -> None:
items = [
_user_item("hello"),
_assistant_item("hi there", model="qwen-max"),
]
records = qnb.qwen_session_records_from_session_items(
items, qwen_session_id="sess-1", cwd=tmp_path, timestamp="2026-01-01T00:00:00.000Z"
)
assert [r["type"] for r in records] == ["user", "assistant"]
user, asst = records
assert user["message"] == {"role": "user", "parts": [{"text": "hello"}]}
assert user["sessionId"] == "sess-1"
assert user["parentUuid"] is None
assert user["cwd"] == os.path.realpath(str(tmp_path))
assert asst["message"] == {"role": "model", "parts": [{"text": "hi there"}]}
assert asst["model"] == "qwen-max"
# Records form a linked list chained by uuid/parentUuid.
assert asst["parentUuid"] == user["uuid"]
assert "contextWindowSize" in asst and "usageMetadata" in asst
def test_records_skip_interrupted_response_group() -> None:
items = [
_user_item("keep me"),
_assistant_item("kept reply"),
_user_item("interrupted question", response_id="resp-x"),
_assistant_item("partial cancelled", interrupted=True, response_id="resp-x"),
]
records = qnb.qwen_session_records_from_session_items(
items, qwen_session_id="sess-2", cwd="/tmp/x"
)
texts = [r["message"]["parts"][0]["text"] for r in records]
assert texts == ["keep me", "kept reply"]
def test_records_skip_empty_and_non_message_items() -> None:
items = [
{"type": "function_call", "name": "ls", "call_id": "c1", "arguments": "{}"},
_user_item(""), # empty text → dropped
_user_item("real"),
_assistant_item("reply"), # so "real" isn't a trailing unanswered prompt
]
records = qnb.qwen_session_records_from_session_items(
items, qwen_session_id="sess-3", cwd="/tmp/x"
)
assert [r["message"]["parts"][0]["text"] for r in records] == ["real", "reply"]
def test_records_drop_trailing_unanswered_user_prompt() -> None:
# A qwen-native source stamps a distinct per-event response_id and never sets
# `interrupted`, so a cancelled last turn leaves a dangling user prompt the
# response-group skip can't catch. It must be dropped from the rebuild.
items = [
_user_item("q1", response_id="qwen:a"),
_assistant_item("a1", response_id="qwen:b"),
_user_item("cancelled prompt", response_id="qwen:c"), # no assistant reply
]
records = qnb.qwen_session_records_from_session_items(items, qwen_session_id="s", cwd="/tmp/x")
texts = [r["message"]["parts"][0]["text"] for r in records]
assert texts == ["q1", "a1"]
def test_records_drop_all_trailing_user_prompts() -> None:
# Multiple consecutive dangling user messages all get dropped.
items = [
_user_item("q1"),
_assistant_item("a1"),
_user_item("dangling 1"),
_user_item("dangling 2"),
]
records = qnb.qwen_session_records_from_session_items(items, qwen_session_id="s", cwd="/tmp/x")
assert [r["type"] for r in records] == ["user", "assistant"]
def test_records_default_model_used_when_item_has_none() -> None:
records = qnb.qwen_session_records_from_session_items(
[_assistant_item("a")], qwen_session_id="s", cwd="/tmp/x", model="fallback-model"
)
assert records[0]["model"] == "fallback-model"
def test_record_uuids_are_deterministic() -> None:
items = [_user_item("hi"), _assistant_item("yo")]
a = qnb.qwen_session_records_from_session_items(items, qwen_session_id="s", cwd="/tmp/x")
b = qnb.qwen_session_records_from_session_items(items, qwen_session_id="s", cwd="/tmp/x")
assert [r["uuid"] for r in a] == [r["uuid"] for r in b]
def test_write_recording_creates_jsonl_and_sidecars(tmp_path: Path, monkeypatch) -> None:
# Point ~/.qwen at a temp HOME so we don't touch the real recording store.
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
workspace = tmp_path / "ws"
workspace.mkdir()
session_id = str(uuid.uuid4())
records = qnb.qwen_session_records_from_session_items(
[_user_item("remember X"), _assistant_item("ok")],
qwen_session_id=session_id,
cwd=workspace,
)
recording = qnb.write_qwen_session_recording(session_id, workspace, records)
# The recording exists where the resume-gate looks for it.
assert recording.is_file()
assert qnb.qwen_session_recording_exists(session_id, workspace)
assert recording == qnb.qwen_session_recording_path(session_id, workspace)
lines = recording.read_text().strip().splitlines()
assert len(lines) == 2
assert json.loads(lines[0])["type"] == "user"
runtime = json.loads((recording.parent / f"{session_id}.runtime.json").read_text())
assert runtime["session_id"] == session_id
assert runtime["work_dir"] == os.path.realpath(str(workspace))
assert isinstance(runtime["started_at"], float)
assert runtime["qwen_version"]
meta = json.loads((recording.parent.parent / "meta.json").read_text())
assert meta["version"] == 1
def test_write_recording_preserves_existing_meta(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
workspace = tmp_path / "ws"
workspace.mkdir()
session_id = str(uuid.uuid4())
records = qnb.qwen_session_records_from_session_items(
[_user_item("hi")], qwen_session_id=session_id, cwd=workspace
)
# Pre-create meta.json with a sentinel createdAt the writer must not clobber.
recording_path = qnb.qwen_session_recording_path(session_id, workspace)
meta_path = recording_path.parent.parent / "meta.json"
meta_path.parent.mkdir(parents=True, exist_ok=True)
meta_path.write_text(json.dumps({"version": 1, "createdAt": "SENTINEL", "updatedAt": "x"}))
qnb.write_qwen_session_recording(session_id, workspace, records)
assert json.loads(meta_path.read_text())["createdAt"] == "SENTINEL"
def test_write_recording_sidecar_failure_leaves_no_gate_jsonl(tmp_path: Path, monkeypatch) -> None:
# The resume gate keys on the .jsonl. If a sidecar write fails, the .jsonl
# must NOT exist — otherwise the gate would pick --resume onto a session with
# no runtime.json and land on qwen's blocking "No saved session" screen (B1).
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
workspace = tmp_path / "ws"
workspace.mkdir()
session_id = str(uuid.uuid4())
records = qnb.qwen_session_records_from_session_items(
[_user_item("hi"), _assistant_item("ok")], qwen_session_id=session_id, cwd=workspace
)
# Fail the runtime.json sidecar write (it's written before the .jsonl).
real_atomic = qnb._atomic_write_text
def _boom(target: Path, text: str) -> None:
if target.name.endswith(".runtime.json"):
raise RuntimeError("disk full")
real_atomic(target, text)
monkeypatch.setattr(qnb, "_atomic_write_text", _boom)
with pytest.raises(RuntimeError):
qnb.write_qwen_session_recording(session_id, workspace, records)
# The gate file was never committed → a clean fresh launch, not the blocking screen.
assert not qnb.qwen_session_recording_exists(session_id, workspace)
@pytest.mark.skipif(
shutil.which("qwen") is None or os.environ.get("OMNIGENT_QWEN_E2E") != "1",
reason="needs the qwen CLI + configured auth; opt in with OMNIGENT_QWEN_E2E=1",
)
def test_synthesized_recording_loads_on_resume(tmp_path: Path) -> None:
"""A real ``qwen --resume`` loads the synthesized recording and recalls the fact.
Network + auth dependent — skipped unless OMNIGENT_QWEN_E2E=1 and qwen is on
PATH. This is the regression guard for the on-disk format (records + the
runtime/meta sidecars) that the resume gate depends on. Uses the real
``~/.qwen`` for auth; the tmp workspace gives a unique project slug so the
recording lands in its own dir and never collides with a real session.
"""
workspace = tmp_path / "ws"
workspace.mkdir()
secret = "PURPLE-PENGUIN-42"
session_id = str(uuid.uuid4())
records = qnb.qwen_session_records_from_session_items(
[
_user_item(f"Remember: the secret code is {secret}."),
_assistant_item(f"Acknowledged. The secret code is {secret}."),
],
qwen_session_id=session_id,
cwd=workspace,
)
qnb.write_qwen_session_recording(session_id, workspace, records)
proc = subprocess.run(
[
"qwen",
"--resume",
session_id,
"-p",
"What is the secret code? Reply with ONLY the code.",
],
cwd=str(workspace),
capture_output=True,
text=True,
timeout=120,
)
assert proc.returncode == 0, proc.stderr
assert secret in proc.stdout
+7
View File
@@ -47,6 +47,10 @@ describe("isNativeHarness", () => {
// NATIVE_HARNESSES (the in-process `antigravity` SDK harness is NOT).
["antigravity-native", true],
["native-antigravity", true],
// qwen-native rebuilds qwen's on-disk chat recording from the copied
// Omnigent items, so it carries fork/switch history (both spellings).
["qwen-native", true],
["native-qwen", true],
["claude-sdk", false],
["claude_sdk", false],
["openai-agents", false],
@@ -99,6 +103,9 @@ describe("forkTargetCarriesHistory", () => {
["native-pi"],
["antigravity-native"],
["native-antigravity"],
// qwen-native rebuilds qwen's on-disk recording from the copied items.
["qwen-native"],
["native-qwen"],
])("native target %s carries history", (target) => {
expect(forkTargetCarriesHistory(target)).toBe(true);
});
+14 -6
View File
@@ -54,11 +54,17 @@ export function harnessFamily(
}
/**
* Whether a harness is a native CLI harness (Claude Code / Codex / Cursor /
* Pi / Antigravity). Mirrors Python `NATIVE_HARNESSES`
* (`omnigent/harness_aliases.py`) — including both native-antigravity spellings
* (the in-process `antigravity` SDK harness is NOT native) — so both sides
* classify the same set.
* Whether a harness is a native CLI harness that carries fork/switch history
* (Claude Code / Codex / Cursor / Pi / Antigravity / Qwen Code). These are the
* native harnesses whose history the runner rebuilds or replays on a fork — the
* subset of Python `NATIVE_HARNESSES` (`omnigent/harness_aliases.py`) that the
* server gates in `_FORK_HISTORY_NATIVE_HARNESSES` /
* `_CURSOR_FORK_HISTORY_HARNESSES` (`server/routes/sessions.py`). A native
* harness that always starts fresh (e.g. goose-native) is intentionally absent
* so the picker doesn't promise history it would drop. Both native-antigravity
* spellings are included (the in-process `antigravity` SDK harness is NOT
* native); qwen-native rebuilds qwen's on-disk chat recording from the copied
* Omnigent items (see `write_qwen_session_recording`).
*/
export function isNativeHarness(harness: string | null | undefined): boolean {
return (
@@ -71,7 +77,9 @@ export function isNativeHarness(harness: string | null | undefined): boolean {
harness === "pi-native" ||
harness === "native-pi" ||
harness === "antigravity-native" ||
harness === "native-antigravity"
harness === "native-antigravity" ||
harness === "qwen-native" ||
harness === "native-qwen"
);
}