mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-15 09:21:56 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b375d7cf09 | ||
|
|
c25c649048 | ||
|
|
f2df968aa5 |
@@ -33,6 +33,33 @@ impl PySQLiteMemory {
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
|
||||
}
|
||||
|
||||
fn replace_source(
|
||||
&self,
|
||||
source: &str,
|
||||
documents: Vec<(String, Option<String>)>,
|
||||
) -> PyResult<Vec<String>> {
|
||||
let parsed_documents = documents
|
||||
.into_iter()
|
||||
.map(|(content, metadata)| {
|
||||
let metadata = metadata
|
||||
.map(|value| serde_json::from_str(&value))
|
||||
.transpose()
|
||||
.map_err(|e| {
|
||||
PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string())
|
||||
})?;
|
||||
Ok((content, metadata))
|
||||
})
|
||||
.collect::<PyResult<Vec<_>>>()?;
|
||||
let document_refs = parsed_documents
|
||||
.iter()
|
||||
.map(|(content, metadata)| (content.as_str(), metadata.as_ref()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
self.inner
|
||||
.replace_source(source, &document_refs)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
|
||||
}
|
||||
|
||||
#[pyo3(signature = (query, top_k=5))]
|
||||
fn retrieve(&self, query: &str, top_k: usize) -> PyResult<String> {
|
||||
let results = self
|
||||
|
||||
@@ -94,6 +94,57 @@ impl SQLiteMemory {
|
||||
pub fn in_memory() -> Result<Self, OpenJarvisError> {
|
||||
Self::new(Path::new(":memory:"))
|
||||
}
|
||||
|
||||
/// Atomically replace every document for *source* with *documents*.
|
||||
pub fn replace_source(
|
||||
&self,
|
||||
source: &str,
|
||||
documents: &[(&str, Option<&Value>)],
|
||||
) -> Result<Vec<String>, OpenJarvisError> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction().map_err(|e| {
|
||||
OpenJarvisError::Io(std::io::Error::other(e.to_string()))
|
||||
})?;
|
||||
|
||||
tx.execute(
|
||||
"DELETE FROM documents_fts
|
||||
WHERE rowid IN (SELECT rowid FROM documents WHERE source = ?1)",
|
||||
rusqlite::params![source],
|
||||
)
|
||||
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
|
||||
tx.execute(
|
||||
"DELETE FROM documents WHERE source = ?1",
|
||||
rusqlite::params![source],
|
||||
)
|
||||
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
|
||||
|
||||
let mut doc_ids = Vec::with_capacity(documents.len());
|
||||
for (content, metadata) in documents {
|
||||
let doc_id = Uuid::new_v4().to_string();
|
||||
let meta_str = metadata
|
||||
.map(|m| serde_json::to_string(m).unwrap_or_default())
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO documents (id, content, source, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![doc_id, content, source, meta_str],
|
||||
)
|
||||
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
|
||||
|
||||
let rowid = tx.last_insert_rowid();
|
||||
tx.execute(
|
||||
"INSERT INTO documents_fts (rowid, content, source) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![rowid, content, source],
|
||||
)
|
||||
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
|
||||
doc_ids.push(doc_id);
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
|
||||
Ok(doc_ids)
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryBackend for SQLiteMemory {
|
||||
@@ -306,6 +357,40 @@ mod tests {
|
||||
assert_eq!(mem.count().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlite_replace_source_is_idempotent() {
|
||||
let mem = SQLiteMemory::in_memory().unwrap();
|
||||
|
||||
mem.replace_source("notes.txt", &[("old project notes", None)])
|
||||
.unwrap();
|
||||
assert_eq!(mem.count().unwrap(), 1);
|
||||
|
||||
mem.replace_source("notes.txt", &[("updated project notes", None)])
|
||||
.unwrap();
|
||||
assert_eq!(mem.count().unwrap(), 1);
|
||||
|
||||
assert!(mem.retrieve("old", 5).unwrap().is_empty());
|
||||
let updated = mem.retrieve("updated", 5).unwrap();
|
||||
assert_eq!(updated.len(), 1);
|
||||
assert_eq!(updated[0].source, "notes.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlite_replace_source_preserves_other_sources() {
|
||||
let mem = SQLiteMemory::in_memory().unwrap();
|
||||
mem.store("keep this manual", "manual.txt", None).unwrap();
|
||||
mem.replace_source("notes.txt", &[("old project notes", None)])
|
||||
.unwrap();
|
||||
|
||||
mem.replace_source("notes.txt", &[("updated project notes", None)])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(mem.count().unwrap(), 2);
|
||||
let manual = mem.retrieve("manual", 5).unwrap();
|
||||
assert_eq!(manual.len(), 1);
|
||||
assert_eq!(manual[0].source, "manual.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlite_case_insensitive_search() {
|
||||
let mem = SQLiteMemory::in_memory().unwrap();
|
||||
|
||||
@@ -89,15 +89,39 @@ def index(
|
||||
|
||||
mem = _get_backend(backend)
|
||||
try:
|
||||
for chunk in track(chunks, description="Storing chunks...", console=console):
|
||||
mem.store(
|
||||
chunk.content,
|
||||
source=chunk.source,
|
||||
metadata={
|
||||
"offset": chunk.offset,
|
||||
"index": chunk.index,
|
||||
},
|
||||
)
|
||||
replace_source = getattr(mem, "replace_source", None)
|
||||
if callable(replace_source):
|
||||
documents_by_source = {}
|
||||
for chunk in chunks:
|
||||
documents_by_source.setdefault(chunk.source, []).append(
|
||||
(
|
||||
chunk.content,
|
||||
{
|
||||
"offset": chunk.offset,
|
||||
"index": chunk.index,
|
||||
},
|
||||
)
|
||||
)
|
||||
for source, documents in track(
|
||||
documents_by_source.items(),
|
||||
description="Replacing sources...",
|
||||
console=console,
|
||||
):
|
||||
replace_source(source, documents)
|
||||
else:
|
||||
for chunk in track(
|
||||
chunks,
|
||||
description="Storing chunks...",
|
||||
console=console,
|
||||
):
|
||||
mem.store(
|
||||
chunk.content,
|
||||
source=chunk.source,
|
||||
metadata={
|
||||
"offset": chunk.offset,
|
||||
"index": chunk.index,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if hasattr(mem, "close"):
|
||||
mem.close()
|
||||
|
||||
@@ -34,6 +34,15 @@ _MEMORY_BACKEND_LOCK_SETUP = threading.Lock()
|
||||
_MCP_LOCK_SETUP = threading.Lock()
|
||||
|
||||
|
||||
def _get_runtime_event_bus(runtime: Any = None) -> Any:
|
||||
"""Return the server-owned event bus, falling back outside app runtimes."""
|
||||
|
||||
from openjarvis.core.events import get_event_bus
|
||||
|
||||
bus = getattr(runtime, "bus", None)
|
||||
return bus if bus is not None else get_event_bus()
|
||||
|
||||
|
||||
def _start_managed_worker(app_state: Any, target: Any, *, name: str) -> Any:
|
||||
"""Start and track a managed-agent worker for orderly app shutdown."""
|
||||
|
||||
@@ -301,14 +310,13 @@ def _make_lightweight_system(
|
||||
# Wrap with InstrumentedEngine so agent ticks are recorded
|
||||
# in telemetry (FLOPs, energy, cost savings).
|
||||
try:
|
||||
from openjarvis.core.events import get_event_bus
|
||||
from openjarvis.telemetry.instrumented_engine import (
|
||||
InstrumentedEngine,
|
||||
)
|
||||
|
||||
plain_engine = InstrumentedEngine(
|
||||
plain_engine,
|
||||
get_event_bus(),
|
||||
_get_runtime_event_bus(runtime),
|
||||
)
|
||||
except Exception:
|
||||
pass # telemetry is optional
|
||||
@@ -1644,26 +1652,27 @@ def create_agent_manager_router(
|
||||
|
||||
# Re-use the server's engine + model so we don't pick a
|
||||
# random model from Ollama's list.
|
||||
server_engine = getattr(request.app.state, "engine", None)
|
||||
server_model = getattr(request.app.state, "model", "")
|
||||
server_config = getattr(request.app.state, "config", None)
|
||||
app_state = request.app.state
|
||||
server_engine = getattr(app_state, "engine", None)
|
||||
server_model = getattr(app_state, "model", "")
|
||||
server_config = getattr(app_state, "config", None)
|
||||
server_bus = _get_runtime_event_bus(app_state)
|
||||
|
||||
def _run_tick():
|
||||
try:
|
||||
from openjarvis.agents.executor import AgentExecutor
|
||||
from openjarvis.core.events import get_event_bus
|
||||
|
||||
_ts = getattr(request.app.state, "trace_store", None)
|
||||
_ts = getattr(app_state, "trace_store", None)
|
||||
executor = AgentExecutor(
|
||||
manager=manager,
|
||||
event_bus=get_event_bus(),
|
||||
event_bus=server_bus,
|
||||
trace_store=_ts,
|
||||
)
|
||||
system = _make_lightweight_system(
|
||||
server_engine,
|
||||
server_model,
|
||||
server_config,
|
||||
request.app.state,
|
||||
app_state,
|
||||
)
|
||||
executor.set_system(system)
|
||||
# The route handler above already called start_tick() to
|
||||
@@ -1690,7 +1699,7 @@ def create_agent_manager_router(
|
||||
|
||||
try:
|
||||
_start_managed_worker(
|
||||
request.app.state,
|
||||
app_state,
|
||||
_run_tick,
|
||||
name=f"managed-agent-run-{agent_id}",
|
||||
)
|
||||
@@ -1997,11 +2006,12 @@ def create_agent_manager_router(
|
||||
import time as _time
|
||||
|
||||
from openjarvis.agents.executor import AgentExecutor
|
||||
from openjarvis.core.events import get_event_bus
|
||||
|
||||
_srv_engine = getattr(request.app.state, "engine", None)
|
||||
_srv_model = getattr(request.app.state, "model", "")
|
||||
_srv_config = getattr(request.app.state, "config", None)
|
||||
_app_state = request.app.state
|
||||
_srv_engine = getattr(_app_state, "engine", None)
|
||||
_srv_model = getattr(_app_state, "model", "")
|
||||
_srv_config = getattr(_app_state, "config", None)
|
||||
_srv_bus = _get_runtime_event_bus(_app_state)
|
||||
|
||||
def _immediate_tick():
|
||||
_start = _time.time()
|
||||
@@ -2011,17 +2021,17 @@ def create_agent_manager_router(
|
||||
_srv_model,
|
||||
)
|
||||
try:
|
||||
_ts2 = getattr(request.app.state, "trace_store", None)
|
||||
_ts2 = getattr(_app_state, "trace_store", None)
|
||||
executor = AgentExecutor(
|
||||
manager=manager,
|
||||
event_bus=get_event_bus(),
|
||||
event_bus=_srv_bus,
|
||||
trace_store=_ts2,
|
||||
)
|
||||
system = _make_lightweight_system(
|
||||
_srv_engine,
|
||||
_srv_model,
|
||||
_srv_config,
|
||||
request.app.state,
|
||||
_app_state,
|
||||
)
|
||||
executor.set_system(system)
|
||||
logger.info(
|
||||
@@ -2055,7 +2065,7 @@ def create_agent_manager_router(
|
||||
|
||||
try:
|
||||
_start_managed_worker(
|
||||
request.app.state,
|
||||
_app_state,
|
||||
_immediate_tick,
|
||||
name=f"managed-agent-immediate-{agent_id}",
|
||||
)
|
||||
@@ -2106,12 +2116,12 @@ def create_agent_manager_router(
|
||||
return {"learning_log": manager.list_learning_log(agent_id)}
|
||||
|
||||
@agents_router.post("/{agent_id}/learning/run")
|
||||
def trigger_learning(agent_id: str):
|
||||
def trigger_learning(agent_id: str, request: Request):
|
||||
if not manager.get_agent(agent_id):
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
from openjarvis.core.events import EventType, get_event_bus
|
||||
from openjarvis.core.events import EventType
|
||||
|
||||
bus = get_event_bus()
|
||||
bus = _get_runtime_event_bus(request.app.state)
|
||||
bus.publish(EventType.AGENT_LEARNING_STARTED, {"agent_id": agent_id})
|
||||
return {"status": "triggered"}
|
||||
|
||||
|
||||
@@ -1087,12 +1087,16 @@ def include_all_routes(app) -> None:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# WebSocket bridge for real-time agent events
|
||||
# WebSocket bridge for real-time agent events. Must subscribe on the
|
||||
# same EventBus instance channels/agents actually publish to
|
||||
# (app.state.bus, set in server/app.py) — the get_event_bus() global
|
||||
# singleton is a *different* bus that nothing in `jarvis serve` ever
|
||||
# publishes to, so events silently never reached this endpoint.
|
||||
try:
|
||||
from openjarvis.core.events import get_event_bus
|
||||
from openjarvis.server.ws_bridge import create_ws_router
|
||||
|
||||
ws_router = create_ws_router(get_event_bus())
|
||||
ws_router = create_ws_router(getattr(app.state, "bus", None) or get_event_bus())
|
||||
app.include_router(ws_router)
|
||||
except Exception:
|
||||
logger.debug("WebSocket bridge not available", exc_info=True)
|
||||
|
||||
@@ -84,6 +84,17 @@ def is_cloud_model(model: str) -> bool:
|
||||
return get_provider(model) is not None
|
||||
|
||||
|
||||
def _openrouter_model_id(model: str) -> str:
|
||||
"""Return the provider-facing ID for an OpenRouter model."""
|
||||
prefix = "openrouter/"
|
||||
candidate = model.removeprefix(prefix)
|
||||
# OpenRouter owns IDs such as "openrouter/auto" itself. Only remove the
|
||||
# LiteLLM routing prefix when the remainder is still a provider/model ID.
|
||||
if model.startswith(prefix) and "/" in candidate:
|
||||
return candidate
|
||||
return model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -371,7 +382,7 @@ async def stream_cloud(
|
||||
"OPENROUTER_API_KEY not set — add it in the Cloud Models tab"
|
||||
)
|
||||
async for token in _stream_openai(
|
||||
model,
|
||||
_openrouter_model_id(model),
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens,
|
||||
|
||||
@@ -95,6 +95,29 @@ class SQLiteMemory(MemoryBackend):
|
||||
)
|
||||
return doc_id
|
||||
|
||||
def replace_source(
|
||||
self,
|
||||
source: str,
|
||||
documents: List[tuple[str, Optional[Dict[str, Any]]]],
|
||||
) -> List[str]:
|
||||
"""Atomically replace all documents associated with *source*."""
|
||||
payload = [
|
||||
(content, json.dumps(metadata) if metadata else None)
|
||||
for content, metadata in documents
|
||||
]
|
||||
doc_ids = self._rust_impl.replace_source(source, payload)
|
||||
bus = get_event_bus()
|
||||
for doc_id in doc_ids:
|
||||
bus.publish(
|
||||
EventType.MEMORY_STORE,
|
||||
{
|
||||
"backend": self.backend_id,
|
||||
"doc_id": doc_id,
|
||||
"source": source,
|
||||
},
|
||||
)
|
||||
return doc_ids
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
|
||||
@@ -40,6 +40,35 @@ def test_memory_index_file(tmp_path: Path, monkeypatch):
|
||||
assert "Indexed" in result.output or "chunk" in result.output
|
||||
|
||||
|
||||
def test_memory_index_replaces_existing_source(tmp_path: Path, monkeypatch):
|
||||
"""Re-indexing a file replaces its previous chunks."""
|
||||
_register_sqlite()
|
||||
db_path = str(tmp_path / "mem.db")
|
||||
doc = tmp_path / "doc.txt"
|
||||
doc.write_text(" ".join(["legacy"] * 100), encoding="utf-8")
|
||||
|
||||
mod = importlib.import_module("openjarvis.cli.memory_cmd")
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_get_backend",
|
||||
lambda b=None: SQLiteMemory(db_path=db_path),
|
||||
)
|
||||
|
||||
first = CliRunner().invoke(cli, ["memory", "index", str(doc)])
|
||||
assert first.exit_code == 0
|
||||
|
||||
doc.write_text(" ".join(["updated"] * 100), encoding="utf-8")
|
||||
second = CliRunner().invoke(cli, ["memory", "index", str(doc)])
|
||||
assert second.exit_code == 0
|
||||
|
||||
backend = SQLiteMemory(db_path=db_path)
|
||||
assert backend.count() == 1
|
||||
assert backend.retrieve("legacy") == []
|
||||
updated = backend.retrieve("updated")
|
||||
assert len(updated) == 1
|
||||
assert updated[0].source == str(doc)
|
||||
|
||||
|
||||
def test_memory_index_nonexistent(tmp_path: Path):
|
||||
"""Indexing a nonexistent path should fail."""
|
||||
_register_sqlite()
|
||||
|
||||
@@ -605,6 +605,39 @@ class TestLightweightSystemEngineResolution:
|
||||
)
|
||||
assert captured["key"] == "llamacpp"
|
||||
|
||||
def test_instrumented_engine_uses_runtime_event_bus(self, monkeypatch):
|
||||
pytest.importorskip("fastapi")
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.server import agent_manager_routes as amr
|
||||
from openjarvis.telemetry import instrumented_engine
|
||||
|
||||
resolved_engine = MagicMock()
|
||||
wrapped_engine = MagicMock()
|
||||
runtime_bus = EventBus()
|
||||
runtime = SimpleNamespace(
|
||||
bus=runtime_bus,
|
||||
memory_backend=object(),
|
||||
channel_backend=None,
|
||||
channel_bridge=None,
|
||||
knowledge_db_path=None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"openjarvis.engine._discovery.get_engine",
|
||||
MagicMock(return_value=("resolved", resolved_engine)),
|
||||
)
|
||||
instrumented = MagicMock(return_value=wrapped_engine)
|
||||
monkeypatch.setattr(instrumented_engine, "InstrumentedEngine", instrumented)
|
||||
|
||||
system = amr._make_lightweight_system(
|
||||
engine=MagicMock(),
|
||||
model="m",
|
||||
config=self._cfg("vllm", "ollama"),
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
instrumented.assert_called_once_with(resolved_engine, runtime_bus)
|
||||
assert system.engine is wrapped_engine
|
||||
|
||||
def test_caches_tool_memory_backend_when_prompt_context_is_disabled(
|
||||
self,
|
||||
monkeypatch,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Regression tests for OpenRouter model ID normalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.core.types import Message
|
||||
from openjarvis.server import cloud_router
|
||||
|
||||
|
||||
def test_get_provider_detects_bare_openrouter_id():
|
||||
assert cloud_router.get_provider("anthropic/claude-haiku-4.5") == "openrouter"
|
||||
|
||||
|
||||
def test_get_provider_detects_litellm_prefixed_openrouter_id():
|
||||
model = "openrouter/anthropic/claude-haiku-4.5"
|
||||
assert cloud_router.get_provider(model) == "openrouter"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"requested_model,expected_forwarded_model",
|
||||
[
|
||||
("anthropic/claude-haiku-4.5", "anthropic/claude-haiku-4.5"),
|
||||
("openrouter/anthropic/claude-haiku-4.5", "anthropic/claude-haiku-4.5"),
|
||||
("openrouter/auto", "openrouter/auto"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_cloud_normalizes_openrouter_model_before_forwarding(
|
||||
monkeypatch, requested_model, expected_forwarded_model
|
||||
):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
async def fake_stream_openai(model, messages, temperature, max_tokens, **kwargs):
|
||||
captured["model"] = model
|
||||
yield "ok"
|
||||
|
||||
monkeypatch.setattr(cloud_router, "_stream_openai", fake_stream_openai)
|
||||
|
||||
tokens = [
|
||||
token
|
||||
async for token in cloud_router.stream_cloud(
|
||||
requested_model, [Message(role="user", content="hi")]
|
||||
)
|
||||
]
|
||||
|
||||
assert tokens == ["ok"]
|
||||
assert captured["model"] == expected_forwarded_model
|
||||
@@ -3,8 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -161,3 +163,91 @@ class TestWSBridge:
|
||||
]
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
class TestIncludeAllRoutesBusWiring:
|
||||
"""Regression: the WS endpoint must subscribe on the same EventBus that
|
||||
channels/agents actually publish to (app.state.bus), not the unrelated
|
||||
get_event_bus() global singleton — publishing on the latter used to
|
||||
silently never reach any connected browser client."""
|
||||
|
||||
def test_uses_app_state_bus_not_global_singleton(self):
|
||||
from openjarvis.core.events import reset_event_bus
|
||||
from openjarvis.server.api_routes import include_all_routes
|
||||
|
||||
reset_event_bus() # isolate from other tests' global singleton state
|
||||
app = FastAPI()
|
||||
real_bus = EventBus()
|
||||
app.state.bus = real_bus
|
||||
include_all_routes(app)
|
||||
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect("/v1/agents/events") as ws:
|
||||
real_bus.publish(EventType.AGENT_TICK_START, {"agent_id": "test-123"})
|
||||
time.sleep(0.05)
|
||||
data = ws.receive_json()
|
||||
assert data["data"]["agent_id"] == "test-123"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "payload"),
|
||||
[
|
||||
("/v1/managed-agents/test-123/run", None),
|
||||
(
|
||||
"/v1/managed-agents/test-123/messages",
|
||||
{"content": "run now", "mode": "immediate", "stream": False},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_managed_agent_run_paths_publish_to_app_bus(self, path, payload):
|
||||
from openjarvis.agents.executor import AgentExecutor
|
||||
from openjarvis.core.events import reset_event_bus
|
||||
from openjarvis.server.api_routes import include_all_routes
|
||||
|
||||
reset_event_bus()
|
||||
app_bus = EventBus()
|
||||
manager = MagicMock()
|
||||
manager.get_agent.return_value = {
|
||||
"id": "test-123",
|
||||
"name": "test",
|
||||
"status": "idle",
|
||||
"config": {},
|
||||
}
|
||||
manager.send_message.return_value = {
|
||||
"id": "message-123",
|
||||
"agent_id": "test-123",
|
||||
"content": "run now",
|
||||
"mode": "immediate",
|
||||
}
|
||||
|
||||
app = FastAPI()
|
||||
app.state.bus = app_bus
|
||||
app.state.agent_manager = manager
|
||||
include_all_routes(app)
|
||||
|
||||
executed = threading.Event()
|
||||
observed_buses = []
|
||||
|
||||
def publish_tick(executor, agent_id, **_kwargs):
|
||||
observed_buses.append(executor._bus)
|
||||
executor._bus.publish(EventType.AGENT_TICK_START, {"agent_id": agent_id})
|
||||
executed.set()
|
||||
|
||||
client = TestClient(app)
|
||||
with (
|
||||
patch.object(AgentExecutor, "execute_tick", publish_tick),
|
||||
patch(
|
||||
"openjarvis.server.agent_manager_routes._make_lightweight_system",
|
||||
return_value=MagicMock(),
|
||||
) as make_system,
|
||||
client.websocket_connect("/v1/agents/events?agent_id=test-123") as ws,
|
||||
):
|
||||
request_kwargs = {"json": payload} if payload is not None else {}
|
||||
response = client.post(path, **request_kwargs)
|
||||
assert response.status_code == 200
|
||||
assert executed.wait(timeout=1)
|
||||
assert observed_buses == [app_bus]
|
||||
data = ws.receive_json()
|
||||
|
||||
assert data["type"] == "agent_tick_start"
|
||||
assert data["data"]["agent_id"] == "test-123"
|
||||
make_system.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user