Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/built-in-web-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ Search uses a **two-stage decision** before spending network or a full answer ca
- word list (`latest`, `weather`, `price`, …) or phrase list (`who won`, `right now`, `search for`, …) → **ForceWeb**
- current/future year, URL, relative-date math (“how many days until…”) → **ForceWeb**
- force-search signals beat skip signals (e.g. “summarise the latest news” still ForceWeb)
- force-search signals apply only to text the user typed: on a turn submitted with an empty ask bar and an auto-captured selection, they yield **Ambiguous** so the classifier judges the highlighted prose instead
4. If nothing matches with certainty → **Ambiguous** (hand to prepass).

**Example.**
Expand Down
1 change: 1 addition & 0 deletions src-tauri/prompts/system_prompt.txt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ You are activated from the user's workflow. A message may carry up to three sign
Read the highlighted text first, then the images for surrounding context, then answer the request with the full picture.

- Only highlighted text, no images: answer from your own knowledge.
- Highlighted text, no request: the selection is the subject. Engage it directly: answer it if it asks something, otherwise do what your role calls for with it. Never ask the user what they want done with it.
- Only images, no highlighted text: engage the image directly. Answer the question if there is one; otherwise describe what you see and offer useful observations. Never reply "Image received" or ask the user to restate the request.
- Neither: a standalone question. Answer it.

Expand Down
75 changes: 69 additions & 6 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,10 @@ async fn run_builtin_search(
warm_state: &crate::warmup::BuiltinWarmState,
cache_scope: u64,
force_search: bool,
// False when the ask bar was submitted empty and `latest_user` carries only
// the host-app selection, so the pre-filter withholds its deterministic
// force-search shortcut and the classifier decides instead (issue #363).
has_user_request: bool,
) -> BuiltinSearchResult {
// The engine is already warm (the caller holds an activity guard); this
// re-ensure just reads back the live port for the pre-pass and writer.
Expand Down Expand Up @@ -1144,6 +1148,7 @@ async fn run_builtin_search(
// of the pre-pass decision, with cache read-bypass, write-through
// semantics (see `SearchDeps::force_search`).
force_search,
has_user_request,
// Vision turns only: classifier + writer keep the photo; engines stay text.
latest_images,
timings: &timing_bag,
Expand Down Expand Up @@ -2266,6 +2271,28 @@ pub(crate) fn record_conversation_start_if_first_turn(
}
}

/// Builds the user message content for a turn, labelling highlighted host-app
/// text explicitly so the model treats it as the primary subject and any
/// attached images as surrounding context.
///
/// A turn may carry a selection with no typed request (issue #363). In that
/// case the highlighted block stands alone: no `[Request]` header is emitted,
/// leaving the system prompt as the sole authority on what to do with the
/// content. Pulled out of [`ask_model`] so the branch is covered by tests
/// instead of the coverage-off Tauri command body.
fn build_user_content(message: String, quoted_text: Option<&str>) -> String {
match quoted_text {
Some(qt) if !qt.trim().is_empty() => {
if message.trim().is_empty() {
format!("[Highlighted Text]\n\"{}\"", qt)
} else {
format!("[Highlighted Text]\n\"{}\"\n\n[Request]\n{}", qt, message)
}
}
_ => message,
}
}

/// Streams a chat response from the local Ollama backend. Appends the user
/// message and assistant response to conversation history after completion
/// or cancellation (retaining context for follow-up requests). Uses an epoch
Expand Down Expand Up @@ -2392,15 +2419,17 @@ pub async fn ask_model(
// high-precision gate anyway.
let clock_probe = message.clone();

// Whether the user typed anything this turn. An empty ask bar submitted with
// an auto-captured selection (issue #363) sends only the highlighted text,
// so the built-in search pre-filter must not read that prose as the user's
// own freshness intent (see `websearch::prefilter::prefilter`). Captured
// before `message` moves into the wrapper below, which would hide it.
let has_user_request = !message.trim().is_empty();

// Build user message content. When quoted text is present, label it
// explicitly so the model knows the highlighted text is the primary
// subject and any attached images provide surrounding context.
let content = match quoted_text {
Some(ref qt) if !qt.trim().is_empty() => {
format!("[Highlighted Text]\n\"{}\"\n\n[Request]\n{}", qt, message)
}
_ => message,
};
let content = build_user_content(message, quoted_text.as_deref());

// Emit UserMessage before any image base64 work, so the trace
// captures the user's intent even if encoding fails. Image paths
Expand Down Expand Up @@ -2655,6 +2684,7 @@ pub async fn ask_model(
&warm_state,
epoch_at_start,
force,
has_user_request,
)
.await
}
Expand Down Expand Up @@ -2944,6 +2974,39 @@ mod tests {
time::macros::datetime!(2026-07-10 01:15:30 UTC)
}

// ── user message content ──────────────────────────────────────────────

#[test]
fn build_user_content_wraps_quote_and_request() {
assert_eq!(
build_user_content("summarize this".to_string(), Some("some page text")),
"[Highlighted Text]\n\"some page text\"\n\n[Request]\nsummarize this"
);
}

#[test]
fn build_user_content_omits_request_header_for_empty_message() {
assert_eq!(
build_user_content(String::new(), Some("some page text")),
"[Highlighted Text]\n\"some page text\""
);
assert_eq!(
build_user_content(" \n".to_string(), Some("some page text")),
"[Highlighted Text]\n\"some page text\""
);
}

#[test]
fn build_user_content_passes_message_through_without_quote() {
assert_eq!(
build_user_content("plain turn".to_string(), None),
"plain turn"
);
// Blank quote is treated as absent; an images-only turn keeps its
// empty message untouched.
assert_eq!(build_user_content(String::new(), Some(" \n ")), "");
}

#[test]
fn builtin_search_outcome_label_covers_all_variants() {
use crate::websearch::orchestrator::SearchOutcome;
Expand Down
83 changes: 82 additions & 1 deletion src-tauri/src/websearch/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,14 @@ pub struct SearchDeps<'a> {
/// `local_zone` does: it is a per-turn caller input the call sites should not
/// each have to thread.
pub force_search: bool,
/// Whether the user typed a request this turn. `false` when the ask bar was
/// submitted empty and `latest_user` is only the auto-captured host-app
/// selection (issue #363). Passed to the pre-filter, where it withholds the
/// deterministic force-search shortcut from text the user did not write (see
/// [`crate::websearch::prefilter::prefilter`]); the turn still reaches the
/// classifier, so auto-search stays available on it. Rides in `deps` for the
/// same reason `force_search` does.
pub has_user_request: bool,
/// Base64 image payloads for the latest user turn when the active model is
/// vision-capable. Passed to the classifier and re-attached on the writer
/// so grounded answers keep the photo. `None` or empty keeps the text-only
Expand Down Expand Up @@ -397,7 +405,7 @@ async fn run_search_inner(
}

// Stage one: deterministic pre-filter, no model call.
let verdict = prefilter(latest_user, today);
let verdict = prefilter(latest_user, today, deps.has_user_request);
eprintln!("[search] prefilter={verdict:?}");
// The `/search` command forces a search even when the pre-filter would
// force-skip (e.g. a message that reads like a greeting): the user asked
Expand Down Expand Up @@ -3048,6 +3056,7 @@ mod tests {
// through, so tests here run without one (date-only event lines).
local_zone: None,
force_search: false,
has_user_request: true,
latest_images: None,
timings: Box::leak(Box::new(TimingBag::new())),
}
Expand Down Expand Up @@ -3089,6 +3098,7 @@ mod tests {
))),
local_zone: None,
force_search: false,
has_user_request: true,
latest_images: None,
timings: Box::leak(Box::new(TimingBag::new())),
}
Expand Down Expand Up @@ -3126,6 +3136,7 @@ mod tests {
web_cache,
local_zone: None,
force_search: false,
has_user_request: true,
latest_images: None,
timings: Box::leak(Box::new(TimingBag::new())),
}
Expand Down Expand Up @@ -3169,6 +3180,7 @@ mod tests {
))),
local_zone: None,
force_search: false,
has_user_request: true,
latest_images: None,
timings: Box::leak(Box::new(TimingBag::new())),
}
Expand Down Expand Up @@ -3602,6 +3614,75 @@ mod tests {
);
}

#[tokio::test]
async fn no_user_request_hands_a_force_web_turn_to_the_classifier() {
// Same "latest ..." text as the test above, but submitted with an empty
// ask bar (issue #363): the freshness word is the highlighted author's,
// not the user's, so the classifier decides and its `no` now stands.
// The Deciding phase proves this is the classifier route, not a skip.
let prepass = FakePrePass::returning(Ok(PrePassDecision {
decision: SearchDecision::No,
route: SearchRoute::Web,
standalone_question: "when was the treaty of versailles signed in paris".into(),
queries: vec![],
explicit_search: false,
lang: "en".into(),
}));
let transport = transport_with_serp_and_page();
let (phases, status) = recorder();
let mut deps = deps(&prepass, &transport, &Bm25Scorer);
deps.has_user_request = false;
let outcome = run_search(
&deps,
"sys",
&[],
"the latest on the treaty",
16384,
"2026-07-05",
"en-US",
&CancellationToken::new(),
&status,
)
.await;
assert!(matches!(outcome, SearchOutcome::NoSearch));
assert_eq!(*phases.lock().unwrap(), vec![SearchPhase::Deciding]);
}

#[tokio::test]
async fn no_user_request_still_searches_when_the_classifier_says_web() {
// Auto-search stays reachable on a no-request turn: withholding the
// deterministic shortcut moves the decision to the model, it does not
// close the web off.
let prepass = FakePrePass::returning(Ok(web_decision(vec![
"when was the treaty of versailles signed in paris",
])));
let transport = transport_with_serp_and_page();
let (phases, status) = recorder();
let mut deps = deps(&prepass, &transport, &Bm25Scorer);
deps.has_user_request = false;
let outcome = run_search(
&deps,
"sys",
&[],
"the latest on the treaty",
16384,
"2026-07-05",
"en-US",
&CancellationToken::new(),
&status,
)
.await;
assert!(matches!(outcome, SearchOutcome::Answer { .. }));
assert_eq!(
*phases.lock().unwrap(),
vec![
SearchPhase::Deciding,
SearchPhase::Searching,
SearchPhase::Reading
]
);
}

#[tokio::test]
async fn cancel_during_page_fetch_yields_cancelled() {
// A cancel raised WHILE the page-fetch stage is awaiting (after the
Expand Down
Loading