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:
@@ -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.**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user