diff --git a/app/src/components/layout/shell/RootShellLayout.tsx b/app/src/components/layout/shell/RootShellLayout.tsx
index 871e0bcf2..00cda6c96 100644
--- a/app/src/components/layout/shell/RootShellLayout.tsx
+++ b/app/src/components/layout/shell/RootShellLayout.tsx
@@ -11,6 +11,7 @@ import {
} from '../../../store/layoutSlice';
import { Tooltip } from '../../ui';
import CollapsedNavRail from './CollapsedNavRail';
+import WindowDragBar from './WindowDragBar';
// `app-shell` (not the older `root-shell`) so the persisted geometry seeds
// fresh with the sidebar visible by default. Exported so the global command
@@ -212,8 +213,14 @@ export default function RootShellLayout({ sidebar, children }: RootShellLayoutPr
)}
-
+
{children}
+ {/* macOS overlay-title-bar drag strip — a transparent overlay pinned on
+ TOP of the routed view (last child) so full-bleed surfaces (Tiny
+ Place world, Chat backdrop) stay edge-to-edge while the top of the
+ window still drags. The sidebar is excluded — its header already
+ drags in place. No-op off macOS / outside Tauri. */}
+
);
diff --git a/app/src/components/layout/shell/WindowDragBar.test.tsx b/app/src/components/layout/shell/WindowDragBar.test.tsx
new file mode 100644
index 000000000..b779c1b2a
--- /dev/null
+++ b/app/src/components/layout/shell/WindowDragBar.test.tsx
@@ -0,0 +1,41 @@
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import WindowDragBar, { WINDOW_DRAG_BAR_HEIGHT } from './WindowDragBar';
+
+// Both gates are mocked so we can drive every platform combination.
+const isMac = vi.fn();
+const isTauri = vi.fn();
+vi.mock('../../../lib/commands/shortcut', () => ({ isMac: () => isMac() }));
+vi.mock('../../../utils/tauriCommands/common', () => ({ isTauri: () => isTauri() }));
+
+describe('WindowDragBar', () => {
+ beforeEach(() => {
+ isMac.mockReset();
+ isTauri.mockReset();
+ });
+ afterEach(cleanup);
+
+ it('renders a draggable strip on macOS inside Tauri', () => {
+ isMac.mockReturnValue(true);
+ isTauri.mockReturnValue(true);
+ const { container } = render();
+ const bar = container.querySelector('[data-tauri-drag-region]');
+ expect(bar).not.toBeNull();
+ expect((bar as HTMLElement).style.height).toBe(`${WINDOW_DRAG_BAR_HEIGHT}px`);
+ });
+
+ it('renders nothing on macOS outside Tauri (plain browser)', () => {
+ isMac.mockReturnValue(true);
+ isTauri.mockReturnValue(false);
+ const { container } = render();
+ expect(container.querySelector('[data-tauri-drag-region]')).toBeNull();
+ });
+
+ it('renders nothing inside Tauri on non-macOS (native title bar handles drag)', () => {
+ isMac.mockReturnValue(false);
+ isTauri.mockReturnValue(true);
+ const { container } = render();
+ expect(container.querySelector('[data-tauri-drag-region]')).toBeNull();
+ });
+});
diff --git a/app/src/components/layout/shell/WindowDragBar.tsx b/app/src/components/layout/shell/WindowDragBar.tsx
new file mode 100644
index 000000000..808412b99
--- /dev/null
+++ b/app/src/components/layout/shell/WindowDragBar.tsx
@@ -0,0 +1,49 @@
+import { isMac } from '../../../lib/commands/shortcut';
+import { isTauri } from '../../../utils/tauriCommands/common';
+
+/**
+ * Height (px) of the drag strip. Matches the macOS traffic-light zone so the
+ * native window controls sit within the band.
+ */
+export const WINDOW_DRAG_BAR_HEIGHT = 28;
+
+/**
+ * Transparent macOS window-drag strip for the overlay title bar.
+ *
+ * The main window runs with `titleBarStyle: "Overlay"` + `hiddenTitle` (see
+ * `app/src-tauri/tauri.conf.json`), so macOS draws transparent traffic lights
+ * over the web content but does NOT make the top draggable on its own — the
+ * webview captures the pointer events. We opt back in with a `data-tauri-drag-
+ * region` strip.
+ *
+ * Rendered as an absolutely-positioned overlay pinned to the top of the main
+ * content pane ({@link RootShellLayout}), as the LAST child so it paints ON TOP
+ * of the routed view. That keeps full-bleed HTML surfaces (the Tiny Place world
+ * canvas, the Chat backdrop) edge-to-edge — the strip floats over them and
+ * drags the window — instead of a reserved inset that would push them down and
+ * reveal the app background above them. It occupies no layout space and is
+ * transparent.
+ *
+ * A drag region must be the top-most element under the pointer, so the band
+ * does sit over the top ~28px of the pane: page chrome with controls in that
+ * band keeps the bulk of each control clickable below the strip, and the
+ * traffic lights (native, composited above the webview) are always clickable.
+ * The sidebar is intentionally excluded — its header already drags in place.
+ * Native CEF provider webviews composite above all HTML and so can't be dragged
+ * through; that's a platform limit, not this strip.
+ *
+ * macOS-only: Windows/Linux keep their native decorated title bar (the
+ * `Overlay` style is a no-op there). Outside the Tauri runtime (browser/iOS)
+ * there is no window to drag, so it renders nothing.
+ */
+export default function WindowDragBar() {
+ if (!isTauri() || !isMac()) return null;
+ return (
+
+ );
+}
diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs
index ebde73d0d..0013d329c 100644
--- a/src/openhuman/agent/harness/session/turn/core.rs
+++ b/src/openhuman/agent/harness/session/turn/core.rs
@@ -626,6 +626,14 @@ impl Agent {
)
}
Ok(result) => {
+ // No usable bundle: leave `agent_context_prepared_sources`
+ // untouched. Recording a marker here would (a) make
+ // `render_agent_context_status_note` tell the model to "use
+ // the prepared context below" when none was injected, and
+ // (b) suppress `agent_prepare_context` for the rest of the
+ // turn — blocking a legitimate retry by any path that still
+ // exposes the tool. The dedup only needs to hold once a
+ // bundle was actually injected (the success arm above).
log::warn!(
"[agent_loop] super_context scout returned an error — proceeding without bundle: {}",
result.output()
diff --git a/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs b/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs
index bba9857b3..e0ac66155 100644
--- a/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs
+++ b/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs
@@ -29,29 +29,43 @@ use std::fmt::Write as _;
/// The sub-agent archetype this tool drives.
const SCOUT_AGENT_ID: &str = "context_scout";
-/// Whether `output` is exactly one well-formed `[context_bundle] …
-/// [/context_bundle]` envelope and *nothing else*: the trimmed output must
-/// start with the open tag, end with the close tag, and contain exactly one of
-/// each.
+/// Extract exactly one `[context_bundle] … [/context_bundle]` envelope from
+/// `output`, tolerating surrounding prose, and return only the envelope
+/// substring (tags included). Returns `None` when no usable envelope is
+/// present.
///
-/// The harness prepends every non-error `run_context_scout` result to turn 1
-/// as "Prepared context", so a prompt drift / model regression in
-/// `context_scout` that emits free-form prose (e.g. `Sure, here's what I
-/// found:\n[context_bundle]…[/context_bundle]`), or a malformed/duplicated
-/// envelope, would otherwise silently inject arbitrary text. We reject those so
-/// the caller falls back to the un-augmented message instead. The scout's own
-/// contract is "emit the single envelope and nothing outside it".
-fn is_well_formed_context_bundle(output: &str) -> bool {
+/// The harness prepends every non-error `run_context_scout` result to turn 1 as
+/// "Prepared context", and the `context_scout` contract is "emit the single
+/// envelope and nothing outside it". But the scout runs on a fast chat-tier
+/// model that regularly wraps the envelope in a preamble (`Sure, here's what I
+/// found:\n[context_bundle]…`) or a closing line (`…[/context_bundle]\nHope
+/// that helps!`). Requiring the *whole* trimmed output to be the envelope made
+/// any such wrap fail validation, so the harness silently dropped an
+/// otherwise-good bundle and injected nothing — the "scout runs, bundle
+/// missing" failure.
+///
+/// Pulling the envelope substring out of the surrounding text keeps the safety
+/// property intact: we still never inject the model's free-form prose, only the
+/// bracketed envelope itself. We still reject genuinely unusable output —
+/// absent, unterminated/reversed, or duplicated (where we can't tell which
+/// envelope is authoritative) — by returning `None`, so the caller falls back
+/// to the un-augmented message.
+fn extract_context_bundle(output: &str) -> Option {
const OPEN: &str = "[context_bundle]";
const CLOSE: &str = "[/context_bundle]";
- let trimmed = output.trim();
- // Exactly one open + one close tag, with no text outside the envelope.
- trimmed.starts_with(OPEN)
- && trimmed.ends_with(CLOSE)
- && trimmed.matches(OPEN).count() == 1
- && trimmed.matches(CLOSE).count() == 1
- // Guard the degenerate case where OPEN/CLOSE overlap on too-short input.
- && trimmed.len() >= OPEN.len() + CLOSE.len()
+ // Exactly one open + one close tag. Duplicates are a contract violation we
+ // reject rather than guess which envelope is authoritative.
+ if output.matches(OPEN).count() != 1 || output.matches(CLOSE).count() != 1 {
+ return None;
+ }
+ let open_idx = output.find(OPEN)?;
+ let close_idx = output.find(CLOSE)?;
+ // Tags must appear in order (open before close) and not overlap.
+ if close_idx < open_idx + OPEN.len() {
+ return None;
+ }
+ let end = close_idx + CLOSE.len();
+ Some(output[open_idx..end].trim().to_string())
}
fn already_prepared_context_bundle(sources: &[AgentContextPreparedSource]) -> String {
@@ -210,11 +224,14 @@ pub async fn run_context_scout_with_catalog(
Ok(outcome) => match &outcome.status {
SubagentRunStatus::Completed => {
// Guard the contract: the scout MUST return exactly one
- // `[context_bundle] … [/context_bundle]` envelope. Reject
- // free-form / malformed output so the harness (which prepends
- // any non-error result to turn 1 as "Prepared context") cannot
- // inject arbitrary prose on a scout prompt drift.
- if !is_well_formed_context_bundle(&outcome.output) {
+ // `[context_bundle] … [/context_bundle]` envelope. We tolerate
+ // surrounding prose by extracting just the envelope (the harness
+ // prepends any non-error result to turn 1 as "Prepared context",
+ // so we still inject only the bracketed envelope, never the
+ // model's free-form text). Genuinely unusable output — absent,
+ // unterminated, or duplicated — is rejected so the caller falls
+ // back to the un-augmented message.
+ let Some(bundle) = extract_context_bundle(&outcome.output) else {
tracing::warn!(
target: "agent_prepare_context",
task_id = %outcome.task_id,
@@ -247,13 +264,17 @@ pub async fn run_context_scout_with_catalog(
"agent_prepare_context: context_scout did not return a well-formed \
[context_bundle] envelope",
));
- }
+ };
+ // From here on use the extracted `bundle`, not the raw
+ // `outcome.output`, so any prose the scout wrapped around the
+ // envelope never reaches the parent's context.
tracing::info!(
target: "agent_prepare_context",
task_id = %outcome.task_id,
elapsed_ms = outcome.elapsed.as_millis() as u64,
iterations = outcome.iterations,
- output_chars = outcome.output.chars().count(),
+ output_chars = bundle.chars().count(),
+ raw_output_chars = outcome.output.chars().count(),
"[agent_prepare_context] context bundle ready"
);
publish_global(DomainEvent::SubagentCompleted {
@@ -261,7 +282,7 @@ pub async fn run_context_scout_with_catalog(
task_id: outcome.task_id.clone(),
agent_id: outcome.agent_id.clone(),
elapsed_ms: outcome.elapsed.as_millis() as u64,
- output_chars: outcome.output.chars().count(),
+ output_chars: bundle.chars().count(),
iterations: outcome.iterations,
});
if let Some(ref tx) = progress_sink {
@@ -271,7 +292,7 @@ pub async fn run_context_scout_with_catalog(
task_id: outcome.task_id.clone(),
elapsed_ms: outcome.elapsed.as_millis() as u64,
iterations: outcome.iterations as u32,
- output_chars: outcome.output.chars().count(),
+ output_chars: bundle.chars().count(),
worktree_path: None,
changed_files: Vec::new(),
dirty_status: None,
@@ -287,9 +308,7 @@ pub async fn run_context_scout_with_catalog(
// Runs for both entry points (LLM-invoked tool + harness
// super-context first turn). Best-effort — never fails the call.
if let (Some(parent), Some(thread_id)) = (current_parent(), current_thread_id()) {
- if let Some(objective) =
- AgentPrepareContextTool::parse_proposed_goal(&outcome.output)
- {
+ if let Some(objective) = AgentPrepareContextTool::parse_proposed_goal(&bundle) {
match crate::openhuman::thread_goals::store::set_if_absent(
&parent.workspace_dir,
&thread_id,
@@ -329,7 +348,7 @@ pub async fn run_context_scout_with_catalog(
}
}
- Ok(ToolResult::success(outcome.output))
+ Ok(ToolResult::success(bundle))
}
// The scout has no `ask_user_clarification` tool, so this
// branch should not fire — handle defensively rather than
@@ -719,52 +738,73 @@ mod tests {
}
#[test]
- fn accepts_a_single_well_formed_bundle() {
+ fn extracts_a_single_well_formed_bundle() {
let out = "[context_bundle]\nhas_enough_context: true\nsummary: ok\n[/context_bundle]";
- assert!(is_well_formed_context_bundle(out));
+ assert_eq!(extract_context_bundle(out).as_deref(), Some(out));
}
#[test]
fn rejects_free_form_prose_without_a_bundle() {
- assert!(!is_well_formed_context_bundle(
- "Sure! Here's what I found about your request..."
- ));
+ assert_eq!(
+ extract_context_bundle("Sure! Here's what I found about your request..."),
+ None
+ );
}
#[test]
fn rejects_unterminated_or_reversed_envelope() {
- assert!(!is_well_formed_context_bundle(
- "[context_bundle]\nsummary: ..."
- ));
- assert!(!is_well_formed_context_bundle(
- "[/context_bundle] stray [context_bundle]"
- ));
+ // Open tag with no close.
+ assert_eq!(
+ extract_context_bundle("[context_bundle]\nsummary: ..."),
+ None
+ );
+ // Close before open — out of order.
+ assert_eq!(
+ extract_context_bundle("[/context_bundle] stray [context_bundle]"),
+ None
+ );
}
#[test]
fn rejects_duplicated_envelope() {
- assert!(!is_well_formed_context_bundle(
- "[context_bundle]a[/context_bundle][context_bundle]b[/context_bundle]"
- ));
+ // Two envelopes — we can't tell which is authoritative, so reject.
+ assert_eq!(
+ extract_context_bundle(
+ "[context_bundle]a[/context_bundle][context_bundle]b[/context_bundle]"
+ ),
+ None
+ );
}
#[test]
- fn rejects_prose_around_the_envelope() {
- // Leading prose before the bundle.
- assert!(!is_well_formed_context_bundle(
- "Sure, here's what I found:\n[context_bundle]\nsummary: x\n[/context_bundle]"
- ));
- // Trailing prose after the bundle.
- assert!(!is_well_formed_context_bundle(
- "[context_bundle]\nsummary: x\n[/context_bundle]\nHope that helps!"
- ));
+ fn extracts_envelope_from_surrounding_prose() {
+ // Regression for the "scout runs, bundle missing" bug: a fast chat-tier
+ // scout wraps the envelope in a preamble and/or a closing line. We must
+ // extract just the envelope, not drop it and not inject the prose.
+ let leading = "Sure, here's what I found:\n[context_bundle]\nsummary: x\n[/context_bundle]";
+ assert_eq!(
+ extract_context_bundle(leading).as_deref(),
+ Some("[context_bundle]\nsummary: x\n[/context_bundle]")
+ );
+ let trailing = "[context_bundle]\nsummary: x\n[/context_bundle]\nHope that helps!";
+ assert_eq!(
+ extract_context_bundle(trailing).as_deref(),
+ Some("[context_bundle]\nsummary: x\n[/context_bundle]")
+ );
+ let both = "Here you go:\n[context_bundle]\nsummary: x\n[/context_bundle]\n\nLet me know!";
+ assert_eq!(
+ extract_context_bundle(both).as_deref(),
+ Some("[context_bundle]\nsummary: x\n[/context_bundle]")
+ );
}
#[test]
- fn accepts_envelope_with_surrounding_whitespace() {
+ fn extracts_envelope_with_surrounding_whitespace() {
// Leading/trailing whitespace is trimmed, not treated as prose.
- assert!(is_well_formed_context_bundle(
- "\n [context_bundle]\nsummary: x\n[/context_bundle]\n "
- ));
+ assert_eq!(
+ extract_context_bundle("\n [context_bundle]\nsummary: x\n[/context_bundle]\n ")
+ .as_deref(),
+ Some("[context_bundle]\nsummary: x\n[/context_bundle]")
+ );
}
}
diff --git a/tests/agent_harness_e2e.rs b/tests/agent_harness_e2e.rs
index d78fa42a7..f75be57da 100644
--- a/tests/agent_harness_e2e.rs
+++ b/tests/agent_harness_e2e.rs
@@ -833,6 +833,81 @@ fn super_context_happy_path() {
run_on_agent_stack("super_context_happy_path", super_context_happy_path_inner);
}
+/// Regression for the "scout runs, bundle missing" failure: the fast chat-tier
+/// `context_scout` wraps its `[context_bundle]` envelope in a preamble and a
+/// closing line. The harness must extract just the envelope and inject it (not
+/// drop the whole thing, and not leak the surrounding prose into the
+/// orchestrator's context).
+#[test]
+fn super_context_extracts_prose_wrapped_bundle() {
+ run_on_agent_stack(
+ "super_context_extracts_prose_wrapped_bundle",
+ super_context_extracts_prose_wrapped_bundle_inner,
+ );
+}
+
+async fn super_context_extracts_prose_wrapped_bundle_inner() {
+ let _lock = env_lock();
+ // The envelope is wrapped in prose on BOTH sides — exactly what the strict
+ // whole-output validator used to reject.
+ let prose_wrapped = "Sure! Here's what I found for you:\n\n\
+ [context_bundle]\n\
+ has_enough_context: true\n\
+ summary: CTX_CANARY_9 — the user wants the marker phrase (memory).\n\
+ recommended_tool_calls:\n\
+ [/context_bundle]\n\n\
+ Hope that helps — let me know if you want me to dig deeper!";
+ reset_script(vec![
+ // request[0]: harness-driven context_scout returns a prose-wrapped bundle.
+ text_completion(prose_wrapped),
+ // request[1]: orchestrator reads the *extracted* bundle and synthesizes.
+ text_completion("Prepared. CTX_CANARY_9 noted."),
+ ]);
+ let stack = boot_stack_with_super_context(true).await;
+
+ let mut events = spawn_sse_collector(format!(
+ "{}/events?client_id=harness-prepctx-prose",
+ stack.rpc_base
+ ));
+ send_web_chat(
+ &stack.rpc_base,
+ 400,
+ "harness-prepctx-prose",
+ "thread-prepctx-prose",
+ "prepare context for the marker",
+ )
+ .await;
+
+ let done = wait_for_terminal(&mut events, Duration::from_secs(120)).await;
+ assert_eq!(
+ done.get("event").and_then(Value::as_str),
+ Some("chat_done"),
+ "expected chat_done for prose-wrapped super_context: {done}"
+ );
+
+ let requests = with_captured(|c| c.clone());
+ let last_messages = serde_json::to_string(
+ requests
+ .last()
+ .and_then(|r| r.pointer("/body/messages"))
+ .unwrap_or(&Value::Null),
+ )
+ .unwrap_or_default();
+ // The extracted envelope (with its canary) must reach the orchestrator …
+ assert!(
+ last_messages.contains("[context_bundle]") && last_messages.contains("CTX_CANARY_9"),
+ "extracted bundle missing from synthesis request; messages: {last_messages}"
+ );
+ // … but the wrapping prose must NOT — we inject only the envelope.
+ assert!(
+ !last_messages.contains("Here's what I found")
+ && !last_messages.contains("Hope that helps"),
+ "scout's wrapping prose leaked into the orchestrator context; messages: {last_messages}"
+ );
+
+ stack.shutdown();
+}
+
async fn super_context_happy_path_inner() {
let _lock = env_lock();
let scout_bundle = "[context_bundle]\n\