diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py index d7df77abc..14376f5d3 100644 --- a/routes/chat_helpers.py +++ b/routes/chat_helpers.py @@ -696,11 +696,10 @@ async def auto_name_session(session_manager, sess): logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}") return - # max_tokens big enough that reasoning models (Minimax M2, - # DeepSeek R1, QwQ, etc.) have headroom for … - # plus the actual title — 200 used to clip them mid-reasoning - # so strip_think left an empty string and no rename happened. - # Timeout matches: 60s gives slow local reasoners room to finish. + # A configured effort is the user's explicit choice. Otherwise thinking + # stays off, because reasoning would use up this small token budget and + # leave no title. + title_effort = effort_for_call(t_url, t_model, owner, sess) try: title = await llm_call_async( t_url, @@ -713,7 +712,8 @@ async def auto_name_session(session_manager, sess): max_tokens=64, headers=t_headers, timeout=15, - reasoning_effort=effort_for_call(t_url, t_model, owner, sess), + reasoning_effort=title_effort, + thinking_mode=None if title_effort else "off", ) except Exception as e: logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e) diff --git a/routes/task/task_routes.py b/routes/task/task_routes.py index 6d152ae75..6fdd49d73 100644 --- a/routes/task/task_routes.py +++ b/routes/task/task_routes.py @@ -340,6 +340,7 @@ def setup_task_routes(task_scheduler) -> APIRouter: max_tokens=20, headers=headers, timeout=15, + thinking_mode="off", ) title = result.strip().strip('"\'').strip() return title[:60] if title else prompt[:50].strip() diff --git a/src/action_intents.py b/src/action_intents.py index eafdb9b96..d4199583a 100644 --- a/src/action_intents.py +++ b/src/action_intents.py @@ -64,6 +64,21 @@ _SHELL_COMMAND = ( r"nvcc|docker|systemctl|journalctl|tmux|git)" ) _BENCHMARK_COMMAND = r"(?:[a-z][a-z0-9_-]*bench(?:mark)?s?|bench(?:mark)?s?)" +# "on " and "from " alone describe people, companies, and topics +# ("Jensen Huang from NVIDIA", "a book on Rome") as often as hosts, so a target +# only counts as a computer next to wording that describes work on a machine. +_NAMED_COMPUTER = ( + r"(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)" + r"[a-z][a-z0-9_.-]{1,31}\b" +) +NAMED_COMPUTER_TASK = ( + r"\b(?:disks?|uptime|processes|logs?|files?|folders?|director(?:y|ies)|running|installed|" + r"(?:storage|memory|ram|vram|cpu|gpu)\s+(?:usage|space|utili[sz]ation|load|temp(?:erature)?))\b" + rf"[^.?!\n]{{0,80}}\bon\s+{_NAMED_COMPUTER}" + r"|\b(?:copy|fetch|get|grab|pull|download|scp|rsync|sync|read|show|list|tail|check)\b" + r"[^.?!\n]{0,60}\b(?:files?|logs?|folders?|director(?:y|ies)|configs?|checkpoints?|backups?)\b" + rf"[^.?!\n]{{0,40}}\bfrom\s+{_NAMED_COMPUTER}" +) _CODE_ACTION = r"(?:write|create|add|edit|modify|code|program|implement|build)" _CODE_ARTIFACT = ( r"(?:code|function|class|script|module|component|snippet|program|app|feature|file|" @@ -164,7 +179,7 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple( ("workspace", "file/code inspection request", rf"{_PLEASE}(?:find|inspect|look\s+at|open|read|check)\b.{{0,120}}\b(?:file|folder|directory|repo|repository|code|source|logs?|trace|stack|diff)\b"), ("workspace", "server/process debugging request", rf"{_PLEASE}(?:check|debug|fix|restart|start|stop|kill|tail|inspect)\b.{{0,120}}\b(?:server|service|process|port|docker|container|tmux|endpoint|logs?)\b"), ("workspace", "local computer task request", r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"), - ("workspace", "named computer task request", r"\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)(?:[a-z][a-z0-9_.-]{1,31})\b"), + ("workspace", "named computer task request", NAMED_COMPUTER_TASK), ("workspace", "terminal workspace request", rf"\b(?:terminal|shell|workspace|tmux|docker|container|git|branch|commit|diff|pytest|stacktrace|traceback|{_BENCHMARK_COMMAND}|eval(?:uation)?s?)\b"), # Shell / remote-host intent. diff --git a/src/agent_loop.py b/src/agent_loop.py index 6c03df34c..b3d4b1ff2 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -44,6 +44,7 @@ from src.model_profiles import ( is_odysseus_merged_tools_model, tool_schema_profile, ) +from src.action_intents import NAMED_COMPUTER_TASK from src.agent_evidence import ( EvidenceLedger, command_has_mutation_effect, @@ -8764,7 +8765,7 @@ _LOCAL_COMPUTER_REFERENCE_RE = re.compile( r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b" r"|\b(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b" r"|\b(?:local|host)\s+(?:computer|machine|files?|system)\b" - r"|\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b)(?:[a-z][a-z0-9_.-]{1,31})\b", + rf"|{NAMED_COMPUTER_TASK}", re.IGNORECASE, ) _LOCAL_NETWORK_REFERENCE_RE = re.compile( diff --git a/src/chat_processor.py b/src/chat_processor.py index 764b4a0a9..bf22c203f 100644 --- a/src/chat_processor.py +++ b/src/chat_processor.py @@ -9,9 +9,61 @@ from src.chat_helpers import extract_urls from src.youtube_handler import is_youtube_url from src.search import comprehensive_web_search, fetch_webpage_content from src.prompt_security import UNTRUSTED_CONTEXT_POLICY, untrusted_context_message +from src.text_helpers import strip_think logger = logging.getLogger(__name__) +_SEARCH_QUERY_HISTORY_TURNS = 4 +_SEARCH_QUERY_HISTORY_TURN_CHARS = 500 + + +def _search_query_messages(message: str, history: List[Any]) -> List[Dict[str, str]]: + """Build the query extraction prompt, with recent turns to resolve references. + + A follow-up such as "Does he work with Jane?" has no searchable subject on + its own. The current turn is already the last history entry when the session + persists it, so a trailing user entry is dropped rather than repeated. + """ + turns = [m for m in history if getattr(m, "role", "") in {"user", "assistant"}] + if turns and turns[-1].role == "user": + turns.pop() + + lines = [] + for turn in turns[-_SEARCH_QUERY_HISTORY_TURNS:]: + content = turn.content + if isinstance(content, list): + content = " ".join( + str(part.get("text") or "") for part in content if isinstance(part, dict) + ) + text = " ".join(strip_think(str(content or ""), prose=False, prompt_echo=False).split()) + if text: + lines.append(f"{turn.role}: {text[:_SEARCH_QUERY_HISTORY_TURN_CHARS]}") + + if not lines: + return [ + { + "role": "system", + "content": "Extract a concise search query from the user's message. Reply ONLY with the query.", + }, + {"role": "user", "content": message}, + ] + conversation = "\n".join(lines) + return [ + { + "role": "system", + "content": ( + "Extract a concise search query from the user's latest message. " + "Use the earlier conversation only to resolve references such as pronouns, " + "and leave out earlier topics the latest message does not refer to. " + "Reply ONLY with the query." + ), + }, + { + "role": "user", + "content": f"Earlier conversation:\n{conversation}\n\nLatest message:\n{message}", + }, + ] + def _clean_search_query(query: str, max_len: int = 200) -> str: """Strip fenced code blocks from a search query while preserving inline @@ -422,21 +474,18 @@ class ChatProcessor: generated_query = llm_call( t_url, t_model, - [ - { - "role": "system", - "content": ( - "Extract a concise search query from the user's message. " - "Reply ONLY with the query." - ), - }, - {"role": "user", "content": message}, - ], + _search_query_messages( + message, + [] if incognito else getattr(session, "history", None) or [], + ), headers=t_headers, temperature=0.1, max_tokens=50, timeout=15, - ).strip() + thinking_mode="off", + ) + # Models without a reasoning parser put thinking in the content. + generated_query = strip_think(generated_query, prose=False, prompt_echo=False).strip() if generated_query: # LLM successfully generated a non-empty query -> use the generated query diff --git a/src/deep_research.py b/src/deep_research.py index df965e051..8cbbada37 100644 --- a/src/deep_research.py +++ b/src/deep_research.py @@ -625,7 +625,8 @@ class DeepResearcher: # LLM helper # ------------------------------------------------------------------ async def _llm(self, messages: List[Dict], temperature: float = 0.3, - max_tokens: int = 4096, timeout: int = 60) -> str: + max_tokens: int = 4096, timeout: int = 60, + thinking_mode: Optional[str] = None) -> str: """Call the LLM asynchronously and strip thinking tags.""" from src.llm_core import llm_call_async response = await llm_call_async( @@ -636,6 +637,7 @@ class DeepResearcher: max_tokens=max_tokens, headers=self.llm_headers, timeout=timeout, + thinking_mode=thinking_mode, ) return strip_thinking(response) @@ -696,7 +698,7 @@ class DeepResearcher: try: result = await self._llm( [{"role": "user", "content": prompt}], - temperature=0, max_tokens=20, timeout=15, + temperature=0, max_tokens=20, timeout=15, thinking_mode="off", ) cat = (result or "").strip().lower() # Clean one-word answer first. diff --git a/src/llm_core.py b/src/llm_core.py index ed11bfab0..fd7910d50 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -2321,6 +2321,25 @@ def _apply_explicit_nonstream_reasoning_effort( payload["reasoning_effort"] = effort +def _nonstream_openai_response_text(msg: Dict, thinking_mode: Optional[str]) -> str: + """Return the answer text of an OpenAI-compatible non-streaming message. + + With thinking off the caller wants only the answer, so an empty answer + stays empty even when the model spent its whole budget on reasoning. + Otherwise reasoning stands in for a missing answer, and Mistral's + structured thinking is kept ahead of its text. + """ + thinking_off = _normalize_thinking_mode(thinking_mode) == "off" + reasoning = "" if thinking_off else msg.get("reasoning_content") + content = msg.get("content") + if not isinstance(content, list): + return content or reasoning or "" + text_part, thinking_part = _normalize_mistral_content(content) + if thinking_part and not thinking_off: + return thinking_part + "\n\n" + (text_part or "") + return text_part or reasoning or "" + + def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE, max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None, timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None, @@ -2412,17 +2431,9 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL elif provider == "ollama": response = _parse_ollama_response(data) else: - msg = data["choices"][0]["message"] - content = msg.get("content") - if isinstance(content, list): - # Mistral structured content — extract thinking + text - text_part, thinking_part = _normalize_mistral_content(content) - if thinking_part: - response = thinking_part + "\n\n" + (text_part or "") - else: - response = text_part or msg.get("reasoning_content") or "" - else: - response = content or msg.get("reasoning_content") or "" + response = _nonstream_openai_response_text( + data["choices"][0]["message"], thinking_mode + ) _set_cached_response(cache_key, response) return response except Exception: @@ -2860,18 +2871,9 @@ async def llm_call_async( elif provider == "ollama": response = _parse_ollama_response(data) else: - msg = data["choices"][0]["message"] - content = msg.get("content") - if isinstance(content, list): - # Mistral structured content — extract thinking + text - # (same contract as llm_call / stream_llm; see #5435). - text_part, thinking_part = _normalize_mistral_content(content) - if thinking_part: - response = thinking_part + "\n\n" + (text_part or "") - else: - response = text_part or msg.get("reasoning_content") or "" - else: - response = content or msg.get("reasoning_content") or "" + response = _nonstream_openai_response_text( + data["choices"][0]["message"], thinking_mode + ) _set_cached_response( cache_key, response, diff --git a/tests/test_action_intents.py b/tests/test_action_intents.py index 87739ec8c..cfe28c9c7 100644 --- a/tests/test_action_intents.py +++ b/tests/test_action_intents.py @@ -1,4 +1,7 @@ +import pytest + from src.action_intents import classify_tool_intent, message_needs_tools +from src.turn_contract import requested_capabilities def test_calendar_entry_request_promotes_to_agent(): @@ -97,6 +100,44 @@ def test_page_references_are_not_mistaken_for_named_computers(): assert intent.needs_tools and intent.category == "workspace" +@pytest.mark.parametrize( + "prompt", + [ + "Who is Jensen Huang from NVIDIA?", + "Who is Tim Cook from Apple?", + "Who is Satya Nadella from Microsoft?", + "What happened on Monday?", + "What is the best book on Rome?", + "Tell me a story from Japan", + "What is the best GPU from NVIDIA?", + "Who is running from the police?", + ], +) +def test_people_and_topics_are_not_mistaken_for_named_computers(prompt): + assert classify_tool_intent(prompt).reason != "named computer task request" + assert "shell_files" not in requested_capabilities(prompt) + + +@pytest.mark.parametrize( + "prompt", + [ + "check disk usage on gpu-box", + "how much free disk on gpu-box?", + "what's the uptime on nas01", + "list running processes on gpu-box", + "is nginx running on gpu-box", + "is ffmpeg installed on workstation", + "what is the gpu usage on gpu-box", + "copy the logs from gpu-box", + "fetch the config file from prod-db", + "download the checkpoint from gpu-box", + ], +) +def test_computer_tasks_on_a_named_host_keep_shell_tools(prompt): + assert classify_tool_intent(prompt).category == "workspace" + assert requested_capabilities(prompt) == {"shell_files"} + + def test_direct_code_requests_promote_to_workspace_agent(): prompts = [ "write a Python function that parses CSV", diff --git a/tests/test_chat_processor_web_search.py b/tests/test_chat_processor_web_search.py index 06a271084..08a3eaba5 100644 --- a/tests/test_chat_processor_web_search.py +++ b/tests/test_chat_processor_web_search.py @@ -1,4 +1,5 @@ from unittest.mock import MagicMock +import httpx from types import SimpleNamespace from src.chat_processor import ChatProcessor @@ -93,3 +94,124 @@ def test_build_context_preface_web_search_query_sanitization(monkeypatch): called_query = mock_web_search.call_args[0][0] assert len(called_query) <= 150 assert " " not in called_query + + +def _run_web_preface(monkeypatch, message): + mock_web_search = MagicMock(return_value=("Search Results", [])) + monkeypatch.setattr("src.chat_processor.comprehensive_web_search", mock_web_search) + processor = ChatProcessor(memory_manager=MagicMock(), personal_docs_manager=MagicMock()) + session = SimpleNamespace( + endpoint_url="http://127.0.0.1:8081/v1/chat/completions", model="gemma4-26b", headers={}, + ) + processor.build_context_preface( + message=message, + session=session, + use_web=True, + use_rag=False, + use_memory=False, + use_skills=False, + ) + return mock_web_search + + +def test_truncated_reasoning_never_becomes_the_web_search_query(monkeypatch): + """A reasoning model that exhausts max_tokens falls back to the user's line.""" + sent = [] + + def fake_post(_url, **kwargs): + sent.append(kwargs["json"]) + return httpx.Response( + 200, + request=httpx.Request("POST", "http://127.0.0.1:8081/v1/chat/completions"), + json={"choices": [{ + "finish_reason": "length", + "message": { + "content": "", + "reasoning_content": '* User message: "Who is Jensen?" * Goal: Extract a query.', + }, + }]}, + ) + + monkeypatch.setattr("src.llm_core.httpx.post", fake_post) + mock_web_search = _run_web_preface(monkeypatch, "Does he work with Lisa Su?\nThanks") + + mock_web_search.assert_called_with( + "Does he work with Lisa Su?", time_filter=None, return_sources=True, + ) + assert sent[0]["chat_template_kwargs"] == {"enable_thinking": False} + + +def test_inline_think_block_never_becomes_the_web_search_query(monkeypatch): + """Without a reasoning parser, thinking arrives as ordinary content.""" + monkeypatch.setattr( + "src.llm_core.llm_call", + MagicMock(return_value="The user is asking about"), + ) + mock_web_search = _run_web_preface(monkeypatch, "Who is Jensen Huang?") + + mock_web_search.assert_called_with("Who is Jensen Huang?", time_filter=None, return_sources=True) + + +def _turn(role, content): + return SimpleNamespace(role=role, content=content) + + +def _extraction_messages(monkeypatch, message, history, **kwargs): + mock_llm_call = MagicMock(return_value="query") + monkeypatch.setattr("src.llm_core.llm_call", mock_llm_call) + monkeypatch.setattr( + "src.chat_processor.comprehensive_web_search", + MagicMock(return_value=("Search Results", [])), + ) + processor = ChatProcessor(memory_manager=MagicMock(), personal_docs_manager=MagicMock()) + processor.build_context_preface( + message=message, + session=SimpleNamespace(endpoint_url="http://local", model="test", headers={}, history=history), + use_web=True, + use_rag=False, + use_memory=False, + use_skills=False, + **kwargs, + ) + return mock_llm_call.call_args.args[2] + + +def test_follow_up_query_extraction_sees_recent_turns(monkeypatch): + followup = "Does he work with Lisa Su?" + messages = _extraction_messages(monkeypatch, followup, [ + _turn("user", "Who is Jensen Huang from NVIDIA?"), + _turn("assistant", "Let me look.Jensen Huang leads NVIDIA."), + _turn("user", followup), + ]) + + assert messages[1]["content"] == ( + "Earlier conversation:\n" + "user: Who is Jensen Huang from NVIDIA?\n" + "assistant: Jensen Huang leads NVIDIA.\n\n" + f"Latest message:\n{followup}" + ) + + +def test_first_message_query_extraction_is_only_the_message(monkeypatch): + messages = _extraction_messages(monkeypatch, "Who is Jensen?", [_turn("user", "Who is Jensen?")]) + + assert messages[1] == {"role": "user", "content": "Who is Jensen?"} + + +def test_query_extraction_history_is_bounded(monkeypatch): + history = [_turn("user", f"old-{i} " + "x" * 2000) for i in range(10)] + history.append(_turn("assistant", "reply")) + prompt = _extraction_messages(monkeypatch, "next", history)[1]["content"] + + assert "old-6" not in prompt + assert "old-7" in prompt and "old-9" in prompt + assert len(prompt) < 2200 + + +def test_incognito_query_extraction_ignores_saved_history(monkeypatch): + messages = _extraction_messages( + monkeypatch, "Does he code?", [_turn("user", "Saved chat"), _turn("assistant", "Reply")], + incognito=True, + ) + + assert messages[1] == {"role": "user", "content": "Does he code?"} diff --git a/tests/test_deep_research_date_context.py b/tests/test_deep_research_date_context.py index 876c80595..a0ecf3b66 100644 --- a/tests/test_deep_research_date_context.py +++ b/tests/test_deep_research_date_context.py @@ -93,3 +93,22 @@ def test_small_model_detection_uses_parameter_size_in_model_title(): assert DeepResearcher._looks_like_small_local_model("model-9B") assert DeepResearcher._looks_like_small_local_model("model-10B") assert not DeepResearcher._looks_like_small_local_model("model-11B") + + +def test_category_classification_turns_thinking_off(monkeypatch): + """Reasoning lists every category name, which would defeat the label scan.""" + sent = {} + + async def fake_call(**kwargs): + sent.update(kwargs) + return "product" + + monkeypatch.setattr("src.llm_core.llm_call_async", fake_call) + r = DeepResearcher.__new__(DeepResearcher) + r.llm_endpoint = "http://x" + r.llm_model = "m" + r.llm_headers = {} + + assert asyncio.run(r._classify_category("Best ThinkPad to buy?")) == "product" + assert sent["thinking_mode"] == "off" + assert sent["max_tokens"] == 20 diff --git a/tests/test_llm_core_reasoning_content_fallback.py b/tests/test_llm_core_reasoning_content_fallback.py index 3335a7bfd..f39f83bf3 100644 --- a/tests/test_llm_core_reasoning_content_fallback.py +++ b/tests/test_llm_core_reasoning_content_fallback.py @@ -87,6 +87,58 @@ def test_llm_call_content_wins_over_reasoning_content(monkeypatch): assert result == "Normal answer" +# --------------------------------------------------------------------------- +# Thinking off: reasoning never stands in for the answer +# --------------------------------------------------------------------------- + +def test_llm_call_with_thinking_off_returns_empty_instead_of_reasoning(monkeypatch): + monkeypatch.setattr( + llm_core.httpx, "post", + lambda *a, **kw: _sync_response(_openai_msg("", "* User message: ...")), + ) + result = llm_core.llm_call( + "http://test/v1", "qwen3-8b", + [{"role": "user", "content": "extract"}], + thinking_mode="off", + ) + assert result == "" + + +def test_llm_call_async_with_thinking_off_returns_empty_instead_of_reasoning(monkeypatch): + class _FakeAsyncClient: + async def post(self, *a, **kw): + req = httpx.Request("POST", "http://test-async/v1/chat/completions") + return httpx.Response(200, request=req, + json=_openai_msg("", "* User message: ...")) + + monkeypatch.setattr(llm_core, "_get_http_client", + lambda: _FakeAsyncClient()) + + result = asyncio.run(llm_core.llm_call_async( + "http://test-async/v1", "qwen3-8b", + [{"role": "user", "content": "extract"}], + thinking_mode="off", + )) + assert result == "" + + +def test_llm_call_with_thinking_off_drops_mistral_structured_thinking(monkeypatch): + content = [ + {"type": "thinking", "thinking": [{"type": "text", "text": "reasoning"}]}, + {"type": "text", "text": "answer"}, + ] + monkeypatch.setattr( + llm_core.httpx, "post", + lambda *a, **kw: _sync_response(_openai_msg(content)), + ) + result = llm_core.llm_call( + "http://test/v1", "some-model", + [{"role": "user", "content": "extract"}], + thinking_mode="off", + ) + assert result == "answer" + + # --------------------------------------------------------------------------- # Streaming agent path tests (4 and 5) # These import and test _empty_response_fallback — the real production helper @@ -94,21 +146,7 @@ def test_llm_call_content_wins_over_reasoning_content(monkeypatch): # changed, these tests will fail. # --------------------------------------------------------------------------- -import sys -from unittest.mock import MagicMock - -# Mock heavy DB/tool deps before importing agent_loop -for _mod in [ - "sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext", - "sqlalchemy.ext.declarative", "sqlalchemy.ext.hybrid", - "sqlalchemy.sql", "sqlalchemy.sql.expression", - "src.database", "src.agent_tools", - "core.models", "core.database", -]: - if _mod not in sys.modules: - sys.modules[_mod] = MagicMock() - -from src.agent_loop import _empty_response_fallback # noqa: E402 +from src.agent_loop import _empty_response_fallback # --------------------------------------------------------------------------- diff --git a/tests/test_task_chain_owner_scope.py b/tests/test_task_chain_owner_scope.py index d13852663..d0417341b 100644 --- a/tests/test_task_chain_owner_scope.py +++ b/tests/test_task_chain_owner_scope.py @@ -17,6 +17,7 @@ clear_fake_database_modules() import core.database as cdb import routes.task_routes as task_routes from core.database import ScheduledTask +from routes.task.task_routes import TaskCreate _TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False) _ENGINE = create_engine( @@ -125,3 +126,32 @@ def test_scheduler_cycle_guard_treats_cross_owner_chain_as_unsafe(): assert scheduler._has_chain_cycle(db, "bob-target-cycle", owner="alice") is True finally: db.close() + + +@pytest.mark.asyncio +async def test_generated_task_name_turns_thinking_off(monkeypatch): + sent = {} + + async def fake_call(**kwargs): + sent.update(kwargs) + return "Daily AI News" + + monkeypatch.setattr("src.llm_core.llm_call_async", fake_call) + db = _TS() + try: + db.add(cdb.Session( + id="task-name-session", name="s", owner="alice", + endpoint_url="http://127.0.0.1:8081/v1", model="gemma4-26b", + )) + db.commit() + finally: + db.close() + create_task = _endpoint("POST", "/api/tasks") + created = await create_task( + SimpleNamespace(state=SimpleNamespace(current_user="alice"), headers={}), + TaskCreate(prompt="Summarize AI news", task_type="llm", trigger_type="webhook"), + ) + + assert sent["thinking_mode"] == "off" + assert sent["max_tokens"] == 20 + assert created["name"] == "Daily AI News" diff --git a/tests/test_tui_round_cap.py b/tests/test_tui_round_cap.py index 7d08f70ff..66ce1ce80 100644 --- a/tests/test_tui_round_cap.py +++ b/tests/test_tui_round_cap.py @@ -196,6 +196,13 @@ def test_network_prompts_are_classified_as_local_computer_work(): assert _looks_like_local_computer_request(prompt), prompt +def test_named_targets_need_computer_task_wording_to_be_local_computer_work(): + assert _looks_like_local_computer_request("check disk usage on gpu-box") + assert _looks_like_local_computer_request("copy the logs from gpu-box") + assert not _looks_like_local_computer_request("Who is Jensen Huang from NVIDIA?") + assert not _looks_like_local_computer_request("What is the best book on Rome?") + + def test_network_lookup_replaces_ping_with_address_evidence(): normalized = _tui_normalize_network_host_command( "ping -c 4 ajax.local", diff --git a/tests/test_utility_reasoning_effort.py b/tests/test_utility_reasoning_effort.py index abba49793..0eb01d7bc 100644 --- a/tests/test_utility_reasoning_effort.py +++ b/tests/test_utility_reasoning_effort.py @@ -393,6 +393,36 @@ def test_auto_title_on_a_different_utility_model_gets_no_chat_effort(util, monke assert sent == [(UTIL[0], UTIL[1], None)] +def _capture_title_thinking(monkeypatch): + sent = [] + + async def fake_call(url, model, messages, **kw): + sent.append((kw.get("reasoning_effort"), kw.get("thinking_mode"))) + return "A title" + + monkeypatch.setattr(llm_core, "llm_call_async", fake_call) + return sent + + +def test_auto_title_turns_thinking_off_without_a_configured_effort(util, monkeypatch): + from routes import chat_helpers + + sent = _capture_title_thinking(monkeypatch) + monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (UTIL[0], UTIL[1], {})) + asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session())) + assert sent == [(None, "off")] + + +def test_auto_title_keeps_thinking_default_with_a_configured_effort(util, monkeypatch): + from routes import chat_helpers + + util.settings["alice"] = "medium" + sent = _capture_title_thinking(monkeypatch) + monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (UTIL[0], UTIL[1], {})) + asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session())) + assert sent == [("medium", None)] + + def test_memory_extraction_passes_chat_effort(util, monkeypatch): from services.memory import memory_extractor