Skip to content

Commit 498330b

Browse files
author
vastraliu
committed
test(chat): qualify model discovery and native cancellation
Signed-off-by: vastraliu <vastraliu@tencent.com>
1 parent 8017f1d commit 498330b

3 files changed

Lines changed: 127 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import assert from "node:assert/strict";
2+
import { clientRequestId } from "./client-request-id.js";
3+
4+
assert.equal(clientRequestId({ randomUUID: () => "host-native-identity" }), "host-native-identity");
5+
const insecureContext = { getRandomValues: (bytes) => {
6+
for (let index = 0; index < bytes.length; index += 1) bytes[index] = index;
7+
return bytes;
8+
} };
9+
assert.equal(clientRequestId(insecureContext), "00010203-0405-4607-8809-0a0b0c0d0e0f");
10+
assert.match(clientRequestId(), /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/);
11+
console.log("Client request identity works without secure-context randomUUID");

‎tests/test_chat_model_catalog.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Catalog discovery follows the selected host without starting model Turns."""
2+
import json
3+
import sys
4+
from types import SimpleNamespace
5+
6+
import pytest
7+
8+
from loopx.chat_model_catalog import chat_model_catalog, codex_models
9+
from loopx.chat_model_catalog_api import ModelCatalogRequestMixin
10+
11+
12+
def _codex(tmp_path, *, malformed=False, legacy=False):
13+
executable = tmp_path / "codex-fixture"
14+
trace = tmp_path / "requests.jsonl"
15+
executable.write_text(f"#!{sys.executable}\n" + f"trace={str(trace)!r}\nmalformed={malformed!r}\nlegacy={legacy!r}\n" + '''
16+
import json,os,sys
17+
if sys.argv[1:3] == ['debug','models']:
18+
print(json.dumps({'models':[{'slug':'compat-model','base_instructions':'synthetic host instructions'}]}));sys.exit(0)
19+
for line in sys.stdin:
20+
d=json.loads(line)
21+
with open(trace,'a') as f:f.write(json.dumps({'method':d['method'],'home':os.environ.get('CODEX_HOME')})+'\\n')
22+
if 'id' not in d:continue
23+
if d['method']=='initialize':result={}
24+
elif d['method']=='model/list':
25+
if legacy and '-c' not in sys.argv:
26+
print(json.dumps({'id':d['id'],'error':{'message':'model_catalog_json missing field base_instructions'}}),flush=True);continue
27+
if malformed:result={'data':[],'nextCursor':'repeated'}
28+
elif not d['params'].get('cursor'):
29+
result={'data':[{'id':'host-one','model':'host-one','displayName':'Host One','base_instructions':'never project these'}],'nextCursor':'second'}
30+
else:result={'data':[{'id':'host-two','model':'host-two','displayName':'Host Two'}],'nextCursor':None}
31+
else:raise RuntimeError('Catalog must not start a thread or Turn')
32+
print(json.dumps({'id':d['id'],'result':result}),flush=True)
33+
''')
34+
executable.chmod(0o700)
35+
return executable, trace
36+
37+
38+
def test_codex_model_list_pages_use_actual_home_and_never_start_a_turn(tmp_path):
39+
executable, trace = _codex(tmp_path)
40+
home = tmp_path / "codex-home"
41+
result = codex_models(str(executable), home)
42+
assert [model["id"] for model in result] == ["host-one", "host-two"]
43+
assert "never project" not in json.dumps(result)
44+
requests = [json.loads(line) for line in trace.read_text().splitlines()]
45+
assert [r["method"] for r in requests] == ["initialize", "initialized", "model/list", "model/list"]
46+
assert all(r["home"] == str(home) for r in requests)
47+
48+
49+
def test_invalid_pagination_is_unavailable_without_placeholder_models(tmp_path):
50+
executable, _ = _codex(tmp_path, malformed=True)
51+
controller = SimpleNamespace(codex_bin=str(executable), codex_home=tmp_path)
52+
result = chat_model_catalog(controller, "codex")
53+
assert result["available"] is False and result["models"] == []
54+
assert str(tmp_path) not in json.dumps(result)
55+
56+
57+
def test_legacy_catalog_retry_remains_visible_in_discovery(tmp_path):
58+
executable, _ = _codex(tmp_path, legacy=True)
59+
result = chat_model_catalog(SimpleNamespace(codex_bin=str(executable), codex_home=tmp_path), "codex")
60+
assert result["available"] is True and result["compatibility_applied"] is True
61+
assert [row["id"] for row in result["models"]] == ["host-one", "host-two"]
62+
63+
64+
def test_native_picker_returns_declared_models_without_env_or_codex_defaults(monkeypatch, tmp_path):
65+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
66+
(tmp_path / "settings.json").write_text(json.dumps({"env": {"PRIVATE_FIELD": "not model metadata"},
67+
"modelPicker": {"options": [{"model": "native-one[1m]", "label": "Native One"},
68+
{"model": "native-two", "description": "Native Two"}]}}))
69+
result = chat_model_catalog(SimpleNamespace(), "claude-code")
70+
assert result["available"] and [row["id"] for row in result["models"]] == ["native-one[1m]", "native-two"]
71+
assert "not model metadata" not in json.dumps(result)
72+
assert str(tmp_path) not in json.dumps(result)
73+
(tmp_path / "settings.json").write_text("[]")
74+
assert chat_model_catalog(SimpleNamespace(), "claude-code")["available"] is False
75+
76+
77+
@pytest.mark.parametrize("query", ["", "?endpoint_id=", "?endpoint_id=codex&endpoint_id=claude-code", "?endpoint_id=codex&command=arbitrary"])
78+
def test_catalog_api_does_not_accept_commands_or_ambiguous_hosts(query):
79+
class Handler(ModelCatalogRequestMixin):
80+
path = "/api/chat/models" + query
81+
def _send_error(self, message, **kwargs):
82+
self.error = kwargs
83+
handler = Handler()
84+
handler._model_catalog()
85+
assert handler.error["status"] == 400

‎tests/test_chat_native_tools.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import io
44
import json
55
import sys
6+
import threading
7+
import time
68
from urllib.error import HTTPError, URLError
79
from urllib.request import Request, urlopen
810

@@ -134,3 +136,32 @@ def test_native_manager_uses_existing_runtime_grant_and_configured_model(monkeyp
134136
def test_native_manager_vendor_default_never_inherits_codex_model():
135137
assert manager_model_config({}, endpoint="claude-code")["model"] == "sonnet"
136138
assert manager_model_config({}, endpoint="codex")["model"] == "gpt-6-astra"
139+
140+
141+
def test_interrupt_native_wrapper_stops_child_before_it_can_write(tmp_path):
142+
marker = tmp_path / "child-side-effect.txt"
143+
executable = tmp_path / "native-wrapper"
144+
child = f"import time; from pathlib import Path; time.sleep(1); Path({str(marker)!r}).write_text('late effect')"
145+
executable.write_text(f"#!{sys.executable}\nimport json,subprocess,sys\n"
146+
+ f"p=subprocess.Popen([sys.executable,'-c',{child!r}])\n"
147+
+ "print(json.dumps({'type':'stream_event','event':{'type':'content_block_start','content_block':{'type':'tool_use','name':'synthetic-child'}}}),flush=True)\np.wait()\n")
148+
executable.chmod(0o700)
149+
adapter = ClaudeCodeAdapter(str(executable), tmp_path, "synthetic-interrupt")
150+
ready = threading.Event()
151+
errors = []
152+
def run():
153+
try:
154+
adapter.start_turn("Wait for owner", lambda kind, _: ready.set()
155+
if kind == "agent.phase" and adapter.current_process is not None else None)
156+
except CodexChatAgentError as error:
157+
errors.append(error)
158+
worker = threading.Thread(target=run)
159+
worker.start()
160+
assert ready.wait(timeout=5)
161+
process = adapter.current_process
162+
adapter.interrupt_turn()
163+
worker.join(timeout=3)
164+
assert not worker.is_alive() and errors
165+
assert process.poll() is not None and adapter.current_process is None
166+
time.sleep(1.1)
167+
assert not marker.exists(), "Native child survived owner interruption"

0 commit comments

Comments
 (0)