fix: synchronize live model reload and preserve config selection
This commit is contained in:
1 parent
7fb22a2f85
commit
2ba5529060
4 files changed
+106
-286
No files matched your search
@@ -156,3 +156,6 @@ frontends/conductor_im_plugins/*
|
||||
|
||||
# Beta 渠道:订阅门户插件(仅 beta 分发携带,不入库)
|
||||
plugins/subscription_portal.py
|
||||
|
||||
# Local-only model reload regression tests
|
||||
/frontends/tests/test_mykeys_reload.py
|
||||
+82
-68
@@ -46,6 +46,7 @@ class GenericAgent:
|
||||
def __init__(self):
|
||||
os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True)
|
||||
self.lock = threading.Lock()
|
||||
self._model_lock = threading.RLock()
|
||||
self.task_dir = None
|
||||
self.history = []; self.handler = None; self.all_outputs = []
|
||||
self.task_queue = queue.Queue()
|
||||
@@ -64,48 +65,57 @@ class GenericAgent:
|
||||
self.intervene = self.extrakeyinfo = None
|
||||
|
||||
def load_llm_sessions(self):
|
||||
mykeys, updated_at = mykeys_store.snapshot()
|
||||
if hasattr(self, 'llmclients') and getattr(self, '_mykeys_updated_at', None) == updated_at: return
|
||||
try: oldhistory, oldname = self.llmclient.backend.history, self.llmclient.backend.name
|
||||
except: oldhistory = oldname = None
|
||||
llm_sessions = []
|
||||
for k, cfg in mykeys.items():
|
||||
if not any(x in k for x in ['api', 'config', 'cookie']): continue
|
||||
try:
|
||||
if 'mixin' in k: llm_sessions += [{'mixin_cfg': cfg}]
|
||||
elif c := resolve_client(k, cfg): llm_sessions += [c]
|
||||
except: pass
|
||||
for i, s in enumerate(llm_sessions):
|
||||
if isinstance(s, dict) and 'mixin_cfg' in s:
|
||||
with self._model_lock:
|
||||
if self.is_running: return
|
||||
mykeys, updated_at = mykeys_store.snapshot()
|
||||
if hasattr(self, 'llmclients') and getattr(self, '_mykeys_updated_at', None) == updated_at: return
|
||||
try: oldhistory = self.llmclient.backend.history
|
||||
except: oldhistory = None
|
||||
try: oldkey = self._llm_config_keys[self.llm_no]
|
||||
except (AttributeError, IndexError): oldkey = None
|
||||
llm_sessions, config_keys = [], []
|
||||
for k, cfg in mykeys.items():
|
||||
if not any(x in k for x in ['api', 'config', 'cookie']): continue
|
||||
try:
|
||||
mixin = MixinSession(llm_sessions, s['mixin_cfg'])
|
||||
if isinstance(mixin._sessions[0], (NativeClaudeSession, NativeOAISession)): llm_sessions[i] = NativeToolClient(mixin)
|
||||
else: llm_sessions[i] = ToolClient(mixin)
|
||||
except Exception as e: print(f'\n\n\n[ERROR] Failed to init MixinSession with cfg {s["mixin_cfg"]}: {e}!!!\n\n')
|
||||
self.llmclients = llm_sessions
|
||||
if not self.llmclients:
|
||||
self.llmclient = None
|
||||
if 'mixin' in k: llm_sessions += [{'mixin_cfg': cfg}]
|
||||
elif c := resolve_client(k, cfg): llm_sessions += [c]
|
||||
else: continue
|
||||
config_keys.append(k)
|
||||
except: pass
|
||||
for i, s in enumerate(llm_sessions):
|
||||
if isinstance(s, dict) and 'mixin_cfg' in s:
|
||||
try:
|
||||
mixin = MixinSession(llm_sessions, s['mixin_cfg'])
|
||||
if isinstance(mixin._sessions[0], (NativeClaudeSession, NativeOAISession)): llm_sessions[i] = NativeToolClient(mixin)
|
||||
else: llm_sessions[i] = ToolClient(mixin)
|
||||
except Exception as e: print(f'\n\n\n[ERROR] Failed to init MixinSession with cfg {s["mixin_cfg"]}: {e}!!!\n\n')
|
||||
self.llmclients, self._llm_config_keys = llm_sessions, config_keys
|
||||
if not self.llmclients:
|
||||
self.llmclient = None
|
||||
self._mykeys_updated_at = updated_at
|
||||
return
|
||||
if oldkey in config_keys: self.llm_no = config_keys.index(oldkey)
|
||||
self.llm_no %= len(self.llmclients)
|
||||
self.llmclient = self.llmclients[self.llm_no]
|
||||
if oldhistory: self.llmclient.backend.history = oldhistory
|
||||
self._mykeys_updated_at = updated_at
|
||||
return
|
||||
names = [c.backend.name if not isinstance(c, dict) else f'BADMIXIN_{i}' for i, c in enumerate(self.llmclients)]
|
||||
if oldname in names: self.llm_no = names.index(oldname)
|
||||
self.llmclient = self.llmclients[self.llm_no%len(self.llmclients)]
|
||||
if oldhistory: self.llmclient.backend.history = oldhistory
|
||||
self._mykeys_updated_at = updated_at
|
||||
|
||||
def next_llm(self, n=-1):
|
||||
self.load_llm_sessions()
|
||||
if not self.llmclients: return
|
||||
self.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self.llmclients)
|
||||
lastc = self.llmclient
|
||||
self.llmclient = self.llmclients[self.llm_no]
|
||||
try: self.llmclient.backend.history = lastc.backend.history
|
||||
except: raise Exception('[ERROR] BAD Mixin config: Check your mykey.py')
|
||||
self.llmclient.last_tools = ''
|
||||
load_tool_schema()
|
||||
with self._model_lock:
|
||||
if self.is_running: return
|
||||
self.load_llm_sessions()
|
||||
if not self.llmclients: return
|
||||
self.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self.llmclients)
|
||||
lastc = self.llmclient
|
||||
self.llmclient = self.llmclients[self.llm_no]
|
||||
try: self.llmclient.backend.history = lastc.backend.history
|
||||
except: raise Exception('[ERROR] BAD Mixin config: Check your mykey.py')
|
||||
self.llmclient.last_tools = ''
|
||||
load_tool_schema()
|
||||
def list_llms(self):
|
||||
self.load_llm_sessions()
|
||||
return [(i, self.get_llm_name(b), i == self.llm_no) for i, b in enumerate(self.llmclients)]
|
||||
with self._model_lock:
|
||||
self.load_llm_sessions()
|
||||
return [(i, self.get_llm_name(b), i == self.llm_no) for i, b in enumerate(self.llmclients)]
|
||||
def get_llm_name(self, b=None, model=False):
|
||||
b = self.llmclient if b is None else b
|
||||
if isinstance(b, dict): return 'BADCONFIG_MIXIN'
|
||||
@@ -158,36 +168,37 @@ class GenericAgent:
|
||||
task = self.task_queue.get()
|
||||
if isinstance(task, str): break
|
||||
raw_query, source, display_queue = task["query"], task["source"], task["output"]
|
||||
raw_query = self._handle_slash_cmd(raw_query, display_queue)
|
||||
if raw_query is None:
|
||||
self.task_queue.task_done(); continue
|
||||
self.is_running = True; self._current_queue = display_queue
|
||||
if len(raw_query) > 2000:
|
||||
task_file = os.path.join(script_dir, 'temp', f'user_prompt_{os.getpid()}_{time.time_ns()}.md')
|
||||
with open(task_file, 'w', encoding='utf-8') as f: f.write(raw_query)
|
||||
raw_query = f'Long user prompt saved to {task_file}. Read and execute.'
|
||||
self.all_outputs.append({"input": raw_query, "outputs": []})
|
||||
if len(self.all_outputs) > 10000: self.all_outputs = self.all_outputs[-5000:]
|
||||
rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200)
|
||||
self.history.append(f"[USER]: {rquery}")
|
||||
sys_prompt = get_system_prompt() + '\n'.join(self.extra_sys_prompts) + getattr(self.llmclient.backend, 'extra_sys_prompt', '')
|
||||
if self.peer_hint: sys_prompt += f"\n[Peer] 用户提及其他会话/后台任务状态时: temp/model_responses/ (只找近期修改的文件尾部)\n"
|
||||
handler = GenericAgentHandler(self, self.history, os.path.join(script_dir, 'temp'))
|
||||
if getattr(self, 'no_print', False): handler.print = lambda *a, **k: None
|
||||
if self.handler and 'key_info' in self.handler.working:
|
||||
ki = re.sub(r'\n\[SYSTEM\] 此为.*?工作记忆[。\n]*', '', self.handler.working['key_info']) # 去旧
|
||||
handler.working['key_info'] = ki
|
||||
handler.working['passed_sessions'] = ps = self.handler.working.get('passed_sessions', 0) + 1
|
||||
if ps > 0: handler.working['key_info'] += f'\n[SYSTEM] 此为 {ps} 个对话前设置的key_info,若已在新任务,先更新或清除工作记忆。\n'
|
||||
self.handler = handler # although new handler, the **full** history is in llmclient, so it is full history!
|
||||
self.llmclient.log_path = self.log_path
|
||||
if self.force_non_stream:
|
||||
self.llmclient.backend.stream = False
|
||||
self.llmclient.backend.read_timeout = max(self.llmclient.backend.read_timeout, 1200)
|
||||
gen = agent_runner_loop(self.llmclient, sys_prompt, raw_query, handler, TOOLS_SCHEMA,
|
||||
max_turns=180, verbose=self.verbose, yield_info=True)
|
||||
full_resp = ""; last_pos = 0; curr_turn = 0; turn_resps = []
|
||||
try:
|
||||
full_resp = ""; last_pos = 0; curr_turn = 0; turn_resps = self.all_outputs[-1]["outputs"]
|
||||
with self._model_lock:
|
||||
self.load_llm_sessions()
|
||||
self.is_running = True; self._current_queue = display_queue
|
||||
raw_query = self._handle_slash_cmd(raw_query, display_queue)
|
||||
if raw_query is None: continue
|
||||
if len(raw_query) > 2000:
|
||||
task_file = os.path.join(script_dir, 'temp', f'user_prompt_{os.getpid()}_{time.time_ns()}.md')
|
||||
with open(task_file, 'w', encoding='utf-8') as f: f.write(raw_query)
|
||||
raw_query = f'Long user prompt saved to {task_file}. Read and execute.'
|
||||
self.all_outputs.append({"input": raw_query, "outputs": turn_resps})
|
||||
if len(self.all_outputs) > 10000: self.all_outputs = self.all_outputs[-5000:]
|
||||
rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200)
|
||||
self.history.append(f"[USER]: {rquery}")
|
||||
sys_prompt = get_system_prompt() + '\n'.join(self.extra_sys_prompts) + getattr(self.llmclient.backend, 'extra_sys_prompt', '')
|
||||
if self.peer_hint: sys_prompt += f"\n[Peer] 用户提及其他会话/后台任务状态时: temp/model_responses/ (只找近期修改的文件尾部)\n"
|
||||
handler = GenericAgentHandler(self, self.history, os.path.join(script_dir, 'temp'))
|
||||
if getattr(self, 'no_print', False): handler.print = lambda *a, **k: None
|
||||
if self.handler and 'key_info' in self.handler.working:
|
||||
ki = re.sub(r'\n\[SYSTEM\] 此为.*?工作记忆[。\n]*', '', self.handler.working['key_info']) # 去旧
|
||||
handler.working['key_info'] = ki
|
||||
handler.working['passed_sessions'] = ps = self.handler.working.get('passed_sessions', 0) + 1
|
||||
if ps > 0: handler.working['key_info'] += f'\n[SYSTEM] 此为 {ps} 个对话前设置的key_info,若已在新任务,先更新或清除工作记忆。\n'
|
||||
self.handler = handler # although new handler, the **full** history is in llmclient, so it is full history!
|
||||
self.llmclient.log_path = self.log_path
|
||||
if self.force_non_stream:
|
||||
self.llmclient.backend.stream = False
|
||||
self.llmclient.backend.read_timeout = max(self.llmclient.backend.read_timeout, 1200)
|
||||
gen = agent_runner_loop(self.llmclient, sys_prompt, raw_query, handler, TOOLS_SCHEMA,
|
||||
max_turns=180, verbose=self.verbose, yield_info=True)
|
||||
for chunk in gen:
|
||||
if consume_file(self.task_dir, '_stop'): self.abort()
|
||||
if self.stop_sig: break
|
||||
@@ -208,9 +219,12 @@ class GenericAgent:
|
||||
display_queue.put({'done': full_resp + f'\n```\n{format_error(e)}\n```', 'source': source, 'turn': curr_turn, 'outputs': turn_resps.copy()})
|
||||
finally:
|
||||
if self.stop_sig: print('User aborted the task.')
|
||||
self.is_running = self.stop_sig = False # keep _current_queue: its final 'done' may still be unclaimed (refreshed UI salvages it); next task overwrites it
|
||||
self.task_queue.task_done()
|
||||
if self.handler is not None: self.handler.code_stop_signal.append(1)
|
||||
with self._model_lock:
|
||||
self.is_running = self.stop_sig = False # keep _current_queue for final output retrieval
|
||||
try: self.load_llm_sessions()
|
||||
except Exception as e: print(f'[ERROR] Failed to reload models: {e}')
|
||||
self.task_queue.task_done()
|
||||
|
||||
GeneraticAgent = GenericAgent
|
||||
|
||||
|
||||
+21
-15
@@ -672,6 +672,14 @@ class AgentManager:
|
||||
|
||||
def _save_mykey_text(self, text: str) -> list:
|
||||
self._mykey_file().write_text(text, encoding="utf-8")
|
||||
with self.lock:
|
||||
for sess in self.sessions.values():
|
||||
if sess.agent is not None:
|
||||
try:
|
||||
sess.agent.list_llms()
|
||||
emit_session_state(sess, sess.status)
|
||||
except Exception as e:
|
||||
bridge_print(f"[model reload] {sess.id}: {e}")
|
||||
return self.list_model_profiles()
|
||||
|
||||
def add_model_profile(self, data: dict) -> dict:
|
||||
@@ -741,6 +749,9 @@ class AgentManager:
|
||||
agentmain = importlib.import_module("agentmain")
|
||||
GA = getattr(agentmain, "GenericAgent")
|
||||
agent = GA()
|
||||
no = sess.llm_no if sess.llm_no is not None else _global_default_llm_no()
|
||||
if no is not None:
|
||||
agent.next_llm(int(no))
|
||||
agent.inc_out = True
|
||||
agent.verbose = True
|
||||
threading.Thread(target=agent.run, daemon=True, name=f"GA-{sess.id}").start()
|
||||
@@ -796,7 +807,7 @@ class AgentManager:
|
||||
cfg = mk.get(k) if isinstance(mk.get(k), dict) else {}
|
||||
if "mixin" in k:
|
||||
members = [str(m) for m in (cfg.get("llm_nos") or [])]
|
||||
out.append({"id": i, "varName": k, "kind": "mixin", "name": "",
|
||||
out.append({"id": i, "varName": k, "kind": "mixin", "name": str(cfg.get("name") or k),
|
||||
"members": members, "active": i == active})
|
||||
else:
|
||||
name = self._base_display_name(k, cfg)
|
||||
@@ -871,14 +882,14 @@ class AgentManager:
|
||||
def _live_model(sess: Session) -> Optional[dict]:
|
||||
"""该会话 agent 当前真正在用的模型(渠道组会随故障转移变化)。
|
||||
agent 还没建(没跑过 turn)时返回静态绑定信息,前端据 llmNo 回显选择器。
|
||||
llmNo: 始终以 sess.llm_no 为权威(用户选择),agent.llm_no 在初始化窗口可能滞后。"""
|
||||
已创建的 agent 是模型选择的唯一来源。"""
|
||||
ag = getattr(sess, "agent", None)
|
||||
if ag is None:
|
||||
return {"current": None, "isMixin": False, "llmNo": sess.llm_no,
|
||||
"runningLlmNo": sess.running_llm_no, "runningModel": sess.running_model}
|
||||
try:
|
||||
back = ag.llmclient.backend
|
||||
live_no = sess.llm_no if sess.llm_no is not None else getattr(ag, "llm_no", 0)
|
||||
live_no = sess.llm_no = ag.llm_no
|
||||
if "Mixin" in type(back).__name__:
|
||||
return {"current": back.current_name, "isMixin": True, "llmNo": live_no,
|
||||
"runningLlmNo": sess.running_llm_no, "runningModel": sess.running_model}
|
||||
@@ -1080,13 +1091,8 @@ class AgentManager:
|
||||
except Exception:
|
||||
pass
|
||||
agent = sess.agent
|
||||
# 模型取会话绑定 sess.llm_no,未绑定回退全局默认。切换走 set_session_model。
|
||||
no = sess.llm_no if sess.llm_no is not None else _global_default_llm_no()
|
||||
if no is not None and hasattr(agent, "next_llm"):
|
||||
with contextlib.suppress(Exception):
|
||||
agent.next_llm(int(no))
|
||||
with self.lock:
|
||||
sess.running_llm_no = getattr(agent, "llm_no", no)
|
||||
sess.running_llm_no = agent.llm_no
|
||||
try:
|
||||
running_backend = agent.llmclient.backend
|
||||
sess.running_model = str(
|
||||
@@ -1311,18 +1317,18 @@ class AgentManager:
|
||||
def set_session_model(self, sid: str, llm_no: int) -> dict:
|
||||
"""Bind a model to a session.
|
||||
|
||||
An idle agent switches immediately. A running turn keeps its captured client;
|
||||
the new binding is applied at the start of the next turn.
|
||||
Existing agents own selection; running turns ignore switch requests.
|
||||
"""
|
||||
with self.mutation():
|
||||
sess = self.sessions.get(sid)
|
||||
if not sess:
|
||||
raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
|
||||
sess.llm_no = int(llm_no)
|
||||
if (sess.status != "running" and sess.agent is not None
|
||||
and hasattr(sess.agent, "next_llm")):
|
||||
with contextlib.suppress(Exception):
|
||||
if sess.status != "running":
|
||||
if sess.agent is None:
|
||||
sess.llm_no = int(llm_no)
|
||||
else:
|
||||
sess.agent.next_llm(int(llm_no))
|
||||
sess.llm_no = sess.agent.llm_no
|
||||
sess.updated_at = time.time()
|
||||
self._persist_session(sess)
|
||||
return {"ok": True, "sessionId": sid, "llmNo": sess.llm_no, "model": self._live_model(sess)}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
"""Isolate configuration loading from real keys, network and agent startup."""
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def extract(path, names, namespace):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
nodes = [n for n in tree.body if getattr(n, "name", None) in names]
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(path), "exec"), namespace)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def core(tmp_path, monkeypatch):
|
||||
# Never resolve or import the user's actual mykey module.
|
||||
source = tmp_path / "mykey.py"
|
||||
def find_spec(name):
|
||||
assert name == "mykey"
|
||||
return importlib.util.spec_from_file_location(name, source) if source.exists() else None
|
||||
monkeypatch.setattr(importlib.util, "find_spec", find_spec)
|
||||
monkeypatch.delitem(sys.modules, "mykey", raising=False)
|
||||
ns = dict(os=os, sys=sys, json=json, time=time, threading=threading,
|
||||
importlib=importlib, __file__=str(tmp_path / "llmcore.py"))
|
||||
extract(ROOT / "llmcore.py", {"MyKeys", "reload_mykeys", "_load_mykeys", "__getattr__", "resolve_session", "resolve_client"}, ns)
|
||||
ns["mykeys_store"] = ns["MyKeys"]()
|
||||
yield ns, source
|
||||
sys.modules.pop("mykey", None)
|
||||
|
||||
|
||||
def write(source, value, stamp=1700000000000000100):
|
||||
source.write_text("oai_config = " + repr({"name": "test", "model": value}) + "\n", encoding="utf-8")
|
||||
os.utime(source, ns=(stamp, stamp))
|
||||
|
||||
|
||||
def agent_type(ns):
|
||||
tree = ast.parse((ROOT / "agentmain.py").read_text(encoding="utf-8"))
|
||||
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "GenericAgent")
|
||||
cls.body = [n for n in cls.body if getattr(n, "name", None) == "load_llm_sessions"]
|
||||
exec(compile(ast.Module(body=[cls], type_ignores=[]), "agentmain.py", "exec"), ns)
|
||||
return ns["GenericAgent"]
|
||||
|
||||
|
||||
def test_two_agents_update_even_if_bridge_read_first(core):
|
||||
ns, source = core
|
||||
write(source, "A")
|
||||
built = []
|
||||
def resolve(name, cfg):
|
||||
built.append(cfg["model"])
|
||||
return SimpleNamespace(backend=SimpleNamespace(name=cfg["name"], model=cfg["model"], history=[]))
|
||||
ns["resolve_client"] = resolve
|
||||
Agent = agent_type(ns)
|
||||
a, b = Agent(), Agent()
|
||||
for ga in (a, b):
|
||||
ga.llm_no = 0
|
||||
ga.load_llm_sessions()
|
||||
history = [{"role": "user", "content": "preserve"}]
|
||||
a.llmclient.backend.history = history
|
||||
old = a.llmclient
|
||||
a.load_llm_sessions()
|
||||
assert a.llmclient is old
|
||||
write(source, "B", 1700000000000000200)
|
||||
assert ns["reload_mykeys"]()[1] is True # bridge consumes old changed signal
|
||||
assert ns["reload_mykeys"]()[1] is False
|
||||
for ga in (a, b):
|
||||
ga.load_llm_sessions()
|
||||
assert ga.llmclient.backend.model == "B"
|
||||
assert a.llmclient.backend.history is history
|
||||
assert built == ["A", "A", "B", "B"]
|
||||
|
||||
|
||||
def test_same_second_same_size_and_backwards_mtime(core):
|
||||
ns, source = core
|
||||
write(source, "A")
|
||||
store = ns["mykeys_store"]
|
||||
first = store.snapshot()[1]
|
||||
write(source, "B", 1700000000000000200)
|
||||
data, second = store.snapshot()
|
||||
assert data["oai_config"]["model"] == "B" and second > first
|
||||
write(source, "C", 1600000000000000000)
|
||||
data, third = store.snapshot()
|
||||
assert data["oai_config"]["model"] == "C" and third > second
|
||||
|
||||
|
||||
def test_json_fallback_and_legacy_interfaces(core):
|
||||
ns, source = core
|
||||
source.with_suffix(".json").write_text('{"oai_config": {"model": "A"}}', encoding="utf-8")
|
||||
data, changed = ns["reload_mykeys"]()
|
||||
assert changed is True
|
||||
assert ns["__getattr__"]("mykeys") is data
|
||||
assert ns["reload_mykeys"]() == (data, False)
|
||||
assert ns["_load_mykeys"]() == data
|
||||
with pytest.raises(AttributeError): ns["__getattr__"]("unknown")
|
||||
|
||||
|
||||
def test_failure_keeps_last_good_snapshot_and_recovers(core):
|
||||
ns, source = core
|
||||
write(source, "A")
|
||||
store = ns["mykeys_store"]
|
||||
before = store.snapshot()
|
||||
source.write_text("oai_config = {", encoding="utf-8")
|
||||
assert store.snapshot() == before
|
||||
source.unlink()
|
||||
assert store.snapshot() == before
|
||||
write(source, "B", 1700000000000000300)
|
||||
assert store.snapshot()[0]["oai_config"]["model"] == "B"
|
||||
|
||||
|
||||
def test_force_refresh_publishes_once_for_all_readers(core):
|
||||
ns, source = core
|
||||
write(source, "A")
|
||||
store = ns["mykeys_store"]
|
||||
before = store.snapshot()[1]
|
||||
store.reload(force=True)
|
||||
after = store.snapshot()[1]
|
||||
assert after > before
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
results = list(pool.map(lambda _: store.snapshot()[1], range(32)))
|
||||
assert results == [after] * 32
|
||||
|
||||
|
||||
def test_resolvers_accept_snapshot_without_reading_global_or_mutating_it(core):
|
||||
ns, source = core
|
||||
class Session:
|
||||
def __init__(self, cfg): self.cfg = cfg
|
||||
ns.update(LLMSession=Session, ClaudeSession=Session, NativeClaudeSession=type("NC", (), {}),
|
||||
NativeOAISession=type("NO", (), {}), ToolClient=lambda s: s)
|
||||
cfg = {"model": "A"}
|
||||
ns["reload_mykeys"] = lambda: pytest.fail("unexpected global config read")
|
||||
session = ns["resolve_client"]("oai_config", cfg)
|
||||
assert session.cfg == {"model": "A", "_mykey_name": "oai_config"}
|
||||
assert cfg == {"model": "A"}
|
||||
ns["reload_mykeys"] = lambda: ({"oai_config": cfg}, False)
|
||||
assert ns["resolve_session"]("oai_config").cfg == session.cfg
|
||||
|
||||
|
||||
def test_bridge_save_only_writes_config_and_reads_profiles(tmp_path):
|
||||
tree = ast.parse((ROOT / "frontends/desktop_bridge.py").read_text(encoding="utf-8"))
|
||||
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "AgentManager")
|
||||
method = next(n for n in cls.body if getattr(n, "name", None) == "_save_mykey_text")
|
||||
ns = {}
|
||||
exec(compile(ast.Module(body=[method], type_ignores=[]), "desktop_bridge.py", "exec"), ns)
|
||||
file = tmp_path / "synthetic_config.py"
|
||||
manager = SimpleNamespace(_mykey_file=lambda: file, list_model_profiles=lambda: ["profile"])
|
||||
assert ns["_save_mykey_text"](manager, "synthetic = {}") == ["profile"]
|
||||
assert file.read_text(encoding="utf-8") == "synthetic = {}"
|
||||
|
||||
|
||||
def test_ga_preserves_selected_model_when_config_order_changes(core):
|
||||
ns, source = core
|
||||
ns["resolve_client"] = lambda name, cfg: SimpleNamespace(backend=SimpleNamespace(name=cfg["name"], history=[]))
|
||||
Agent = agent_type(ns)
|
||||
ga = Agent()
|
||||
ga.llm_no = 1
|
||||
source.write_text("oai_config_a = {'name': 'A'}\noai_config_b = {'name': 'B'}\n", encoding="utf-8")
|
||||
ga.load_llm_sessions()
|
||||
assert ga.llmclient.backend.name == "B"
|
||||
source.write_text("oai_config_b = {'name': 'B'}\noai_config_a = {'name': 'A'}\n", encoding="utf-8")
|
||||
os.utime(source, ns=(1700000000000000000, 1700000000000000000))
|
||||
ga.load_llm_sessions()
|
||||
assert ga.llmclient.backend.name == "B" and ga.llm_no == 0
|
||||
|
||||
|
||||
def test_ga_drops_stale_client_when_models_removed(core):
|
||||
ns, source = core
|
||||
ns["resolve_client"] = lambda name, cfg: SimpleNamespace(backend=SimpleNamespace(name=cfg["name"], history=[]))
|
||||
Agent = agent_type(ns)
|
||||
ga = Agent()
|
||||
ga.llm_no = 0
|
||||
write(source, "A")
|
||||
ga.load_llm_sessions()
|
||||
source.write_text("", encoding="utf-8")
|
||||
os.utime(source, ns=(1700000000000000500, 1700000000000000500))
|
||||
ga.load_llm_sessions()
|
||||
assert ga.llmclients == [] and ga.llmclient is None
|
||||
|
||||
|
||||
def test_loader_does_not_reuse_same_second_bytecode(core):
|
||||
import py_compile
|
||||
ns, source = core
|
||||
write(source, "A")
|
||||
py_compile.compile(str(source), doraise=True)
|
||||
write(source, "B", 1700000000000000200)
|
||||
assert ns["mykeys_store"].snapshot()[0]["oai_config"]["model"] == "B"
|
||||
|
||||
|
||||
def test_legacy_direct_loader_raises_and_cleans_failed_module(core):
|
||||
ns, source = core
|
||||
source.write_text("partial = 1\nraise RuntimeError('synthetic failure')\n", encoding="utf-8")
|
||||
with pytest.raises(RuntimeError, match="synthetic failure"):
|
||||
ns["_load_mykeys"]()
|
||||
assert "mykey" not in sys.modules
|
||||
assert ns["reload_mykeys"]() == ({}, False)
|
||||
Reference in new issue
Block a user