diff --git a/api/gateway_watcher.py b/api/gateway_watcher.py index fe1a37b..409e4d5 100644 --- a/api/gateway_watcher.py +++ b/api/gateway_watcher.py @@ -75,7 +75,7 @@ def _get_agent_sessions_from_db() -> list: sessions.append({ 'session_id': row['id'], 'title': row['title'] or 'Agent Session', - 'model': row['model'] or 'unknown', + 'model': row['model'] or None, 'message_count': row['message_count'] or 0, 'created_at': row['started_at'], 'updated_at': row['last_activity'] or row['started_at'], diff --git a/api/models.py b/api/models.py index 19acb75..be5dde2 100644 --- a/api/models.py +++ b/api/models.py @@ -309,7 +309,7 @@ def get_cli_sessions() -> list: 'session_id': sid, 'title': _display_title, 'workspace': str(get_last_workspace()), - 'model': row['model'] or 'unknown', + 'model': row['model'] or None, 'message_count': row['message_count'] or 0, 'created_at': row['started_at'], 'updated_at': raw_ts, diff --git a/tests/test_sprint40_ui_polish.py b/tests/test_sprint40_ui_polish.py index a2060cc..e31db96 100644 --- a/tests/test_sprint40_ui_polish.py +++ b/tests/test_sprint40_ui_polish.py @@ -12,7 +12,12 @@ import unittest REPO_ROOT = pathlib.Path(__file__).parent.parent STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text() +# Combined tests for Sprint 40 — Session + UI Polish +# Covers: active title color, unknown model, Telegram badge, +# custom endpoint model routing, workspace chip + +# ── #451 active title ───────────────────────────────────────────── class TestActiveSessionTitleThemeColor(unittest.TestCase): def test_active_session_title_uses_theme_variable(self): @@ -49,3 +54,256 @@ class TestActiveSessionTitleThemeColor(unittest.TestCase): if __name__ == "__main__": unittest.main() + +# ── #452 unknown model ───────────────────────────────────────────── +class TestGatewaySessionNullModel(unittest.TestCase): + """Verify that api/models.py and api/gateway_watcher.py do not + fall back to the string 'unknown' for missing model values.""" + + def test_gateway_session_null_model_returns_none_not_unknown(self): + """api/models.py must not use `or 'unknown'` for the model field + so that a NULL model in state.db is returned as None (falsy) to + the frontend rather than the truthy string 'unknown'.""" + models_src = (REPO_ROOT / "api" / "models.py").read_text() + # Ensure the old fallback pattern is gone + self.assertNotIn( + "'model': row['model'] or 'unknown'", + models_src, + "api/models.py must not use `or 'unknown'` for the model field " + "(fixes #443: gateway sessions showed 'telegram · unknown')", + ) + + def test_gateway_watcher_null_model_returns_none_not_unknown(self): + """api/gateway_watcher.py must not use `or 'unknown'` for the model + field so that a NULL model in state.db is returned as None (falsy).""" + gw_src = (REPO_ROOT / "api" / "gateway_watcher.py").read_text() + self.assertNotIn( + "'model': row['model'] or 'unknown'", + gw_src, + "api/gateway_watcher.py must not use `or 'unknown'` for the model " + "field (fixes #443: gateway sessions showed 'telegram · unknown')", + ) + + def test_gateway_session_model_uses_none_fallback(self): + """Both source files must use `row['model'] or None` (explicit None + fallback) for the model field assignment.""" + models_src = (REPO_ROOT / "api" / "models.py").read_text() + gw_src = (REPO_ROOT / "api" / "gateway_watcher.py").read_text() + self.assertIn( + "'model': row['model'] or None,", + models_src, + "api/models.py should assign `row['model'] or None` for the model field", + ) + self.assertIn( + "'model': row['model'] or None,", + gw_src, + "api/gateway_watcher.py should assign `row['model'] or None` for the model field", + ) + + +if __name__ == "__main__": + unittest.main() + +# ── #453 telegram badge ───────────────────────────────────────────── +class TestTelegramBadgeMutedColor(unittest.TestCase): + + def test_telegram_badge_uses_muted_color(self): + """Telegram badge rules must use rgba(0, 136, 204, 0.55) not #0088cc.""" + # Extract only the telegram-related CSS block + telegram_lines = [ + line for line in STYLE_CSS.splitlines() + if 'data-source="telegram"' in line or "data-source='telegram'" in line + ] + self.assertTrue( + len(telegram_lines) >= 2, + "Expected at least 2 telegram badge CSS rules" + ) + muted_color = "rgba(0, 136, 204, 0.55)" + for line in telegram_lines: + self.assertIn( + muted_color, line, + f"Telegram CSS rule should use {muted_color!r}, got: {line!r}" + ) + self.assertNotIn( + "#0088cc", line, + f"Telegram CSS rule must not use saturated #0088cc, got: {line!r}" + ) + + def test_telegram_border_left_color_muted(self): + """The border-left-color rule for telegram uses rgba.""" + pattern = r'\.session-item\.cli-session\[data-source=["\']telegram["\']\]\s*\{[^}]*border-left-color:\s*rgba\(0,\s*136,\s*204,\s*0\.55\)' + self.assertRegex(STYLE_CSS, pattern, + "border-left-color for telegram should be rgba(0, 136, 204, 0.55)") + + def test_telegram_after_color_muted(self): + """The ::after color rule for telegram uses rgba.""" + pattern = r'\.session-item\.cli-session\[data-source=["\']telegram["\']\]::after\s*\{[^}]*color:\s*rgba\(0,\s*136,\s*204,\s*0\.55\)' + self.assertRegex(STYLE_CSS, pattern, + "::after color for telegram should be rgba(0, 136, 204, 0.55)") + + +class TestFormatSourceTagHelper(unittest.TestCase): + + def test_format_source_tag_helper_exists(self): + """_formatSourceTag function must be defined in sessions.js.""" + self.assertIn("function _formatSourceTag(", SESSIONS_JS, + "_formatSourceTag helper function not found in sessions.js") + + def test_format_source_tag_maps_telegram(self): + """_formatSourceTag maps 'telegram' to 'via Telegram'.""" + self.assertIn("telegram:'via Telegram'", SESSIONS_JS, + "sessions.js should map telegram -> 'via Telegram'") + + def test_format_source_tag_maps_discord(self): + """_formatSourceTag maps 'discord' to 'via Discord'.""" + self.assertIn("discord:'via Discord'", SESSIONS_JS, + "sessions.js should map discord -> 'via Discord'") + + def test_format_source_tag_maps_slack(self): + """_formatSourceTag maps 'slack' to 'via Slack'.""" + self.assertIn("slack:'via Slack'", SESSIONS_JS, + "sessions.js should map slack -> 'via Slack'") + + def test_metabits_uses_format_helper(self): + """The metaBits push for source_tag should use _formatSourceTag.""" + self.assertIn("metaBits.push(_formatSourceTag(s.source_tag))", SESSIONS_JS, + "metaBits push should wrap source_tag with _formatSourceTag()") + + def test_raw_source_tag_not_pushed_directly(self): + """The old raw metaBits.push(s.source_tag) should not exist.""" + self.assertNotIn("metaBits.push(s.source_tag)", SESSIONS_JS, + "Raw s.source_tag should not be pushed directly to metaBits") + + +if __name__ == "__main__": + unittest.main() + +# ── #454 model routing ───────────────────────────────────────────── +class TestCustomEndpointModelStripping: + """Tests for fix #433: strip provider prefix when custom base_url is set.""" + + def _resolve(self, model_id, provider=None, base_url=None): + """Helper: set cfg directly (same pattern as test_model_resolver.py).""" + old_cfg = dict(config.cfg) + model_cfg = {} + if provider: + model_cfg['provider'] = provider + if base_url: + model_cfg['base_url'] = base_url + config.cfg['model'] = model_cfg + try: + return config.resolve_model_provider(model_id) + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + + def test_prefixed_model_stripped_for_custom_endpoint(self): + """Issue #433: 'openai/gpt-5.4' with custom base_url returns bare 'gpt-5.4'.""" + model, provider, base_url = self._resolve( + 'openai/gpt-5.4', + provider='custom', + base_url='http://my-proxy.local:8080/v1', + ) + assert model == 'gpt-5.4', ( + "Expected bare 'gpt-5.4' for custom endpoint, got '{}'." + " Stale provider-prefix must be stripped.".format(model) + ) + assert base_url == 'http://my-proxy.local:8080/v1' + assert provider == 'custom' + + def test_bare_model_unchanged_for_custom_endpoint(self): + """Bare model ID (no slash) must pass through untouched with custom base_url.""" + model, provider, base_url = self._resolve( + 'gpt-4o', + provider='custom', + base_url='http://my-proxy.local:8080/v1', + ) + assert model == 'gpt-4o', ( + "Bare model 'gpt-4o' should not be modified, got '{}'.".format(model) + ) + assert base_url == 'http://my-proxy.local:8080/v1' + assert provider == 'custom' + + def test_prefixed_model_kept_for_openrouter(self): + """When NO custom base_url (openrouter route), prefixed model must stay prefixed.""" + model, provider, base_url = self._resolve( + 'openai/gpt-5.4', + provider='anthropic', # cross-provider pick triggers openrouter routing + ) + # Cross-provider model with openrouter routing must keep full provider/model path + assert 'openai/gpt-5.4' in model or provider == 'openrouter', ( + "Expected prefixed model or openrouter routing for non-custom endpoint, " + "got model='{}', provider='{}'.".format(model, provider) + ) + assert base_url is None, ( + "OpenRouter routing must not set a base_url, got '{}'.".format(base_url) + ) + +# ── #455 workspace chip ───────────────────────────────────────────── +class TestWorkspaceChipAfterProfileSwitch(unittest.TestCase): + """Verify that switchToProfile() applies the profile default workspace + to the new session when a conversation is in progress (fixes #424).""" + + def test_workspace_chip_updated_after_profile_switch(self): + """After await newSession(false) in the sessionInProgress branch, + the code must call updateWorkspaceChip() so the chip reflects the + new profile's default workspace instead of showing 'No active workspace'.""" + # Find the sessionInProgress block + idx = PANELS_JS.find('if (sessionInProgress)') + self.assertGreater(idx, -1, "sessionInProgress branch must exist in panels.js") + + # Slice from that point to cover the relevant block + block = PANELS_JS[idx:idx + 1000] + + # newSession(false) must be called first + self.assertIn('await newSession(false)', block, + "sessionInProgress branch must call await newSession(false)") + + # The fix: updateWorkspaceChip() must be called after newSession(false) + pos_new_session = block.find('await newSession(false)') + pos_update_chip = block.find('updateWorkspaceChip()') + self.assertGreater(pos_update_chip, -1, + "updateWorkspaceChip() must be called in the sessionInProgress branch") + self.assertGreater(pos_update_chip, pos_new_session, + "updateWorkspaceChip() must be called AFTER newSession(false)") + + def test_profile_default_workspace_applied_to_new_session(self): + """After newSession(false) the code must assign S._profileDefaultWorkspace + to S.session.workspace so the session is correctly tagged.""" + idx = PANELS_JS.find('if (sessionInProgress)') + self.assertGreater(idx, -1) + block = PANELS_JS[idx:idx + 1000] + + # The fix block must set S.session.workspace from S._profileDefaultWorkspace + self.assertIn('S.session.workspace = S._profileDefaultWorkspace', block, + "S.session.workspace must be set from S._profileDefaultWorkspace " + "in the sessionInProgress branch after newSession(false)") + + def test_api_session_update_called_for_new_session_workspace(self): + """The fix must call /api/session/update to persist the workspace on the server.""" + idx = PANELS_JS.find('if (sessionInProgress)') + self.assertGreater(idx, -1) + block = PANELS_JS[idx:idx + 1000] + + # Must patch the session on the backend too + self.assertIn('/api/session/update', block, + "The sessionInProgress branch must call /api/session/update " + "to persist the new workspace after newSession(false)") + + def test_update_workspace_chip_before_render_session_list(self): + """updateWorkspaceChip() should be called before renderSessionList() + so the chip is correct when the UI re-renders.""" + idx = PANELS_JS.find('if (sessionInProgress)') + self.assertGreater(idx, -1) + block = PANELS_JS[idx:idx + 1000] + + pos_chip = block.find('updateWorkspaceChip()') + pos_render = block.find('await renderSessionList()') + self.assertGreater(pos_chip, -1, "updateWorkspaceChip() must exist in block") + self.assertGreater(pos_render, -1, "renderSessionList() must exist in block") + self.assertLess(pos_chip, pos_render, + "updateWorkspaceChip() must be called before renderSessionList()") + + +if __name__ == '__main__': + unittest.main()