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