84e85346fb
* 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
270 lines
10 KiB
Python
270 lines
10 KiB
Python
"""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
|