mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-11 17:32:20 +02:00
refactor(model-routing): centralize explicit foreground fallback policy (#6020)
* refactor(model-routing): centralize explicit foreground fallback policy Make foreground fallback an explicit per-user, availability-only policy shared by streaming Chat, non-stream Chat, and Agent runs. Preserve strict defaults, owner/model and credential boundaries, pinned Agent routes, and truthful per-round provenance/accounting. Carry provider-reported model identifiers through native streaming adapters, non-stream responses, and caches, and keep legacy default_model_fallbacks as tombstoned raw storage that generic settings APIs and agent tools cannot expose or mutate. * fix(agent-loop): restore rebase-dropped qwen routing, workspace prompt, and temperature clamp * fix(model-routing): thread selected endpoint identity, fix cost classification and fallback eligibility * fix(chat): restore stream helpers and harden run stop lifecycle * fix(model-routing): let numeric provider codes win over symbolic rate-limit statuses * fix(agent-loop): apply qwen temperature and notes-tool clamps per fallback candidate * fix(chat): honor queued stop across resend and reload canonical terminal on EOF * fix(chat): track stop queue and cleanup ownership by per-send generation * fix(agent-loop): preserve requested temperature for non-qwen fallback candidates * fix(chat): reserve send ownership before any await and scope stop to the current send * fix(chat): clear the previous run identity at send reservation --------- Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Co-authored-by: StressTestor <212606152+StressTestor@users.noreply.github.com>
This commit is contained in:
co-authored by
RaresKeY
StressTestor
parent
b52296471b
commit
c4369305f0
@@ -314,17 +314,21 @@ class TestComputeFinalMetrics:
|
||||
def test_tool_events_included(self):
|
||||
events = [{"tool": "bash", "duration": 1.0}]
|
||||
texts = ["round 1 text"]
|
||||
models = ["round-1-model"]
|
||||
m = _compute_final_metrics(**self._base_args(
|
||||
tool_events=events,
|
||||
round_texts=texts,
|
||||
round_models=models,
|
||||
))
|
||||
assert m["tool_events"] == events
|
||||
assert m["round_texts"] == texts
|
||||
assert m["round_models"] == models
|
||||
|
||||
def test_no_tool_events_excluded(self):
|
||||
m = _compute_final_metrics(**self._base_args(tool_events=[], round_texts=[]))
|
||||
assert "tool_events" not in m
|
||||
assert "round_texts" not in m
|
||||
assert "round_models" not in m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Saved Agent rounds must render and bill with actual per-round provenance."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_SOURCE = (
|
||||
Path(__file__).resolve().parents[1] / "static" / "js" / "chatRenderer.js"
|
||||
).read_text(encoding="utf-8")
|
||||
_CHAT_SOURCE = (
|
||||
Path(__file__).resolve().parents[1] / "static" / "js" / "chat.js"
|
||||
).read_text(encoding="utf-8")
|
||||
_SLASH_SOURCE = (
|
||||
Path(__file__).resolve().parents[1] / "static" / "js" / "slashCommands.js"
|
||||
).read_text(encoding="utf-8")
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _function_source(name):
|
||||
match = re.search(
|
||||
rf"^(?:export )?function {name}\(.*?^\}}",
|
||||
_SOURCE,
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
assert match, f"{name} not found"
|
||||
return match.group(0).replace("export function", "function", 1)
|
||||
|
||||
|
||||
def _run_node(source):
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=source,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip())
|
||||
|
||||
|
||||
def test_saved_agent_rounds_prefer_round_model_provenance():
|
||||
assert "const roundModels = metadata.round_models || [];" in _SOURCE
|
||||
assert "const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;" in _SOURCE
|
||||
assert "Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1" in _SOURCE
|
||||
assert "const roundEndpointIds = metadata.round_endpoint_ids || [];" in _SOURCE
|
||||
assert "const roundEndpointLabels = metadata.round_endpoint_labels || [];" in _SOURCE
|
||||
assert "r < roundEndpointIds.length" in _SOURCE
|
||||
assert "r < roundEndpointLabels.length" in _SOURCE
|
||||
assert "roundEndpointIds[r] || pair.actualEndpointId" not in _SOURCE
|
||||
|
||||
|
||||
def test_metrics_cost_uses_actual_fallback_endpoint_classification():
|
||||
assert "metrics.endpoint_cost_tracked" in _SOURCE
|
||||
assert "endpointCostTracked === false" in _SOURCE
|
||||
assert "endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)" in _SOURCE
|
||||
assert "Array.isArray(metrics.usage_buckets)" in _SOURCE
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_agent_usage_buckets_sum_only_billable_answering_routes():
|
||||
source = "\n".join([
|
||||
"let currentUrl = '';",
|
||||
"function _currentEndpointUrl() { return currentUrl; }",
|
||||
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
"const paidSelected = {usage_buckets: [",
|
||||
" {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true},",
|
||||
" {model: 'local-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: false},",
|
||||
"]};",
|
||||
"const localSelected = {usage_buckets: [",
|
||||
" {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: false},",
|
||||
" {model: 'paid-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: true},",
|
||||
"]};",
|
||||
"currentUrl = 'local';",
|
||||
"const paidToLocal = _metricsBillableCost(paidSelected, 'final', 300, 30);",
|
||||
"currentUrl = 'paid';",
|
||||
"const localToPaid = _metricsBillableCost(localSelected, 'final', 300, 30);",
|
||||
"console.log(JSON.stringify({paidToLocal, localToPaid}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {"paidToLocal": 0.11, "localToPaid": 0.22}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_force_answer_synthesis_segment_is_included_in_fallback_cost():
|
||||
source = "\n".join([
|
||||
"function _currentEndpointUrl() { return 'local-selected'; }",
|
||||
"function isCostTrackedEndpoint() { return false; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
"const metrics = {usage_buckets: [",
|
||||
" {round: 6, model: 'paid-fallback', input_tokens: 100, output_tokens: 0, endpoint_cost_tracked: true},",
|
||||
" {round: 6, model: 'paid-fallback', input_tokens: 80, output_tokens: 20, endpoint_cost_tracked: true},",
|
||||
"]};",
|
||||
"console.log(JSON.stringify({cost: _metricsBillableCost(metrics, 'paid-fallback', 180, 20)}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {"cost": 0.2}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_repeated_live_metrics_render_records_session_cost_once():
|
||||
source = "\n".join([
|
||||
"const _COST_KEY = 'ody-session-cost';",
|
||||
"const state = {};",
|
||||
"const localStorage = {",
|
||||
" getItem(key) { return state[key] || null; },",
|
||||
" setItem(key, value) { state[key] = value; },",
|
||||
"};",
|
||||
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
|
||||
"function updateSessionCostUI() {}",
|
||||
"function _currentEndpointUrl() { return 'local'; }",
|
||||
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
_function_source("recordSessionMetricsCost"),
|
||||
"const metrics = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true};",
|
||||
"recordSessionMetricsCost(metrics);",
|
||||
"recordSessionMetricsCost(metrics);",
|
||||
"console.log(JSON.stringify({cost: JSON.parse(state[_COST_KEY]).session, recorded: metrics._costRecorded}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {"cost": 0.11, "recorded": True}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_replayed_metrics_use_run_identity_for_durable_cost_deduplication():
|
||||
source = "\n".join([
|
||||
"const _COST_KEY = 'ody-session-cost';",
|
||||
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
|
||||
"const _MAX_COST_RUNS_PER_SESSION = 256;",
|
||||
"const state = {};",
|
||||
"const localStorage = {",
|
||||
" getItem(key) { return state[key] || null; },",
|
||||
" setItem(key, value) { state[key] = value; },",
|
||||
"};",
|
||||
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
|
||||
"function updateSessionCostUI() {}",
|
||||
"function _currentEndpointUrl() { return 'local'; }",
|
||||
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
_function_source("recordSessionMetricsCost"),
|
||||
_function_source("getSessionCost"),
|
||||
"const firstObject = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true, _costRecordId: 'run-1'};",
|
||||
"const replayedObject = {...firstObject};",
|
||||
"recordSessionMetricsCost(firstObject);",
|
||||
"recordSessionMetricsCost(replayedObject);",
|
||||
"console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {"cost": 0.11, "runs": {"run-1": 0.11}}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_run_cost_ledger_sums_segments_and_updates_repeated_segment_metrics():
|
||||
source = "\n".join([
|
||||
"const _COST_KEY = 'ody-session-cost';",
|
||||
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
|
||||
"const _MAX_COST_RUNS_PER_SESSION = 256;",
|
||||
"const state = {};",
|
||||
"const localStorage = {",
|
||||
" getItem(key) { return state[key] || null; },",
|
||||
" setItem(key, value) { state[key] = value; },",
|
||||
"};",
|
||||
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
|
||||
"function updateSessionCostUI() {}",
|
||||
"function _currentEndpointUrl() { return 'paid'; }",
|
||||
"function isCostTrackedEndpoint() { return true; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
_function_source("recordSessionMetricsCost"),
|
||||
_function_source("getSessionCost"),
|
||||
"recordSessionMetricsCost({model: 'student', input_tokens: 100, output_tokens: 10, _costRecordId: 'run:primary'});",
|
||||
"recordSessionMetricsCost({model: 'student', input_tokens: 120, output_tokens: 20, _costRecordId: 'run:primary'});",
|
||||
"recordSessionMetricsCost({model: 'teacher', input_tokens: 200, output_tokens: 30, _costRecordId: 'run:teacher'});",
|
||||
"console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {
|
||||
"cost": 0.37,
|
||||
"runs": {"run:primary": 0.14, "run:teacher": 0.23},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_local_selected_endpoint_does_not_erase_paid_fallback_ledger():
|
||||
source = "\n".join([
|
||||
"const _COST_KEY = 'ody-session-cost';",
|
||||
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
|
||||
"const state = {'ody-session-cost': JSON.stringify({session: 0.125})};",
|
||||
"const localStorage = {",
|
||||
" getItem(key) { return state[key] || null; },",
|
||||
" setItem(key, value) { state[key] = value; },",
|
||||
"};",
|
||||
"const badge = {style: {}, textContent: ''};",
|
||||
"const document = {getElementById() { return badge; }};",
|
||||
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }, getCurrentEndpointUrl() { return 'local'; }}};",
|
||||
_function_source("getSessionCost"),
|
||||
_function_source("updateSessionCostUI"),
|
||||
"updateSessionCostUI();",
|
||||
"console.log(JSON.stringify({stored: JSON.parse(state[_COST_KEY]).session, display: badge.style.display, text: badge.textContent}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {
|
||||
"stored": 0.125,
|
||||
"display": "",
|
||||
"text": "$0.125",
|
||||
}
|
||||
|
||||
|
||||
def test_live_and_resumed_terminal_events_apply_usage_metrics_before_reload():
|
||||
assert "metrics = json.data || metrics;" in _CHAT_SOURCE
|
||||
assert "displayMetrics(terminalMetricsTarget, metrics);" in _CHAT_SOURCE
|
||||
assert "metricsData = json.data || metricsData;" in _CHAT_SOURCE
|
||||
assert "displayMetrics(holder, metricsData);" in _CHAT_SOURCE
|
||||
assert "json.type === 'agent_terminal' || json.type === 'chat_terminal'" in _CHAT_SOURCE
|
||||
assert "chatRenderer.recordSessionMetricsCost(metrics, streamSessionId);" in _CHAT_SOURCE
|
||||
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId);" in _CHAT_SOURCE
|
||||
assert "metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);" in _CHAT_SOURCE
|
||||
assert "bgTerminal.status = 'completed';" in _CHAT_SOURCE
|
||||
|
||||
|
||||
def test_usage_command_does_not_hide_existing_fallback_cost_for_local_selection():
|
||||
assert "const cost = chatRenderer.getSessionCost" in _SLASH_SOURCE
|
||||
assert "const cost = costTracked && chatRenderer.getSessionCost" not in _SLASH_SOURCE
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Execute the round-aware live model-provenance state helper under Node."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_MODULE = (_REPO / "static" / "js" / "chatModelProvenance.js").as_uri()
|
||||
|
||||
|
||||
def test_round_two_fallback_then_provider_alias_does_not_relabel_round_one():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{ applyModelRouteEventState }} from {json.dumps(_MODULE)};
|
||||
const round1 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
|
||||
const round2 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
|
||||
|
||||
const fallbackTarget = applyModelRouteEventState({{
|
||||
type: 'fallback', round: 2,
|
||||
selected_model: 'selected-model', answered_by: 'backup-model'
|
||||
}}, round1, round2, 'selected-model');
|
||||
const aliasTarget = applyModelRouteEventState({{
|
||||
type: 'model_actual', round: 2,
|
||||
requested_model: 'selected-model', model: 'provider-backup-alias'
|
||||
}}, round1, round2, 'selected-model');
|
||||
|
||||
console.log(JSON.stringify({{
|
||||
fallbackIsRound2: fallbackTarget === round2,
|
||||
aliasIsRound2: aliasTarget === round2,
|
||||
round1,
|
||||
round2,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
state = json.loads(result.stdout)
|
||||
assert state == {
|
||||
"fallbackIsRound2": True,
|
||||
"aliasIsRound2": True,
|
||||
"round1": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "selected-model",
|
||||
},
|
||||
"round2": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "provider-backup-alias",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_next_round_and_final_metrics_preserve_each_agent_round_route():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{
|
||||
applyModelMetricsState,
|
||||
applyModelRouteEventState,
|
||||
inheritModelRouteState,
|
||||
}} from {json.dumps(_MODULE)};
|
||||
const round1 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
|
||||
const round2 = {{}};
|
||||
inheritModelRouteState(round1, round1, round2, 'selected-model');
|
||||
applyModelRouteEventState({{
|
||||
type: 'fallback', round: 2,
|
||||
selected_model: 'selected-model', answered_by: 'backup-model'
|
||||
}}, round1, round2, 'selected-model');
|
||||
applyModelRouteEventState({{
|
||||
type: 'model_actual', round: 2,
|
||||
requested_model: 'selected-model', model: 'provider-backup-alias'
|
||||
}}, round1, round2, 'selected-model');
|
||||
|
||||
const round3 = {{}};
|
||||
inheritModelRouteState(round1, round2, round3, 'selected-model');
|
||||
const metricsTarget = applyModelMetricsState({{
|
||||
requested_model: 'selected-model',
|
||||
model: 'provider-backup-alias',
|
||||
round_models: ['selected-model', 'provider-backup-alias', 'backup-model'],
|
||||
}}, round1, round3, 'selected-model');
|
||||
|
||||
console.log(JSON.stringify({{
|
||||
metricsIsRound3: metricsTarget === round3,
|
||||
round1,
|
||||
round2,
|
||||
round3,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"metricsIsRound3": True,
|
||||
"round1": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "selected-model",
|
||||
},
|
||||
"round2": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "provider-backup-alias",
|
||||
},
|
||||
"round3": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "backup-model",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_same_model_fallback_preserves_distinct_endpoint_route_state():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{ applyModelMetricsState, applyModelRouteEventState }} from {json.dumps(_MODULE)};
|
||||
const holder = {{ _requestedModel: 'same-model', _actualModel: 'same-model' }};
|
||||
applyModelRouteEventState({{
|
||||
type: 'fallback',
|
||||
selected_model: 'same-model', answered_by: 'same-model',
|
||||
selected_endpoint_id: 'account-one', selected_endpoint_label: 'Account one',
|
||||
answered_by_endpoint_id: 'account-two', answered_by_endpoint_label: 'Account two',
|
||||
}}, holder, null, 'same-model');
|
||||
applyModelMetricsState({{
|
||||
requested_model: 'same-model', model: 'same-model',
|
||||
requested_endpoint_id: 'account-one', requested_endpoint_label: 'Account one',
|
||||
endpoint_id: 'account-two', endpoint_label: 'Account two',
|
||||
}}, holder, null, 'same-model');
|
||||
console.log(JSON.stringify(holder));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"_requestedModel": "same-model",
|
||||
"_actualModel": "same-model",
|
||||
"_requestedEndpointId": "account-one",
|
||||
"_requestedEndpointLabel": "Account one",
|
||||
"_actualEndpointId": "account-two",
|
||||
"_actualEndpointLabel": "Account two",
|
||||
}
|
||||
|
||||
|
||||
def test_metrics_preserve_explicitly_unknown_round_endpoint():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{ applyModelMetricsState }} from {json.dumps(_MODULE)};
|
||||
const holder = {{
|
||||
_requestedModel: 'same-model',
|
||||
_actualModel: 'same-model',
|
||||
_requestedEndpointId: 'account-one',
|
||||
_requestedEndpointLabel: 'Account one',
|
||||
}};
|
||||
const roundHolder = {{}};
|
||||
applyModelMetricsState({{
|
||||
requested_model: 'same-model', model: 'same-model',
|
||||
requested_endpoint_id: 'account-one', requested_endpoint_label: 'Account one',
|
||||
endpoint_id: 'account-two', endpoint_label: 'Account two',
|
||||
round_endpoint_ids: [null], round_endpoint_labels: [null],
|
||||
}}, holder, roundHolder, 'same-model');
|
||||
console.log(JSON.stringify(roundHolder));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"_requestedModel": "same-model",
|
||||
"_actualModel": "same-model",
|
||||
"_requestedEndpointId": "account-one",
|
||||
"_requestedEndpointLabel": "Account one",
|
||||
"_actualEndpointId": None,
|
||||
"_actualEndpointLabel": None,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Execute terminal stream-error classification under Node."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_MODULE = (_REPO / "static" / "js" / "chatStreamErrors.js").as_uri()
|
||||
|
||||
|
||||
def test_terminal_provider_errors_preserve_text_and_never_auto_retry():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{ createTerminalStreamError, isRecoverableStreamError }} from {json.dumps(_MODULE)};
|
||||
const stringError = createTerminalStreamError({{ status: 401, error: 'invalid key' }});
|
||||
const objectError = createTerminalStreamError({{ status: 404, error: {{ message: 'model missing' }} }});
|
||||
console.log(JSON.stringify({{
|
||||
stringMessage: stringError.message,
|
||||
objectMessage: objectError.message,
|
||||
terminalRecoverable: isRecoverableStreamError(stringError),
|
||||
eofRecoverable: isRecoverableStreamError(new Error('Stream closed before completion')),
|
||||
networkRecoverable: isRecoverableStreamError(new TypeError('fetch failed')),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"stringMessage": "invalid key",
|
||||
"objectMessage": "model missing",
|
||||
"terminalRecoverable": False,
|
||||
"eofRecoverable": True,
|
||||
"networkRecoverable": True,
|
||||
}
|
||||
@@ -81,6 +81,13 @@ def _make_stream_with_save(sink, chunks, *, hang_after=None):
|
||||
return gen()
|
||||
|
||||
|
||||
async def _collect_subscription(session_id, expected_run=None):
|
||||
return [
|
||||
event
|
||||
async for event in agent_runs.subscribe(session_id, expected_run)
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# agent_runs: detached-run semantics (what NORMAL chat/agent streams use)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -136,7 +143,7 @@ async def test_stop_cancels_detached_run_and_saves_partial_exactly_once():
|
||||
break
|
||||
await sub.aclose()
|
||||
|
||||
stopped = agent_runs.stop(session_id)
|
||||
stopped = agent_runs.stop(session_id, run.run_id)
|
||||
assert stopped is True
|
||||
|
||||
await run.task # propagates promptly — not stuck on the hung await
|
||||
@@ -165,6 +172,172 @@ async def test_normal_completion_saves_exactly_once_not_partial():
|
||||
assert sink.saves == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detached_run_identity_is_stable_for_replay_and_unique_per_run():
|
||||
session_id = "sess-detached-run-identity"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
|
||||
first = agent_runs.start(session_id, _make_stream_with_save(_FakeSaveSink(), ["one"]))
|
||||
first_id = first.run_id
|
||||
assert agent_runs.get_run_id(session_id) == first_id
|
||||
await first.task
|
||||
assert agent_runs.get_run_id(session_id) == first_id
|
||||
|
||||
second = agent_runs.start(session_id, _make_stream_with_save(_FakeSaveSink(), ["two"]))
|
||||
assert second.run_id != first_id
|
||||
assert agent_runs.get_run_id(session_id) == second.run_id
|
||||
await second.task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_subscription_stays_bound_to_header_run_after_replacement():
|
||||
session_id = "sess-detached-lazy-subscription"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
|
||||
async def stream(label):
|
||||
yield f'data: {{"delta":"{label}"}}\n\n'
|
||||
|
||||
first = agent_runs.start(session_id, stream("first"))
|
||||
await first.task
|
||||
# StreamingResponse does not iterate its body until after construction.
|
||||
# Capture the same exact run object used for its identity header.
|
||||
lazy_body = agent_runs.subscribe(session_id, first)
|
||||
|
||||
second = agent_runs.start(session_id, stream("second"))
|
||||
await second.task
|
||||
|
||||
replayed = [event async for event in lazy_body]
|
||||
assert replayed == ['data: {"delta":"first"}\n\n']
|
||||
assert agent_runs.get_run_id(session_id) == second.run_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_run_identity_cannot_stop_replacement_run():
|
||||
session_id = "sess-detached-stale-stop"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def finished():
|
||||
yield 'data: {"delta":"old"}\n\n'
|
||||
|
||||
async def replacement():
|
||||
yield 'data: {"delta":"new"}\n\n'
|
||||
await release.wait()
|
||||
|
||||
first = agent_runs.start(session_id, finished())
|
||||
await first.task
|
||||
second = agent_runs.start(session_id, replacement())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert agent_runs.stop(session_id) is False
|
||||
assert agent_runs.stop(session_id, first.run_id) is False
|
||||
assert second.task is not None and not second.task.done()
|
||||
assert agent_runs.stop(session_id, second.run_id) is True
|
||||
await second.task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triple_replacement_closes_middle_subscriber_and_preserves_save_order():
|
||||
session_id = "sess-detached-triple-replacement"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
first_closing = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
third_started = asyncio.Event()
|
||||
|
||||
async def first_stream():
|
||||
try:
|
||||
yield 'data: {"delta":"first"}\n\n'
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
first_closing.set()
|
||||
await release_first.wait()
|
||||
|
||||
async def middle_stream():
|
||||
yield 'data: {"delta":"middle"}\n\n'
|
||||
|
||||
async def third_stream():
|
||||
third_started.set()
|
||||
yield 'data: {"delta":"third"}\n\n'
|
||||
|
||||
first = agent_runs.start(session_id, first_stream())
|
||||
while not first.buffer:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
middle = agent_runs.start(session_id, middle_stream())
|
||||
await first_closing.wait()
|
||||
assert middle.task is not None and not middle.task.done()
|
||||
|
||||
middle_events_task = asyncio.create_task(
|
||||
_collect_subscription(session_id, middle)
|
||||
)
|
||||
while not middle.subscribers:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
third = agent_runs.start(session_id, third_stream())
|
||||
|
||||
# The superseded middle response closes immediately even though its task
|
||||
# remains as the transitive barrier for the first run's partial save.
|
||||
assert await asyncio.wait_for(middle_events_task, timeout=1) == []
|
||||
assert middle.status == "stopped"
|
||||
assert middle.task is not None and not middle.task.done()
|
||||
assert not third_started.is_set()
|
||||
|
||||
release_first.set()
|
||||
await asyncio.wait_for(first.task, timeout=1)
|
||||
await asyncio.wait_for(middle.task, timeout=1)
|
||||
await asyncio.wait_for(third.task, timeout=1)
|
||||
|
||||
assert first.status == "stopped"
|
||||
assert middle.status == "stopped"
|
||||
assert third.status == "done"
|
||||
assert third_started.is_set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_replays_pinned_fallback_run_without_restarting_tools():
|
||||
session_id = "sess-detached-fallback-resume"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
release = asyncio.Event()
|
||||
tool_executions = 0
|
||||
fallback = 'data: {"type":"fallback","answered_by":"backup","candidate_index":1}\n\n'
|
||||
tool = 'data: {"type":"tool_output","tool":"bash","output":"ok"}\n\n'
|
||||
|
||||
async def pinned_run():
|
||||
nonlocal tool_executions
|
||||
yield fallback
|
||||
tool_executions += 1
|
||||
yield tool
|
||||
await release.wait()
|
||||
yield 'data: {"delta":"backup finished"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
run = agent_runs.start(session_id, pinned_run())
|
||||
first = agent_runs.subscribe(session_id)
|
||||
first_events = []
|
||||
async for event in first:
|
||||
first_events.append(event)
|
||||
if len(first_events) == 2:
|
||||
break
|
||||
await first.aclose()
|
||||
|
||||
assert run.status == "running"
|
||||
assert tool_executions == 1
|
||||
assert agent_runs._RUNS[session_id] is run
|
||||
|
||||
resumed_events = []
|
||||
resumed = agent_runs.subscribe(session_id)
|
||||
async for event in resumed:
|
||||
resumed_events.append(event)
|
||||
if len(resumed_events) == 2:
|
||||
release.set()
|
||||
await run.task
|
||||
|
||||
assert resumed_events[:2] == [fallback, tool]
|
||||
assert resumed_events[-1] == "data: [DONE]\n\n"
|
||||
assert tool_executions == 1
|
||||
assert agent_runs._RUNS[session_id] is run
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# chat_stream: Compare panes must NOT be detached, so the Stop button (closing
|
||||
# the SSE) cancels the upstream generator promptly — exercising the same
|
||||
|
||||
@@ -63,6 +63,23 @@ class TestSelfSummaryPrompt:
|
||||
|
||||
|
||||
class TestTrimForContext:
|
||||
def test_system_truncation_preserves_internal_route_metadata(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "persona\n\n" + ("agent prompt " * 2000),
|
||||
"_agent_injected": "merged_prompt",
|
||||
"_agent_base_message": {"role": "system", "content": "persona"},
|
||||
},
|
||||
{"role": "user", "content": "latest"},
|
||||
]
|
||||
|
||||
trimmed = trim_for_context(messages, context_length=1024, reserve_tokens=256)
|
||||
|
||||
system = next(message for message in trimmed if message.get("role") == "system")
|
||||
assert system["_agent_injected"] == "merged_prompt"
|
||||
assert system["_agent_base_message"] == {"role": "system", "content": "persona"}
|
||||
|
||||
def test_keeps_current_large_user_message_by_truncating(self):
|
||||
huge = "A" * 20000
|
||||
messages = [
|
||||
@@ -194,6 +211,50 @@ class TestMaybeCompactFourthMessage:
|
||||
assert len(result) == 3 and result[2] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deferred_compaction_persists_only_after_route_commit(monkeypatch):
|
||||
updates = []
|
||||
state = {}
|
||||
messages = [
|
||||
{"role": "system", "content": "system " * 100},
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
{"role": "user", "content": "three"},
|
||||
{"role": "assistant", "content": "four"},
|
||||
{"role": "user", "content": "five"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(cc, "get_context_length", lambda *args: 100)
|
||||
monkeypatch.setattr(cc, "resolve_endpoint", lambda *args, **kwargs: (None, None, None))
|
||||
|
||||
async def fake_summary(*args, **kwargs):
|
||||
return "route-specific summary"
|
||||
|
||||
monkeypatch.setattr(cc, "llm_call_async", fake_summary)
|
||||
monkeypatch.setattr(
|
||||
cc,
|
||||
"_update_session_history",
|
||||
lambda *args, **kwargs: updates.append((args, kwargs)),
|
||||
)
|
||||
|
||||
_compacted, _context, was_compacted = await cc.maybe_compact(
|
||||
object(),
|
||||
"https://candidate.example/v1",
|
||||
"candidate-model",
|
||||
messages,
|
||||
persist=False,
|
||||
compaction_state=state,
|
||||
)
|
||||
|
||||
assert was_compacted is True
|
||||
assert updates == []
|
||||
assert state["summary"] == "route-specific summary"
|
||||
assert cc.apply_compaction_state(object(), state) is True
|
||||
assert len(updates) == 1
|
||||
assert cc.apply_compaction_state(object(), state) is False
|
||||
assert len(updates) == 1
|
||||
|
||||
|
||||
class TestResearchPrimerPreserved:
|
||||
"""A research-spinoff primer (metadata research_spinoff_from) must never be
|
||||
trimmed away — it is the Discuss chat's sole knowledge base (drift fix)."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,15 +8,15 @@ from bs4 import BeautifulSoup
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_legacy_default_fallback_editor_is_hidden():
|
||||
def test_legacy_default_fallback_editor_is_absent():
|
||||
soup = BeautifulSoup(
|
||||
(_REPO / "static" / "index.html").read_text(encoding="utf-8"),
|
||||
"html.parser",
|
||||
)
|
||||
editor = soup.find(id="set-defaultFallbacks")
|
||||
|
||||
assert editor is not None
|
||||
assert editor.find_parent(class_="settings-row").has_attr("hidden")
|
||||
assert editor is None
|
||||
assert soup.find(id="set-defaultAddFallback") is None
|
||||
|
||||
|
||||
def test_default_model_save_does_not_rewrite_legacy_fallbacks():
|
||||
@@ -27,3 +27,5 @@ def test_default_model_save_does_not_rewrite_legacy_fallbacks():
|
||||
|
||||
assert "settings.default_model_fallbacks" not in default_chat_source
|
||||
assert "default_model_fallbacks:" not in default_chat_source
|
||||
assert "set-defaultFallbacks" not in default_chat_source
|
||||
assert "set-defaultAddFallback" not in default_chat_source
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Source contract for live multi-round fallback attribution."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
CHAT_JS = Path("static/js/chat.js").read_text(encoding="utf-8")
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _resume_function_source():
|
||||
body = CHAT_JS.split("export async function resumeStream", 1)[1].split(
|
||||
"export function checkBackgroundStream", 1
|
||||
)[0]
|
||||
return "async function resumeStream" + body.rstrip()
|
||||
|
||||
|
||||
def _run_node(source):
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=source,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip())
|
||||
|
||||
|
||||
def test_live_fallback_targets_the_active_round_and_replaces_actual_model():
|
||||
fallback_block = CHAT_JS.split("json.type === 'fallback'", 1)[1].split(
|
||||
"json.type === 'doc_stream_open'", 1
|
||||
)[0]
|
||||
|
||||
assert "applyModelRouteEventState(json, holder, roundHolder, modelName)" in fallback_block
|
||||
assert "_fallbackHolder.querySelector('.role')" in fallback_block
|
||||
assert "_hasResolvedActual" not in fallback_block
|
||||
|
||||
|
||||
def test_provider_alias_uses_the_same_round_aware_holder_selection():
|
||||
actual_block = CHAT_JS.split("json.type === 'model_actual'", 1)[1].split(
|
||||
"json.type === 'attachments'", 1
|
||||
)[0]
|
||||
|
||||
assert "applyModelRouteEventState(json, holder, roundHolder, modelName)" in actual_block
|
||||
assert "_modelHolder.querySelector('.role')" in actual_block
|
||||
|
||||
|
||||
def test_new_round_and_final_metrics_target_the_active_round():
|
||||
agent_step_block = CHAT_JS.split("} else if (json.type === 'agent_step')", 1)[1].split(
|
||||
"json.type === 'budget_exceeded'", 1
|
||||
)[0]
|
||||
metrics_block = CHAT_JS.split("json.type === 'metrics'", 1)[1].split(
|
||||
"json.type === 'message_saved'", 1
|
||||
)[0]
|
||||
final_block = CHAT_JS.split("const _isBgFinal", 1)[1].split(
|
||||
"holder.dataset.raw", 1
|
||||
)[0]
|
||||
|
||||
assert "inheritModelRouteState(holder, roundHolder, newWrap" in agent_step_block
|
||||
assert "applyModelMetricsState(metrics, holder, roundHolder, modelName)" in metrics_block
|
||||
assert "_finalModelHolder.querySelector('.role')" in final_block
|
||||
assert "holder.querySelector('.role')" not in final_block
|
||||
|
||||
|
||||
def test_terminal_sse_error_bypasses_eof_auto_recovery():
|
||||
parser_block = CHAT_JS.split("if (_nextIsError || json.status >= 400)", 1)[1].split(
|
||||
"if (json.delta", 1
|
||||
)[0]
|
||||
completion_gate = CHAT_JS.split("if (_streamTerminalError)", 1)[1].split(
|
||||
"if (!_streamSawDone)", 1
|
||||
)[0]
|
||||
recovery_block = CHAT_JS.split("isRecoverableStreamError(err)", 1)[1].split(
|
||||
"const errorHolder", 1
|
||||
)[0]
|
||||
|
||||
assert "createTerminalStreamError(json)" in parser_block
|
||||
assert "throw _streamTerminalError" in completion_gate
|
||||
assert "if (err.terminalStreamError)" in recovery_block
|
||||
assert "await sessionModule.selectSession(streamSessionId, { showLoading: false })" in recovery_block
|
||||
|
||||
|
||||
def test_connection_recovery_resumes_detached_run_without_resubmitting_selected_model():
|
||||
recovery = CHAT_JS.split("function _tryAutoRecover", 1)[1].split(
|
||||
"function _removeStallBanner", 1
|
||||
)[0]
|
||||
|
||||
assert "await resumeStream(sessionId, holder || null)" in recovery
|
||||
assert "/api/chat_stream" not in recovery
|
||||
assert ".click()" not in recovery
|
||||
assert "_pendingContinue" not in recovery
|
||||
assert "if (_streamSessionId === streamSessionId) _streamSessionId = null" in CHAT_JS
|
||||
|
||||
|
||||
def test_detached_resume_reloads_canonical_terminal_failures():
|
||||
resume = CHAT_JS.split("export async function resumeStream", 1)[1].split(
|
||||
"export function checkBackgroundStream", 1
|
||||
)[0]
|
||||
|
||||
assert "l.trim() === 'event: error'" in resume
|
||||
assert "json.type === 'agent_terminal'" in resume
|
||||
assert "rich = true" in resume
|
||||
assert "Network drop or parse failure: fall through to the canonical reload" in resume
|
||||
assert "if (onThisSession && !rich && roundText.trim())" in resume
|
||||
assert "res.headers.get('X-Odysseus-Run-Id')" in resume
|
||||
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId)" in resume
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_detached_resume_surfaces_fallback_then_provider_alias_before_reload():
|
||||
source = "\n".join([
|
||||
"import { applyModelRouteEventState } from './static/js/chatModelProvenance.js';",
|
||||
"class Element {",
|
||||
" constructor(tag = 'div') { this.tag = tag; this.children = []; this.parentNode = null; this.style = {}; this.textContent = ''; this._html = ''; }",
|
||||
" appendChild(child) { child.parentNode = this; this.children.push(child); return child; }",
|
||||
" remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(c => c !== this); this.parentNode = null; }",
|
||||
" set innerHTML(value) {",
|
||||
" this._html = value;",
|
||||
" if (value.includes('stream-content')) {",
|
||||
" this._role = new Element('div'); this._role.parentNode = this;",
|
||||
" this._body = new Element('div'); this._body.parentNode = this;",
|
||||
" this._content = new Element('div'); this._body.appendChild(this._content);",
|
||||
" }",
|
||||
" }",
|
||||
" get innerHTML() { return this._html; }",
|
||||
" querySelector(selector) { if (selector === '.role') return this._role || null; if (selector === '.body') return this._body || null; if (selector === '.stream-content') return this._content || null; return null; }",
|
||||
"}",
|
||||
"const box = new Element('main');",
|
||||
"const document = { getElementById(id) { return id === 'chat-history' ? box : null; }, createElement(tag) { return new Element(tag); } };",
|
||||
"const window = {};",
|
||||
"let selectCalls = 0; const labels = []; const toasts = [];",
|
||||
"const sessionModule = { getSessions() { return [{id: 's1', model: 'selected-model'}]; }, getCurrentSessionId() { return 's1'; }, selectSession() { selectCalls += 1; }, loadSessions() {} };",
|
||||
"const uiModule = { esc(value) { return String(value); }, scrollHistory() {}, showToast(value) { toasts.push(value); } };",
|
||||
"const spinnerModule = { create() { return { element: null, createElement() { this.element = new Element('spinner'); return this.element; }, start() {}, destroy() { if (this.element) this.element.remove(); } }; } };",
|
||||
"const markdownModule = { normalizeThinkingMarkup(v) { return v; }, mdToHtml(v) { return v; }, squashOutsideCode(v) { return v; } };",
|
||||
"const documentModule = null; const chatRenderer = { recordSessionMetricsCost() {}, addMessage() {} };",
|
||||
"const _resumingStreams = new Set(); const _streamRunIds = new Map(); const API_BASE = '';",
|
||||
"function hasActiveStream() { return false; } function _shortModel(v) { return v; } function _applyModelColor() {}",
|
||||
"function _setRoleModelLabel(role, requested, actual) { labels.push({requested, actual}); role.textContent = requested + ' -> ' + actual; }",
|
||||
"function _streamDisplayText(v) { return v; } function _showDocumentWritingStatus() {} function _finishDocumentWritingStatus() {} function _metricsCostRecordId() { return 'run'; }",
|
||||
"const events = [",
|
||||
" 'data: {\"type\":\"fallback\",\"selected_model\":\"selected-model\",\"answered_by\":\"fallback-model\",\"reason\":\"429\"}\\n\\n',",
|
||||
" 'data: {\"type\":\"model_actual\",\"model\":\"provider/fallback-alias\"}\\n\\n',",
|
||||
" 'data: {\"delta\":\"hello\"}\\n\\n',",
|
||||
" 'data: [DONE]\\n\\n',",
|
||||
"].join('');",
|
||||
"const encoded = new TextEncoder().encode(events); let reads = 0;",
|
||||
"const reader = { async read() { return reads++ === 0 ? {done:false, value:encoded} : {done:true}; }, async cancel() {} };",
|
||||
"async function fetch() { return { ok:true, body:{getReader(){return reader;}}, headers:{get(){return 'run-1';}} }; }",
|
||||
_resume_function_source(),
|
||||
"await resumeStream('s1');",
|
||||
"console.log(JSON.stringify({labels, toasts, selectCalls, holderCount: box.children.length}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {
|
||||
"labels": [
|
||||
{"requested": "selected-model", "actual": "fallback-model"},
|
||||
{"requested": "selected-model", "actual": "provider/fallback-alias"},
|
||||
],
|
||||
"toasts": ["Fallback: selected-model failed — answered by fallback-model"],
|
||||
"selectCalls": 1,
|
||||
"holderCount": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_detached_resume_renders_preoutput_error_without_empty_reload():
|
||||
source = "\n".join([
|
||||
"import { createTerminalStreamError } from './static/js/chatStreamErrors.js';",
|
||||
"class Element {",
|
||||
" constructor(tag = 'div') { this.tag = tag; this.children = []; this.parentNode = null; this.style = {}; this.textContent = ''; this._html = ''; }",
|
||||
" appendChild(child) { child.parentNode = this; this.children.push(child); return child; }",
|
||||
" remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(c => c !== this); this.parentNode = null; }",
|
||||
" set innerHTML(value) {",
|
||||
" this._html = value;",
|
||||
" if (value.includes('stream-content')) {",
|
||||
" this._role = new Element('div'); this._role.parentNode = this;",
|
||||
" this._body = new Element('div'); this._body.parentNode = this;",
|
||||
" this._content = new Element('div'); this._body.appendChild(this._content);",
|
||||
" }",
|
||||
" }",
|
||||
" get innerHTML() { return this._html; }",
|
||||
" querySelector(selector) { if (selector === '.role') return this._role || null; if (selector === '.body') return this._body || null; if (selector === '.stream-content') return this._content || null; return null; }",
|
||||
"}",
|
||||
"const box = new Element('main');",
|
||||
"const document = { getElementById(id) { return id === 'chat-history' ? box : null; }, createElement(tag) { return new Element(tag); } };",
|
||||
"const window = {};",
|
||||
"let selectCalls = 0;",
|
||||
"const sessionModule = { getSessions() { return [{id: 's1', model: 'selected'}]; }, getCurrentSessionId() { return 's1'; }, selectSession() { selectCalls += 1; }, loadSessions() {} };",
|
||||
"const uiModule = { esc(value) { return String(value); }, scrollHistory() {} };",
|
||||
"const spinnerModule = { create() { return { element: null, createElement() { this.element = new Element('spinner'); return this.element; }, start() {}, destroy() { if (this.element) this.element.remove(); } }; } };",
|
||||
"const markdownModule = { normalizeThinkingMarkup(v) { return v; }, mdToHtml(v) { return v; }, squashOutsideCode(v) { return v; } };",
|
||||
"const documentModule = null;",
|
||||
"const chatRenderer = { recordSessionMetricsCost() {}, addMessage() {} };",
|
||||
"const _resumingStreams = new Set(); const _streamRunIds = new Map(); const API_BASE = '';",
|
||||
"function hasActiveStream() { return false; } function _shortModel(v) { return v; } function _applyModelColor() {}",
|
||||
"function _streamDisplayText(v) { return v; } function _showDocumentWritingStatus() {} function _finishDocumentWritingStatus() {} function _metricsCostRecordId() { return 'run'; }",
|
||||
"const encoded = new TextEncoder().encode('event: error\\ndata: {\"status\":401,\"error\":\"invalid key <img src=x>\"}\\n\\n');",
|
||||
"let reads = 0; const reader = { async read() { return reads++ === 0 ? {done:false, value:encoded} : {done:true}; }, async cancel() {} };",
|
||||
"async function fetch() { return { ok:true, body:{getReader(){return reader;}}, headers:{get(){return 'run-1';}} }; }",
|
||||
_resume_function_source(),
|
||||
"const result = await resumeStream('s1');",
|
||||
"const holder = box.children[0]; const errorNode = holder && holder._content.children.find(node => node.textContent.startsWith('[Error:'));",
|
||||
"console.log(JSON.stringify({result, selectCalls, holderCount: box.children.length, errorText: errorNode && errorNode.textContent}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {
|
||||
"result": True,
|
||||
"selectCalls": 0,
|
||||
"holderCount": 1,
|
||||
"errorText": "[Error: invalid key <img src=x>]",
|
||||
}
|
||||
|
||||
|
||||
def test_terminal_then_session_switch_preserves_completed_background_state():
|
||||
terminal = CHAT_JS.split(
|
||||
"json.type === 'agent_terminal' || json.type === 'chat_terminal'", 1
|
||||
)[1].split("json.type === 'metrics'", 1)[0]
|
||||
detach = CHAT_JS.split("export function detachCurrentStream", 1)[1].split(
|
||||
"export async function resumeStream", 1
|
||||
)[0]
|
||||
background_catch = CHAT_JS.split("if (_isBgCatch)", 1)[1].split(
|
||||
"} else {", 1
|
||||
)[0]
|
||||
|
||||
assert "_terminalSavedStreams.add(streamSessionId)" in terminal
|
||||
assert "terminalSaved ? 'completed' : 'running'" in detach
|
||||
assert "!terminalSaved && sessionModule && sessionModule.markStreaming" in detach
|
||||
assert "_terminalSavedStreams.has(streamSessionId)" in background_catch
|
||||
|
||||
|
||||
def test_detached_run_identity_is_attached_to_live_metrics():
|
||||
routes = Path("routes/chat_routes.py").read_text(encoding="utf-8")
|
||||
assert "headers={\"X-Odysseus-Run-Id\": _detached_run.run_id}" in routes
|
||||
assert "agent_runs.subscribe(session, _detached_run)" in routes
|
||||
assert "agent_runs.subscribe(session_id, _active_run)" in routes
|
||||
assert "const streamRunId = res.headers.get('X-Odysseus-Run-Id')" in CHAT_JS
|
||||
assert "metrics._costRecordId = _metricsCostRecordId(streamRunId, json)" in CHAT_JS
|
||||
assert "'X-Odysseus-Run-Id': runId" in CHAT_JS
|
||||
assert "agent_runs.stop(session_id, _expected_run_id)" in routes
|
||||
assert "_stopExactRun(streamSessionId)" in CHAT_JS
|
||||
timeout_block = CHAT_JS.split("timeoutId = setTimeout", 1)[1].split(
|
||||
"clearResponseTimeout", 1
|
||||
)[0]
|
||||
assert "/api/chat/stop/" not in timeout_block
|
||||
|
||||
|
||||
def test_replay_cost_identity_distinguishes_primary_and_teacher_segments():
|
||||
identity = CHAT_JS.split("function _metricsCostRecordId", 1)[1].split("\n }", 1)[0]
|
||||
resume = CHAT_JS.split("export async function resumeStream", 1)[1].split(
|
||||
"export function checkBackgroundStream", 1
|
||||
)[0]
|
||||
|
||||
assert "event.teacher ? 'teacher' : 'primary'" in identity
|
||||
assert "_metricsCostRecordId(resumeRunId, json)" in resume
|
||||
metrics_block = resume.split("json.type === 'metrics'", 1)[1].split(
|
||||
"json.type === 'agent_terminal'", 1
|
||||
)[0]
|
||||
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId)" in metrics_block
|
||||
|
||||
routes = Path("routes/chat_routes.py").read_text(encoding="utf-8")
|
||||
route_metrics = routes.split('elif data.get("type") == "metrics"', 1)[1].split(
|
||||
"except json.JSONDecodeError", 1
|
||||
)[0]
|
||||
assert 'if data.get("teacher") is True' in route_metrics
|
||||
assert '_metrics_event["teacher"] = True' in route_metrics
|
||||
|
||||
|
||||
def test_foreground_terminal_error_reloads_saved_partial_without_typewriter_race():
|
||||
parser = CHAT_JS.split("if (_nextIsError || json.status >= 400)", 1)[1].split(
|
||||
"if (json.delta", 1
|
||||
)[0]
|
||||
terminal_catch = CHAT_JS.split("if (err.terminalStreamError)", 1)[1].split(
|
||||
"const errorHolder", 1
|
||||
)[0]
|
||||
|
||||
assert "typewriterInto" not in parser
|
||||
assert "json.type === 'agent_terminal'" in CHAT_JS
|
||||
assert "_canonicalTerminalSaved = true" in CHAT_JS
|
||||
assert "await sessionModule.selectSession(streamSessionId, { showLoading: false })" in terminal_catch
|
||||
+1267
-14
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@ stream_llm only captured usage when the delta was exactly None / {} /
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src import llm_core
|
||||
|
||||
|
||||
@@ -116,7 +118,8 @@ def test_null_choice_chunk_does_not_crash(monkeypatch):
|
||||
|
||||
|
||||
def test_null_choice_with_null_usage_does_not_crash(monkeypatch):
|
||||
# Chunk with both choices:[null] and usage:null — neither field should panic.
|
||||
# Chunk with both choices:[null] and usage:null is a keepalive, not a real
|
||||
# zero-token accounting record.
|
||||
lines = [
|
||||
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
||||
'data: ' + json.dumps({"choices": [None], "usage": None}),
|
||||
@@ -124,6 +127,66 @@ def test_null_choice_with_null_usage_does_not_crash(monkeypatch):
|
||||
]
|
||||
result = _drive(monkeypatch, lines)
|
||||
assert "Hi" in result
|
||||
assert _usage_events(result) == []
|
||||
|
||||
|
||||
def test_empty_usage_object_is_not_reported_as_real_zero_usage(monkeypatch):
|
||||
lines = [
|
||||
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
||||
'data: ' + json.dumps({"choices": [], "usage": {}}),
|
||||
'data: [DONE]',
|
||||
]
|
||||
result = _drive(monkeypatch, lines)
|
||||
assert "Hi" in result
|
||||
assert _usage_events(result) == []
|
||||
|
||||
|
||||
def test_explicit_zero_token_usage_is_preserved(monkeypatch):
|
||||
lines = [
|
||||
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
||||
'data: ' + json.dumps({
|
||||
"choices": [],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0},
|
||||
}),
|
||||
'data: [DONE]',
|
||||
]
|
||||
usage = _usage_events(_drive(monkeypatch, lines))
|
||||
assert usage == [{"input_tokens": 0, "output_tokens": 0}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"usage_payload",
|
||||
[
|
||||
{"prompt_tokens": None, "completion_tokens": 1},
|
||||
{"prompt_tokens": "bad", "completion_tokens": 1},
|
||||
{"prompt_tokens": -1, "completion_tokens": 1},
|
||||
{"prompt_tokens": True, "completion_tokens": 1},
|
||||
{"prompt_tokens": 1.5, "completion_tokens": 1},
|
||||
{"prompt_tokens": float("inf"), "completion_tokens": 1},
|
||||
],
|
||||
)
|
||||
def test_malformed_token_values_do_not_emit_usage(monkeypatch, usage_payload):
|
||||
lines = [
|
||||
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
||||
'data: ' + json.dumps({"choices": [], "usage": usage_payload}),
|
||||
'data: [DONE]',
|
||||
]
|
||||
result = _drive(monkeypatch, lines)
|
||||
assert "Hi" in result
|
||||
assert _usage_events(result) == []
|
||||
|
||||
|
||||
def test_missing_usage_counterpart_defaults_to_zero(monkeypatch):
|
||||
lines = [
|
||||
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
||||
'data: ' + json.dumps({
|
||||
"choices": [],
|
||||
"usage": {"completion_tokens": 2},
|
||||
}),
|
||||
'data: [DONE]',
|
||||
]
|
||||
usage = _usage_events(_drive(monkeypatch, lines))
|
||||
assert usage == [{"input_tokens": 0, "output_tokens": 2}]
|
||||
|
||||
|
||||
def test_null_tool_call_in_delta_is_skipped(monkeypatch):
|
||||
|
||||
@@ -98,6 +98,9 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
|
||||
{"endpoint_id": "dead", "model": "fallback-a"},
|
||||
{"endpoint_id": "keep", "model": "fallback-b"},
|
||||
],
|
||||
"foreground_model_fallbacks": [
|
||||
{"endpoint_id": "dead", "model": "foreground"},
|
||||
],
|
||||
"utility_model_fallbacks": [{"endpoint_id": "dead", "model": "utility"}],
|
||||
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
|
||||
"stt_provider": "endpoint:dead",
|
||||
@@ -106,12 +109,14 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
|
||||
|
||||
assert _endpoint_settings_using_endpoint(settings, "dead", include_speech=True) == [
|
||||
"Default Model",
|
||||
"Foreground Model Fallbacks",
|
||||
"Utility Model Fallbacks",
|
||||
"Vision Model Fallbacks",
|
||||
"Speech to Text",
|
||||
]
|
||||
assert _clear_endpoint_settings_for_endpoint(settings, "dead", include_speech=True) == [
|
||||
"Default Model",
|
||||
"Foreground Model Fallbacks",
|
||||
"Utility Model Fallbacks",
|
||||
"Vision Model Fallbacks",
|
||||
"Speech to Text",
|
||||
@@ -122,6 +127,7 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
|
||||
{"endpoint_id": "dead", "model": "fallback-a"},
|
||||
{"endpoint_id": "keep", "model": "fallback-b"},
|
||||
]
|
||||
assert settings["foreground_model_fallbacks"] == []
|
||||
assert settings["utility_model_fallbacks"] == []
|
||||
assert settings["vision_model_fallbacks"] == []
|
||||
assert settings["stt_provider"] == "disabled"
|
||||
@@ -130,10 +136,19 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
|
||||
|
||||
def test_endpoint_cleanup_updates_active_scoped_prefs_but_preserves_legacy_data():
|
||||
scoped = {
|
||||
"foreground_model_fallbacks": [
|
||||
{"endpoint_id": "dead", "model": "ownerless"},
|
||||
],
|
||||
"default_model_fallbacks": [
|
||||
{"endpoint_id": "dead", "model": "legacy-ownerless"},
|
||||
],
|
||||
"_users": {
|
||||
"alice": {
|
||||
"utility_endpoint_id": "dead",
|
||||
"utility_model": "utility",
|
||||
"foreground_model_fallbacks": [
|
||||
{"endpoint_id": "dead", "model": "foreground"},
|
||||
],
|
||||
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
|
||||
},
|
||||
"bob": {
|
||||
@@ -142,10 +157,15 @@ def test_endpoint_cleanup_updates_active_scoped_prefs_but_preserves_legacy_data(
|
||||
},
|
||||
},
|
||||
}
|
||||
assert _clear_user_pref_endpoint_refs(scoped, "dead") == 1
|
||||
assert _clear_user_pref_endpoint_refs(scoped, "dead") == 2
|
||||
assert scoped["foreground_model_fallbacks"] == []
|
||||
assert scoped["default_model_fallbacks"] == [
|
||||
{"endpoint_id": "dead", "model": "legacy-ownerless"},
|
||||
]
|
||||
assert scoped["_users"]["alice"] == {
|
||||
"utility_endpoint_id": "",
|
||||
"utility_model": "",
|
||||
"foreground_model_fallbacks": [],
|
||||
"vision_model_fallbacks": [],
|
||||
}
|
||||
assert scoped["_users"]["bob"]["default_endpoint_id"] == "keep"
|
||||
|
||||
@@ -0,0 +1,933 @@
|
||||
"""Executable regressions for the browser/run-lifecycle review of PR #6020.
|
||||
|
||||
These tests intentionally exercise JavaScript under Node rather than treating
|
||||
``node --check`` or source-string presence as proof that the browser paths are
|
||||
usable. The detached-run replacement case drives the real Python manager.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from src import agent_runs
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_CHAT_PATH = _REPO / "static" / "js" / "chat.js"
|
||||
_CHAT = _CHAT_PATH.read_text(encoding="utf-8")
|
||||
_RENDERER = (_REPO / "static" / "js" / "chatRenderer.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
_STREAM_ERRORS_URI = (_REPO / "static" / "js" / "chatStreamErrors.js").as_uri()
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _extract_source(source: str, start: str, end: str) -> str:
|
||||
"""Slice module source between two anchors, failing loudly if one moved.
|
||||
|
||||
The extracted region ships to Node verbatim, so the anchors must stay
|
||||
unique strings in the module. A refactor that renames or duplicates an
|
||||
anchor fails here with the anchor named, not with an opaque split error.
|
||||
"""
|
||||
assert source.count(start) == 1, f"start anchor not unique in source: {start!r}"
|
||||
tail = source.split(start, 1)[1]
|
||||
assert end in tail, f"end anchor not found after start anchor: {end!r}"
|
||||
return start + tail.split(end, 1)[0]
|
||||
|
||||
|
||||
def _run_node(source: str) -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=source,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
# A few imported browser modules log optional-service status at startup.
|
||||
# Keep the runtime smoke honest while reading only its final JSON result.
|
||||
return json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def _chat_smoke_source(extra_source: str) -> str:
|
||||
"""Return chat.js source with its real imports made absolute."""
|
||||
|
||||
def absolute_import(match: re.Match[str]) -> str:
|
||||
relative = match.group("relative")
|
||||
path_part, separator, query = relative.partition("?")
|
||||
target = (_CHAT_PATH.parent / path_part).resolve().as_uri()
|
||||
if separator:
|
||||
target += "?" + query
|
||||
return match.group("prefix") + target + match.group("quote")
|
||||
|
||||
source = re.sub(
|
||||
r"(?P<prefix>from\s+(?P<quote>['\"]))(?P<relative>\./[^'\"]+)(?P=quote)",
|
||||
absolute_import,
|
||||
_CHAT,
|
||||
)
|
||||
source += "\n" + extra_source
|
||||
return source
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_chat_runtime_stream_state_helpers_are_all_callable(tmp_path):
|
||||
"""Import the real module and execute all three rebased-away helpers."""
|
||||
|
||||
module_source = _chat_smoke_source(
|
||||
"""
|
||||
export function __pr6020StreamStateSmoke() {
|
||||
const sid = 'pr6020-runtime-smoke';
|
||||
const controller = { abort() {}, signal: { aborted: false } };
|
||||
const originalGetSessionId = sessionModule.getCurrentSessionId;
|
||||
sessionModule.getCurrentSessionId = () => sid;
|
||||
try {
|
||||
_activeStreams.set(sid, {
|
||||
abortCtrl: controller,
|
||||
holder: { id: 'holder' },
|
||||
lastActivity: 0,
|
||||
});
|
||||
const active = _getForegroundStreamState();
|
||||
const touchedAt = _touchStreamActivity(sid);
|
||||
const synced = _syncForegroundStreamGlobals();
|
||||
return {
|
||||
activeController: active && active.abortCtrl === controller,
|
||||
touched: touchedAt > 0 && _activeStreams.get(sid).lastActivity === touchedAt,
|
||||
synced: synced === active && currentAbort === controller && isStreaming,
|
||||
};
|
||||
} finally {
|
||||
_activeStreams.delete(sid);
|
||||
sessionModule.getCurrentSessionId = originalGetSessionId;
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
module_path = tmp_path / "chat-runtime-smoke.mjs"
|
||||
module_path.write_text(module_source, encoding="utf-8")
|
||||
module_uri = module_path.as_uri()
|
||||
script = f"""
|
||||
globalThis.window = globalThis;
|
||||
globalThis.addEventListener = () => {{}};
|
||||
globalThis.removeEventListener = () => {{}};
|
||||
globalThis.dispatchEvent = () => {{}};
|
||||
globalThis.requestAnimationFrame = () => 0;
|
||||
globalThis.cancelAnimationFrame = () => {{}};
|
||||
globalThis.fetch = async () => ({{
|
||||
ok: false,
|
||||
json: async () => ({{}}),
|
||||
text: async () => '',
|
||||
headers: {{ get() {{ return null; }} }},
|
||||
}});
|
||||
class Element {{
|
||||
constructor() {{
|
||||
this.children = [];
|
||||
this.classList = {{
|
||||
add() {{}}, remove() {{}}, toggle() {{}}, contains() {{ return false; }},
|
||||
}};
|
||||
this.style = {{ setProperty() {{}} }};
|
||||
this.dataset = {{}};
|
||||
}}
|
||||
querySelector() {{ return null; }}
|
||||
querySelectorAll() {{ return []; }}
|
||||
appendChild(child) {{ this.children.push(child); return child; }}
|
||||
addEventListener() {{}}
|
||||
removeEventListener() {{}}
|
||||
}}
|
||||
class HTMLInputElement extends Element {{
|
||||
get value() {{ return this._value || ''; }}
|
||||
set value(value) {{ this._value = value; }}
|
||||
}}
|
||||
globalThis.HTMLInputElement = HTMLInputElement;
|
||||
const root = new Element();
|
||||
globalThis.document = {{
|
||||
body: root,
|
||||
head: root,
|
||||
documentElement: root,
|
||||
getElementById() {{ return null; }},
|
||||
querySelector() {{ return null; }},
|
||||
querySelectorAll() {{ return []; }},
|
||||
createElement(tag) {{ return tag === 'input' ? new HTMLInputElement() : new Element(); }},
|
||||
createTextNode(text) {{ return {{ textContent: text }}; }},
|
||||
addEventListener() {{}},
|
||||
removeEventListener() {{}},
|
||||
}};
|
||||
globalThis.localStorage = {{ getItem() {{ return null; }}, setItem() {{}}, removeItem() {{}} }};
|
||||
globalThis.location = {{}};
|
||||
globalThis.history = {{}};
|
||||
globalThis.MutationObserver = class {{ observe() {{}} }};
|
||||
globalThis.CustomEvent = class {{}};
|
||||
globalThis.Storage = class {{}};
|
||||
const chat = await import({json.dumps(module_uri)});
|
||||
console.log(JSON.stringify(chat.__pr6020StreamStateSmoke()));
|
||||
process.exit(0);
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"activeController": True,
|
||||
"touched": True,
|
||||
"synced": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_canonical_terminal_followed_by_eof_is_not_auto_recovered():
|
||||
"""A persisted terminal marker owns EOF; a plain premature EOF still retries."""
|
||||
|
||||
completion_gate = _extract_source(
|
||||
_CHAT,
|
||||
"if (_streamTerminalError)",
|
||||
"// The final foreground render below is authoritative.",
|
||||
)
|
||||
script = f"""
|
||||
import {{
|
||||
createTerminalStreamError,
|
||||
isRecoverableStreamError,
|
||||
}} from {json.dumps(_STREAM_ERRORS_URI)};
|
||||
function runCompletionGate(canonicalTerminalSaved) {{
|
||||
let _streamTerminalError = null;
|
||||
let _streamSawDone = false;
|
||||
let _canonicalTerminalSaved = canonicalTerminalSaved;
|
||||
try {{
|
||||
{completion_gate}
|
||||
return {{ recovered: false, completed: true }};
|
||||
}} catch (error) {{
|
||||
return {{
|
||||
recovered: isRecoverableStreamError(error),
|
||||
completed: false,
|
||||
terminal: !!error.terminalStreamError,
|
||||
message: error.message,
|
||||
}};
|
||||
}}
|
||||
}}
|
||||
console.log(JSON.stringify({{
|
||||
savedTerminal: runCompletionGate(true),
|
||||
plainEof: runCompletionGate(false),
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
# A saved canonical terminal must neither auto-recover nor render as a
|
||||
# clean success: it takes the terminal-error path, whose catch handler
|
||||
# reloads the persisted record.
|
||||
"savedTerminal": {
|
||||
"recovered": False,
|
||||
"completed": False,
|
||||
"terminal": True,
|
||||
"message": "Stream closed after canonical terminal event",
|
||||
},
|
||||
"plainEof": {
|
||||
"recovered": True,
|
||||
"completed": False,
|
||||
"terminal": False,
|
||||
"message": "Stream closed before completion",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_immediate_replacement_closes_subscriber_bound_to_never_started_run():
|
||||
"""Cancellation before _drain's first instruction must still terminalize run 1."""
|
||||
|
||||
session_id = "pr6020-immediate-replacement"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
|
||||
async def never_started():
|
||||
yield 'data: {"delta":"old"}\n\n'
|
||||
|
||||
async def replacement():
|
||||
yield 'data: {"delta":"new"}\n\n'
|
||||
|
||||
first = agent_runs.start(session_id, never_started())
|
||||
first_subscription = asyncio.create_task(
|
||||
_collect_run_events(session_id, first)
|
||||
)
|
||||
# Do not yield between starts: first.task is cancelled before _drain gets
|
||||
# its first instruction, exactly the race a rapid double-send creates.
|
||||
second = agent_runs.start(session_id, replacement())
|
||||
|
||||
assert await asyncio.wait_for(first_subscription, timeout=0.5) == []
|
||||
await asyncio.wait_for(second.task, timeout=0.5)
|
||||
assert first.status == "stopped"
|
||||
assert second.status == "done"
|
||||
|
||||
|
||||
async def _collect_run_events(session_id: str, run: object) -> list[str]:
|
||||
return [event async for event in agent_runs.subscribe(session_id, run)]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_stop_before_response_headers_waits_for_exact_run_identity():
|
||||
"""Never send headerless Stop, but flush the queued Stop once headers arrive."""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
header_capture = _extract_source(
|
||||
_CHAT,
|
||||
"const streamRunId = res.headers.get('X-Odysseus-Run-Id')",
|
||||
"// Mark the chat log busy",
|
||||
)
|
||||
script = f"""
|
||||
const calls = [];
|
||||
function _setForegroundChatBusy() {{}}
|
||||
const window = {{}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
|
||||
{state_and_stop}
|
||||
{{
|
||||
const streamSessionId = 'normal-session';
|
||||
const streamGeneration = 1;
|
||||
_streamGenerations.set(streamSessionId, streamGeneration);
|
||||
const res = {{ headers: {{ get(name) {{
|
||||
return name === 'X-Odysseus-Run-Id' ? 'normal-run' : null;
|
||||
}} }} }};
|
||||
{header_capture}
|
||||
}}
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const normalHeaderCalls = calls.length;
|
||||
let beforeHeaders;
|
||||
{{
|
||||
const streamSessionId = 'session-1';
|
||||
const streamGeneration = 1;
|
||||
_streamGenerations.set(streamSessionId, streamGeneration);
|
||||
_stopExactRun(streamSessionId);
|
||||
beforeHeaders = calls.length;
|
||||
const res = {{ headers: {{ get(name) {{
|
||||
return name === 'X-Odysseus-Run-Id' ? 'run-1' : null;
|
||||
}} }} }};
|
||||
{header_capture}
|
||||
}}
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
console.log(JSON.stringify({{
|
||||
normalHeaderCalls,
|
||||
beforeHeaders,
|
||||
calls: calls.map(call => ({{
|
||||
url: call.url,
|
||||
method: call.options.method,
|
||||
runId: call.options.headers['X-Odysseus-Run-Id'],
|
||||
}})),
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"normalHeaderCalls": 0,
|
||||
"beforeHeaders": 0,
|
||||
"calls": [
|
||||
{
|
||||
"url": "/api/chat/stop/session-1",
|
||||
"method": "POST",
|
||||
"runId": "run-1",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_timeout_before_response_headers_also_waits_for_exact_run_identity():
|
||||
"""The automatic timeout must preserve the POST until its run id arrives."""
|
||||
|
||||
script = f"""
|
||||
{_timeout_harness_prelude()}
|
||||
callbacks[0]();
|
||||
const beforeHeaders = {{ aborted: abortCtrl.signal.aborted, calls: calls.length }};
|
||||
_rememberStreamRunId(streamSessionId, 'run-1', streamGeneration);
|
||||
await Promise.resolve();
|
||||
console.log(JSON.stringify({{
|
||||
beforeHeaders,
|
||||
afterHeaders: {{
|
||||
aborted: abortCtrl.signal.aborted,
|
||||
runId: calls[0] && calls[0].options.headers['X-Odysseus-Run-Id'],
|
||||
}},
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"beforeHeaders": {"aborted": False, "calls": 0},
|
||||
"afterHeaders": {"aborted": True, "runId": "run-1"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_resend_preserves_queued_stop_until_old_run_identity_arrives():
|
||||
"""A replacement must not sever the superseded POST's identity channel.
|
||||
|
||||
The queued Stop stays generation-tagged and fires from the OLD send's own
|
||||
header arrival, so the old run is cancelled even when the replacement dies
|
||||
before its POST reaches the server (which is what would otherwise cancel
|
||||
it). The old run id must not leak into the replacement's identity map.
|
||||
"""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
resend_reset = _extract_source(
|
||||
_CHAT,
|
||||
"const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;",
|
||||
"_sendInFlight = false;",
|
||||
)
|
||||
header_capture = _extract_source(
|
||||
_CHAT,
|
||||
"const streamRunId = res.headers.get('X-Odysseus-Run-Id')",
|
||||
"// Mark the chat log busy",
|
||||
)
|
||||
script = f"""
|
||||
const calls = [];
|
||||
function _setForegroundChatBusy() {{}}
|
||||
const window = {{}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
|
||||
{state_and_stop}
|
||||
const oldCtrl = {{
|
||||
_reason: '',
|
||||
signal: {{ aborted: false }},
|
||||
abort() {{ this.signal.aborted = true; }},
|
||||
}};
|
||||
// Old send (generation 1) queues a Stop before its headers arrive.
|
||||
_streamGenerations.set('session-1', 1);
|
||||
const oldGeneration = 1;
|
||||
_stopExactRun('session-1', oldCtrl);
|
||||
const queuedBefore = _pendingRunStops.has('session-1:1');
|
||||
// Replacement send starts: bumps the generation, leaves the queued Stop.
|
||||
{{
|
||||
const streamSessionId = 'session-1';
|
||||
{resend_reset}
|
||||
}}
|
||||
// The replacement is ALSO stopped before its headers arrive: both
|
||||
// sends' cancellation intents must coexist, neither displacing the
|
||||
// other (a single session-keyed slot loses the old send's Stop, and
|
||||
// with it the only cancel for that run if this replacement dies
|
||||
// before its own POST reaches the server).
|
||||
const newCtrl = {{
|
||||
_reason: '',
|
||||
signal: {{ aborted: false }},
|
||||
abort() {{ this.signal.aborted = true; }},
|
||||
}};
|
||||
_stopExactRun('session-1', newCtrl);
|
||||
const afterResend = {{
|
||||
oldQueuedKept: _pendingRunStops.has('session-1:1'),
|
||||
newQueued: _pendingRunStops.has('session-1:2'),
|
||||
oldAborted: oldCtrl.signal.aborted,
|
||||
generation: _streamGenerations.get('session-1'),
|
||||
}};
|
||||
// The old POST's headers finally arrive: its queued Stop fires with its
|
||||
// own run id, and the old controller aborts.
|
||||
{{
|
||||
const streamSessionId = 'session-1';
|
||||
const streamGeneration = oldGeneration;
|
||||
const res = {{ headers: {{ get(name) {{
|
||||
return name === 'X-Odysseus-Run-Id' ? 'old-run' : null;
|
||||
}} }} }};
|
||||
{header_capture}
|
||||
}}
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
console.log(JSON.stringify({{
|
||||
queuedBefore,
|
||||
afterResend,
|
||||
afterOldHeaders: {{
|
||||
oldQueued: _pendingRunStops.has('session-1:1'),
|
||||
newQueuedKept: _pendingRunStops.has('session-1:2'),
|
||||
oldAborted: oldCtrl.signal.aborted,
|
||||
oldReason: oldCtrl._reason,
|
||||
newAborted: newCtrl.signal.aborted,
|
||||
currentRunIdPolluted: _streamRunIds.has('session-1'),
|
||||
stopCalls: calls.map(call => ({{
|
||||
url: call.url,
|
||||
runId: call.options.headers['X-Odysseus-Run-Id'],
|
||||
}})),
|
||||
}},
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"queuedBefore": True,
|
||||
"afterResend": {
|
||||
"oldQueuedKept": True,
|
||||
"newQueued": True,
|
||||
"oldAborted": False,
|
||||
"generation": 2,
|
||||
},
|
||||
"afterOldHeaders": {
|
||||
"oldQueued": False,
|
||||
# The replacement's own queued Stop must survive the old send's
|
||||
# flush untouched.
|
||||
"newQueuedKept": True,
|
||||
"oldAborted": True,
|
||||
"oldReason": "user-stop",
|
||||
"newAborted": False,
|
||||
# The stale send's run id must not become the replacement's
|
||||
# identity, but its exact Stop must still go out.
|
||||
"currentRunIdPolluted": False,
|
||||
"stopCalls": [
|
||||
{"url": "/api/chat/stop/session-1", "runId": "old-run"}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_superseded_stream_cleanup_leaves_replacement_state_alone():
|
||||
"""A stale send's finally must not clear state the replacement owns.
|
||||
|
||||
Ownership is decided by generation, which the replacement bumps at its
|
||||
very first synchronous step — so the guard holds even in the window
|
||||
BEFORE the replacement registers its own stream entry (where the old
|
||||
finally still sees its own registration and controller identity alone
|
||||
would call it the owner).
|
||||
"""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
finally_cleanup = _extract_source(
|
||||
_CHAT,
|
||||
"const _ownsStreamState =",
|
||||
"// Streaming done — let screen readers announce",
|
||||
)
|
||||
script = f"""
|
||||
let currentAbort = null;
|
||||
let isStreaming = false;
|
||||
let currentHolder = null;
|
||||
let _sendInFlight = false;
|
||||
function _setForegroundChatBusy() {{}}
|
||||
const window = {{}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
{state_and_stop}
|
||||
function runCleanup(abortCtrl, streamGeneration) {{
|
||||
const streamSessionId = 'session-1';
|
||||
const _sendState = {{ generation: streamGeneration, abortCtrl }};
|
||||
{finally_cleanup}
|
||||
return _ownsStreamState;
|
||||
}}
|
||||
const oldCtrl = {{ signal: {{ aborted: true }}, abort() {{}} }};
|
||||
const newCtrl = {{ signal: {{ aborted: false }}, abort() {{}} }};
|
||||
// Pre-registration supersession: the replacement bumped the generation
|
||||
// and set the session id, but has NOT registered its stream entry yet —
|
||||
// the old send's own entry is still the one in the map.
|
||||
_streamGenerations.set('session-1', 2);
|
||||
_streamSessionId = 'session-1';
|
||||
_activeStreams.set('session-1', {{ abortCtrl: oldCtrl, holder: null, lastActivity: 1 }});
|
||||
_pendingRunStops.set('session-1:2', newCtrl);
|
||||
const preRegOwns = runCleanup(oldCtrl, 1);
|
||||
const afterPreReg = {{
|
||||
ownEntryRemoved: !_activeStreams.has('session-1'),
|
||||
replacementPendingKept: _pendingRunStops.has('session-1:2'),
|
||||
sessionKept: _streamSessionId === 'session-1',
|
||||
}};
|
||||
// Post-registration supersession: the replacement's entry is in the map.
|
||||
_activeStreams.set('session-1', {{ abortCtrl: newCtrl, holder: null, lastActivity: 2 }});
|
||||
const postRegOwns = runCleanup(oldCtrl, 1);
|
||||
const afterPostReg = {{
|
||||
replacementRegistrationKept: _activeStreams.has('session-1'),
|
||||
replacementPendingKept: _pendingRunStops.has('session-1:2'),
|
||||
sessionKept: _streamSessionId === 'session-1',
|
||||
}};
|
||||
// Owner: the current-generation send cleans up normally.
|
||||
const ownerOwns = runCleanup(newCtrl, 2);
|
||||
const afterOwner = {{
|
||||
registered: _activeStreams.has('session-1'),
|
||||
pendingKept: _pendingRunStops.has('session-1:2'),
|
||||
sessionCleared: _streamSessionId === null,
|
||||
}};
|
||||
console.log(JSON.stringify({{
|
||||
preRegOwns, afterPreReg, postRegOwns, afterPostReg, ownerOwns, afterOwner,
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"preRegOwns": False,
|
||||
"afterPreReg": {
|
||||
"ownEntryRemoved": True,
|
||||
"replacementPendingKept": True,
|
||||
"sessionKept": True,
|
||||
},
|
||||
"postRegOwns": False,
|
||||
"afterPostReg": {
|
||||
"replacementRegistrationKept": True,
|
||||
"replacementPendingKept": True,
|
||||
"sessionKept": True,
|
||||
},
|
||||
"ownerOwns": True,
|
||||
"afterOwner": {
|
||||
"registered": False,
|
||||
"pendingKept": False,
|
||||
"sessionCleared": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_real_reservation_supersedes_and_stale_cleanup_keeps_gate_closed():
|
||||
"""Drive the REAL send-commit reservation, then a stale send's cleanup.
|
||||
|
||||
The reservation is synchronous, so the previous send is superseded before
|
||||
any await runs; its cleanup must then neither clear session state nor
|
||||
resync the foreground globals (a stale sync would set isStreaming false
|
||||
while _sendInFlight is already false, reopening the send gate before the
|
||||
replacement registers).
|
||||
"""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
reservation = _extract_source(
|
||||
_CHAT,
|
||||
"const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;",
|
||||
"_sendInFlight = false;",
|
||||
)
|
||||
finally_cleanup = _extract_source(
|
||||
_CHAT,
|
||||
"const _ownsStreamState =",
|
||||
"// Streaming done — let screen readers announce",
|
||||
)
|
||||
script = f"""
|
||||
let currentAbort = null;
|
||||
let isStreaming = true;
|
||||
let currentHolder = null;
|
||||
let _sendInFlight = false;
|
||||
function _setForegroundChatBusy() {{}}
|
||||
const window = {{}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
{state_and_stop}
|
||||
const oldCtrl = {{ signal: {{ aborted: false }}, abort() {{ this.signal.aborted = true; }} }};
|
||||
// Old send (generation 1) is mid-stream and registered.
|
||||
_streamGenerations.set('session-1', 1);
|
||||
_streamSessionId = 'session-1';
|
||||
_activeStreams.set('session-1', {{ abortCtrl: oldCtrl, holder: null, lastActivity: 1 }});
|
||||
// Replacement commits: run the REAL reservation block synchronously.
|
||||
let installed;
|
||||
{{
|
||||
const streamSessionId = 'session-1';
|
||||
{reservation}
|
||||
installed = {{ generation: streamGeneration, sendState: _sendState }};
|
||||
}}
|
||||
const afterReservation = {{
|
||||
generation: _streamGenerations.get('session-1'),
|
||||
sendStateInstalled: _sendStates.get('session-1') === installed.sendState,
|
||||
controllerPending: installed.sendState.abortCtrl === null,
|
||||
}};
|
||||
// Old send's cleanup runs mid-preflight (before the replacement
|
||||
// registers): it must treat itself as superseded.
|
||||
let staleOwns;
|
||||
{{
|
||||
const streamSessionId = 'session-1';
|
||||
const streamGeneration = 1;
|
||||
const abortCtrl = oldCtrl;
|
||||
const _sendState = {{ generation: 1, abortCtrl: oldCtrl }};
|
||||
{finally_cleanup}
|
||||
staleOwns = _ownsStreamState;
|
||||
}}
|
||||
console.log(JSON.stringify({{
|
||||
afterReservation,
|
||||
staleOwns,
|
||||
afterStaleCleanup: {{
|
||||
sessionKept: _streamSessionId === 'session-1',
|
||||
sendStateKept: _sendStates.get('session-1') === installed.sendState,
|
||||
gateStillClosed: isStreaming === true,
|
||||
}},
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"afterReservation": {
|
||||
"generation": 2,
|
||||
"sendStateInstalled": True,
|
||||
"controllerPending": True,
|
||||
},
|
||||
"staleOwns": False,
|
||||
"afterStaleCleanup": {
|
||||
"sessionKept": True,
|
||||
"sendStateKept": True,
|
||||
# isStreaming untouched because the superseded send skipped the
|
||||
# foreground resync entirely.
|
||||
"gateStillClosed": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_stale_preflight_bails_before_creating_controller():
|
||||
"""A send superseded during preflight must not proceed to register/POST."""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
preflight_gate = _extract_source(
|
||||
_CHAT, "// Superseded during preflight", "currentAbort = abortCtrl;"
|
||||
) + "currentAbort = abortCtrl;"
|
||||
script = f"""
|
||||
let currentAbort = null;
|
||||
let isStreaming = false;
|
||||
let currentHolder = null;
|
||||
let _sendInFlight = false;
|
||||
function _setForegroundChatBusy() {{}}
|
||||
const window = {{}};
|
||||
const document = {{
|
||||
createElement() {{ return {{ style: {{}}, textContent: '' }}; }},
|
||||
}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
{state_and_stop}
|
||||
function runPreflightGate(streamGeneration, _sendState, _userMsgEl) {{
|
||||
const streamSessionId = 'session-1';
|
||||
let abortCtrl = null;
|
||||
{preflight_gate}
|
||||
return abortCtrl;
|
||||
}}
|
||||
// Stale: generation 1 resumes after generation 2 reserved the session.
|
||||
// Its optimistic user bubble must be marked undelivered, not left as a
|
||||
// ghost that looks sent.
|
||||
_streamGenerations.set('session-1', 2);
|
||||
const staleState = {{ generation: 1, abortCtrl: null }};
|
||||
const staleBubble = {{
|
||||
parentNode: {{}},
|
||||
notes: [],
|
||||
appendChild(node) {{ this.notes.push(node.textContent); }},
|
||||
}};
|
||||
const staleResult = runPreflightGate(1, staleState, staleBubble);
|
||||
// Current: generation 2 proceeds and wires its controller.
|
||||
const currentState = {{ generation: 2, abortCtrl: null }};
|
||||
const currentBubble = {{
|
||||
parentNode: {{}},
|
||||
notes: [],
|
||||
appendChild(node) {{ this.notes.push(node.textContent); }},
|
||||
}};
|
||||
const currentResult = runPreflightGate(2, currentState, currentBubble);
|
||||
console.log(JSON.stringify({{
|
||||
staleBailed: staleResult === undefined,
|
||||
staleControllerNever: staleState.abortCtrl === null,
|
||||
staleBubbleNotes: staleBubble.notes,
|
||||
currentProceeded: !!currentResult,
|
||||
currentWired: currentState.abortCtrl === currentResult && currentAbort === currentResult,
|
||||
currentBubbleNotes: currentBubble.notes,
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"staleBailed": True,
|
||||
"staleControllerNever": True,
|
||||
"staleBubbleNotes": ["[Not sent — superseded by a newer message]"],
|
||||
"currentProceeded": True,
|
||||
"currentWired": True,
|
||||
"currentBubbleNotes": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_stop_during_replacement_preflight_never_borrows_old_controller():
|
||||
"""Stop must take the current send's controller from its send state.
|
||||
|
||||
During the replacement's preflight the stream registry still holds the
|
||||
superseded send's entry; borrowing that controller would abort the only
|
||||
identity channel able to name the old run while queueing a Stop for the
|
||||
new one. A committed-but-pre-POST send has a null controller: the Stop
|
||||
queues under the new generation and nothing is aborted yet.
|
||||
"""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
abort_current = _extract_source(
|
||||
_CHAT, "export function abortCurrentRequest", "// ── Stall watchdog"
|
||||
).replace("export function", "function")
|
||||
reservation = _extract_source(
|
||||
_CHAT,
|
||||
"const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;",
|
||||
"_sendInFlight = false;",
|
||||
)
|
||||
script = f"""
|
||||
let currentAbort = null;
|
||||
let isStreaming = false;
|
||||
let currentHolder = null;
|
||||
let _sendInFlight = false;
|
||||
function _setForegroundChatBusy() {{}}
|
||||
const calls = [];
|
||||
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
|
||||
const window = {{}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
{state_and_stop}
|
||||
{abort_current}
|
||||
const oldCtrl = {{ signal: {{ aborted: false }}, abort() {{ this.signal.aborted = true; }} }};
|
||||
// Generation 1 is registered, streaming, and its run id is KNOWN — the
|
||||
// exact window daybreak probed: a Stop right after the replacement
|
||||
// commits must not consume the old run identity.
|
||||
_streamGenerations.set('session-1', 1);
|
||||
_streamRunIds.set('session-1', 'old-run');
|
||||
_activeStreams.set('session-1', {{ abortCtrl: oldCtrl, holder: null, lastActivity: 1 }});
|
||||
currentAbort = oldCtrl;
|
||||
// Replacement (generation 2) commits via the REAL reservation block; no
|
||||
// controller exists yet and the model-switch await has not resolved.
|
||||
{{
|
||||
const streamSessionId = 'session-1';
|
||||
{reservation}
|
||||
}}
|
||||
abortCurrentRequest(true);
|
||||
const preRegistration = {{
|
||||
oldRunIdCleared: !_streamRunIds.has('session-1'),
|
||||
queuedForNew: _pendingRunStops.has('session-1:2'),
|
||||
queuedController: _pendingRunStops.get('session-1:2') || null,
|
||||
oldAborted: oldCtrl.signal.aborted,
|
||||
stopCalls: calls.length,
|
||||
}};
|
||||
// Normal case: the current send's own controller, run id known.
|
||||
const ownCtrl = {{ _reason: '', signal: {{ aborted: false }}, abort() {{ this.signal.aborted = true; }} }};
|
||||
_sendStates.set('session-1', {{ generation: 2, abortCtrl: ownCtrl }});
|
||||
_streamRunIds.set('session-1', 'run-2');
|
||||
abortCurrentRequest(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
console.log(JSON.stringify({{
|
||||
preRegistration,
|
||||
normal: {{
|
||||
ownAborted: ownCtrl.signal.aborted,
|
||||
oldStillUntouched: oldCtrl.signal.aborted,
|
||||
stopRunId: calls[0] && calls[0].options.headers['X-Odysseus-Run-Id'],
|
||||
}},
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"preRegistration": {
|
||||
# The old run identity dies at reservation: the Stop queues for
|
||||
# the NEW send instead of firing against the old run and skipping
|
||||
# the queue entirely.
|
||||
"oldRunIdCleared": True,
|
||||
"queuedForNew": True,
|
||||
"queuedController": None,
|
||||
"oldAborted": False,
|
||||
"stopCalls": 0,
|
||||
},
|
||||
"normal": {
|
||||
"ownAborted": True,
|
||||
"oldStillUntouched": False,
|
||||
"stopRunId": "run-2",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_timeout_grace_hard_aborts_when_run_identity_never_arrives():
|
||||
"""A POST hung before headers is still cancelled by the timeout's grace."""
|
||||
|
||||
script = f"""
|
||||
{_timeout_harness_prelude()}
|
||||
callbacks[0]();
|
||||
const afterTimeout = {{ aborted: abortCtrl.signal.aborted, pending: callbacks.length }};
|
||||
callbacks[1]();
|
||||
console.log(JSON.stringify({{
|
||||
afterTimeout,
|
||||
afterGrace: {{ aborted: abortCtrl.signal.aborted, stopCalls: calls.length }},
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"afterTimeout": {"aborted": False, "pending": 2},
|
||||
"afterGrace": {"aborted": True, "stopCalls": 0},
|
||||
}
|
||||
|
||||
|
||||
def _timeout_harness_prelude() -> str:
|
||||
"""Shared Node harness: real stop/state and timeout blocks, fake timers."""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
timeout_setup = _extract_source(
|
||||
_CHAT, "timeoutId = setTimeout(() =>", "}, timeoutMs);"
|
||||
) + "}, timeoutMs);"
|
||||
return f"""
|
||||
const calls = [];
|
||||
const callbacks = [];
|
||||
function _setForegroundChatBusy() {{}}
|
||||
function setTimeout(callback) {{ callbacks.push(callback); return 1; }}
|
||||
const window = {{}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
|
||||
const RUN_ID_ABORT_GRACE_MS = 2000;
|
||||
{state_and_stop}
|
||||
const streamSessionId = 'session-1';
|
||||
const streamGeneration = 1;
|
||||
_streamGenerations.set(streamSessionId, streamGeneration);
|
||||
const timeoutMs = 1;
|
||||
let timeoutId;
|
||||
let timedOut = false;
|
||||
const abortCtrl = {{
|
||||
_reason: '',
|
||||
signal: {{ aborted: false }},
|
||||
abort() {{ this.signal.aborted = true; }},
|
||||
}};
|
||||
{timeout_setup}
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_cost_ledger_serializes_stale_cross_tab_writers():
|
||||
"""A stale writer must merge, not overwrite a distinct run recorded by a peer."""
|
||||
|
||||
ledger = _extract_source(
|
||||
_RENDERER, "const _COST_KEY", "/** Create a timestamp span"
|
||||
).replace("export function", "function")
|
||||
script = f"""
|
||||
const state = {{}};
|
||||
let triggerPeerWrite = true;
|
||||
let lockTail = Promise.resolve();
|
||||
const navigator = {{ locks: {{
|
||||
request(_name, callback) {{
|
||||
const next = lockTail.then(callback);
|
||||
lockTail = next.catch(() => {{}});
|
||||
return next;
|
||||
}},
|
||||
}} }};
|
||||
const window = {{ sessionModule: {{ getCurrentSessionId() {{ return 'session'; }} }} }};
|
||||
const document = {{ getElementById() {{ return null; }} }};
|
||||
function _metricsBillableCost(metrics) {{ return metrics.testCost; }}
|
||||
const peerMetrics = {{ testCost: 0.22, _costRecordId: 'run-b' }};
|
||||
let tabA;
|
||||
let tabB;
|
||||
const localStorage = {{
|
||||
getItem(key) {{
|
||||
const staleSnapshot = state[key] || null;
|
||||
if (key === 'ody-session-cost-runs' && triggerPeerWrite) {{
|
||||
triggerPeerWrite = false;
|
||||
tabB.recordSessionMetricsCost(peerMetrics, 'session');
|
||||
}}
|
||||
return staleSnapshot;
|
||||
}},
|
||||
setItem(key, value) {{ state[key] = value; }},
|
||||
}};
|
||||
function createTab() {{
|
||||
{ledger}
|
||||
return {{ recordSessionMetricsCost }};
|
||||
}}
|
||||
tabA = createTab();
|
||||
tabB = createTab();
|
||||
const metricsA = {{ testCost: 0.11, _costRecordId: 'run-a' }};
|
||||
tabA.recordSessionMetricsCost(metricsA, 'session');
|
||||
const queued = {{
|
||||
recorded: !!metricsA._costRecorded,
|
||||
pending: !!metricsA._costRecordPending,
|
||||
}};
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await lockTail;
|
||||
const runs = JSON.parse(state['ody-session-cost-runs'] || '{{}}').session || {{}};
|
||||
console.log(JSON.stringify({{
|
||||
queued,
|
||||
settled: {{
|
||||
recorded: !!metricsA._costRecorded,
|
||||
pending: !!metricsA._costRecordPending,
|
||||
}},
|
||||
runs,
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
# Recorded must not be claimed while the write only sits queued behind
|
||||
# the lock; it flips once the write has actually run.
|
||||
"queued": {"recorded": False, "pending": True},
|
||||
"settled": {"recorded": True, "pending": False},
|
||||
"runs": {"run-a": 0.11, "run-b": 0.22},
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Executable regression coverage for behavior lost in PR #6020's rebase."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import src.agent_loop as agent_loop
|
||||
|
||||
|
||||
ODY_QWEN = "odysseus-qwen3-4b"
|
||||
NOTES_TOOLS = {
|
||||
"manage_notes",
|
||||
"manage_calendar",
|
||||
"manage_tasks",
|
||||
"ask_user",
|
||||
"update_plan",
|
||||
}
|
||||
|
||||
|
||||
def _collect(generator):
|
||||
async def _run():
|
||||
return [chunk async for chunk in generator]
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
def _events(chunks):
|
||||
return [
|
||||
json.loads(chunk[6:])
|
||||
for chunk in chunks
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]")
|
||||
]
|
||||
|
||||
|
||||
def _install_route_probe(monkeypatch):
|
||||
prompt_calls = []
|
||||
stream_calls = []
|
||||
|
||||
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
|
||||
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
|
||||
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
|
||||
monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
|
||||
monkeypatch.setattr(
|
||||
agent_loop,
|
||||
"_agent_route_tool_mode",
|
||||
lambda *args, **kwargs: (True, False, False),
|
||||
)
|
||||
|
||||
def fake_build(
|
||||
messages,
|
||||
model,
|
||||
_active_document,
|
||||
_mcp_mgr,
|
||||
disabled_tools=None,
|
||||
**kwargs,
|
||||
):
|
||||
prompt_calls.append(
|
||||
{
|
||||
"model": model,
|
||||
"relevant_tools": set(kwargs.get("relevant_tools") or set()),
|
||||
"disabled_tools": set(disabled_tools or set()),
|
||||
"workspace": kwargs.get("workspace"),
|
||||
}
|
||||
)
|
||||
return (list(messages), [])
|
||||
|
||||
async def fake_stream(_candidates, _messages, **kwargs):
|
||||
stream_calls.append(kwargs)
|
||||
yield 'data: {"delta": "ok"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
monkeypatch.setattr(agent_loop, "_build_system_prompt", fake_build)
|
||||
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
|
||||
return prompt_calls, stream_calls
|
||||
|
||||
|
||||
def _run_probe(messages, *, relevant_tools, **kwargs):
|
||||
return _collect(
|
||||
agent_loop.stream_agent_loop(
|
||||
"https://api.example/v1",
|
||||
kwargs.pop("model", ODY_QWEN),
|
||||
messages,
|
||||
max_rounds=1,
|
||||
relevant_tools=set(relevant_tools),
|
||||
_is_teacher_run=True,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_odysseus_notes_mode_clamps_and_reenables_all_personal_managers(monkeypatch):
|
||||
prompt_calls, _ = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Add buy milk to my notes."}],
|
||||
relevant_tools={"bash", "manage_notes", "manage_calendar", "manage_tasks"},
|
||||
disabled_tools={"manage_notes", "manage_calendar", "manage_tasks"},
|
||||
)
|
||||
|
||||
route = prompt_calls[0]
|
||||
assert route["relevant_tools"] == NOTES_TOOLS
|
||||
assert route["disabled_tools"].isdisjoint(
|
||||
{"manage_notes", "manage_calendar", "manage_tasks"}
|
||||
)
|
||||
|
||||
|
||||
def test_odysseus_general_mode_disables_every_tool(monkeypatch):
|
||||
from src.tool_policy import known_tool_names
|
||||
|
||||
prompt_calls, _ = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Explain the CAP theorem."}],
|
||||
relevant_tools={"bash", "manage_notes", "ask_user"},
|
||||
)
|
||||
|
||||
route = prompt_calls[0]
|
||||
assert route["relevant_tools"] == set()
|
||||
assert known_tool_names() <= route["disabled_tools"]
|
||||
|
||||
|
||||
def test_odysseus_calendar_intent_uses_notes_mode(monkeypatch):
|
||||
prompt_calls, _ = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Add lunch tomorrow to my calendar."}],
|
||||
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
|
||||
)
|
||||
|
||||
assert prompt_calls[0]["relevant_tools"] == NOTES_TOOLS
|
||||
|
||||
|
||||
def test_odysseus_calendar_followup_keeps_notes_mode(monkeypatch):
|
||||
prompt_calls, _ = _install_route_probe(monkeypatch)
|
||||
messages = [
|
||||
{"role": "user", "content": "Add lunch tomorrow to my calendar."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Done.",
|
||||
"metadata": {
|
||||
"tool_events": [
|
||||
{
|
||||
"tool": "manage_calendar",
|
||||
"command": '{"action":"create_event","summary":"Lunch"}',
|
||||
"output": "Created event evt-123 at noon.",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
{"role": "user", "content": "Move it to 3pm."},
|
||||
]
|
||||
|
||||
_run_probe(
|
||||
messages,
|
||||
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
|
||||
)
|
||||
|
||||
assert prompt_calls[0]["relevant_tools"] == NOTES_TOOLS
|
||||
|
||||
|
||||
def test_agent_route_passes_workspace_to_system_prompt(monkeypatch):
|
||||
prompt_calls, _ = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Fix the failing test in this project."}],
|
||||
model="gpt-4o",
|
||||
relevant_tools={"bash", "read_file", "apply_patch"},
|
||||
workspace="/tmp/example-repo",
|
||||
)
|
||||
|
||||
assert prompt_calls[0]["workspace"] == "/tmp/example-repo"
|
||||
|
||||
|
||||
def test_odysseus_qwen_temperature_is_capped_for_agent_requests(monkeypatch):
|
||||
_, stream_calls = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Add buy milk to my notes."}],
|
||||
relevant_tools={"manage_notes"},
|
||||
temperature=1.2,
|
||||
)
|
||||
|
||||
assert stream_calls[0]["temperature"] == 0.2
|
||||
|
||||
|
||||
def test_qwen_fallback_candidate_gets_capped_temperature(monkeypatch):
|
||||
"""A non-qwen primary must not leak its temperature into a qwen fallback."""
|
||||
|
||||
_, stream_calls = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Explain the CAP theorem."}],
|
||||
model="gpt-4o",
|
||||
relevant_tools={"bash"},
|
||||
temperature=1.2,
|
||||
fallbacks=[("https://qwen.example/v1", ODY_QWEN, {})],
|
||||
)
|
||||
|
||||
assert stream_calls[0]["temperature"] == 1.2
|
||||
factory = stream_calls[0]["candidate_request_factory"]
|
||||
request = asyncio.run(factory(1, "https://qwen.example/v1", ODY_QWEN, {}))
|
||||
assert request["kwargs"]["temperature"] == 0.2
|
||||
|
||||
|
||||
def test_non_qwen_fallback_keeps_requested_temperature(monkeypatch):
|
||||
"""A qwen primary's 0.2 cap must not leak into a non-qwen fallback."""
|
||||
|
||||
_, stream_calls = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Add buy milk to my notes."}],
|
||||
relevant_tools={"manage_notes"},
|
||||
temperature=1.2,
|
||||
fallbacks=[("https://backup.example/v1", "gpt-4o", {})],
|
||||
)
|
||||
|
||||
assert stream_calls[0]["temperature"] == 0.2
|
||||
factory = stream_calls[0]["candidate_request_factory"]
|
||||
request = asyncio.run(factory(1, "https://backup.example/v1", "gpt-4o", {}))
|
||||
assert request["kwargs"]["temperature"] == 1.2
|
||||
|
||||
|
||||
def test_qwen_notes_fallback_reenables_personal_managers(monkeypatch):
|
||||
"""The answering candidate's notes mode must unblock the managers for
|
||||
execution, not just enable them in its own route schemas."""
|
||||
|
||||
_install_route_probe(monkeypatch)
|
||||
stream_round = 0
|
||||
resolve_round = 0
|
||||
seen_exec = {}
|
||||
|
||||
async def fake_stream(_candidates, _messages, **kwargs):
|
||||
nonlocal stream_round
|
||||
stream_round += 1
|
||||
if stream_round == 1:
|
||||
yield (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "fallback",
|
||||
"answered_by": ODY_QWEN,
|
||||
"candidate_index": 1,
|
||||
}
|
||||
)
|
||||
+ "\n\n"
|
||||
)
|
||||
yield 'data: {"delta": "Adding the note."}\n\n'
|
||||
else:
|
||||
yield 'data: {"delta": "Done."}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
def fake_resolve(*args, **kwargs):
|
||||
nonlocal resolve_round
|
||||
resolve_round += 1
|
||||
if resolve_round == 1:
|
||||
return ([agent_loop.ToolBlock("manage_notes", "{}")], False, [])
|
||||
return ([], False, [])
|
||||
|
||||
async def fake_execute(block, *args, **kwargs):
|
||||
# Execution is the consumer daybreak's probe showed rejecting the
|
||||
# managers: it receives the shared disabled_tools set, not the
|
||||
# answering route's own tool state.
|
||||
seen_exec["disabled_tools"] = set(kwargs.get("disabled_tools") or [])
|
||||
return ("manage_notes: saved", {"output": "noted", "exit_code": 0})
|
||||
|
||||
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
|
||||
monkeypatch.setattr(agent_loop, "_resolve_tool_blocks", fake_resolve)
|
||||
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
|
||||
|
||||
_collect(
|
||||
agent_loop.stream_agent_loop(
|
||||
"https://api.example/v1",
|
||||
"gpt-4o",
|
||||
[{"role": "user", "content": "Add buy milk to my notes."}],
|
||||
max_rounds=2,
|
||||
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
|
||||
disabled_tools={"manage_notes", "manage_calendar", "manage_tasks"},
|
||||
fallbacks=[("https://qwen.example/v1", ODY_QWEN, {})],
|
||||
_is_teacher_run=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert seen_exec["disabled_tools"].isdisjoint(
|
||||
{"manage_notes", "manage_calendar", "manage_tasks"}
|
||||
)
|
||||
|
||||
|
||||
def test_persisted_mcp_tool_event_keeps_description_and_resolved_name(monkeypatch):
|
||||
_install_route_probe(monkeypatch)
|
||||
stream_round = 0
|
||||
resolve_round = 0
|
||||
|
||||
async def fake_stream(_candidates, _messages, **kwargs):
|
||||
nonlocal stream_round
|
||||
stream_round += 1
|
||||
if stream_round == 1:
|
||||
yield 'data: {"delta": "Calling calendar."}\n\n'
|
||||
else:
|
||||
yield 'data: {"delta": "Finished."}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
def fake_resolve(*args, **kwargs):
|
||||
nonlocal resolve_round
|
||||
resolve_round += 1
|
||||
if resolve_round == 1:
|
||||
return ([agent_loop.ToolBlock("mcp", "{}")], False, [])
|
||||
return ([], False, [])
|
||||
|
||||
async def fake_execute(block, *args, **kwargs):
|
||||
assert block.tool_type == "mcp"
|
||||
return (
|
||||
"mcp__calendar__create_event: created team sync",
|
||||
{"output": "Created event evt-456.", "exit_code": 0},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
|
||||
monkeypatch.setattr(agent_loop, "_resolve_tool_blocks", fake_resolve)
|
||||
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
|
||||
|
||||
chunks = _collect(
|
||||
agent_loop.stream_agent_loop(
|
||||
"https://api.example/v1",
|
||||
"gpt-4o",
|
||||
[{"role": "user", "content": "Create the team sync event."}],
|
||||
max_rounds=2,
|
||||
relevant_tools={"mcp"},
|
||||
_is_teacher_run=True,
|
||||
)
|
||||
)
|
||||
metrics = next(
|
||||
event["data"] for event in _events(chunks) if event.get("type") == "metrics"
|
||||
)
|
||||
persisted = metrics["tool_events"][0]
|
||||
|
||||
assert persisted["tool"] == "mcp__calendar__create_event"
|
||||
assert persisted["desc"] == "mcp__calendar__create_event: created team sync"
|
||||
@@ -17,4 +17,26 @@ def test_load_keeps_object_prefs_file(tmp_path, monkeypatch):
|
||||
prefs_file.write_text(json.dumps({"theme": "dark"}), encoding="utf-8")
|
||||
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
||||
|
||||
assert prefs_routes._load_for_user("alice") == {"theme": "dark"}
|
||||
assert prefs_routes._load_for_user(None) == {"theme": "dark"}
|
||||
assert prefs_routes._load_for_user("alice") == {}
|
||||
|
||||
|
||||
def test_named_preference_write_does_not_copy_flat_fallback_consent(tmp_path, monkeypatch):
|
||||
prefs_file = tmp_path / "user_prefs.json"
|
||||
prefs_file.write_text(json.dumps({
|
||||
"theme": "light",
|
||||
"foreground_fallback_enabled": True,
|
||||
"foreground_model_fallbacks": [
|
||||
{"endpoint_id": "legacy-single-user", "model": "legacy-model"},
|
||||
],
|
||||
}), encoding="utf-8")
|
||||
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
||||
|
||||
bob = prefs_routes._load_for_user("bob")
|
||||
bob["theme"] = "dark"
|
||||
prefs_routes._save_for_user("bob", bob)
|
||||
|
||||
raw = prefs_routes._load()
|
||||
assert raw["_users"] == {"bob": {"theme": "dark"}}
|
||||
assert raw["foreground_fallback_enabled"] is True
|
||||
assert raw["foreground_model_fallbacks"][0]["endpoint_id"] == "legacy-single-user"
|
||||
|
||||
@@ -6,6 +6,9 @@ every other user's preferences (a realistic ops transition: auth turned off
|
||||
on a deployment that previously ran multi-user). It must preserve the other
|
||||
users and round-trip the change into the same (first) slot _load_for_user
|
||||
reads from.
|
||||
|
||||
Foreground fallback keys are the exception: auth-disabled consent is stored
|
||||
at the flat root so it can never become consent for the first named owner.
|
||||
"""
|
||||
import json
|
||||
|
||||
@@ -51,3 +54,58 @@ def test_named_user_save_unaffected(tmp_path, monkeypatch):
|
||||
data = json.loads(f.read_text())
|
||||
assert data["_users"]["alice"] == {"theme": "light"}
|
||||
assert data["_users"]["bob"] == {"theme": "dark"}
|
||||
|
||||
|
||||
def test_auth_disabled_fallback_consent_does_not_mutate_first_named_user(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
f = tmp_path / "user_prefs.json"
|
||||
f.write_text(json.dumps({"_users": {
|
||||
"alice": {"theme": "light"},
|
||||
"bob": {"theme": "paper"},
|
||||
}}), encoding="utf-8")
|
||||
monkeypatch.setattr(pr, "PREFS_FILE", str(f))
|
||||
|
||||
current = pr._load_for_user(None)
|
||||
current["foreground_fallback_enabled"] = True
|
||||
current["foreground_model_fallbacks"] = [
|
||||
{"endpoint_id": "single-user", "model": "single-model"},
|
||||
]
|
||||
pr._save_for_user(None, current)
|
||||
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
assert data["foreground_fallback_enabled"] is True
|
||||
assert data["foreground_model_fallbacks"][0]["endpoint_id"] == "single-user"
|
||||
assert data["_users"]["alice"] == {"theme": "light"}
|
||||
assert data["_users"]["bob"] == {"theme": "paper"}
|
||||
|
||||
|
||||
def test_auth_disabled_save_preserves_named_fallback_consent(tmp_path, monkeypatch):
|
||||
f = tmp_path / "user_prefs.json"
|
||||
alice_fallbacks = [{"endpoint_id": "alice", "model": "alice-model"}]
|
||||
f.write_text(json.dumps({"_users": {
|
||||
"alice": {
|
||||
"theme": "light",
|
||||
"foreground_fallback_enabled": True,
|
||||
"foreground_model_fallbacks": alice_fallbacks,
|
||||
},
|
||||
}}), encoding="utf-8")
|
||||
monkeypatch.setattr(pr, "PREFS_FILE", str(f))
|
||||
|
||||
current = pr._load_for_user(None)
|
||||
assert "foreground_fallback_enabled" not in current
|
||||
assert "foreground_model_fallbacks" not in current
|
||||
current["theme"] = "dark"
|
||||
current["foreground_fallback_enabled"] = False
|
||||
current["foreground_model_fallbacks"] = []
|
||||
pr._save_for_user(None, current)
|
||||
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
assert data["foreground_fallback_enabled"] is False
|
||||
assert data["foreground_model_fallbacks"] == []
|
||||
assert data["_users"]["alice"] == {
|
||||
"theme": "dark",
|
||||
"foreground_fallback_enabled": True,
|
||||
"foreground_model_fallbacks": alice_fallbacks,
|
||||
}
|
||||
|
||||
@@ -3,8 +3,16 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import src.endpoint_resolver as endpoint_resolver
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.endpoint_resolver import (
|
||||
endpoint_cost_tracked,
|
||||
resolve_endpoint,
|
||||
resolve_endpoint_by_id,
|
||||
resolve_fallback_entries,
|
||||
resolve_fallback_entries_with_descriptors,
|
||||
)
|
||||
|
||||
|
||||
class _FakeColumn:
|
||||
@@ -34,6 +42,9 @@ class _FakeQuery:
|
||||
def first(self):
|
||||
return self.rows[0] if self.rows else None
|
||||
|
||||
def all(self):
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
def __init__(self, rows):
|
||||
@@ -49,6 +60,7 @@ class _FakeDb:
|
||||
def _endpoint(ep_id, model, *, hidden=None):
|
||||
return SimpleNamespace(
|
||||
id=ep_id,
|
||||
name=f"Endpoint {ep_id}",
|
||||
base_url=f"https://{ep_id}.example/v1",
|
||||
api_key=f"key-{ep_id}",
|
||||
cached_models=json.dumps([model]),
|
||||
@@ -191,3 +203,140 @@ def test_hidden_configured_model_selects_first_enabled_chat_model(monkeypatch):
|
||||
assert url == "https://default.example/v1/chat/completions"
|
||||
assert model == "enabled-chat"
|
||||
assert headers == {"Authorization": "Bearer key-default"}
|
||||
|
||||
|
||||
def test_exact_fallback_drops_hidden_model_instead_of_substituting(monkeypatch):
|
||||
endpoint = SimpleNamespace(
|
||||
id="fallback",
|
||||
base_url="https://fallback.example/v1",
|
||||
api_key="key-fallback",
|
||||
cached_models=json.dumps(["chosen-hidden", "different-live"]),
|
||||
hidden_models=json.dumps(["chosen-hidden"]),
|
||||
is_enabled=True,
|
||||
)
|
||||
_install_resolver_fakes(monkeypatch, {}, [endpoint])
|
||||
|
||||
assert resolve_endpoint_by_id(
|
||||
"fallback",
|
||||
"chosen-hidden",
|
||||
require_exact_model=True,
|
||||
) is None
|
||||
assert resolve_endpoint_by_id("fallback", "chosen-hidden")[1] == "different-live"
|
||||
|
||||
|
||||
def test_exact_fallback_drops_known_missing_model(monkeypatch):
|
||||
_install_resolver_fakes(monkeypatch, {}, [_endpoint("fallback", "known-live")])
|
||||
|
||||
assert resolve_endpoint_by_id(
|
||||
"fallback",
|
||||
"unlisted-model",
|
||||
require_exact_model=True,
|
||||
) is None
|
||||
|
||||
|
||||
def test_fallback_entry_resolution_preserves_credential_distinct_endpoints(monkeypatch):
|
||||
seen = []
|
||||
|
||||
def fake_resolve(ep_id, model, owner=None, *, require_exact_model=False):
|
||||
seen.append((ep_id, model, owner, require_exact_model))
|
||||
return (
|
||||
"https://provider.example/v1/chat/completions",
|
||||
model,
|
||||
{"Authorization": f"Bearer {ep_id}"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_by_id", fake_resolve)
|
||||
entries = [
|
||||
{"endpoint_id": "key-one", "model": "same-model"},
|
||||
{"endpoint_id": "key-two", "model": "same-model"},
|
||||
]
|
||||
|
||||
assert resolve_fallback_entries(
|
||||
entries,
|
||||
owner="alice",
|
||||
require_exact_model=True,
|
||||
) == [
|
||||
("https://provider.example/v1/chat/completions", "same-model", {"Authorization": "Bearer key-one"}),
|
||||
("https://provider.example/v1/chat/completions", "same-model", {"Authorization": "Bearer key-two"}),
|
||||
]
|
||||
assert seen == [
|
||||
("key-one", "same-model", "alice", True),
|
||||
("key-two", "same-model", "alice", True),
|
||||
]
|
||||
|
||||
|
||||
def test_descriptor_resolution_preserves_safe_endpoint_identity(monkeypatch):
|
||||
_install_resolver_fakes(monkeypatch, {}, [_endpoint("backup", "backup-model")])
|
||||
|
||||
routes = resolve_fallback_entries_with_descriptors(
|
||||
[{"endpoint_id": "backup", "model": "backup-model"}],
|
||||
require_exact_model=True,
|
||||
)
|
||||
|
||||
assert routes == [(
|
||||
(
|
||||
"https://backup.example/v1/chat/completions",
|
||||
"backup-model",
|
||||
{"Authorization": "Bearer key-backup"},
|
||||
),
|
||||
{
|
||||
"endpoint_id": "backup",
|
||||
"endpoint_label": "Endpoint backup",
|
||||
"endpoint_cost_tracked": True,
|
||||
},
|
||||
)]
|
||||
|
||||
|
||||
def test_exact_id_descriptor_wins_when_routes_are_identical(monkeypatch):
|
||||
first = _endpoint("account-one", "same-model")
|
||||
second = _endpoint("account-two", "same-model")
|
||||
for endpoint in (first, second):
|
||||
endpoint.base_url = "https://provider.example/v1"
|
||||
endpoint.api_key = "shared-key"
|
||||
_install_resolver_fakes(monkeypatch, {}, [first, second])
|
||||
|
||||
import src.auth_helpers as auth_helpers
|
||||
|
||||
seen_owners = []
|
||||
|
||||
def scoped(query, model_cls, owner, *, include_shared=True):
|
||||
seen_owners.append(owner)
|
||||
return query
|
||||
|
||||
monkeypatch.setattr(auth_helpers, "owner_filter", scoped)
|
||||
resolver = getattr(endpoint_resolver, "resolve_route_descriptor_by_id", None)
|
||||
|
||||
assert resolver is not None
|
||||
assert resolver(
|
||||
"account-two",
|
||||
"https://provider.example/v1/chat/completions",
|
||||
"same-model",
|
||||
{"Authorization": "Bearer shared-key"},
|
||||
owner="alice",
|
||||
) == {
|
||||
"endpoint_id": "account-two",
|
||||
"endpoint_label": "Endpoint account-two",
|
||||
"endpoint_cost_tracked": True,
|
||||
}
|
||||
assert seen_owners == ["alice"]
|
||||
|
||||
|
||||
def test_endpoint_cost_tracking_is_non_secret_route_classification():
|
||||
assert endpoint_cost_tracked("http://localhost:11434/v1") is False
|
||||
assert endpoint_cost_tracked("http://model-service:8000/v1") is False
|
||||
assert endpoint_cost_tracked("http://192.168.1.20:8000/v1") is False
|
||||
assert endpoint_cost_tracked("https://chatgpt.com/backend-api/codex") is False
|
||||
assert endpoint_cost_tracked("https://api.example.com/v1") is True
|
||||
assert endpoint_cost_tracked("http://192.168.1.20:8000/v1", "api") is True
|
||||
assert endpoint_cost_tracked("https://api.example.com/v1", "local") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected"),
|
||||
[
|
||||
("https://[2606:4700:4700::1111]/v1", True),
|
||||
("http://169.254.10.20:8000/v1", False),
|
||||
],
|
||||
)
|
||||
def test_endpoint_cost_tracking_classifies_public_ipv6_and_link_local_ipv4(url, expected):
|
||||
assert endpoint_cost_tracked(url) is expected
|
||||
|
||||
@@ -163,53 +163,3 @@ def test_chatgpt_subscription_clears_previously_persisted_bearer(monkeypatch):
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_chatgpt_subscription_fallback_auth_is_not_written_to_sessions_table(monkeypatch):
|
||||
"""Fallback endpoint selection must keep the resolved bearer request-local."""
|
||||
TestSessionLocal = _mem_db(monkeypatch)
|
||||
db = TestSessionLocal()
|
||||
try:
|
||||
db.add(ModelEndpoint(
|
||||
id="ep1", name="ChatGPT Subscription", base_url=_CODEX_BASE,
|
||||
provider_auth_id="auth1", owner="alice", is_enabled=True, api_key=None,
|
||||
cached_models='["gpt-5.1-codex"]',
|
||||
))
|
||||
db.add(DbSession(
|
||||
id="sess1", name="chat", endpoint_url="https://old.example/v1",
|
||||
model="old-model", owner="alice", headers={},
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
monkeypatch.setattr(
|
||||
endpoint_resolver,
|
||||
"resolve_endpoint_runtime",
|
||||
lambda ep, owner=None: (_CODEX_BASE, "live-access-token"),
|
||||
)
|
||||
|
||||
sess = types.SimpleNamespace(
|
||||
id="sess1", endpoint_url="https://old.example/v1", model="old-model",
|
||||
owner="alice", headers={},
|
||||
)
|
||||
result = chat_helpers.try_fallback_endpoint(sess, "sess1")
|
||||
|
||||
assert result == {
|
||||
"model": "gpt-5.1-codex",
|
||||
"endpoint_url": _CODEX_BASE + "/responses",
|
||||
"endpoint_name": "ChatGPT Subscription",
|
||||
}
|
||||
assert sess.headers["Authorization"] == "Bearer live-access-token"
|
||||
|
||||
db = TestSessionLocal()
|
||||
try:
|
||||
row = db.query(DbSession).filter(DbSession.id == "sess1").first()
|
||||
assert row.model == "gpt-5.1-codex"
|
||||
assert row.endpoint_url == _CODEX_BASE + "/responses"
|
||||
stored = row.headers or {}
|
||||
assert not any(k.lower() == "authorization" for k in stored), (
|
||||
f"ChatGPT fallback bearer leaked into sessions table: {stored}"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Retired settings stay stored but cannot leak through generic interfaces."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import core.database as database
|
||||
import routes.auth_routes as auth_routes
|
||||
import src.settings as settings_mod
|
||||
from src.agent_tools.admin_tools import do_manage_settings
|
||||
|
||||
|
||||
LEGACY_VALUE = [
|
||||
{"endpoint_id": "private-endpoint-id", "model": "private-model-name"},
|
||||
]
|
||||
|
||||
|
||||
class _AuthManager:
|
||||
def get_username_for_token(self, token):
|
||||
return "admin" if token == "admin-session" else None
|
||||
|
||||
def is_admin(self, username):
|
||||
return username == "admin"
|
||||
|
||||
|
||||
class _Request(SimpleNamespace):
|
||||
def __init__(self, body=None, *, admin=False):
|
||||
super().__init__(
|
||||
cookies={
|
||||
auth_routes.SESSION_COOKIE: "admin-session"
|
||||
} if admin else {},
|
||||
_body=body,
|
||||
)
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
def _route(router, path, method):
|
||||
return next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if route.path == path and method in route.methods
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_settings_hide_and_preserve_retired_fallbacks(monkeypatch):
|
||||
store = {
|
||||
**settings_mod.DEFAULT_SETTINGS,
|
||||
"default_model_fallbacks": list(LEGACY_VALUE),
|
||||
"tts_enabled": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
|
||||
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store))
|
||||
|
||||
def save_settings(updated):
|
||||
store.clear()
|
||||
store.update(updated)
|
||||
|
||||
monkeypatch.setattr(auth_routes, "_save_settings", save_settings)
|
||||
router = auth_routes.setup_auth_routes(_AuthManager())
|
||||
get_settings = _route(router, "/api/auth/settings", "GET")
|
||||
set_settings = _route(router, "/api/auth/settings", "POST")
|
||||
|
||||
anonymous = await get_settings(_Request())
|
||||
admin = await get_settings(_Request(admin=True))
|
||||
|
||||
assert "default_model_fallbacks" not in anonymous
|
||||
assert "default_model_fallbacks" not in admin
|
||||
assert store["default_model_fallbacks"] == LEGACY_VALUE
|
||||
|
||||
response = await set_settings(_Request({
|
||||
"default_model_fallbacks": [],
|
||||
"tts_enabled": False,
|
||||
}, admin=True))
|
||||
|
||||
assert "default_model_fallbacks" not in response
|
||||
assert store["default_model_fallbacks"] == LEGACY_VALUE
|
||||
assert store["tts_enabled"] is False
|
||||
|
||||
|
||||
def test_manage_settings_tombstones_legacy_fallback_key(monkeypatch):
|
||||
store = {
|
||||
**settings_mod.DEFAULT_SETTINGS,
|
||||
"default_model_fallbacks": list(LEGACY_VALUE),
|
||||
}
|
||||
save_calls = []
|
||||
|
||||
class _Db:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(database, "SessionLocal", lambda: _Db())
|
||||
monkeypatch.setattr(settings_mod, "load_settings", lambda: dict(store))
|
||||
|
||||
def save_settings(updated):
|
||||
save_calls.append(dict(updated))
|
||||
store.clear()
|
||||
store.update(updated)
|
||||
|
||||
monkeypatch.setattr(settings_mod, "save_settings", save_settings)
|
||||
|
||||
listed = asyncio.run(do_manage_settings(json.dumps({"action": "list"})))
|
||||
assert "default_model_fallbacks" not in listed["settings"]
|
||||
|
||||
for action in ("get", "set", "reset", "delete"):
|
||||
payload = {"action": action, "key": "default_model_fallbacks"}
|
||||
if action == "set":
|
||||
payload["value"] = []
|
||||
result = asyncio.run(do_manage_settings(json.dumps(payload)))
|
||||
assert result["exit_code"] == 1
|
||||
assert "Unknown setting" in result["error"]
|
||||
|
||||
assert save_calls == []
|
||||
assert store["default_model_fallbacks"] == LEGACY_VALUE
|
||||
@@ -6,8 +6,15 @@ Verifies two critical cases:
|
||||
2. api.deepseek.com must still be treated as tool-capable via the host
|
||||
allow-list (_API_HOSTS), so cloud deepseek users keep working.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from src.agent_loop import _API_HOSTS, _endpoint_lookup_keys, _is_ollama_openai_compat_url
|
||||
from src.agent_loop import (
|
||||
_API_HOSTS,
|
||||
_agent_route_tool_mode,
|
||||
_endpoint_lookup_keys,
|
||||
_is_ollama_openai_compat_url,
|
||||
)
|
||||
from src.llm_core import _is_ollama_native_url
|
||||
|
||||
|
||||
@@ -164,3 +171,57 @@ class TestEndpointLookupKeys:
|
||||
keys = _endpoint_lookup_keys("http://host.docker.internal:11434/api/chat")
|
||||
|
||||
assert "http://host.docker.internal:11434/api" in keys
|
||||
|
||||
|
||||
def test_route_tool_mode_matches_credential_distinct_endpoint(monkeypatch):
|
||||
from core import database
|
||||
from src import endpoint_resolver
|
||||
|
||||
rows = [
|
||||
SimpleNamespace(
|
||||
id="one",
|
||||
base_url="https://same.example/v1",
|
||||
api_key="key-one",
|
||||
provider_auth_id=None,
|
||||
supports_tools=True,
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="two",
|
||||
base_url="https://same.example/v1",
|
||||
api_key="key-two",
|
||||
provider_auth_id=None,
|
||||
supports_tools=False,
|
||||
),
|
||||
]
|
||||
|
||||
class Query:
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return rows
|
||||
|
||||
class Db:
|
||||
def query(self, *args, **kwargs):
|
||||
return Query()
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(database, "SessionLocal", lambda: Db())
|
||||
monkeypatch.setattr(
|
||||
endpoint_resolver,
|
||||
"resolve_endpoint_runtime",
|
||||
lambda endpoint, owner=None: (endpoint.base_url, endpoint.api_key),
|
||||
)
|
||||
|
||||
assert _agent_route_tool_mode(
|
||||
"https://same.example/v1",
|
||||
"custom-model",
|
||||
headers={"Authorization": "Bearer key-one"},
|
||||
)[0] is True
|
||||
assert _agent_route_tool_mode(
|
||||
"https://same.example/v1",
|
||||
"custom-model",
|
||||
headers={"Authorization": "Bearer key-two"},
|
||||
)[0] is False
|
||||
|
||||
@@ -117,6 +117,31 @@ def test_agent_system_prompt_includes_shared_current_time(monkeypatch):
|
||||
assert "Australia/Brisbane, UTC+10:00" in datetime_messages[0]["content"]
|
||||
|
||||
|
||||
def test_route_prompt_rebuild_restores_leading_user_system_message(monkeypatch):
|
||||
import src.agent_loop as agent_loop
|
||||
|
||||
monkeypatch.setattr(agent_loop, "_build_base_prompt", lambda *args, **kwargs: ("AGENT PROMPT", ""))
|
||||
monkeypatch.setattr(agent_loop, "set_active_model", lambda model: None)
|
||||
monkeypatch.setattr(agent_loop, "get_builtin_overrides", lambda: {})
|
||||
monkeypatch.setattr(agent_loop, "_cached_base_prompt", None)
|
||||
monkeypatch.setattr(agent_loop, "_cached_base_prompt_key", None)
|
||||
|
||||
original = [
|
||||
{"role": "system", "content": "USER PERSONA"},
|
||||
{"role": "user", "content": "hello"},
|
||||
]
|
||||
built, _ = agent_loop._build_system_prompt(
|
||||
original,
|
||||
model="selected-model",
|
||||
active_document=None,
|
||||
mcp_mgr=None,
|
||||
)
|
||||
|
||||
assert built[0]["content"] == "USER PERSONA\n\nAGENT PROMPT"
|
||||
assert built[0]["_agent_injected"] == "merged_prompt"
|
||||
assert agent_loop._strip_agent_injected_messages(built) == original
|
||||
|
||||
|
||||
def test_calendar_relative_time_parser_handles_dotted_pm(monkeypatch):
|
||||
import routes.calendar_routes as calendar_routes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user