From dd762d07bba2d059892d66b7dd55f6bb962847d7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 12:27:16 -0500 Subject: [PATCH 01/55] fix(desktop): only the foreground session may write the composer atoms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyRuntimeInfo unconditionally mirrored a runtime's cwd, branch, model and usage into the global composer atoms. Every tile create and session branch called it, so opening a session in another worktree re-pointed the MAIN pane's coding rail at that tile's repo — and persisted it, so the wrong workspace cwd survived a restart. Collect the patch first, then mirror it once behind a `foreground` gate. Background callers still get the full patch for their own session state; they just stop publishing into state they don't own. --- .../hooks/use-session-actions/utils.test.ts | 31 +++++++ .../hooks/use-session-actions/utils.ts | 87 +++++++++++++++---- 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index a83f8a3840..ea40e7705a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -4,6 +4,7 @@ import type { ChatMessage } from '@/lib/chat-messages' import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode' import { $desktopOnboarding } from '@/store/onboarding' import { $activeGatewayProfile } from '@/store/profile' +import { $currentBranch, $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session' import type { SessionInfo } from '@/types/hermes' import { @@ -65,6 +66,36 @@ describe('applyRuntimeInfo credential warnings', () => { }) }) +describe('applyRuntimeInfo foreground scoping', () => { + beforeEach(() => { + setCurrentCwd('/main-repo') + setCurrentBranch('main') + }) + + afterEach(() => { + setCurrentCwd('') + setCurrentBranch('') + }) + + it('publishes a foreground runtime into the composer atoms', () => { + const patch = applyRuntimeInfo({ branch: 'bb/feature', cwd: '/main-repo/worktree' }) + + expect($currentCwd.get()).toBe('/main-repo/worktree') + expect($currentBranch.get()).toBe('bb/feature') + expect(patch).toMatchObject({ branch: 'bb/feature', cwd: '/main-repo/worktree' }) + }) + + it('keeps a background runtime out of the composer atoms but still returns its patch', () => { + const patch = applyRuntimeInfo({ branch: 'bb/tile', cwd: '/other-worktree' }, { foreground: false }) + + // The main pane's rail must stay on its own tree. + expect($currentCwd.get()).toBe('/main-repo') + expect($currentBranch.get()).toBe('main') + // ...while the caller still gets everything it needs for its own session. + expect(patch).toMatchObject({ branch: 'bb/tile', cwd: '/other-worktree' }) + }) +}) + describe('isSessionGoneError', () => { it('is true for 404 / session-not-found, false otherwise', () => { expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 2c0913fb6d..1f86fd7c40 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -706,13 +706,72 @@ type SessionRuntimeStatePatch = Partial< > > -export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null { +interface ApplyRuntimeInfoOptions { + /** + * Whether this runtime belongs to the session the MAIN pane is showing. + * Foreground (the default) mirrors into the composer atoms every main-pane + * surface reads. + * + * A tile or a background branch must pass `false`: it owns a different + * worktree, and writing its cwd into `$currentCwd` re-pointed the main + * composer's coding rail (and the persisted workspace cwd) at the tile's + * repo — the main rail painted a branch from a tree its session was never + * in. The returned patch still carries every field, so the caller's own + * per-session state is unaffected. + */ + foreground?: boolean +} + +/** Mirror a session's runtime state into the composer atoms the MAIN pane + * renders from. Foreground sessions only — see ApplyRuntimeInfoOptions. */ +function publishRuntimeToComposer(state: SessionRuntimeStatePatch): void { + if (state.model !== undefined) { + setCurrentModel(state.model) + } + + if (state.provider !== undefined) { + setCurrentProvider(state.provider) + } + + if (state.cwd !== undefined) { + setCurrentCwd(state.cwd) + } + + if (state.branch !== undefined) { + setCurrentBranch(state.branch) + } + + if (state.personality !== undefined) { + setCurrentPersonality(state.personality) + } + + if (state.reasoningEffort !== undefined) { + setCurrentReasoningEffort(state.reasoningEffort) + } + + if (state.serviceTier !== undefined) { + setCurrentServiceTier(state.serviceTier) + } + + if (state.fast !== undefined) { + setCurrentFastMode(state.fast) + } + + if (state.yolo !== undefined) { + setYoloActive(state.yolo) + } +} + +export function applyRuntimeInfo( + info: SessionRuntimeInfo | undefined, + { foreground = true }: ApplyRuntimeInfoOptions = {} +): SessionRuntimeStatePatch | null { if (!info) { return null } - const sessionState: SessionRuntimeStatePatch = {} - + // App/profile-level reporting is session-independent — a tile's runtime + // reports backend skew and credential warnings just as usefully. reportBackendContract(info.desktop_contract) if (info.approval_mode !== undefined) { @@ -723,54 +782,50 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR reportInstallMethodWarning(info.install_warning) + const sessionState: SessionRuntimeStatePatch = {} + if (typeof info.model === 'string') { - setCurrentModel(info.model) sessionState.model = info.model } if (typeof info.provider === 'string') { - setCurrentProvider(info.provider) sessionState.provider = info.provider } if (info.cwd) { - setCurrentCwd(info.cwd) sessionState.cwd = info.cwd } if (info.branch !== undefined) { - setCurrentBranch(info.branch || '') sessionState.branch = info.branch || '' } if (typeof info.personality === 'string') { - const personality = normalizePersonalityValue(info.personality) - setCurrentPersonality(personality) - sessionState.personality = personality + sessionState.personality = normalizePersonalityValue(info.personality) } if (typeof info.reasoning_effort === 'string') { - setCurrentReasoningEffort(info.reasoning_effort) sessionState.reasoningEffort = info.reasoning_effort } if (typeof info.service_tier === 'string') { - setCurrentServiceTier(info.service_tier) sessionState.serviceTier = info.service_tier } if (typeof info.fast === 'boolean') { - setCurrentFastMode(info.fast) sessionState.fast = info.fast } if (typeof info.yolo === 'boolean') { - setYoloActive(info.yolo) sessionState.yolo = info.yolo } - if (info.usage) { - setCurrentUsage(current => ({ ...current, ...info.usage })) + if (foreground) { + publishRuntimeToComposer(sessionState) + + if (info.usage) { + setCurrentUsage(current => ({ ...current, ...info.usage })) + } } return sessionState From c48d9a9c6dc38da03160a582eef9f07b855ffe16 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 12:27:23 -0500 Subject: [PATCH 02/55] fix(desktop): mark tile and branch runtimes as background A tile and a branched session each live in their own worktree and render from their own SessionView slice, so neither is the main pane's session. Pass foreground: false at both call sites. --- .../src/app/session/hooks/use-session-actions/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 266a33b65f..9bcabd6d3b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -504,7 +504,9 @@ export function useSessionActions({ upsertOptimisticSession(created, stored, null, null) } - const runtimeInfo = applyRuntimeInfo(created.info) + // A tile lives in its OWN worktree — it must not publish its cwd/branch + // into the composer atoms the main pane renders from. + const runtimeInfo = applyRuntimeInfo(created.info, { foreground: false }) updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored) openSessionTile(stored, dir) @@ -1182,7 +1184,9 @@ export function useSessionActions({ routedSessionId ) - const runtimeInfo = applyRuntimeInfo(branched.info) + // The branch opens as its own tile in the parent's worktree, not as the + // primary session — keep its runtime out of the main composer atoms. + const runtimeInfo = applyRuntimeInfo(branched.info, { foreground: false }) patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd) if (runtimeInfo) { From 8f4ab7ad2c53a5ffda2ba4ff4f1f29d7a94633ac Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 12:27:23 -0500 Subject: [PATCH 03/55] fix(desktop): coding rail reads only its own worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row fell back to the global $repoStatus whenever repoPath was blank, painting the main pane's branch and ± onto a tile whose cwd hadn't resolved yet. The fallback bought nothing — the primary computed is keyed to $currentCwd, which is empty in exactly that case — and cost a rail showing a tree the session was never in. --- .../app/chat/composer/status-stack/coding-row.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx index 6ad820765e..4d25320d95 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx @@ -16,8 +16,6 @@ import { DiffCount } from '@/components/ui/diff-count' import type { HermesGitBranch } from '@/global' import { useI18n } from '@/i18n' import { - $repoStatus, - $repoWorktrees, registerRepoStatusCwd, repoStatusForCwd, repoWorktreesForCwd @@ -69,10 +67,13 @@ export const CodingStatusRow = memo(function CodingStatusRow({ const s = t.statusStack.coding const p = t.sidebar.projects const resolvedRepoPath = repoPath?.trim() || undefined - // Per-cwd slice when this surface knows its worktree (tiles); otherwise the - // primary main-pane computed — so a blank/missing repoPath still paints. - const status = useStore(resolvedRepoPath ? repoStatusForCwd(resolvedRepoPath) : $repoStatus) - const worktrees = useStore(resolvedRepoPath ? repoWorktreesForCwd(resolvedRepoPath) : $repoWorktrees) + // This surface's OWN worktree, always — never the primary's. The row used to + // fall back to the global `$repoStatus` for a blank repoPath, which painted + // the main pane's branch/± onto a tile whose cwd hadn't resolved yet. That + // fallback bought nothing (the primary's computed is keyed to `$currentCwd`, + // which is blank in exactly the same case) and cost a wrong-tree rail. + const status = useStore(repoStatusForCwd(resolvedRepoPath)) + const worktrees = useStore(repoWorktreesForCwd(resolvedRepoPath)) // While mounted, keep this worktree in the coding-status refresh set so the // turn-settle / tool-complete / focus edges re-probe it too (tiles otherwise From 07447bd5dbd291389438c19586780b7f7fe67c66 Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:52:15 -0400 Subject: [PATCH 04/55] nous portal video gen (#74963) --- hermes_cli/tools_config.py | 1 + tests/tools/test_flux3_video_tool.py | 568 ++++++++++++++ tests/tools/test_managed_tool_gateway.py | 143 ++++ tools/flux3_video_tool.py | 914 +++++++++++++++++++++++ tools/image_source.py | 94 ++- tools/managed_tool_gateway.py | 230 ++++++ toolsets.py | 27 + 7 files changed, 1956 insertions(+), 21 deletions(-) create mode 100644 tests/tools/test_flux3_video_tool.py create mode 100644 tools/flux3_video_tool.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 85a62aeea7..a777db1f1e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -102,6 +102,7 @@ CONFIGURABLE_TOOLSETS = [ ("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"), ("image_gen", "🎨 Image Generation", "image_generate"), ("video_gen", "🎬 Video Generation", "video_generate (text/image/reference)"), + ("bfl", "🎬 BFL FLUX 3 Video", "bfl_flux3_*"), ("x_search", "🐦 X (Twitter) Search", "x_search (requires xAI OAuth or XAI_API_KEY)"), ("tts", "🔊 Text-to-Speech", "text_to_speech"), ("stt", "🎙️ Speech-to-Text", "voice transcription (gateway voice messages + voice mode)"), diff --git a/tests/tools/test_flux3_video_tool.py b/tests/tools/test_flux3_video_tool.py new file mode 100644 index 0000000000..caab52aa28 --- /dev/null +++ b/tests/tools/test_flux3_video_tool.py @@ -0,0 +1,568 @@ +"""Native BFL FLUX 3 tools: gating, transport, media delivery, redaction.""" + +import asyncio +import base64 +import json +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from tools import flux3_video_tool as flux3 + +GATEWAY = "https://tool-gateway.example.com" +BASE_URL = f"{GATEWAY}/api/bfl" +UPLOAD_PATH = "/api/uploads/bfl" + +_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) + + +@pytest.fixture(autouse=True) +def _endpoints(): + """Every test runs as if the mount is reachable unless it says otherwise.""" + with patch.object( + flux3, + "managed_vendor_endpoints", + return_value={"origin": GATEWAY, "base_url": BASE_URL, "upload_path": UPLOAD_PATH}, + ): + yield + + +class _FakeResponse: + def __init__(self, status_code=200, payload=None, text=""): + self.status_code = status_code + self._payload = payload + self.text = text or (json.dumps(payload) if payload is not None else "") + + def json(self): + if self._payload is None: + raise ValueError("no json") + return self._payload + + +class _FakeClient: + """Captures the one request each handler makes.""" + + def __init__(self, response, sink): + self._response = response + self._sink = sink + + async def __aenter__(self): + return self + + async def __aexit__(self, *_exc): + return False + + async def request(self, method, url, headers=None, json=None): + self._sink.append({"method": method, "url": url, "headers": headers or {}, "json": json}) + if isinstance(self._response, Exception): + raise self._response + return self._response + + +class _FakeStream: + """A streaming GET that yields `body` in one chunk.""" + + def __init__(self, body, status_code=200): + self._body = body + self.status_code = status_code + + async def __aenter__(self): + return self + + async def __aexit__(self, *_exc): + return False + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + async def aiter_bytes(self): + yield self._body + + +@contextmanager +def _fake_download(body, status_code=200): + """Stub the clip download; yields the list of URLs that were fetched. + + Patched at `create_ssrf_safe_async_client` rather than at httpx, which both + stubs the transport and asserts the download goes through the SSRF-guarded + client — the URL is vendor-supplied and fetched from the user's machine. + """ + from tools import url_safety + + fetched = [] + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, *_exc): + return False + + def stream(self, _method, url): + fetched.append(url) + return _FakeStream(body, status_code) + + with patch.object(url_safety, "create_ssrf_safe_async_client", lambda **_kw: _Client()): + yield fetched + + +def _run(coro): + return asyncio.run(coro) + + +def _call(handler, args, response, headers=None): + """Invoke a handler with the transport stubbed; returns (parsed, requests).""" + sink = [] + import httpx + + with patch.object( + flux3, + "managed_gateway_auth_headers", + return_value=headers if headers is not None else {"Authorization": "Bearer nous-token"}, + ), patch.object(httpx, "AsyncClient", lambda **_kw: _FakeClient(response, sink)): + raw = _run(handler(args)) + return json.loads(raw), sink + + +class TestGating: + def test_hidden_without_a_reachable_mount(self): + with patch.object(flux3, "managed_vendor_endpoints", return_value=None): + assert flux3.check_bfl_requirements() is False + + def test_hidden_without_paid_service_access(self): + # The free tool pool does not fund BFL, so a pool-only user must never + # see the tools rather than see them and be refused. + account = SimpleNamespace(logged_in=True, paid_service_access=False, tool_gateway_entitled=True) + with patch("hermes_cli.nous_account.get_nous_portal_account_info", return_value=account): + assert flux3.check_bfl_requirements() is False + + def test_hidden_when_logged_out(self): + account = SimpleNamespace(logged_in=False, paid_service_access=False) + with patch("hermes_cli.nous_account.get_nous_portal_account_info", return_value=account): + assert flux3.check_bfl_requirements() is False + + def test_visible_for_a_paid_portal_account(self): + account = SimpleNamespace(logged_in=True, paid_service_access=True) + with patch("hermes_cli.nous_account.get_nous_portal_account_info", return_value=account): + assert flux3.check_bfl_requirements() is True + + def test_fails_closed_when_the_account_probe_raises(self): + with patch("hermes_cli.nous_account.get_nous_portal_account_info", side_effect=RuntimeError("portal down")): + assert flux3.check_bfl_requirements() is False + + +class TestSubmitTransport: + def test_text_to_video_posts_the_mode_and_arguments(self): + response = _FakeResponse(200, {"id": "bfl_job_1", "status": "submitted", "guidance": "Poll bfl_flux3_get_result with id=bfl_job_1"}) + + parsed, requests = _call( + flux3._handle_text_to_video, + {"prompt": "a lake", "aspect_ratio": "16:9", "duration": 5}, + response, + ) + + assert requests[0]["method"] == "POST" + assert requests[0]["url"] == f"{BASE_URL}/generations" + assert requests[0]["json"] == { + "prompt": "a lake", + "aspect_ratio": "16:9", + "duration": 5, + "mode": "text_to_video", + } + assert requests[0]["headers"]["Authorization"] == "Bearer nous-token" + # The gateway's guidance is the model-facing text, verbatim. + assert parsed["result"] == "Poll bfl_flux3_get_result with id=bfl_job_1" + assert parsed["details"]["id"] == "bfl_job_1" + + def test_each_generate_tool_sends_its_own_mode(self): + for handler, args, mode in [ + (flux3._handle_text_to_video, {"prompt": "a"}, "text_to_video"), + (flux3._handle_image_to_video, {"prompt": "a", "input_image": "https://x/a.png"}, "image_to_video"), + ( + flux3._handle_keyframes_to_video, + {"prompt": "a", "input_images": ["https://x/a.png"], "keyframe_indices": [0]}, + "keyframes_to_video", + ), + (flux3._handle_video_continuation, {"prompt": "a", "input_video": "https://x/c.mp4"}, "video_continuation"), + ]: + _parsed, requests = _call(handler, args, _FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"})) + assert requests[0]["json"]["mode"] == mode + + def test_urls_pass_through_without_an_upload(self): + # Forwarding a URL is cheaper than downloading and re-uploading it. + _parsed, requests = _call( + flux3._handle_image_to_video, + {"prompt": "a", "input_image": "https://example.com/a.png"}, + _FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}), + ) + assert requests[0]["json"]["input_image"] == "https://example.com/a.png" + + def test_a_refusal_is_surfaced_as_the_tools_result_text(self): + # Throttles are designed to be hit: the message is written for the + # model and must reach it intact, with the machine detail alongside. + response = _FakeResponse( + 429, + { + "error": { + "code": "BFL_GENERATION_COOLDOWN", + "message": "A new BFL video generation may be started once every 5 minutes. Wait 210 seconds.", + "details": {"retryAfterSeconds": 210}, + } + }, + ) + + parsed, _requests = _call(flux3._handle_text_to_video, {"prompt": "a"}, response) + + assert parsed["error"] == "A new BFL video generation may be started once every 5 minutes. Wait 210 seconds." + assert parsed["details"] == {"retryAfterSeconds": 210} + + def test_a_401_asks_for_a_nous_sign_in(self): + parsed, _requests = _call(flux3._handle_text_to_video, {"prompt": "a"}, _FakeResponse(401, {"error": {"code": "AUTH_ERROR"}})) + + assert parsed["needs_reauth"] is True + assert "sign in" in parsed["error"].lower() + + def test_missing_credentials_ask_for_a_sign_in_without_calling_out(self): + parsed, requests = _call(flux3._handle_text_to_video, {"prompt": "a"}, _FakeResponse(200, {}), headers={}) + + assert requests == [] + assert "sign in" in parsed["error"].lower() + + def test_a_transport_failure_reports_the_cause(self): + parsed, _requests = _call( + flux3._handle_text_to_video, + {"prompt": "a"}, + RuntimeError("connect failed"), + ) + + assert "Could not reach the video-generation gateway" in parsed["error"] + assert "connect failed" in parsed["error"] + + def test_an_unreadable_body_does_not_masquerade_as_success(self): + parsed, _requests = _call(flux3._handle_text_to_video, {"prompt": "a"}, _FakeResponse(502, None, text="upstream exploded")) + + assert "error" in parsed + + +class TestPollTransport: + def test_poll_gets_the_job_and_returns_guidance(self): + response = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."}) + + parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response) + + assert requests[0]["method"] == "GET" + assert requests[0]["url"] == f"{BASE_URL}/generations/bfl_job_1" + assert requests[0]["json"] is None + assert parsed["result"] == "Still going." + assert parsed["details"]["status"] == "Generating" + + def test_ready_saves_the_clip_and_never_returns_the_signed_url(self, tmp_path): + # The signed URL is a bearer credential for the clip and it used to be + # re-keyed into a shell command by hand, dropping characters. Neither + # can happen if the model never sees it. + signed = "https://cdn.example/container/flux3-clip.mp4?sig=abc%2Bdef%3D&se=2026" + response = _FakeResponse(200, {"id": "bfl_job_1", "status": "Ready", "result": {"sample": signed}, "guidance": "Deliver the saved file."}) + + with _fake_download(b"x" * (128 * 1024)) as fetched: + parsed, _requests = _call( + flux3._handle_get_result, + {"id": "bfl_job_1", "save_to": str(tmp_path)}, + response, + ) + + saved = tmp_path / "flux3-clip.mp4" + assert saved.read_bytes() == b"x" * (128 * 1024) + assert parsed["details"]["saved_path"] == str(saved) + assert parsed["details"]["result"].get("sample") is None + assert signed not in json.dumps(parsed) + # The gateway still owns the delivery wording; the client only supplies + # the path it cannot know. + assert parsed["result"].startswith(f"Saved to {saved}.") + assert "Deliver the saved file." in parsed["result"] + assert fetched == [signed] + + def test_ready_never_overwrites_an_existing_file(self, tmp_path): + (tmp_path / "flux3-clip.mp4").write_bytes(b"an earlier clip") + response = _FakeResponse(200, {"id": "bfl_job_1", "status": "Ready", "result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=a"}, "guidance": "g"}) + + with _fake_download(b"y" * (128 * 1024)): + parsed, _requests = _call(flux3._handle_get_result, {"id": "bfl_job_1", "save_to": str(tmp_path)}, response) + + assert parsed["details"]["saved_path"] == str(tmp_path / "flux3-clip-2.mp4") + assert (tmp_path / "flux3-clip.mp4").read_bytes() == b"an earlier clip" + + def test_a_rejected_download_fails_loudly_and_leaves_no_file(self, tmp_path): + # The original bug: a bad signature returns an XML error body, curl + # writes it to the .mp4 and exits 0, and it reads as success. A short + # body is not a video whatever the status code said. + response = _FakeResponse(200, {"id": "bfl_job_1", "status": "Ready", "result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=bad"}, "guidance": "g"}) + + with _fake_download(b"AuthenticationFailed"): + parsed, _requests = _call(flux3._handle_get_result, {"id": "bfl_job_1", "save_to": str(tmp_path)}, response) + + assert "saving it failed" in parsed["result"] + assert "Poll this job again" in parsed["result"] + # Neither a half-written .part nor a plausible-looking .mp4 survives. + assert [p.name for p in tmp_path.glob("*.mp4*")] == [] + assert "saved_path" not in parsed["details"] + + def test_poll_requires_an_id(self): + parsed, requests = _call(flux3._handle_get_result, {}, _FakeResponse(200, {})) + + assert "id is required" in parsed["error"] + assert requests == [] + + def test_poll_url_encodes_the_job_id(self): + _parsed, requests = _call( + flux3._handle_get_result, + {"id": "weird/../id"}, + _FakeResponse(200, {"id": "x", "guidance": "ok"}), + ) + assert requests[0]["url"] == f"{BASE_URL}/generations/weird%2F..%2Fid" + + +class TestMediaDelivery: + def _resolved(self, mime="image/png", data=_PNG): + return SimpleNamespace(data=data, mime=mime) + + def test_a_local_path_is_uploaded_and_replaced_with_a_reference(self): + async def fake_uploader(data, mime): + assert data == _PNG + assert mime == "image/png" + return "nous-upload:token-1" + + with patch.object(flux3, "build_managed_media_uploader", return_value=fake_uploader), patch( + "tools.image_source.resolve_image_source", return_value=self._resolved() + ) as resolve: + _parsed, requests = _call( + flux3._handle_image_to_video, + {"prompt": "a", "input_image": "/tmp/frame.png"}, + _FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}), + ) + + assert requests[0]["json"]["input_image"] == "nous-upload:token-1" + # Images and video ride the same safety pipeline; only the permitted + # type differs, and an image field must not accept a video. + assert resolve.call_args.kwargs["permitted"] == ("image",) + + def test_video_fields_permit_video_only(self): + async def fake_uploader(data, mime): + return "nous-upload:token-v" + + with patch.object(flux3, "build_managed_media_uploader", return_value=fake_uploader), patch( + "tools.image_source.resolve_image_source", return_value=self._resolved("video/mp4", b"\x00\x00\x00\x18ftypmp42") + ) as resolve: + _parsed, requests = _call( + flux3._handle_video_continuation, + {"prompt": "a", "input_video": "/tmp/clip.mp4"}, + _FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}), + ) + + assert requests[0]["json"]["input_video"] == "nous-upload:token-v" + assert resolve.call_args.kwargs["permitted"] == ("video",) + + def test_every_keyframe_path_is_uploaded(self): + uploads = [] + + async def fake_uploader(data, mime): + uploads.append(mime) + return f"nous-upload:token-{len(uploads)}" + + with patch.object(flux3, "build_managed_media_uploader", return_value=fake_uploader), patch( + "tools.image_source.resolve_image_source", return_value=self._resolved() + ): + _parsed, requests = _call( + flux3._handle_keyframes_to_video, + {"prompt": "a", "input_images": ["/tmp/a.png", "https://x/b.png", "/tmp/c.png"], "keyframe_indices": [0, 24, 48]}, + _FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}), + ) + + # The URL in the middle is forwarded untouched. + assert requests[0]["json"]["input_images"] == [ + "nous-upload:token-1", + "https://x/b.png", + "nous-upload:token-2", + ] + + def test_a_list_valued_input_image_is_still_uploaded(self): + # The gateway accepts input_image as a string OR a list, so a list of + # local paths must not slip past unsanitized — that would send raw + # filesystem paths to the vendor and disclose the user's directories. + async def fake_uploader(data, mime): + return "nous-upload:token-1" + + with patch.object(flux3, "build_managed_media_uploader", return_value=fake_uploader), patch( + "tools.image_source.resolve_image_source", return_value=self._resolved() + ): + _parsed, requests = _call( + flux3._handle_image_to_video, + {"prompt": "a", "input_image": ["/tmp/frame.png"]}, + _FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}), + ) + + assert requests[0]["json"]["input_image"] == ["nous-upload:token-1"] + assert "/tmp/frame.png" not in json.dumps(requests[0]["json"]) + + def test_media_fields_are_sanitized_whatever_the_mode_expects(self): + # The gateway prefers input_image over input_images, so sanitizing only + # the field this mode documents would let the other one through. + uploads = [] + + async def fake_uploader(data, mime): + uploads.append(mime) + return f"nous-upload:token-{len(uploads)}" + + with patch.object(flux3, "build_managed_media_uploader", return_value=fake_uploader), patch( + "tools.image_source.resolve_image_source", return_value=self._resolved() + ): + _parsed, requests = _call( + flux3._handle_keyframes_to_video, + { + "prompt": "a", + "input_images": ["https://x/b.png"], + "input_image": "/tmp/sneaky.png", + "keyframe_indices": [0], + }, + _FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}), + ) + + body = json.dumps(requests[0]["json"]) + assert "/tmp/sneaky.png" not in body + assert requests[0]["json"]["input_image"] == "nous-upload:token-1" + + def test_text_to_video_strips_media_fields_instead_of_uploading_them(self): + # The mode takes no media, so an upload would spend the caller's quota + # on a value the gateway ignores. + def _must_not_upload(*_args, **_kwargs): + raise AssertionError("text-to-video must not upload anything") + + with patch.object(flux3, "build_managed_media_uploader", _must_not_upload): + _parsed, requests = _call( + flux3._handle_text_to_video, + {"prompt": "a", "input_image": "/tmp/frame.png"}, + _FakeResponse(200, {"id": "j", "status": "submitted", "guidance": "ok"}), + ) + + assert "input_image" not in requests[0]["json"] + assert requests[0]["json"]["mode"] == "text_to_video" + + def test_an_over_long_image_list_is_refused_before_any_upload(self): + def _must_not_upload(*_args, **_kwargs): + raise AssertionError("an over-long list must be refused before uploading") + + with patch.object(flux3, "build_managed_media_uploader", _must_not_upload): + parsed, requests = _call( + flux3._handle_keyframes_to_video, + {"prompt": "a", "input_images": [f"/tmp/{i}.png" for i in range(11)], "keyframe_indices": [0]}, + _FakeResponse(200, {}), + ) + + assert "at most 10" in parsed["error"] + assert requests == [] + + def test_an_upload_refusal_becomes_the_tools_error(self): + async def failing_uploader(data, mime): + raise RuntimeError("the daily upload budget for this account is exhausted") + + with patch.object(flux3, "build_managed_media_uploader", return_value=failing_uploader), patch( + "tools.image_source.resolve_image_source", return_value=self._resolved() + ): + parsed, requests = _call( + flux3._handle_image_to_video, + {"prompt": "a", "input_image": "/tmp/frame.png"}, + _FakeResponse(200, {}), + ) + + assert "daily upload budget" in parsed["error"] + # A failed upload must not reach the gateway as a bare local path. + assert requests == [] + + def test_an_unreadable_file_is_reported_without_dumping_the_value(self): + from tools.image_source import SourceNotFound + + with patch.object(flux3, "build_managed_media_uploader", return_value=lambda *a: None), patch( + "tools.image_source.resolve_image_source", side_effect=SourceNotFound("media file not found", src="/tmp/x.png") + ): + parsed, requests = _call( + flux3._handle_image_to_video, + # Long, but unmistakably a path (dots and dashes are outside + # the base64 alphabet, so the payload guard leaves it alone). + {"prompt": "a", "input_image": "/tmp/" + "a-b." * 2000 + "frame.png"}, + _FakeResponse(200, {}), + ) + + assert "error" in parsed + # The offending value is truncated: echoing it whole would blow up the + # model's context on the way to reporting a bad path. + assert len(parsed["error"]) < 500 + assert requests == [] + + +class TestLocalPathDetection: + @pytest.mark.parametrize( + "value", + ["/tmp/frame.png", "~/Pictures/f.png", "./f.png", "../f.png", "file:///tmp/f.png", r"C:\Users\me\f.png", r"\\nas\share\f.png"], + ) + def test_rooted_paths_are_read_off_disk(self, value): + assert flux3._looks_like_local_path(value) is True + + @pytest.mark.parametrize( + "value", + [ + "frame.png", + "https://example.com/f.png", + "nous-upload:eyJhbGciOiJIUzI1NiJ9.e30.sig", + "C:frame.png", + # Inline base64 of a JPEG always starts "/9j/" (first byte 0xFF), + # which must not read as an absolute POSIX path. + "/9j/4AAQSkZJRgABAQAAAQ" + "A" * 300 + "==", + ], + ) + def test_ambiguous_and_remote_values_are_forwarded(self, value): + assert flux3._looks_like_local_path(value) is False + + def test_a_short_base64_lookalike_path_is_still_a_path(self): + assert flux3._looks_like_local_path("/tmp/frames/a1") is True + + +class TestSchemas: + def test_every_tool_is_registered_under_the_bfl_toolset(self): + from tools.registry import registry + + for name in [ + "bfl_flux3_text_to_video", + "bfl_flux3_image_to_video", + "bfl_flux3_keyframes_to_video", + "bfl_flux3_video_continuation", + "bfl_flux3_get_result", + "bfl_flux3_prompting_guide", + ]: + entry = registry.get_entry(name) + assert entry is not None, f"{name} is not registered" + assert entry.toolset == "bfl" + assert entry.check_fn is flux3.check_bfl_requirements + + def test_generate_tools_point_at_the_guide_and_the_poll_tool(self): + # Descriptions are the only text guaranteed to be in context when a + # model picks a tool, so the pointers live there. + for schema in [flux3.TEXT_TO_VIDEO_SCHEMA, flux3.IMAGE_TO_VIDEO_SCHEMA, flux3.KEYFRAMES_TO_VIDEO_SCHEMA, flux3.VIDEO_CONTINUATION_SCHEMA]: + assert "bfl_flux3_prompting_guide" in schema["description"] + assert "bfl_flux3_get_result" in schema["description"] + + def test_the_guide_covers_the_methodology_without_pinning_server_policy(self): + guide = flux3.FLUX3_PROMPTING_GUIDE + assert "grounding" in guide.lower() + assert "bfl_flux3_get_result" in guide + # Waits and limits ship live in the gateway's responses; pinning them + # here would let the client lie about what the server enforces. + assert "5 minutes" not in guide + assert "per minute" not in guide + + def test_the_guide_tool_takes_no_arguments_and_calls_nothing(self): + assert flux3.PROMPTING_GUIDE_SCHEMA["parameters"]["properties"] == {} + assert _run(flux3._handle_prompting_guide({})) == flux3.FLUX3_PROMPTING_GUIDE diff --git a/tests/tools/test_managed_tool_gateway.py b/tests/tools/test_managed_tool_gateway.py index a1aac581ae..1757d5141f 100644 --- a/tests/tools/test_managed_tool_gateway.py +++ b/tests/tools/test_managed_tool_gateway.py @@ -6,6 +6,8 @@ from pathlib import Path import sys from unittest.mock import patch +import pytest + MODULE_PATH = Path(__file__).resolve().parents[2] / "tools" / "managed_tool_gateway.py" MODULE_SPEC = spec_from_file_location("managed_tool_gateway_test_module", MODULE_PATH) assert MODULE_SPEC and MODULE_SPEC.loader @@ -35,6 +37,147 @@ def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain() assert result.managed_mode is True +def test_resolve_managed_tool_gateway_uses_vendor_specific_override(): + with patch.dict( + os.environ, + { + "BROWSER_USE_GATEWAY_URL": "http://browser-use-gateway.localhost:3009/", + }, + clear=False, + ), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + result = resolve_managed_tool_gateway( + "browser-use", + token_reader=lambda: "nous-token", + ) + + assert result is not None + assert result.gateway_origin == "http://browser-use-gateway.localhost:3009" + + +def test_resolve_managed_tool_gateway_is_inactive_without_nous_token(): + with patch.dict( + os.environ, + { + "TOOL_GATEWAY_DOMAIN": "nousresearch.com", + }, + clear=False, + ), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + result = resolve_managed_tool_gateway( + "firecrawl", + token_reader=lambda: None, + ) + + assert result is None + + +def test_resolve_managed_tool_gateway_is_disabled_without_subscription(): + with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False), \ + patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=False): + result = resolve_managed_tool_gateway( + "firecrawl", + token_reader=lambda: "nous-token", + ) + + assert result is None + + +def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch): + monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + expires_at = (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat() + (tmp_path / "auth.json").write_text(json.dumps({ + "providers": { + "nous": { + "access_token": "stale-token", + "refresh_token": "refresh-token", + "expires_at": expires_at, + } + } + })) + monkeypatch.setattr( + "hermes_cli.auth.resolve_nous_access_token", + lambda refresh_skew_seconds=120: "fresh-token", + ) + + assert managed_tool_gateway.read_nous_access_token() == "fresh-token" + + +def test_managed_vendor_endpoints_pin_the_deployed_gateway_url(): + """The exact URL an agent may connect to is a code fact, not a lookup. + + Exercises the real ``build_vendor_gateway_url`` (which once resolved a + typo'd pseudo-vendor to a non-existent host while every other test stubbed + it): default builder, real deployed host, pinned vendor path. + """ + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True), \ + patch.dict( + os.environ, + {"TOOL_GATEWAY_DOMAIN": "nousresearch.com", "TOOL_GATEWAY_SCHEME": "https"}, + clear=False, + ): + os.environ.pop("TOOL_GATEWAY_URL", None) + endpoints = managed_tool_gateway.managed_vendor_endpoints("bfl") + + assert endpoints == { + "origin": "https://tool-gateway.nousresearch.com", + "base_url": "https://tool-gateway.nousresearch.com/api/bfl", + "upload_path": "/api/uploads/bfl", + } + + +def test_managed_vendor_endpoints_unreachable_when_managed_tools_disabled(): + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=False): + assert managed_tool_gateway.managed_vendor_endpoints("bfl") is None + + +def test_managed_gateway_auth_headers_carry_the_bearer(): + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + headers = managed_tool_gateway.managed_gateway_auth_headers( + "https://tool-gateway.example.com/api/bfl/generations", + gateway_builder=lambda vendor: f"https://{vendor}-gateway.example.com", + token_reader=lambda: "nous-token", + ) + + assert headers == {"Authorization": "Bearer nous-token"} + + +def test_managed_gateway_auth_headers_reflect_a_rotated_token(): + # Read fresh on every call: a Nous access token expires within the hour, + # and a long session must not keep presenting a dead bearer. + tokens = iter(["first-token", "second-token"]) + builder = lambda vendor: f"https://{vendor}-gateway.example.com" + url = "https://tool-gateway.example.com/api/bfl/generations" + + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + first = managed_tool_gateway.managed_gateway_auth_headers(url, builder, lambda: next(tokens)) + second = managed_tool_gateway.managed_gateway_auth_headers(url, builder, lambda: next(tokens)) + + assert first["Authorization"] == "Bearer first-token" + assert second["Authorization"] == "Bearer second-token" + + +def test_managed_gateway_auth_headers_refuse_a_url_off_the_gateway_origin(): + # Gated on the URL, never a name: our bearer must never be handed to a + # host that merely looks managed. + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + assert managed_tool_gateway.managed_gateway_auth_headers( + "https://attacker.example/api/bfl/generations", + gateway_builder=lambda vendor: f"https://{vendor}-gateway.example.com", + token_reader=lambda: "nous-token", + ) == {} + + +def test_managed_gateway_auth_headers_empty_without_a_token(): + # Empty rather than raising, so a caller can say "sign in" instead of + # sending an unauthenticated request. + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + assert managed_tool_gateway.managed_gateway_auth_headers( + "https://tool-gateway.example.com/api/bfl/generations", + gateway_builder=lambda vendor: f"https://{vendor}-gateway.example.com", + token_reader=lambda: None, + ) == {} + + def test_is_managed_tool_gateway_ready_skips_refresh_for_expired_cached_token(tmp_path, monkeypatch): monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) diff --git a/tools/flux3_video_tool.py b/tools/flux3_video_tool.py new file mode 100644 index 0000000000..49dda1c86e --- /dev/null +++ b/tools/flux3_video_tool.py @@ -0,0 +1,914 @@ +"""Native BFL FLUX 3 video generation tools, backed by the Nous tool gateway. + +These are service-gated native tools in the ``image_generate`` mold: schemas +and descriptions are pinned here as build-time facts, the handlers speak the +gateway's own REST contract, and ``check_fn`` hides the whole toolset unless +the user is signed in to Nous Portal with paid service access. No runtime +discovery, and no server-supplied schema is ever consulted — that is the point +of the design. + +The wire is two calls against the gateway's managed mount, and it names the +vendor but not the vendor's API: + +- ``POST {base}/generations`` with ``{mode, prompt, ...}`` -> ``{id, status, + guidance}`` +- ``GET {base}/generations/`` -> the job state plus ``guidance`` + +``guidance`` is the gateway's live policy channel: exact waits, what to do +next, and how to deliver a finished clip ship from the server so they cannot +drift from what it actually enforces. Handlers surface it verbatim as the +tool's result text, and surface ``error.message`` the same way on a refusal. + +Media inputs: handlers know their own media fields explicitly. A local file +path is resolved through :func:`tools.image_source.resolve_image_source` +(sandbox confinement, credential guard) and delivered via the Nous upload +protocol (presign, direct PUT to storage, ``nous-upload:`` reference). +URLs pass through untouched. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from typing import Optional + +from tools.registry import registry +from tools.managed_tool_gateway import ( + build_managed_media_uploader, + managed_gateway_auth_headers, + managed_vendor_endpoints, + read_nous_access_token, +) + +logger = logging.getLogger(__name__) + +_TOOLSET = "bfl" +_VENDOR = "bfl" + +# Submit sits behind the gateway's upstream call plus upload-reference +# resolution, and the gateway bounds a poll server-side. One generous read +# timeout covers both without ever approaching the agent's per-tool budget. +_TRANSPORT_READ_TIMEOUT_SECONDS = 180.0 +_TRANSPORT_CONNECT_TIMEOUT_SECONDS = 10.0 + +_SIGN_IN_MESSAGE = ( + "BFL video generation needs a Nous Portal sign-in with an active paid plan. " + "Ask the user to run `hermes model` and sign in to Nous, then retry." +) + +# --------------------------------------------------------------------------- +# Local-path detection (moved from the retired MCP media-argument walker) +# --------------------------------------------------------------------------- + +# Drive-absolute Windows paths: ``C:\Users\...`` / ``C:/Users/...``. +_WINDOWS_DRIVE_PATH = re.compile(r"^[A-Za-z]:[\\/]") + +# A whole string of base64 alphabet (optionally line-wrapped, optionally +# padded). Base64's alphabet includes "/", so an inline JPEG payload always +# starts with "/9j/" — which reads as an absolute path unless caught first. +_BASE64_PAYLOAD = re.compile(r"^[A-Za-z0-9+/\r\n]+={0,2}[\r\n]*$") +# Real filesystem paths are short; base64 of even a thumbnail runs to +# thousands of characters. Anything this long and alphabet-pure is a payload. +_MIN_BASE64_PAYLOAD_LENGTH = 256 + + +def _looks_like_local_path(value: str) -> bool: + """True for things we should read off disk rather than forward as-is. + + Deliberately narrow: only explicitly rooted paths and ``file://`` qualify. + Bare names are ambiguous with opaque ids, URLs pass through, and base64 + payloads (checked first — a JPEG's base64 starts ``/9j/``) fall through + untouched. + """ + if len(value) >= _MIN_BASE64_PAYLOAD_LENGTH and _BASE64_PAYLOAD.match(value): + return False + if value.startswith("file://"): + return True + if value == "~" or value.startswith(("~/", "~\\")): + return True + if value.startswith(("/", "./", "../", ".\\", "..\\")): + return True + if _WINDOWS_DRIVE_PATH.match(value) or value.startswith("\\\\"): + return True + return False + + +def _display_path(path: str) -> str: + """A value safe to embed in an error message shown to the model.""" + return path if len(path) <= 200 else f"{path[:200]}… ({len(path)} characters)" + + +# --------------------------------------------------------------------------- +# Gateway transport +# --------------------------------------------------------------------------- + +def _endpoints() -> Optional[dict]: + """Absolute URLs for the managed BFL routes, or None when unreachable.""" + return managed_vendor_endpoints(_VENDOR) + + +async def _call_gateway(method: str, url: str, json_body: Optional[dict] = None) -> str: + """One REST round trip, rendered as this tool's result. + + The gateway's ``guidance`` (on success) and ``error.message`` (on a + refusal) are both written for a model to act on, so they are surfaced + verbatim. A refusal is a normal outcome the model can respond to — being + throttled is not a broken tool — so only genuinely unreadable responses + become ``error``. + """ + import httpx + + headers = managed_gateway_auth_headers(url) + if not headers: + return json.dumps({"error": _SIGN_IN_MESSAGE}) + headers["Content-Type"] = "application/json" + + timeout = httpx.Timeout(_TRANSPORT_CONNECT_TIMEOUT_SECONDS, read=_TRANSPORT_READ_TIMEOUT_SECONDS) + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.request(method, url, headers=headers, json=json_body) + except Exception as exc: + return json.dumps({ + "error": f"Could not reach the video-generation gateway: {type(exc).__name__}: {exc}", + }) + + if response.status_code == 401: + return json.dumps({"error": _SIGN_IN_MESSAGE, "needs_reauth": True}) + + try: + payload = response.json() + except Exception: + payload = None + + if not isinstance(payload, dict): + return json.dumps({ + "error": f"The video-generation gateway answered HTTP {response.status_code} with an unreadable body.", + }) + + if response.status_code >= 400: + error = payload.get("error") if isinstance(payload.get("error"), dict) else {} + message = error.get("message") or f"the gateway refused the request (HTTP {response.status_code})" + out: dict = {"error": str(message)} + if isinstance(error.get("details"), dict): + out["details"] = error["details"] + return json.dumps(out, ensure_ascii=False) + + guidance = payload.pop("guidance", None) + return json.dumps( + {"result": guidance or "Request accepted.", "details": payload}, + ensure_ascii=False, + ) + + +# --------------------------------------------------------------------------- +# Media delivery +# --------------------------------------------------------------------------- + +# Every media field the gateway understands, and the media kind each accepts. +# `input_image` and `input_images` are interchangeable server-side, so both +# must be sanitized whatever the mode. +_MEDIA_FIELDS = { + "input_image": ("image",), + "input_images": ("image",), + "input_video": ("video",), +} +# The vendor takes at most ten keyframes. Checked here as well as server-side +# so an over-long list is refused before it spends the caller's upload quota. +_MAX_IMAGES = 10 + + +def _warm_nous_token() -> None: + """Refresh the Nous token once, before any parallel upload needs it. + + ``read_nous_access_token`` takes no lock and, when a refresh fails, falls + back to returning the stale cached token. Uploading in parallel therefore + had every request discover the token was expiring at the same instant and + fire its own refresh; the rotating refresh token means the first wins and + the rest quietly send the stale bearer, which the gateway answers with 401. + One warm-up call first puts a fresh token in the cache, so the fan-out all + reads it instead of racing for it. + """ + try: + read_nous_access_token() + except Exception as exc: # pragma: no cover — the real read retries below + logger.debug("Nous token warm-up failed before parallel uploads: %s", exc) + + +async def _prepare_media(args: dict, task_id: Optional[str]) -> dict: + """Replace local paths with upload references in every media field. + + Deliberately covers all media fields rather than the one this mode + expects. The gateway accepts `input_image` and `input_images` + interchangeably, so a value left unsanitized in the "wrong" field still + reaches the vendor — as a raw local path, which both fails the generation + and discloses the user's directory layout to a third party. + """ + prepared = dict(args or {}) + _warm_nous_token() + for field, permitted in _MEDIA_FIELDS.items(): + value = prepared.get(field) + if value is None: + continue + if isinstance(value, list): + if len(value) > _MAX_IMAGES: + raise ValueError(f"{field} takes at most {_MAX_IMAGES} images; you passed {len(value)}.") + # Uploaded together rather than one after another: each entry is a + # presign round trip plus a full-file PUT, and ten keyframes in + # sequence took long enough that a submit looked hung. + prepared[field] = list( + await asyncio.gather(*(_deliver_media(entry, permitted, task_id) for entry in value)) + ) + else: + prepared[field] = await _deliver_media(value, permitted, task_id) + return prepared + + +def _without_media(args: dict) -> dict: + """Drop media fields entirely — for the mode that takes none. + + The gateway ignores them for text-to-video, so stripping here matches its + behaviour while avoiding an upload the caller never needed. + """ + return {k: v for k, v in dict(args or {}).items() if k not in _MEDIA_FIELDS} + + +async def _deliver_media(value, permitted: tuple, task_id: Optional[str]): + """Replace a local path with a ``nous-upload:`` reference; pass URLs through. + + Raises ``ValueError`` with a model-readable sentence when the file cannot + be read or uploaded — the caller turns that into the tool's error payload. + """ + if not isinstance(value, str) or not _looks_like_local_path(value): + return value + + from tools.image_source import ImageResolutionError, ResolveContext, resolve_image_source + + try: + resolved = await resolve_image_source(value, ResolveContext(task_id=task_id), permitted=permitted) + except ImageResolutionError as exc: + raise ValueError(f"Could not read {_display_path(value)}: {exc}") from exc + + endpoints = _endpoints() + uploader = None + if endpoints is not None: + uploader = build_managed_media_uploader(endpoints["base_url"], endpoints["upload_path"]) + if uploader is None: + raise ValueError("Local file uploads are not available in this build; pass a URL instead.") + + try: + return await uploader(resolved.data, resolved.mime) + except Exception as exc: + raise ValueError(f"Could not upload {_display_path(value)}: {exc}") from exc + + +# --------------------------------------------------------------------------- +# Saving the finished clip +# --------------------------------------------------------------------------- + +# Generous: a 50MB clip over a slow link, still well inside the agent's budget. +_DOWNLOAD_READ_TIMEOUT_SECONDS = 300.0 +_DOWNLOAD_CONNECT_TIMEOUT_SECONDS = 15.0 +# A rejection page is a few hundred bytes of XML; a clip is megabytes. Anything +# smaller than this is not the video, whatever the HTTP status said. +_MIN_PLAUSIBLE_VIDEO_BYTES = 64 * 1024 +# Enough collision suffixes to be useful, few enough to fail fast if something +# is generating files in a loop. +_MAX_FILENAME_ATTEMPTS = 50 + + +async def _save_if_ready(raw: str, save_to) -> str: + """Download a finished clip and swap the signed URL for a local path. + + The URL is handled here rather than by the model on purpose. It is long and + percent-encoded, and re-keying it into a shell command dropped characters + often enough to be the main failure mode of this tool — a corrupted + signature is rejected, but `curl` still writes the rejection body to the + output file and exits 0, so it read as success. Passing the exact string + from this response removes the transcription step entirely. + + It also keeps a bearer credential out of the transcript: the signed URL + grants the clip to anyone holding it for the next 15-60 minutes, and + returning it put it in the model's context and the saved conversation. The + gateway already scrubs presigned URLs for *input* media for exactly this + reason; this makes the output side match. + """ + try: + payload = json.loads(raw) + except Exception: + return raw + if not isinstance(payload, dict): + return raw + + details = payload.get("details") + if not isinstance(details, dict) or details.get("status") != "Ready": + return raw + + result = details.get("result") + url = result.get("sample") if isinstance(result, dict) else None + if not isinstance(url, str) or not url.strip(): + return raw + + # Dropped whether or not the save succeeds. A retry re-polls, which mints a + # fresh URL, so nothing is lost by not handing this one to the model. + result.pop("sample", None) + + try: + target, size = await _download_video(url.strip(), save_to) + except Exception as exc: + payload["result"] = ( + f"The clip finished but saving it failed: {type(exc).__name__}: {exc}. " + "Poll this job again to retry the download; the job itself is unaffected." + ) + return json.dumps(payload, ensure_ascii=False) + + details["saved_path"] = str(target) + details["saved_bytes"] = size + payload["result"] = f"Saved to {target}. " + str(payload.get("result") or "") + return json.dumps(payload, ensure_ascii=False) + + +async def _download_video(url: str, save_to) -> tuple: + """Stream the clip to disk, returning (path, bytes). + + SSRF-guarded, for the same reason the upload PUT is: this URL comes from + the vendor by way of the gateway, and it is fetched from the user's own + machine. Real result URLs are public CDN objects, which the guard allows. + """ + import httpx + + from tools.url_safety import create_ssrf_safe_async_client + + target = _resolve_destination(save_to, _filename_from_url(url)) + # Written under a .part name and renamed only once it is complete and + # plausible, so a failed download can never leave something that looks like + # a playable file behind. + partial = target.with_name(target.name + ".part") + timeout = httpx.Timeout(_DOWNLOAD_CONNECT_TIMEOUT_SECONDS, read=_DOWNLOAD_READ_TIMEOUT_SECONDS) + + try: + async with create_ssrf_safe_async_client(timeout=timeout, follow_redirects=True) as client: + async with client.stream("GET", url) as response: + response.raise_for_status() + with partial.open("wb") as handle: + async for chunk in response.aiter_bytes(): + handle.write(chunk) + + size = partial.stat().st_size + if size < _MIN_PLAUSIBLE_VIDEO_BYTES: + raise ValueError(f"the download returned only {size} bytes, which is not a video") + partial.replace(target) + except BaseException: + partial.unlink(missing_ok=True) + raise + + return target, size + + +def _filename_from_url(url: str) -> str: + from pathlib import PurePosixPath + from urllib.parse import unquote, urlsplit + + name = PurePosixPath(unquote(urlsplit(url).path)).name + # The path segment is vendor-controlled, so keep only a plain filename. + name = re.sub(r"[^A-Za-z0-9._-]", "_", name).lstrip(".")[:120] + return name or "flux3-video.mp4" + + +def _resolve_destination(save_to, filename: str): + """Where to write, honouring an explicit request and never overwriting.""" + from pathlib import Path + + if isinstance(save_to, str) and save_to.strip(): + requested = Path(save_to.strip()).expanduser() + if requested.is_dir() or save_to.rstrip().endswith(("/", "\\")): + directory, name = requested, filename + else: + directory, name = requested.parent, requested.name + else: + downloads = Path.home() / "Downloads" + directory, name = (downloads if downloads.is_dir() else Path.cwd()), filename + + directory.mkdir(parents=True, exist_ok=True) + return _free_path(directory / name) + + +def _free_path(candidate): + """`name.mp4` -> `name-2.mp4` -> `name-3.mp4` … so nothing is clobbered.""" + if not candidate.exists(): + return candidate + for suffix in range(2, _MAX_FILENAME_ATTEMPTS + 2): + sibling = candidate.with_name(f"{candidate.stem}-{suffix}{candidate.suffix}") + if not sibling.exists(): + return sibling + raise ValueError(f"could not find a free filename next to {candidate}") + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + +def _error(message: str) -> str: + return json.dumps({"error": message}, ensure_ascii=False) + + +def _submit_args(mode: str, args: dict) -> dict: + """The wire body: the model's arguments, minus Nones, plus our mode. + + Arguments pass through as the model gave them; the gateway owns validation + and the translation onto the vendor's own fields. + """ + body = {k: v for k, v in dict(args or {}).items() if v is not None} + body["mode"] = mode + return body + + +async def _submit(mode: str, args: dict) -> str: + endpoints = _endpoints() + if endpoints is None: + return _error("BFL video generation is not available in this build.") + return await _call_gateway("POST", f"{endpoints['base_url']}/generations", _submit_args(mode, args)) + + +async def _handle_text_to_video(args: dict, **kwargs) -> str: + return await _submit("text_to_video", _without_media(args)) + + +async def _handle_image_to_video(args: dict, **kwargs) -> str: + try: + prepared = await _prepare_media(args, kwargs.get("task_id")) + except ValueError as exc: + return _error(str(exc)) + return await _submit("image_to_video", prepared) + + +async def _handle_keyframes_to_video(args: dict, **kwargs) -> str: + images = (args or {}).get("input_images") + if not isinstance(images, list) or not images: + return _error("input_images must be a non-empty list of 1-10 images (local paths or URLs).") + try: + prepared = await _prepare_media(args, kwargs.get("task_id")) + except ValueError as exc: + return _error(str(exc)) + return await _submit("keyframes_to_video", prepared) + + +async def _handle_video_continuation(args: dict, **kwargs) -> str: + try: + prepared = await _prepare_media(args, kwargs.get("task_id")) + except ValueError as exc: + return _error(str(exc)) + return await _submit("video_continuation", prepared) + + +async def _handle_get_result(args: dict, **kwargs) -> str: + job_id = (args or {}).get("id") + if not isinstance(job_id, str) or not job_id.strip(): + return _error("id is required: the job id returned by the generate tool.") + endpoints = _endpoints() + if endpoints is None: + return _error("BFL video generation is not available in this build.") + from urllib.parse import quote + + raw = await _call_gateway("GET", f"{endpoints['base_url']}/generations/{quote(job_id.strip(), safe='')}") + return await _save_if_ready(raw, (args or {}).get("save_to")) + + +async def _handle_prompting_guide(args: dict, **kwargs) -> str: + return FLUX3_PROMPTING_GUIDE + + +# --------------------------------------------------------------------------- +# Gating +# --------------------------------------------------------------------------- + +def check_bfl_requirements() -> bool: + try: + if _endpoints() is None: + return False + from hermes_cli.nous_account import get_nous_portal_account_info + + info = get_nous_portal_account_info() + return bool(getattr(info, "logged_in", False) and getattr(info, "paid_service_access", False)) + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Pinned schemas +# --------------------------------------------------------------------------- + +_ASPECT_RATIOS = ["auto", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"] +_RESOLUTIONS = ["720p"] + +_GUIDE_POINTER = "Read bfl_flux3_prompting_guide before your first generation. " +_MEDIA_SENTENCE = ( + "Media fields accept a local file path (uploaded automatically to Nous-managed temporary " + "storage and deleted when the generation finishes) or a URL. " +) +_OVERRIDE_SENTENCE = "All guidance is defaults: explicit user instructions override it." + + +def _shared_submit_properties() -> dict: + return { + "prompt": { + "type": "string", + "minLength": 1, + "description": ( + "Generation brief in plain prose (it is interpreted and expanded by a reasoning " + "harness). Order it subject, distinguishing visual specifics, action, camera, " + "lighting, environment, audio, style. Audio is generated by default — say " + '"no music" if unwanted.' + ), + }, + "aspect_ratio": { + "type": "string", + "enum": list(_ASPECT_RATIOS), + "default": "auto", + "description": 'Output aspect ratio. "auto" lets the model choose.', + }, + "duration": { + "oneOf": [ + {"type": "integer", "minimum": 5, "maximum": 20}, + {"type": "string", "const": "auto"}, + ], + "default": "auto", + "description": 'Clip duration in whole seconds (5-20), or "auto".', + }, + "resolution": { + "type": "string", + "enum": list(_RESOLUTIONS), + "default": "720p", + "description": "Output resolution bin.", + }, + "generate_audio": { + "type": "boolean", + "default": True, + "description": "Generate synchronized audio.", + }, + "grounding": { + "type": "boolean", + "default": True, + "description": "Allow a short research pass before generation.", + }, + "seed": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "Optional reproducibility seed.", + }, + "version": { + "type": "string", + "description": 'Model version pin. Defaults to "latest".', + }, + } + + +TEXT_TO_VIDEO_SCHEMA = { + "name": "bfl_flux3_text_to_video", + "description": ( + _GUIDE_POINTER + + "FLUX 3 text-to-video: generates a clip (with audio) from the prompt alone. Nothing but " + "the prompt anchors the subject here, so research anything with a real, checkable " + "appearance before writing it — whatever you leave unspecified is filled in for you. " + "Generation takes several minutes: this returns a job id immediately; poll " + "bfl_flux3_get_result. " + + _OVERRIDE_SENTENCE + ), + "parameters": { + "type": "object", + "properties": _shared_submit_properties(), + "required": ["prompt"], + "additionalProperties": False, + }, +} + +IMAGE_TO_VIDEO_SCHEMA = { + "name": "bfl_flux3_image_to_video", + "description": ( + _GUIDE_POINTER + + "FLUX 3 image-to-video: animates one image as the literal opening frame — those pixels " + "are frame 0 and the clip moves from there. " + + _MEDIA_SENTENCE + + "Returns a job id; poll bfl_flux3_get_result. " + + _OVERRIDE_SENTENCE + ), + "parameters": { + "type": "object", + "properties": { + **_shared_submit_properties(), + "input_image": { + "type": "string", + "minLength": 1, + "description": "Exactly one opening-frame image: a local file path or a URL (PNG/JPEG/WebP, up to 10MB).", + }, + }, + "required": ["prompt", "input_image"], + "additionalProperties": False, + }, +} + +KEYFRAMES_TO_VIDEO_SCHEMA = { + "name": "bfl_flux3_keyframes_to_video", + "description": ( + _GUIDE_POINTER + + "FLUX 3 keyframe video: a storyboard of 1-10 images pinned at chosen frame positions " + "(24fps). Name the subject and describe the motion that carries it between the pins, and " + "keep the subject consistent across every pinned image. " + + _MEDIA_SENTENCE + + "Returns a job id; poll bfl_flux3_get_result. " + + _OVERRIDE_SENTENCE + ), + "parameters": { + "type": "object", + "properties": { + **_shared_submit_properties(), + "input_images": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "minItems": 1, + "maxItems": 10, + "description": "1-10 keyframe images: local file paths or URLs (PNG/JPEG/WebP, up to 10MB each).", + }, + "keyframe_indices": { + "type": "array", + "items": {"type": "integer", "minimum": 0, "maximum": 480}, + "minItems": 1, + "maxItems": 10, + "description": ( + "One unique non-negative frame index per image (24fps). Each must be at most " + 'duration×24, so set an explicit duration rather than "auto" whenever you pin ' + 'indices — "auto" resolves to 5, 10, 15 or 20 seconds and an index past the ' + "length it picks is rejected." + ), + }, + }, + "required": ["prompt", "input_images", "keyframe_indices"], + "additionalProperties": False, + }, +} + +VIDEO_CONTINUATION_SCHEMA = { + "name": "bfl_flux3_video_continuation", + "description": ( + _GUIDE_POINTER + + "FLUX 3 video continuation: the new generation picks up from the input clip's final " + 'frames. Open the prompt with "Continue this video from its final frames:", re-establish ' + "the subject and the moment it ended on, then describe what happens next. input_video " + "must be an mp4 of at most 50MB and 15 seconds, and the generated segment tops out at 15s " + "too — chain a second continuation for a longer sequence. duration is the " + "new segment only. " + + _MEDIA_SENTENCE + + "Returns a job id; poll bfl_flux3_get_result. " + + _OVERRIDE_SENTENCE + ), + "parameters": { + "type": "object", + "properties": { + **_shared_submit_properties(), + "input_video": { + "type": "string", + "minLength": 1, + "description": "The clip to continue: a local file path or a URL. mp4 only, at most 50MB and 15 seconds.", + }, + }, + "required": ["prompt", "input_video"], + "additionalProperties": False, + }, +} + +GET_RESULT_SCHEMA = { + "name": "bfl_flux3_get_result", + "description": ( + "Poll a FLUX 3 video job by the job id a generate tool returned. Generation takes minutes " + "and a long Generating phase is normal. Every response states the job's status, how long " + "to wait before polling again, and what you may do next — follow it rather than guessing. " + "On Ready the clip is downloaded for you and the response gives its local path; your only " + "remaining step is to deliver that file as the response describes." + ), + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Job id from a previous bfl_flux3_* generate call.", + }, + "save_to": { + "type": "string", + "description": ( + "Where to save the finished clip: a directory or a full file path. Set this " + "only when the user asked for a particular location; the default is " + "~/Downloads. An existing file is never overwritten." + ), + }, + }, + "required": ["id"], + "additionalProperties": False, + }, +} + +PROMPTING_GUIDE_SCHEMA = { + "name": "bfl_flux3_prompting_guide", + "description": ( + "Read this before your first FLUX 3 generation. The prompting and grounding guide: how " + "to research a subject so it renders as itself, how to assemble a prompt, which generate " + "tool fits, and how to save and deliver the finished clip. Takes no arguments and spends " + "no generation budget." + ), + "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, +} + + +# --------------------------------------------------------------------------- +# Pinned prompting guide (methodology only — policy numbers such as waits and +# limits arrive live in the gateway's tool responses, so they cannot drift) +# --------------------------------------------------------------------------- + +FLUX3_PROMPTING_GUIDE = """# FLUX 3 video generation — how to get the best results + +Everything here is guidance, not policy: the user's explicit instructions +always win. If they say skip the research, save somewhere specific, or deliver +differently — do it their way without arguing. Only the server's own validation +and limits are non-negotiable. + +Start from the user's own wording. The prompt is rewritten by a reasoning +harness before anything is generated, so restyling it yourself just stacks a +second rewrite on top and gives their intent another chance to drift. There are +two reasons to add anything: grounding facts for a subject with a real, +checkable appearance, and the behaviours below, which they had no way to know +about. Absent those, send what they wrote. + +## Grounding + +Grounding is your job, and it is the highest-leverage step in the workflow. +What you specify is preserved; what you leave out is filled in for you. So +when a subject has a real, checkable appearance, research it first and put +what you found into the prompt — that is what makes the result yours rather +than an approximation of it. + +Ground whenever someone who knows the subject could watch the clip and say +"that's not what that is": a named person or place, a landmark, a particular +vehicle or machine, anything technical, anything culturally specific, or a +period setting. Skip it for generics ("a dog on a beach") — there is no fact +to get wrong. + +To ground: search for VISUAL references, where three good photographs beat any +amount of prose. If you can analyze images, analyze what you find. Then put +only what a camera could see into the prompt: silhouette and proportion, +materials and finish, specific colours, distinctive details, era-correct +context. + +## How this model behaves + +The prompt is read by a reasoning harness rather than a tag encoder, so keyword +tricks and word order do nothing. Write plain prose at whatever length the +brief deserves; a single line is a valid prompt. + +Audio is generated by default, whether or not you mention it, so leaving it out +gets you invented sound rather than silence. Name the ambient sound, the music +and any speech separately — each lands as its own layer — and say "no music" +when you do not want it. + +A quoted line becomes speech only if a speaker is visible on camera. Without +one it tends to render as burned-in text instead, so quote the line, describe +the speaker, and add "no on-screen text, no subtitles". + +Multi-shot sequences work inside a single generation: "SHOT ONE ... HARD CUT. +SHOT TWO ..." produces real cuts, and "one continuous unbroken shot" gets an +uncut take. Consecutive shots have to contrast in scale, location or colour or +the cut will not read as one — near-identical coverage blends back into a +continuous take. + +## Choosing a tool + +- No input media -> bfl_flux3_text_to_video. +- Animate one image as the literal opening frame -> + bfl_flux3_image_to_video (those pixels are frame 0, and the clip moves from + there). Name the subject and its distinguishing specifics here as you would + anywhere else: only frame 0 is pinned, every frame after it is generated, + and what you leave out is the model's call rather than yours. +- Several images pinned at chosen moments -> bfl_flux3_keyframes_to_video + (name the subject and describe the motion that carries it between the pins; + keep it consistent across every pin, since a mismatch becomes a visible + morph mid-clip). Pin exact indices and you want an explicit duration too — + every index must fall within duration×24. +- Keep going from where a clip ends, or chain segments -> + bfl_flux3_video_continuation. Open with "Continue this video from its final + frames:", re-establish the subject and the moment the clip ended on, then + describe what happens next; duration is the new segment only, so plan + segments of 15 seconds or less to feed outputs back in. + +## Media inputs + +Pass local file paths directly — never hand-encode file contents into an argument. +URLs also work. Limits per file: +images 10MB (PNG/JPEG/WebP), video one mp4 of at most 50MB and 15 seconds. +Do not pre-shrink files to fit imagined caps; oversized pixel dimensions are +auto-downscaled and output tops out at 720p. + +## Workflow + +Submit returns a job id immediately — the video does not exist yet. Poll +bfl_flux3_get_result with that id; generation takes several minutes and a long +Generating phase is normal, not a stall. Nothing reaches disk before the job is +Ready, so checking folders mid-run tells you nothing. Every response +states the job status, how long to wait before polling again, and what you may +do next — follow it literally rather than guessing an interval. A job survives +client restarts: re-poll the same id rather than resubmitting, which would only +spend your budgets on duplicate work. + +## Save and deliver + +bfl_flux3_get_result saves the clip itself and returns saved_path. The download +is not yours to do and no URL is handed to you to fetch. Pass save_to only when +the user named a location; otherwise it lands in ~/Downloads, and an existing +file is never overwritten. If saving fails the response says so — poll the same +job again to retry, which is safe and spends no generation budget. + +Then deliver that file so the clip plays inline. Which markup plays inline is the +host's decision, so check your system prompt or platform instructions and use +exactly the form they give; the common ones are a MEDIA: tag alone on its own +line and a markdown embed. Two things break it. Write the real absolute path, +with ~ expanded. And keep the markup plain: wrapping it in bold, backticks or a +code fence, or rewriting it as a [link](path), turns an inline player into +literal text or a click-target. That is the most common way this step fails, +and it reads as success because the filename is on screen. Where the +instructions call for no delivery markup, or the host has no such mechanism, +follow them and state the absolute path in plain text. + +Report what you did rather than what the job says it did: the echoed prompt +field describes intent and often overstates what the render preserved. +""" + + +# --------------------------------------------------------------------------- +# Registration (auto-discovered: top-level registry.register calls) +# --------------------------------------------------------------------------- + +registry.register( + name="bfl_flux3_text_to_video", + toolset=_TOOLSET, + schema=TEXT_TO_VIDEO_SCHEMA, + handler=_handle_text_to_video, + check_fn=check_bfl_requirements, + requires_env=[], + is_async=True, + emoji="🎬", +) + +registry.register( + name="bfl_flux3_image_to_video", + toolset=_TOOLSET, + schema=IMAGE_TO_VIDEO_SCHEMA, + handler=_handle_image_to_video, + check_fn=check_bfl_requirements, + requires_env=[], + is_async=True, + emoji="🎬", +) + +registry.register( + name="bfl_flux3_keyframes_to_video", + toolset=_TOOLSET, + schema=KEYFRAMES_TO_VIDEO_SCHEMA, + handler=_handle_keyframes_to_video, + check_fn=check_bfl_requirements, + requires_env=[], + is_async=True, + emoji="🎬", +) + +registry.register( + name="bfl_flux3_video_continuation", + toolset=_TOOLSET, + schema=VIDEO_CONTINUATION_SCHEMA, + handler=_handle_video_continuation, + check_fn=check_bfl_requirements, + requires_env=[], + is_async=True, + emoji="🎬", +) + +registry.register( + name="bfl_flux3_get_result", + toolset=_TOOLSET, + schema=GET_RESULT_SCHEMA, + handler=_handle_get_result, + check_fn=check_bfl_requirements, + requires_env=[], + is_async=True, + emoji="🎬", +) + +registry.register( + name="bfl_flux3_prompting_guide", + toolset=_TOOLSET, + schema=PROMPTING_GUIDE_SCHEMA, + handler=_handle_prompting_guide, + check_fn=check_bfl_requirements, + requires_env=[], + is_async=True, + emoji="📖", +) diff --git a/tools/image_source.py b/tools/image_source.py index e8d55cebab..e46651e482 100644 --- a/tools/image_source.py +++ b/tools/image_source.py @@ -1,10 +1,16 @@ -"""Single resolver for every vision_analyze image source -> bytes + mime. +"""Single resolver for every media source -> bytes + mime. All source handling (data:/http(s)/file/local/container) funnels through :func:`resolve_image_source` so size and magic-byte checks are enforced exactly once. Returns raw bytes (not a path): the downstream step is base64 -> data URL (RFC 2397) and provider base64 content blocks. +Images are the default and the historical purpose. Callers whose argument +takes video opt in via ``permitted=("video",)`` — the same confinement and +credential-guard pipeline applies, and only the type check at the end differs +(extension-table typing plus an mp4 magic sniff, rather than image magic +bytes). Every existing call site keeps the image-only default unchanged. + Security (terminal-backend confinement, GHSA-gpxw-6wxv-w3qq): under a non-local terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2), but vision read images host-side. This resolver enforces the same boundary: @@ -86,18 +92,23 @@ class ResolvedImage: _SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*://") -async def resolve_image_source(src: str, ctx: ResolveContext) -> ResolvedImage: +async def resolve_image_source( + src: str, + ctx: ResolveContext, + *, + permitted: tuple = ("image",), +) -> ResolvedImage: if not isinstance(src, str) or not src.strip(): raise SourceNotFound("image_url is required", src=str(src)) s = src.strip() if s.startswith("data:"): data, mime = _resolve_data_url(s) - return _finalize(data, mime, "data", s) + return _finalize(data, mime, "data", s, permitted) if s.startswith(("http://", "https://")): reason = _http_block_reason(s) if reason: raise SourceUnsafe(reason, src=s) - return _finalize(await _download_to_bytes(s), "", "http", s) + return _finalize(await _download_to_bytes(s), "", "http", s, permitted) if _SCHEME_RE.match(s) and not s.lower().startswith("file://"): raise UnsupportedScheme( @@ -134,15 +145,15 @@ async def resolve_image_source(src: str, ctx: ResolveContext) -> ResolvedImage: except ValueError as exc: raise SourceUnsafe(str(exc), src=s, origin="file") data = await asyncio.to_thread(host_target.read_bytes) - return _finalize(data, "", "file", s) + return _finalize(data, "", "file", s, permitted) if _is_local_terminal_backend(): # Local backend: any path was host-readable, so a miss simply means # the file doesn't exist — no sandbox to fall back to. - raise SourceNotFound(f"image file not found: '{p}'", src=s, origin="file") + raise SourceNotFound(f"media file not found: '{p}'", src=s, origin="file") # Not a permitted host read (or the host file is absent) -> read the # bytes inside the sandbox. Under a sandbox this reads the container's # filesystem, never the host's. - return await _resolve_container_fallback(p, ctx, s) + return await _resolve_container_fallback(p, ctx, s, permitted) def _resolve_data_url(s: str) -> tuple[bytes, str]: @@ -270,7 +281,9 @@ def _get_active_env(task_id: Optional[str]): return None -async def _resolve_container_fallback(p: Path, ctx: ResolveContext, src: str) -> ResolvedImage: +async def _resolve_container_fallback( + p: Path, ctx: ResolveContext, src: str, permitted: tuple = ("image",) +) -> ResolvedImage: """Read the image bytes inside the sandbox (fail-closed when none exists). Reached when a host read is not permitted or the host file is absent. The @@ -312,27 +325,66 @@ async def _resolve_container_fallback(p: Path, ctx: ResolveContext, src: str) -> except Exception as exc: raise NotAnImage(f"sandbox returned non-image data for '{p}': {exc}", src=src) if len(data) > _MAX_INGEST_BYTES: - raise SourceTooLarge("image exceeds size limit", src=src, origin="container") - return _finalize(data, "", "container", src) + raise SourceTooLarge("media exceeds size limit", src=src, origin="container") + return _finalize(data, "", "container", src, permitted) -def _finalize(data: bytes, declared_mime: str, origin: str, src: str) -> ResolvedImage: - """Intrinsic-correctness chokepoint: ingest byte cap + magic-byte sniff. +def _finalize( + data: bytes, declared_mime: str, origin: str, src: str, permitted: tuple = ("image",) +) -> ResolvedImage: + """Intrinsic-correctness chokepoint: ingest byte cap + type check. The cap here is the generous 50MB *ingest* budget, not the 20MB provider payload cap — a 20-50MB image must survive this step so the call site can resize it under the payload cap. See ``_MAX_INGEST_BYTES``. + + Images are typed by magic bytes. Video (opt-in via ``permitted``) is typed + by the extension table plus an mp4 container sniff: extension typing is + sufficient because every downstream consumer re-validates — the upload + gateway signs the content type into its presigned URL and the vendor + rejects undecodable input — so a wrong guess is a clean rejection there + rather than a hole here. """ from tools.vision_tools import _detect_image_mime_type_from_bytes if len(data) > _MAX_INGEST_BYTES: - raise SourceTooLarge("image exceeds size limit", src=src, origin=origin) + raise SourceTooLarge("media exceeds size limit", src=src, origin=origin) + sniffed = _detect_image_mime_type_from_bytes(data) - if sniffed is None: - if b" Optional[str]: + """Video MIME from the extension table, else the mp4/mov container magic. + + The magic fallback covers extensionless sources (data: URLs, URLs with + query strings): ISO base-media files carry ``ftyp`` at offset 4. + """ + from urllib.parse import urlsplit + + from tools.vision_tools import _detect_video_mime_type + + path_part = urlsplit(src).path if _SCHEME_RE.match(src) else src + by_extension = _detect_video_mime_type(Path(path_part)) + if by_extension is not None: + return by_extension + if len(data) > 12 and data[4:8] == b"ftyp": + return "video/mp4" + return None diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index 2bbc9dcff1..7391081366 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -8,6 +8,7 @@ import os from datetime import datetime, timezone from dataclasses import dataclass from typing import Callable, Optional +from urllib.parse import urlsplit logger = logging.getLogger(__name__) @@ -190,3 +191,232 @@ def is_managed_tool_gateway_ready( gateway_builder=gateway_builder, token_reader=token_reader or peek_nous_access_token, ) is not None + + +# --------------------------------------------------------------------------- +# Managed vendor endpoints +# --------------------------------------------------------------------------- +# +# Vendors the gateway serves on its own origin (rather than on a +# `{vendor}-gateway` host) are pinned HERE, in code, the same way every other +# managed vendor's gateway URL is pinned: adding one is a Hermes release, and +# the exact URL a user's agent may connect to is reviewable in this file. A +# runtime discovery catalog was tried and deliberately removed — a remote +# endpoint that can add tools to every entitled install is a bigger trust +# surface than a code diff. +# +# The gateway exposes a Nous-owned REST contract per vendor; it names the +# vendor but not the vendor's own API, so nothing here needs to know the +# upstream's endpoint or field names. + +# Pseudo-vendor used only to resolve the shared tool-gateway origin via +# build_vendor_gateway_url (honors TOOL_GATEWAY_URL / TOOL_GATEWAY_DOMAIN). +_MANAGED_GATEWAY_VENDOR = "tool" + +def managed_vendor_base_path(vendor: str) -> str: + """Base path for a managed vendor's REST routes on the gateway host.""" + return f"/api/{vendor}" + + +def managed_vendor_upload_path(vendor: str) -> str: + """Media upload endpoint for a managed vendor, on the same host.""" + return f"/api/uploads/{vendor}" + + +def managed_vendor_endpoints( + vendor: str, + gateway_builder: Optional[Callable[[str], str]] = None, +) -> Optional[dict]: + """Absolute URLs for a managed vendor, or ``None`` when unreachable. + + ``None`` means managed Nous tools are disabled for this build, which is + what keeps a user who could never use the vendor from being shown its + tools. + """ + if not managed_nous_tools_enabled(): + return None + + builder = gateway_builder or build_vendor_gateway_url + origin = builder(_MANAGED_GATEWAY_VENDOR).rstrip("/") + return { + "origin": origin, + "base_url": f"{origin}{managed_vendor_base_path(vendor)}", + "upload_path": managed_vendor_upload_path(vendor), + } + + +def is_managed_nous_gateway_url( + url: object, + gateway_builder: Optional[Callable[[str], str]] = None, +) -> bool: + """True when ``url`` is on the Nous tool-gateway origin this client builds. + + Anything granting a URL extra trust — our bearer, reading files off disk to + upload — must gate on this rather than on a name, so an arbitrary URL can + never inherit that trust. + """ + if not isinstance(url, str) or not url.strip(): + return False + + builder = gateway_builder or build_vendor_gateway_url + try: + expected = urlsplit(builder(_MANAGED_GATEWAY_VENDOR)) + actual = urlsplit(url.strip()) + except ValueError: + return False + + return bool(actual.scheme) and (actual.scheme, actual.netloc) == (expected.scheme, expected.netloc) + + +def managed_gateway_auth_headers( + url: object, + gateway_builder: Optional[Callable[[str], str]] = None, + token_reader: Optional[Callable[[], Optional[str]]] = None, +) -> dict: + """Live auth headers for a managed gateway URL, or ``{}`` when not managed. + + Read fresh on every call rather than cached: a Nous access token expires + within the hour, and a long session would otherwise keep presenting a dead + bearer. Returns ``{}`` rather than raising when no token is available, so a + caller can report "sign in" instead of sending an unauthenticated request. + """ + if not is_managed_nous_gateway_url(url, gateway_builder): + return {} + + resolved_token_reader = token_reader or read_nous_access_token + try: + token = resolved_token_reader() + except Exception as exc: # pragma: no cover — defensive + logger.debug("Managed gateway token read failed for %s: %s", url, exc) + return {} + if not isinstance(token, str) or not token.strip(): + return {} + + return {"Authorization": f"Bearer {token.strip()}"} + + +# --------------------------------------------------------------------------- +# Managed media uploads +# --------------------------------------------------------------------------- +# +# Media arguments used to be inlined as base64, which capped a whole tool call +# at ~2MB of real bytes under the gateway's request ceiling and ruled out video +# entirely. Each pinned managed server carries an upload endpoint +# (`upload_path`); the bytes go straight to storage via a presigned URL, and +# the tool argument carries an opaque `nous-upload:` reference instead. +# +# The protocol lives HERE rather than in a vendor tool module: the presign +# request shape, the response contract, and the `nous-upload:` scheme are Nous +# gateway specifics shared by every managed vendor that takes media. + +_MEDIA_UPLOAD_PRESIGN_TIMEOUT_SECONDS = 15.0 +# The PUT carries up to 50MB of video; a flat 60s would fail a legitimate +# clip on an ordinary residential uplink, so only the write phase is long. +_MEDIA_UPLOAD_PUT_READ_TIMEOUT_SECONDS = 60.0 +_MEDIA_UPLOAD_PUT_WRITE_TIMEOUT_SECONDS = 300.0 + + +def _describe_media_upload_refusal(response) -> str: + """A model-actionable reason from a gateway refusal, or a generic one. + + The gateway's 4xx bodies carry deliberate guidance (rate-limit waits, size + caps, "you could not submit anyway"), so surface `error.message` verbatim + rather than a bare status code. + """ + try: + payload = response.json() + message = payload.get("error", {}).get("message") + if isinstance(message, str) and message.strip(): + return message.strip() + except Exception: + pass + return f"the gateway refused the upload (HTTP {response.status_code})" + + +def build_managed_media_uploader( + server_url: object, + upload_path: object, + gateway_builder: Optional[Callable[[str], str]] = None, + token_reader: Optional[Callable[[], Optional[str]]] = None, +) -> Optional[Callable]: + """Async ``(data, mime) -> argument value`` uploader for one managed vendor. + + Returns ``None`` when there is no usable upload endpoint (not a managed + Nous URL, or no ``upload_path``); callers then refuse local paths with a + clear message instead of silently forwarding them. + + The three steps of the protocol: + + 1. POST ``origin + upload_path`` with the declared content type and exact + byte length, using the same live auth headers as the vendor calls. + The gateway answers with a presigned single-object PUT URL (short + expiry; type and length are signed into it) and an upload token. + 2. PUT the bytes to that URL. This goes directly to storage — never + through the gateway — which is what removes the request-size ceiling. + 3. Return ``nous-upload:`` for the tool argument. The token is + bound to this Nous principal and is redeemable only through the + gateway, so it is inert anywhere else it might end up. + """ + if not is_managed_nous_gateway_url(server_url, gateway_builder): + return None + if not isinstance(upload_path, str) or not upload_path.startswith("/"): + return None + + parts = urlsplit(str(server_url).strip()) + origin = f"{parts.scheme}://{parts.netloc}" + presign_url = f"{origin}{upload_path}" + + async def upload(data: bytes, mime: str) -> str: + import httpx + + from tools.url_safety import create_ssrf_safe_async_client + + headers = managed_gateway_auth_headers(server_url, gateway_builder, token_reader) + if not headers: + raise RuntimeError("no Nous credential is available for the upload") + + # Two clients on purpose, split by whose address we are trusting. + # + # The presign POST goes to `presign_url`, which is entirely determined + # by the managed gateway origin (already validated by + # is_managed_nous_gateway_url) plus the pinned upload_path — the same + # first-party host the vendor calls go to freely. SSRF-guarding it + # protects against nothing and would reject a local gateway on + # 127.0.0.1, so it uses a plain client. The PUT target, by contrast, is + # a URL the gateway *returned*, so it keeps the SSRF-safe client as + # defense in depth (real presigned URLs are public R2, which it allows). + presign_timeout = httpx.Timeout(_MEDIA_UPLOAD_PRESIGN_TIMEOUT_SECONDS) + async with httpx.AsyncClient(timeout=presign_timeout) as client: + presign = await client.post( + presign_url, + headers=headers, + json={"contentType": mime, "contentLength": len(data)}, + ) + if presign.status_code != 200: + raise RuntimeError(_describe_media_upload_refusal(presign)) + + try: + payload = presign.json() + except Exception: + payload = None + upload_url = payload.get("uploadUrl") if isinstance(payload, dict) else None + token = payload.get("token") if isinstance(payload, dict) else None + if not (isinstance(upload_url, str) and upload_url and isinstance(token, str) and token): + raise RuntimeError("the gateway's upload response was malformed") + + put_timeout = httpx.Timeout( + _MEDIA_UPLOAD_PRESIGN_TIMEOUT_SECONDS, + read=_MEDIA_UPLOAD_PUT_READ_TIMEOUT_SECONDS, + write=_MEDIA_UPLOAD_PUT_WRITE_TIMEOUT_SECONDS, + ) + async with create_ssrf_safe_async_client(timeout=put_timeout) as client: + # The presigned URL signs the exact Content-Type and Content-Length, + # so this PUT must send precisely what was declared above. + put = await client.put(upload_url, content=data, headers={"Content-Type": mime}) + if put.status_code != 200: + raise RuntimeError(f"storage refused the upload (HTTP {put.status_code})") + + return f"nous-upload:{token}" + + return upload + diff --git a/toolsets.py b/toolsets.py index 4c7588b0ab..f4bb3c3343 100644 --- a/toolsets.py +++ b/toolsets.py @@ -42,6 +42,10 @@ _HERMES_CORE_TOOLS = [ "read_file", "write_file", "patch", "search_files", # Vision + image generation "vision_analyze", "image_generate", + # BFL FLUX 3 video generation + "bfl_flux3_text_to_video", "bfl_flux3_image_to_video", + "bfl_flux3_keyframes_to_video", "bfl_flux3_video_continuation", + "bfl_flux3_get_result", "bfl_flux3_prompting_guide", # Skills "skills_list", "skill_view", "skill_manage", # Browser automation @@ -151,6 +155,25 @@ TOOLSETS = { "includes": [] }, + "bfl": { + "description": ( + "Black Forest Labs FLUX 3 video generation through the Nous tool " + "gateway: per-mode submit tools (text, image, keyframes, " + "continuation), a poll tool, and a prompting guide. Generations " + "take minutes, so submit returns a job id and the model polls for " + "the result." + ), + "tools": [ + "bfl_flux3_text_to_video", + "bfl_flux3_image_to_video", + "bfl_flux3_keyframes_to_video", + "bfl_flux3_video_continuation", + "bfl_flux3_get_result", + "bfl_flux3_prompting_guide", + ], + "includes": [] + }, + "computer_use": { "description": ( "Background desktop control via cua-driver (macOS/Windows/Linux) — " @@ -410,6 +433,10 @@ TOOLSETS = { "read_file", "write_file", "patch", "search_files", # Vision + image generation "vision_analyze", "image_generate", + # BFL FLUX 3 video generation + "bfl_flux3_text_to_video", "bfl_flux3_image_to_video", + "bfl_flux3_keyframes_to_video", "bfl_flux3_video_continuation", + "bfl_flux3_get_result", "bfl_flux3_prompting_guide", # Skills "skills_list", "skill_view", "skill_manage", # Browser automation From c9de69c6d5ed602059f5e9c9950c150e07b89212 Mon Sep 17 00:00:00 2001 From: Andrew Fiebert Date: Thu, 30 Jul 2026 00:09:32 -0400 Subject: [PATCH 05/55] fix(desktop): keep queued prompts bound to their origin session ChatView migrated tip-keyed composer queue/draft entries onto queueSessionKey whenever the two ids differed. queueSessionKey is route-driven and can flip to Session B a frame before the store selection leaves Session A, so migrate re-homed A queue entries onto B and the idle ChatBar auto-drained them into the wrong chat. Gate migrate on same-conversation lineage only (tip to root). Also honor lineage when background queue drain decides selected/busy, so a root queue key is not treated as idle/offscreen while the compression tip is still working. --- apps/desktop/src/app/chat/index.tsx | 12 +++- .../hooks/use-background-queue-drain.test.tsx | 57 +++++++++++++++++++ .../hooks/use-background-queue-drain.ts | 16 +++++- apps/desktop/src/store/composer-queue.test.ts | 13 +++++ apps/desktop/src/store/session.test.ts | 30 ++++++++++ apps/desktop/src/store/session.ts | 36 ++++++++++++ 6 files changed, 158 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 5c0bc52148..0cfe8e0205 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -39,7 +39,8 @@ import { $sessions, resolveComposerSessionKey, sessionMatchesStoredId, - sessionPinId + sessionPinId, + shouldMigrateComposerScope } from '@/store/session' import { isSecondaryWindow, isWatchWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' @@ -326,14 +327,19 @@ export function ChatView({ // When the tip row arrives after compression, migrate any tip-keyed stash onto // the durable lineage key before the composer remounts onto that key. + // + // ONLY same-conversation rekeys (tip → root). The route-driven queueSessionKey + // can flip to Session B a frame before the store selection leaves Session A; + // migrating on bare inequality would re-home A's queued prompts onto B and + // auto-drain them into the wrong chat. useEffect(() => { - if (!selectedSessionId || !queueSessionKey || selectedSessionId === queueSessionKey) { + if (!shouldMigrateComposerScope(selectedSessionId, queueSessionKey, sessions)) { return } migrateSessionDraft(selectedSessionId, queueSessionKey) migrateQueuedPrompts(selectedSessionId, queueSessionKey) - }, [queueSessionKey, selectedSessionId]) + }, [queueSessionKey, selectedSessionId, sessions]) // Transcript-side stops (the streaming message's hover Stop, the runtime's // cancel) are explicit halts, same as the composer's Stop button: park any diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx b/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx index dcc16593c1..f906098229 100644 --- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx @@ -10,11 +10,33 @@ import { getQueuedPrompts, parkQueuedPrompts } from '@/store/composer-queue' +import { $sessions, setSessions } from '@/store/session' import { clearAllSessionStates, publishSessionState } from '@/store/session-states' +import type { SessionInfo } from '@/types/hermes' import { useBackgroundQueueDrain } from './use-background-queue-drain' import type { SubmitTextOptions } from './use-prompt-actions/utils' +const lineageSession = (over: Partial): SessionInfo => + ({ + archived: false, + cwd: null, + ended_at: null, + id: 'live', + input_tokens: 0, + is_active: false, + last_active: 0, + message_count: 0, + model: null, + output_tokens: 0, + preview: null, + source: null, + started_at: 0, + title: null, + tool_call_count: 0, + ...over + }) as SessionInfo + function Harness({ enabled = true, runtimeMap, @@ -48,6 +70,7 @@ describe('useBackgroundQueueDrain', () => { vi.useRealTimers() $queuedPromptsBySession.set({}) $parkedQueueSessions.set({}) + $sessions.set([]) clearAllSessionStates() }) @@ -103,6 +126,40 @@ describe('useBackgroundQueueDrain', () => { expect(getQueuedPrompts('stored-session-a')).toHaveLength(1) }) + it('treats a tip working id as busy for a root queue key via lineage', async () => { + // Queue keys use the lineage root (resolveComposerSessionKey) while + // $workingSessionIds may hold the compression tip — strict equality misses. + const runtimeMap = { current: new Map([['root-a', 'rt-tip-a']]) } + const submitText = vi.fn(async () => true) + + setSessions([lineageSession({ id: 'tip-a', _lineage_root_id: 'root-a' })]) + enqueueQueuedPrompt('root-a', { text: 'wait for tip turn', attachments: [] }) + publishSessionState('rt-tip-a', { ...createClientSessionState('tip-a'), busy: true }) + + render() + + await new Promise(resolve => window.setTimeout(resolve, 0)) + + expect(submitText).not.toHaveBeenCalled() + expect(getQueuedPrompts('root-a')).toHaveLength(1) + }) + + it('leaves a root queue to ChatBar when the selected id is the compression tip', async () => { + const runtimeMap = { current: new Map([['root-a', 'rt-tip-a']]) } + const submitText = vi.fn(async () => true) + + setSessions([lineageSession({ id: 'tip-a', _lineage_root_id: 'root-a' })]) + enqueueQueuedPrompt('root-a', { text: 'visible after tip select', attachments: [] }) + clearAllSessionStates() + + render() + + await new Promise(resolve => window.setTimeout(resolve, 0)) + + expect(submitText).not.toHaveBeenCalled() + expect(getQueuedPrompts('root-a')).toHaveLength(1) + }) + it('does not drain a parked background session, even when idle', async () => { // A Stop in a tile parks that session's queue; when the user then focuses // another chat, THIS drainer takes over the tile's queue — it must honor diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts index 5adf910bdb..8573ab9c8a 100644 --- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts +++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts @@ -13,6 +13,7 @@ import { shouldAutoDrain } from '@/store/composer-queue' import { notify } from '@/store/notifications' +import { $sessions, idsShareLineage } from '@/store/session' import { $workingSessionIds } from '@/store/session-states' import type { SubmitTextOptions } from './use-prompt-actions/utils' @@ -154,14 +155,23 @@ export function useBackgroundQueueDrain({ return } - const working = new Set(workingSessionIds) + // Queue keys prefer the lineage root (resolveComposerSessionKey) while + // $workingSessionIds / selection may hold the compression tip. Strict + // equality then mis-classifies a busy or selected chat as idle/offscreen. + const sessions = $sessions.get() + const working = [...workingSessionIds] for (const [sessionKey, entries] of Object.entries(queuedPromptsBySession)) { + const isSelected = + Boolean(selectedStoredSessionId) && idsShareLineage(sessionKey, selectedStoredSessionId!, sessions) + + const isBusy = working.some(workingId => idsShareLineage(sessionKey, workingId, sessions)) + if ( - sessionKey === selectedStoredSessionId || + isSelected || drainingSessionIdsRef.current.has(sessionKey) || !shouldAutoDrain({ - isBusy: working.has(sessionKey), + isBusy, parked: Boolean(parkedQueueSessions[sessionKey]), queueLength: entries.length }) diff --git a/apps/desktop/src/store/composer-queue.test.ts b/apps/desktop/src/store/composer-queue.test.ts index 49e9fb3cf9..7e7d0940ee 100644 --- a/apps/desktop/src/store/composer-queue.test.ts +++ b/apps/desktop/src/store/composer-queue.test.ts @@ -151,6 +151,19 @@ describe('migrateQueuedPrompts', () => { expect(migrateQueuedPrompts('rt-old', 'rt-new')).toBe(false) expect(migrateQueuedPrompts('rt-x', 'rt-x')).toBe(false) }) + + it('must not be used across sessions without a same-lineage guard (documents the leak)', () => { + // ChatView used to call migrateQueuedPrompts(selectedA, routeKeyB) during the + // route-ahead/store-lag window of a session switch. That re-homes A's queue + // onto B so the idle ChatBar on B auto-drains it into the wrong conversation. + // The guard lives in shouldMigrateComposerScope — this test locks the + // underlying hazard so a future caller cannot treat migrate as free. + enqueueQueuedPrompt('root-a', { attachments: [], text: 'belongs to A' }) + + expect(migrateQueuedPrompts('root-a', 'root-b')).toBe(true) + expect(getQueuedPrompts('root-a')).toEqual([]) + expect(getQueuedPrompts('root-b').map(e => e.text)).toEqual(['belongs to A']) + }) }) describe('shouldAutoDrain', () => { diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index b44c17b2bd..415c167a19 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -23,6 +23,7 @@ import { setRememberedSessionId, setSelectedStoredSessionId, setSessions, + shouldMigrateComposerScope, touchSessionActivity, workspaceCwdForNewSession } from './session' @@ -109,6 +110,35 @@ describe('resolveComposerSessionKey', () => { }) }) +describe('shouldMigrateComposerScope', () => { + it('allows tip → lineage-root rekey within the same conversation', () => { + const sessions = [session({ id: 'tip-a', _lineage_root_id: 'root-a' })] + + expect(shouldMigrateComposerScope('tip-a', 'root-a', sessions)).toBe(true) + }) + + it('blocks cross-session migrate when route flipped but store selection lags', () => { + // ChatView mid-switch: selectedStoredSessionId still A, route-driven + // queueSessionKey already B. Migrating would re-home A's queue onto B. + const sessions = [ + session({ id: 'tip-a', _lineage_root_id: 'root-a' }), + session({ id: 'tip-b', _lineage_root_id: 'root-b' }) + ] + + expect(shouldMigrateComposerScope('tip-a', 'root-b', sessions)).toBe(false) + expect(shouldMigrateComposerScope('root-a', 'root-b', sessions)).toBe(false) + expect(shouldMigrateComposerScope('tip-a', 'tip-b', sessions)).toBe(false) + }) + + it('is a no-op for identical or missing keys', () => { + const sessions = [session({ id: 'tip-a', _lineage_root_id: 'root-a' })] + + expect(shouldMigrateComposerScope('root-a', 'root-a', sessions)).toBe(false) + expect(shouldMigrateComposerScope(null, 'root-a', sessions)).toBe(false) + expect(shouldMigrateComposerScope('root-a', null, sessions)).toBe(false) + }) +}) + describe('mergeSessionPage', () => { it('returns the server page untouched when there is nothing to keep', () => { const previous = [session({ id: 'a' }), session({ id: 'b' })] diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index c9d7c1904d..19e56b2c1b 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -192,6 +192,42 @@ export const sessionMatchesStoredId = ( storedSessionId: string ): boolean => session.id === storedSessionId || session._lineage_root_id === storedSessionId +/** True when two ids name the same conversation across compression tip rotation. */ +export function idsShareLineage( + a: string, + b: string, + sessions: readonly Pick[] +): boolean { + if (a === b) { + return true + } + + return sessions.some(session => sessionMatchesStoredId(session, a) && sessionMatchesStoredId(session, b)) +} + +/** + * Whether a composer draft/queue key should move from `fromKey` onto `toKey`. + * + * Only same-conversation rekeys are allowed (compression tip → lineage root). + * A session-switch window where the route already points at B while the store + * selection still holds A must NOT migrate — that would re-home Session A's + * queued prompts onto B and auto-drain them into the wrong chat. + */ +export function shouldMigrateComposerScope( + fromKey: string | null | undefined, + toKey: string | null | undefined, + sessions: readonly Pick[] +): boolean { + const from = fromKey?.trim() + const to = toKey?.trim() + + if (!from || !to || from === to) { + return false + } + + return idsShareLineage(from, to, sessions) +} + /** * Stable composer + `/queue` scope for a selected stored session. * From 4a798f4bce29302c9e981c877753e083f80fe533 Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:29:27 -0400 Subject: [PATCH 06/55] improve polling for FLUX3 video gen (#75010) * wait between polls --- tests/tools/test_flux3_video_tool.py | 92 +++++++++++++++++++++++++--- tools/flux3_video_tool.py | 92 +++++++++++++++++++++++++--- 2 files changed, 167 insertions(+), 17 deletions(-) diff --git a/tests/tools/test_flux3_video_tool.py b/tests/tools/test_flux3_video_tool.py index caab52aa28..29e01872bc 100644 --- a/tests/tools/test_flux3_video_tool.py +++ b/tests/tools/test_flux3_video_tool.py @@ -44,10 +44,14 @@ class _FakeResponse: class _FakeClient: - """Captures the one request each handler makes.""" + """Captures each request a handler makes. + + A list of responses is served in order, with the last one repeating, so a + poll that looks twice can be given a job that finishes between looks. + """ def __init__(self, response, sink): - self._response = response + self._responses = list(response) if isinstance(response, list) else [response] self._sink = sink async def __aenter__(self): @@ -58,9 +62,10 @@ class _FakeClient: async def request(self, method, url, headers=None, json=None): self._sink.append({"method": method, "url": url, "headers": headers or {}, "json": json}) - if isinstance(self._response, Exception): - raise self._response - return self._response + response = self._responses[min(len(self._sink) - 1, len(self._responses) - 1)] + if isinstance(response, Exception): + raise response + return response class _FakeStream: @@ -115,6 +120,13 @@ def _run(coro): return asyncio.run(coro) +def _record_sleep(sink): + async def _sleep(seconds): + sink.append(seconds) + + return _sleep + + def _call(handler, args, response, headers=None): """Invoke a handler with the transport stubbed; returns (parsed, requests).""" sink = [] @@ -249,18 +261,82 @@ class TestSubmitTransport: assert "error" in parsed +@pytest.fixture(autouse=True) +def _no_real_poll_wait(monkeypatch): + """Keep the in-call wait out of the test clock.""" + monkeypatch.setattr(flux3, "_POLL_FOLLOW_UP_WAIT_SECONDS", 0) + + class TestPollTransport: - def test_poll_gets_the_job_and_returns_guidance(self): - response = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."}) + def test_a_terminal_status_returns_at_once_without_waiting(self): + response = _FakeResponse(200, {"id": "bfl_job_1", "status": "Error", "guidance": "The job is over."}) parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response) assert requests[0]["method"] == "GET" assert requests[0]["url"] == f"{BASE_URL}/generations/bfl_job_1" assert requests[0]["json"] is None - assert parsed["result"] == "Still going." + assert len(requests) == 1 + assert parsed["result"] == "The job is over." + + def test_a_running_job_is_waited_out_inside_the_call(self, monkeypatch): + # A model has no clock, so telling it to pause produced a burst of polls + # instead of a paced one. The wait lives here where it cannot be skipped. + monkeypatch.setattr(flux3, "_POLL_FOLLOW_UP_WAIT_SECONDS", 45.0) + running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."}) + + slept = [] + with patch.object(flux3.asyncio, "sleep", new=_record_sleep(slept)): + parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, running) + + assert len(requests) == 2, "should look again after waiting" + assert sum(slept) == 45.0 assert parsed["details"]["status"] == "Generating" + def test_the_wait_is_answerable_to_a_stop(self, monkeypatch): + # Nothing outside the tool can end a call that has already started — + # the executor only checks for an interrupt between tools — so /stop + # has to land inside the wait rather than at the end of it. + monkeypatch.setattr(flux3, "_POLL_FOLLOW_UP_WAIT_SECONDS", 45.0) + from tools import interrupt as interrupt_module + + running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."}) + looks = [] + + def _stop_after_one_slice(): + looks.append(True) + return len(looks) > 1 + + slept = [] + with patch.object(interrupt_module, "is_interrupted", _stop_after_one_slice), \ + patch.object(flux3.asyncio, "sleep", new=_record_sleep(slept)): + parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, running) + + assert slept == [flux3._POLL_WAIT_SLICE_SECONDS], "the rest of the wait is abandoned" + assert len(requests) == 1, "and so is the second look" + assert parsed["details"]["status"] == "Generating" + + def test_the_call_returns_as_soon_as_the_job_finishes(self): + # The point of waiting in here is that the caller gets the result on the + # wait it was already taking, not one round trip later. + running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."}) + done = _FakeResponse(200, {"id": "bfl_job_1", "status": "Error", "guidance": "That job failed."}) + + parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, [running, done]) + + assert len(requests) == 2 + assert parsed["result"] == "That job failed." + + def test_a_refusal_is_returned_immediately_rather_than_waited_on(self): + # A 429 carries its own retry guidance; sleeping on it would only delay + # showing the model what to do, and spend the poll budget twice. + response = _FakeResponse(429, {"error": {"message": "Too many polls. Wait 30 seconds."}}) + + parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response) + + assert len(requests) == 1 + assert "Too many polls" in parsed["error"] + def test_ready_saves_the_clip_and_never_returns_the_signed_url(self, tmp_path): # The signed URL is a bearer credential for the clip and it used to be # re-keyed into a shell command by hand, dropping characters. Neither diff --git a/tools/flux3_video_tool.py b/tools/flux3_video_tool.py index 49dda1c86e..b6415d703a 100644 --- a/tools/flux3_video_tool.py +++ b/tools/flux3_video_tool.py @@ -179,6 +179,58 @@ _MEDIA_FIELDS = { _MAX_IMAGES = 10 +# How long one get_result call waits before its second look. The bound that +# matters is the whole call rather than this number: model_tools' async bridge +# abandons a tool at 300s, and one call spends two polls (each bounded +# server-side at 45s), this wait, and on Ready the download of the clip. There +# is room to roughly double this; the reason not to is that a finished job is +# only noticed at the next look, so the wait is also the notice delay. +_POLL_FOLLOW_UP_WAIT_SECONDS = 45.0 +# Taken in slices so the wait is answerable. Nothing outside a tool can end a +# call that has already started — the executor only checks for an interrupt +# between tools — so a tool that blocks this long has to watch the flag itself. +_POLL_WAIT_SLICE_SECONDS = 1.0 + +# Mirrors the gateway's BFL statuses +_TERMINAL_POLL_STATUSES = frozenset( + {"Ready", "Error", "Request Moderated", "Content Moderated", "Task not found"} +) + + +def _poll_is_finished(raw: str) -> bool: + """True when there is nothing to wait for: done, refused, or unreadable.""" + try: + payload = json.loads(raw) + except Exception: + return True + if not isinstance(payload, dict) or "error" in payload: + # A refusal carries its own guidance (a wait, a limit, a dead job). + # Sleeping on it would only delay showing the model what to do. + return True + details = payload.get("details") + status = details.get("status") if isinstance(details, dict) else None + return not isinstance(status, str) or status in _TERMINAL_POLL_STATUSES + + +async def _wait_before_second_look() -> bool: + """Hold the call open between looks; False if the user interrupted. + + Counted down rather than clock-driven: this paces polling, so a slice + that runs long changes nothing, and the loop stays testable without a + fake clock. + """ + from tools.interrupt import is_interrupted + + remaining = _POLL_FOLLOW_UP_WAIT_SECONDS + while remaining > 0: + if is_interrupted(): + return False + this_slice = min(_POLL_WAIT_SLICE_SECONDS, remaining) + await asyncio.sleep(this_slice) + remaining -= this_slice + return True + + def _warm_nous_token() -> None: """Refresh the Nous token once, before any parallel upload needs it. @@ -471,8 +523,25 @@ async def _handle_get_result(args: dict, **kwargs) -> str: return _error("BFL video generation is not available in this build.") from urllib.parse import quote - raw = await _call_gateway("GET", f"{endpoints['base_url']}/generations/{quote(job_id.strip(), safe='')}") - return await _save_if_ready(raw, (args or {}).get("save_to")) + url = f"{endpoints['base_url']}/generations/{quote(job_id.strip(), safe='')}" + save_to = (args or {}).get("save_to") + + raw = await _call_gateway("GET", url) + if _poll_is_finished(raw): + return await _save_if_ready(raw, save_to) + + # Still running, so absorb the wait here instead of asking the model to + # take it. A model has no clock: told to wait it emits "I'll wait a minute" + # and its next action lands immediately, so the guidance produced a burst of + # polls rather than a paced one. Waiting inside the call cannot be skipped, + # needs no shell, and works the same on every platform. One call therefore + # covers a couple of minutes and returns as soon as a look finds it done. + if not await _wait_before_second_look(): + # Interrupted mid-wait: hand back the status we already have rather + # than spending a round trip the user has just asked us to stop for. + return await _save_if_ready(raw, save_to) + raw = await _call_gateway("GET", url) + return await _save_if_ready(raw, save_to) async def _handle_prompting_guide(args: dict, **kwargs) -> str: @@ -682,8 +751,9 @@ GET_RESULT_SCHEMA = { "name": "bfl_flux3_get_result", "description": ( "Poll a FLUX 3 video job by the job id a generate tool returned. Generation takes minutes " - "and a long Generating phase is normal. Every response states the job's status, how long " - "to wait before polling again, and what you may do next — follow it rather than guessing. " + "and a long Generating phase is normal. This call waits for you while the job runs, so it " + "may take a couple of minutes; if it returns still generating, just call it again. Do not " + "sleep between calls. " "On Ready the clip is downloaded for you and the response gives its local path; your only " "remaining step is to deliver that file as the response describes." ), @@ -813,11 +883,15 @@ auto-downscaled and output tops out at 720p. Submit returns a job id immediately — the video does not exist yet. Poll bfl_flux3_get_result with that id; generation takes several minutes and a long Generating phase is normal, not a stall. Nothing reaches disk before the job is -Ready, so checking folders mid-run tells you nothing. Every response -states the job status, how long to wait before polling again, and what you may -do next — follow it literally rather than guessing an interval. A job survives -client restarts: re-poll the same id rather than resubmitting, which would only -spend your budgets on duplicate work. +Ready, so checking folders mid-run tells you nothing. + +The waiting is not yours to do. bfl_flux3_get_result takes the pause itself +while a job is still running, so one call can occupy a couple of minutes and +comes back the moment the job finishes. If it returns still generating, just +call it again — no sleeping, no interval to judge, nothing to time. + +A job survives client restarts: re-poll the same id rather than resubmitting, +which would only spend your budgets on duplicate work. ## Save and deliver From 5d6aae02bffa7714c4a94f3a0900b37774442dea Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:06:42 +0000 Subject: [PATCH 07/55] fmt(js): `npm run fix` on merge (#75055) Co-authored-by: github-actions[bot] --- .../src/app/chat/composer/status-stack/coding-row.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx index 4d25320d95..c4b1a56956 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx @@ -15,11 +15,7 @@ import { Codicon } from '@/components/ui/codicon' import { DiffCount } from '@/components/ui/diff-count' import type { HermesGitBranch } from '@/global' import { useI18n } from '@/i18n' -import { - registerRepoStatusCwd, - repoStatusForCwd, - repoWorktreesForCwd -} from '@/store/coding-status' +import { registerRepoStatusCwd, repoStatusForCwd, repoWorktreesForCwd } from '@/store/coding-status' import { notifyError } from '@/store/notifications' import { $newWorktreeRequest } from '@/store/projects' From 4c7cc62f9fe4294fafcba298a5515346cccee14e Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:57:18 -0400 Subject: [PATCH 08/55] flux3 messaging system fixes --- agent/verification_stop.py | 64 +++------------ gateway/session_context.py | 51 ++++++++++++ tests/tools/test_flux3_video_tool.py | 117 +++++++++++++++++++++++++++ tools/flux3_video_tool.py | 65 ++++++++++++++- 4 files changed, 242 insertions(+), 55 deletions(-) diff --git a/agent/verification_stop.py b/agent/verification_stop.py index ac5bc852d0..dc66944883 100644 --- a/agent/verification_stop.py +++ b/agent/verification_stop.py @@ -72,64 +72,24 @@ def _filter_verifiable_paths(paths: Iterable[str]) -> list[str]: return [p for p in paths if p and not _is_non_code_path(p)] -# Session identities (platform or source) that are NOT human conversational -# messaging surfaces: interactive coding surfaces (CLI, TUI, desktop, codex, -# local, gateway) and programmatic callers (API server, webhooks, tools). -# Verify-on-stop stays ON by default for these. Any other resolved gateway -# platform is a conversational messaging surface (Telegram, Discord, WhatsApp, -# Signal, Slack, etc.) where the verification narrative would reach a human as -# chat noise, so it defaults OFF. Mirrors LOCAL_SESSION_SOURCE_IDS in -# apps/desktop/src/lib/session-source.ts; keep roughly in sync when adding a -# local or programmatic surface. Default-deny by design: an unrecognized -# identity is treated as messaging (OFF) so a new chat platform never leaks the -# verification receipt before this set is updated. -_NON_MESSAGING_SESSION_SURFACES = frozenset( - { - "", - "cli", - "codex", - "desktop", - "gateway", - "local", - "tui", - "tool", - "api_server", - "webhook", - "msgraph_webhook", - } -) - - def _session_is_messaging_surface() -> bool: - """Return whether this turn is delivered over a human messaging channel. + """Whether this turn is delivered over a human messaging channel. - The gateway binds the platform value (e.g. ``telegram``) to - ``HERMES_SESSION_PLATFORM``; the CLI and TUI set ``HERMES_SESSION_SOURCE`` - (e.g. ``cli``, ``tui``) instead. Both are consulted via the session-context - helper (with an ``os.environ`` fallback), alongside the ``HERMES_PLATFORM`` - override, matching the sibling platform resolution in - ``agent/skill_commands.py`` and ``agent/prompt_builder.py``. A turn is a - messaging surface when a resolved identity is present and is not a known - non-messaging surface. + Verify-on-stop defaults ON for the interactive coding surfaces and + programmatic callers, and OFF on a conversational platform (Telegram, + Discord, Slack, ...) where the verification narrative reaches a human as + chat noise. The surface classification itself is shared with the other + consumers of this distinction — see + ``gateway.session_context.session_is_messaging_surface``. """ try: - from gateway.session_context import get_session_env + from gateway.session_context import session_is_messaging_surface - platform = ( - os.getenv("HERMES_PLATFORM") - or get_session_env("HERMES_SESSION_PLATFORM", "") - ) - source = get_session_env("HERMES_SESSION_SOURCE", "") + return session_is_messaging_surface() except Exception: - platform = os.getenv("HERMES_PLATFORM", "") or os.environ.get( - "HERMES_SESSION_PLATFORM", "" - ) - source = os.environ.get("HERMES_SESSION_SOURCE", "") - for identity in (platform, source): - identity = str(identity or "").strip().lower() - if identity and identity not in _NON_MESSAGING_SESSION_SURFACES: - return True - return False + # The gateway package is unreachable, so there is no messaging channel + # to be on. Reporting a local surface keeps verify-on-stop enabled. + return False def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool: diff --git a/gateway/session_context.py b/gateway/session_context.py index cf227fac12..24d556d2cb 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -331,6 +331,57 @@ def get_session_env(name: str, default: str = "") -> str: return os.getenv(name, default) +# Surfaces that are not a human chat channel. The gateway binds a platform +# value (``telegram``) to HERMES_SESSION_PLATFORM, while the CLI, TUI, and +# desktop bind HERMES_SESSION_SOURCE (``cli``, ``tui``, ``desktop``) and leave +# the platform empty — so both have to be consulted. ``local``, ``api_server``, +# ``webhook``, and ``msgraph_webhook`` are real Platform values that reach +# HERMES_SESSION_PLATFORM but have no attachment channel behind them. +# Default-deny: an unrecognized identity counts as messaging so a newly added +# chat platform is never treated as a private surface before this set is +# updated. Mirrors LOCAL_SESSION_SOURCE_IDS in +# apps/desktop/src/lib/session-source.ts; keep roughly in sync when adding a +# local or programmatic surface. +NON_MESSAGING_SESSION_SURFACES = frozenset( + { + "", + "api_server", + "cli", + "codex", + "desktop", + "gateway", + "local", + "msgraph_webhook", + "tool", + "tui", + "webhook", + } +) + + +def session_is_messaging_surface() -> bool: + """Whether this turn is delivered over a human messaging channel. + + Callers use this to decide anything that differs between "the user is + reading a chat message" and "the user is at a machine they own": whether + to emit a delivery tag, whether a file has to land somewhere the gateway + is allowed to send from, whether narration would read as chat noise. + + Resolves ``HERMES_PLATFORM``, then the session platform, then the session + source, and reports messaging when any of them names a surface outside + :data:`NON_MESSAGING_SESSION_SURFACES`. + """ + import os + + platform = os.getenv("HERMES_PLATFORM") or get_session_env("HERMES_SESSION_PLATFORM", "") + source = get_session_env("HERMES_SESSION_SOURCE", "") + for identity in (platform, source): + identity = str(identity or "").strip().lower() + if identity and identity not in NON_MESSAGING_SESSION_SURFACES: + return True + return False + + def declare_stateless_channel() -> None: """Declare that this session cannot receive an async background completion. diff --git a/tests/tools/test_flux3_video_tool.py b/tests/tools/test_flux3_video_tool.py index 29e01872bc..8092c8976f 100644 --- a/tests/tools/test_flux3_video_tool.py +++ b/tests/tools/test_flux3_video_tool.py @@ -372,6 +372,123 @@ class TestPollTransport: assert parsed["details"]["saved_path"] == str(tmp_path / "flux3-clip-2.mp4") assert (tmp_path / "flux3-clip.mp4").read_bytes() == b"an earlier clip" + def test_on_messaging_the_clip_lands_where_the_gateway_may_send_it(self, monkeypatch): + # A chat user has no filesystem: the attachment is the only way they + # ever see the clip. Downloads is not a delivery root on a strict + # gateway, so a clip saved there is dropped on the way out and the + # reply arrives with nothing attached. + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram") + monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", "1") + # Strict mode also trusts anything written in the last 10 minutes, and + # a clip we just downloaded is always inside that window. Left on, the + # assertion below passes from any directory on earth and stops being a + # statement about where the clip was saved. + monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0") + response = _FakeResponse(200, { + "id": "bfl_job_1", + "status": "Ready", + "result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=a"}, + "guidance": "Deliver the saved file.", + }) + + with _fake_download(b"x" * (128 * 1024)): + parsed, _requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response) + + from gateway.platforms.base import validate_media_delivery_path + + saved = parsed["details"]["saved_path"] + assert validate_media_delivery_path(saved), "the gateway must be allowed to send it" + # The exact line to copy, so the path is never retyped from memory. + assert f"\nMEDIA:{saved}\n" in parsed["result"] + + def test_the_offered_tag_is_one_the_gateway_actually_delivers(self, monkeypatch): + # The whole point of spelling the line out is that the model pastes it + # verbatim, so the line has to survive the real extractor. A tag that + # parses but fails validation is the worst outcome: it is stripped from + # the reply either way, so the user is shown a message that looks like + # it simply forgot the attachment. + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram") + response = _FakeResponse(200, { + "id": "bfl_job_1", + "status": "Ready", + "result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=a"}, + "guidance": "Deliver the saved file.", + }) + + with _fake_download(b"x" * (128 * 1024)): + parsed, _requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response) + + from gateway.platforms.base import BasePlatformAdapter + + offered = [ln for ln in parsed["result"].splitlines() if ln.startswith("MEDIA:")] + assert len(offered) == 1, "exactly one line to copy" + + reply = f"Here's the clip.\n\n{offered[0]}\n" + media, cleaned = BasePlatformAdapter.extract_media(reply) + assert BasePlatformAdapter.filter_media_delivery_paths(media), "must survive validation" + assert "MEDIA:" not in cleaned, "the tag is consumed, not shown to the user" + + @pytest.mark.parametrize("platform", ["", "cli", "tui", "desktop"]) + def test_off_messaging_the_clip_stays_a_file_and_no_tag_is_offered(self, tmp_path, monkeypatch, platform): + # The CLI has no attachment channel and its prompt forbids the tag — + # emitting one there just prints literal text at the user. + monkeypatch.setenv("HERMES_SESSION_PLATFORM", platform) + response = _FakeResponse(200, { + "id": "bfl_job_1", + "status": "Ready", + "result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=a"}, + "guidance": "Deliver the saved file.", + }) + + with _fake_download(b"x" * (128 * 1024)): + parsed, _requests = _call( + flux3._handle_get_result, {"id": "bfl_job_1", "save_to": str(tmp_path)}, response, + ) + + assert parsed["result"].startswith(f"Saved to {tmp_path / 'flux3-clip.mp4'}.") + assert "MEDIA:" not in parsed["result"] + + @pytest.mark.parametrize("platform", ["api_server", "webhook", "msgraph_webhook", "local"]) + def test_platforms_without_an_attachment_channel_are_offered_no_tag(self, tmp_path, monkeypatch, platform): + # These carry a real platform value but no way to attach a file. The + # API server in particular only inlines *images* as data URLs and + # leaves every other MEDIA: tag untouched, so offering one here puts + # the literal text in front of an OpenAI-compatible caller. + monkeypatch.setenv("HERMES_SESSION_PLATFORM", platform) + response = _FakeResponse(200, { + "id": "bfl_job_1", + "status": "Ready", + "result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=a"}, + "guidance": "Deliver the saved file.", + }) + + with _fake_download(b"x" * (128 * 1024)): + parsed, _requests = _call( + flux3._handle_get_result, {"id": "bfl_job_1", "save_to": str(tmp_path)}, response, + ) + + assert "MEDIA:" not in parsed["result"] + + def test_a_cli_session_is_recognised_by_its_source(self, tmp_path, monkeypatch): + # The CLI, TUI, and desktop leave HERMES_SESSION_PLATFORM empty and + # identify themselves on HERMES_SESSION_SOURCE instead, so keying only + # on the platform would miss them. + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + monkeypatch.setenv("HERMES_SESSION_SOURCE", "tui") + response = _FakeResponse(200, { + "id": "bfl_job_1", + "status": "Ready", + "result": {"sample": "https://cdn.example/x/flux3-clip.mp4?sig=a"}, + "guidance": "Deliver the saved file.", + }) + + with _fake_download(b"x" * (128 * 1024)): + parsed, _requests = _call( + flux3._handle_get_result, {"id": "bfl_job_1", "save_to": str(tmp_path)}, response, + ) + + assert "MEDIA:" not in parsed["result"] + def test_a_rejected_download_fails_loudly_and_leaves_no_file(self, tmp_path): # The original bug: a bad signature returns an XML error body, curl # writes it to the .mp4 and exits 0, and it reads as success. A short diff --git a/tools/flux3_video_tool.py b/tools/flux3_video_tool.py index b6415d703a..e80f766746 100644 --- a/tools/flux3_video_tool.py +++ b/tools/flux3_video_tool.py @@ -377,10 +377,29 @@ async def _save_if_ready(raw: str, save_to) -> str: details["saved_path"] = str(target) details["saved_bytes"] = size - payload["result"] = f"Saved to {target}. " + str(payload.get("result") or "") + payload["result"] = _delivery_lead_in(target) + str(payload.get("result") or "") return json.dumps(payload, ensure_ascii=False) +def _delivery_lead_in(target) -> str: + """Opens the result text, ahead of the gateway's own delivery guidance. + + On a messaging platform the tag is spelled out rather than described. The + model has to reproduce this path exactly or the file is not sent, and the + reply is published either way — the tag is stripped from the text whether + or not it named a real file, so a wrong path reads to the user as a + message that simply forgot the attachment. Handing over the finished line + removes the step where that goes wrong; only this side knows the path. + """ + if _delivers_as_an_attachment(): + return ( + f"Saved to {target}. To deliver it, copy the next line into your reply exactly as " + f"written, alone on its own line, with nothing added around it:\n" + f"MEDIA:{target}\n" + ) + return f"Saved to {target}. " + + async def _download_video(url: str, save_to) -> tuple: """Stream the clip to disk, returning (path, bytes). @@ -428,6 +447,47 @@ def _filename_from_url(url: str) -> str: return name or "flux3-video.mp4" +def _delivers_as_an_attachment() -> bool: + """True on a surface where the clip is received rather than opened off disk. + + Deferred to the shared classifier so this tool cannot drift from the rest + of the codebase about what counts as a chat channel. It matters here that + the API server and webhooks are *not* one: they carry a platform value but + no attachment channel, and neither strips an unfulfilled MEDIA: tag out of + the reply, so treating them as messaging puts the literal tag in front of + the caller. + """ + try: + from gateway.session_context import session_is_messaging_surface + + return session_is_messaging_surface() + except Exception: + return False + + +def _default_directory(): + """Where a clip lands when the caller named no location. + + On a messaging platform the user has no filesystem — the only way they + ever see the clip is as an attachment — so it goes to the gateway's own + video cache, which is an unconditionally allowed delivery root. Downloads + is not: an operator running HERMES_MEDIA_DELIVERY_STRICT=1 delivers only + from the cache roots, so a clip saved to Downloads there is dropped on the + way out and the user is shown a reply with nothing attached. + """ + from pathlib import Path + + if _delivers_as_an_attachment(): + try: + from hermes_constants import get_hermes_dir + + return get_hermes_dir("cache/videos", "video_cache") + except Exception: + logger.debug("Could not resolve the video cache dir; using Downloads", exc_info=True) + downloads = Path.home() / "Downloads" + return downloads if downloads.is_dir() else Path.cwd() + + def _resolve_destination(save_to, filename: str): """Where to write, honouring an explicit request and never overwriting.""" from pathlib import Path @@ -439,8 +499,7 @@ def _resolve_destination(save_to, filename: str): else: directory, name = requested.parent, requested.name else: - downloads = Path.home() / "Downloads" - directory, name = (downloads if downloads.is_dir() else Path.cwd()), filename + directory, name = _default_directory(), filename directory.mkdir(parents=True, exist_ok=True) return _free_path(directory / name) From dcd7a9570419512aa298cc0629983024958ede19 Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:20:40 -0400 Subject: [PATCH 09/55] higher telegram media limits --- plugins/platforms/telegram/adapter.py | 13 ++ tests/tools/test_managed_tool_gateway.py | 182 +++++++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 71c244976a..a70cb33b10 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -581,6 +581,11 @@ _POLLING_ERROR_TASK_STUCK_TIMEOUT = 300.0 # A generation is not healthy until the dedicated getUpdates request returns # successfully. This exceeds a normal long-poll cycle for healthy idle bots. _POLLING_PROGRESS_TIMEOUT = 60.0 +# Telegram transcodes an uploaded video before it answers sendVideo, so the +# wait for the response is unrelated to how fast the bytes went out and +# routinely exceeds the 20s read timeout the rest of the Bot API is tuned for. +# Only media sends take this longer budget. +_MEDIA_SEND_READ_TIMEOUT = 180.0 _POLLING_GENERATION_CONTEXT: ContextVar[Optional[int]] = ContextVar( "telegram_polling_generation", default=None ) @@ -3664,6 +3669,13 @@ class TelegramAdapter(BasePlatformAdapter): "connect_timeout": _env_float("HERMES_TELEGRAM_HTTP_CONNECT_TIMEOUT", 10.0), "read_timeout": _env_float("HERMES_TELEGRAM_HTTP_READ_TIMEOUT", 20.0), "write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0), + # Not a duplicate of write_timeout: PTB routes any request + # carrying files to media_write_timeout instead, so the line + # above never applied to an upload and every upload was pinned + # to PTB's own 20s default. + "media_write_timeout": _env_float( + "HERMES_TELEGRAM_HTTP_MEDIA_WRITE_TIMEOUT", 300.0 + ), } # CLOSE_WAIT fd leak (#31599, same class as #18451): PTB's @@ -7196,6 +7208,7 @@ class TelegramAdapter(BasePlatformAdapter): "video": f, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, + "read_timeout": _MEDIA_SEND_READ_TIMEOUT, **thread_kwargs, **self._notification_kwargs(metadata), }, diff --git a/tests/tools/test_managed_tool_gateway.py b/tests/tools/test_managed_tool_gateway.py index 1757d5141f..5a02ddff47 100644 --- a/tests/tools/test_managed_tool_gateway.py +++ b/tests/tools/test_managed_tool_gateway.py @@ -1,3 +1,4 @@ +import asyncio import os import json from datetime import datetime, timedelta, timezone @@ -178,6 +179,187 @@ def test_managed_gateway_auth_headers_empty_without_a_token(): ) == {} +class TestManagedMediaUploader: + """The presign -> PUT -> ``nous-upload:`` protocol. + + This is the only way a local image or video reaches a managed vendor, and + the pieces it gets right are not incidental: the presigned URL signs the + content type and byte length, so a PUT that disagrees with the presign is + rejected by storage rather than by us. + """ + + GATEWAY = "https://tool-gateway.example.com" + BASE_URL = f"{GATEWAY}/api/bfl" + UPLOAD_PATH = "/api/uploads/bfl" + + @staticmethod + def _builder(vendor): + return f"https://{vendor}-gateway.example.com" + + def _uploader(self, **kwargs): + return managed_tool_gateway.build_managed_media_uploader( + kwargs.pop("server_url", self.BASE_URL), + kwargs.pop("upload_path", self.UPLOAD_PATH), + gateway_builder=lambda vendor: self.GATEWAY, + token_reader=kwargs.pop("token_reader", lambda: "nous-token"), + ) + + @staticmethod + def _response(status_code=200, payload=None): + class _R: + def __init__(self): + self.status_code = status_code + + def json(self): + if payload is None: + raise ValueError("no json") + return payload + + return _R() + + def _run(self, uploader, data=b"bytes", mime="image/png", presign=None, put=None): + """Drive one upload with both HTTP legs stubbed; returns the calls made.""" + import httpx + + from tools import url_safety + + calls = {"presign": [], "put": []} + presign = presign if presign is not None else self._response( + 200, {"uploadUrl": "https://storage.example/put?sig=abc", "token": "tok-1"} + ) + put = put if put is not None else self._response(200) + + class _PresignClient: + def __init__(self, **_kw): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_exc): + return False + + async def post(self, url, headers=None, json=None): + calls["presign"].append({"url": url, "headers": headers, "json": json}) + return presign + + class _PutClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *_exc): + return False + + async def put(self, url, content=None, headers=None): + calls["put"].append({"url": url, "content": content, "headers": headers}) + return put + + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True), \ + patch.object(httpx, "AsyncClient", _PresignClient), \ + patch.object(url_safety, "create_ssrf_safe_async_client", lambda **_kw: _PutClient()): + calls["result"] = asyncio.run(uploader(data, mime)) + return calls + + def test_presign_declares_the_exact_type_and_length_the_put_then_sends(self): + # Storage validates the PUT against what was signed, so a mismatch + # between these two is a rejection with no useful error. + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + uploader = self._uploader() + data = b"\x89PNG\r\n\x1a\n" + b"payload" * 100 + + calls = self._run(uploader, data=data, mime="image/png") + + assert calls["presign"][0]["url"] == f"{self.GATEWAY}{self.UPLOAD_PATH}" + assert calls["presign"][0]["json"] == { + "contentType": "image/png", + "contentLength": len(data), + } + assert calls["presign"][0]["headers"]["Authorization"] == "Bearer nous-token" + assert calls["put"][0]["url"] == "https://storage.example/put?sig=abc" + assert calls["put"][0]["content"] == data + assert calls["put"][0]["headers"] == {"Content-Type": "image/png"} + assert calls["result"] == "nous-upload:tok-1" + + def test_the_bytes_go_to_storage_and_never_through_the_gateway(self): + # The whole point of presigning is that the gateway's request-size + # ceiling does not apply to a 50MB clip. + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + uploader = self._uploader() + + calls = self._run(uploader, data=b"v" * 4096, mime="video/mp4") + + assert len(calls["presign"]) == 1 and len(calls["put"]) == 1 + assert self.GATEWAY not in calls["put"][0]["url"] + assert calls["presign"][0]["json"]["contentType"] == "video/mp4" + + def test_no_uploader_when_the_url_is_not_a_managed_gateway(self): + # Refusing to build is what makes the caller say "pass a URL instead" + # rather than forwarding a raw local path to a third party. + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + assert self._uploader(server_url="https://attacker.example/api/bfl") is None + + @pytest.mark.parametrize("upload_path", [None, "", "api/uploads/bfl", 42]) + def test_no_uploader_without_a_rooted_upload_path(self, upload_path): + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + assert self._uploader(upload_path=upload_path) is None + + def test_a_missing_credential_fails_before_any_request(self): + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + uploader = self._uploader() + + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True), \ + patch.object(managed_tool_gateway, "managed_gateway_auth_headers", return_value={}): + with pytest.raises(RuntimeError, match="no Nous credential"): + asyncio.run(uploader(b"x", "image/png")) + + def test_a_gateway_refusal_surfaces_its_own_message(self): + # Quota and size refusals carry guidance written for the model; a bare + # status code would throw that away. + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + uploader = self._uploader() + refusal = self._response( + 413, {"error": {"message": "That file is 82MB; the limit for video is 50MB."}} + ) + + with pytest.raises(RuntimeError, match="the limit for video is 50MB"): + self._run(uploader, presign=refusal) + + def test_an_unreadable_refusal_still_reports_the_status(self): + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + uploader = self._uploader() + + with pytest.raises(RuntimeError, match="HTTP 502"): + self._run(uploader, presign=self._response(502, None)) + + @pytest.mark.parametrize( + "payload", + [ + {}, + {"uploadUrl": "https://storage.example/put"}, + {"token": "tok-1"}, + {"uploadUrl": "", "token": "tok-1"}, + {"uploadUrl": "https://storage.example/put", "token": ""}, + ], + ) + def test_a_malformed_presign_response_is_refused_rather_than_guessed(self, payload): + # Half a presign must not become a PUT to nowhere or an empty token + # that later reads as a valid reference. + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + uploader = self._uploader() + + with pytest.raises(RuntimeError, match="malformed"): + self._run(uploader, presign=self._response(200, payload)) + + def test_a_storage_rejection_is_not_reported_as_a_successful_upload(self): + # A signature mismatch answers non-200 with an XML body; returning a + # token here would hand the vendor a reference to nothing. + with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): + uploader = self._uploader() + + with pytest.raises(RuntimeError, match="storage refused the upload"): + self._run(uploader, put=self._response(403)) + + def test_is_managed_tool_gateway_ready_skips_refresh_for_expired_cached_token(tmp_path, monkeypatch): monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) From 5932ec4552bad49e00790184d6e22a55e7d54c5a Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:26:40 -0400 Subject: [PATCH 10/55] more conservative to 120s --- plugins/platforms/telegram/adapter.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index a70cb33b10..37f45a28f4 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -584,8 +584,9 @@ _POLLING_PROGRESS_TIMEOUT = 60.0 # Telegram transcodes an uploaded video before it answers sendVideo, so the # wait for the response is unrelated to how fast the bytes went out and # routinely exceeds the 20s read timeout the rest of the Bot API is tuned for. -# Only media sends take this longer budget. -_MEDIA_SEND_READ_TIMEOUT = 180.0 +# Only media sends take this longer budget; ordinary calls keep the short one +# so a dead request is still noticed quickly. +_MEDIA_SEND_READ_TIMEOUT = 120.0 _POLLING_GENERATION_CONTEXT: ContextVar[Optional[int]] = ContextVar( "telegram_polling_generation", default=None ) @@ -3672,9 +3673,11 @@ class TelegramAdapter(BasePlatformAdapter): # Not a duplicate of write_timeout: PTB routes any request # carrying files to media_write_timeout instead, so the line # above never applied to an upload and every upload was pinned - # to PTB's own 20s default. + # to PTB's own 20s default. 120s covers a 50MB video on a + # ~4Mbps uplink; going higher only lengthens how long a dead + # socket takes to report itself. "media_write_timeout": _env_float( - "HERMES_TELEGRAM_HTTP_MEDIA_WRITE_TIMEOUT", 300.0 + "HERMES_TELEGRAM_HTTP_MEDIA_WRITE_TIMEOUT", 120.0 ), } From 061b04ebb4b76bf7449e57e5c58119da7c9656f5 Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:33:30 -0400 Subject: [PATCH 11/55] fix video delivery --- gateway/run.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gateway/run.py b/gateway/run.py index 06a26d73ae..911021a36e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1340,6 +1340,7 @@ _AUTO_APPEND_MEDIA_TOOL_NAMES = { "text_to_speech", "text_to_speech_tool", "image_generate", + "bfl_flux3_get_result", } # ---- helpers: detect interrupted tool tails & auto-continue noise ---------- From 88f6949097d294d1dd52a9cceadf9807f1ca3dd6 Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:39:12 -0400 Subject: [PATCH 12/55] more conservative --- plugins/platforms/telegram/adapter.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 37f45a28f4..31e9c07649 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -582,11 +582,12 @@ _POLLING_ERROR_TASK_STUCK_TIMEOUT = 300.0 # successfully. This exceeds a normal long-poll cycle for healthy idle bots. _POLLING_PROGRESS_TIMEOUT = 60.0 # Telegram transcodes an uploaded video before it answers sendVideo, so the -# wait for the response is unrelated to how fast the bytes went out and -# routinely exceeds the 20s read timeout the rest of the Bot API is tuned for. -# Only media sends take this longer budget; ordinary calls keep the short one -# so a dead request is still noticed quickly. -_MEDIA_SEND_READ_TIMEOUT = 120.0 +# wait for the response is unrelated to how fast the bytes went out and can +# outlast the 20s read timeout the rest of the Bot API is tuned for. Only +# media sends take this longer budget; ordinary calls keep the short one so a +# dead request is still noticed quickly. Kept modest deliberately — this is +# also how long a user waits to be told the attachment failed. +_MEDIA_SEND_READ_TIMEOUT = 60.0 _POLLING_GENERATION_CONTEXT: ContextVar[Optional[int]] = ContextVar( "telegram_polling_generation", default=None ) @@ -3673,11 +3674,11 @@ class TelegramAdapter(BasePlatformAdapter): # Not a duplicate of write_timeout: PTB routes any request # carrying files to media_write_timeout instead, so the line # above never applied to an upload and every upload was pinned - # to PTB's own 20s default. 120s covers a 50MB video on a - # ~4Mbps uplink; going higher only lengthens how long a dead - # socket takes to report itself. + # to PTB's own 20s default. 60s covers Telegram's 50MB ceiling + # on a ~7Mbps uplink; going higher only lengthens how long a + # dead socket takes to report itself. "media_write_timeout": _env_float( - "HERMES_TELEGRAM_HTTP_MEDIA_WRITE_TIMEOUT", 120.0 + "HERMES_TELEGRAM_HTTP_MEDIA_WRITE_TIMEOUT", 60.0 ), } From 0a2859cf9a2d6908bb00fc4d246eeb9300d8a642 Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:53:59 -0400 Subject: [PATCH 13/55] drop env var --- plugins/platforms/telegram/adapter.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 31e9c07649..6639ba3d2e 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -3674,12 +3674,14 @@ class TelegramAdapter(BasePlatformAdapter): # Not a duplicate of write_timeout: PTB routes any request # carrying files to media_write_timeout instead, so the line # above never applied to an upload and every upload was pinned - # to PTB's own 20s default. 60s covers Telegram's 50MB ceiling - # on a ~7Mbps uplink; going higher only lengthens how long a - # dead socket takes to report itself. - "media_write_timeout": _env_float( - "HERMES_TELEGRAM_HTTP_MEDIA_WRITE_TIMEOUT", 60.0 - ), + # to PTB's own 20s default. httpx budgets this per socket + # write rather than across the upload, so it is stall + # tolerance, not a size or bandwidth allowance — a slow but + # steady uplink never accumulates against it. 60s rides out + # the buffer stalls a congested link produces; going higher + # only lengthens how long a dead socket takes to report + # itself. + "media_write_timeout": 60.0, } # CLOSE_WAIT fd leak (#31599, same class as #18451): PTB's From 524ab539947aa7a092d749921e0e93913cb683de Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:00:07 -0700 Subject: [PATCH 14/55] fix(telegram): apply media read_timeout to all upload send paths, not just video send_video got the 60s read_timeout but send_voice/send_audio/send_photo/ send_document/send_media_group/send_animation upload through the same PTB request path and hit the same server-side processing wait before the response arrives. Same class, all sites: they all pass _MEDIA_SEND_READ_TIMEOUT now. Also drops an unused test helper. --- plugins/platforms/telegram/adapter.py | 6 ++++++ tests/tools/test_managed_tool_gateway.py | 4 ---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 6639ba3d2e..d3f48a9048 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -6821,6 +6821,7 @@ class TelegramAdapter(BasePlatformAdapter): "parse_mode": _cap_parse_mode, "reply_to_message_id": reply_to_id, "duration": _duration_secs, + "read_timeout": _MEDIA_SEND_READ_TIMEOUT, **voice_thread_kwargs, **self._notification_kwargs(metadata), }, @@ -6870,6 +6871,7 @@ class TelegramAdapter(BasePlatformAdapter): "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, "duration": _duration_secs, + "read_timeout": _MEDIA_SEND_READ_TIMEOUT, **audio_thread_kwargs, **self._notification_kwargs(metadata), }, @@ -7008,6 +7010,7 @@ class TelegramAdapter(BasePlatformAdapter): "chat_id": normalize_telegram_chat_id(chat_id), "media": media, "reply_to_message_id": reply_to_id, + "read_timeout": _MEDIA_SEND_READ_TIMEOUT, **thread_kwargs, **self._notification_kwargs(metadata), }, @@ -7067,6 +7070,7 @@ class TelegramAdapter(BasePlatformAdapter): "photo": image_file, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, + "read_timeout": _MEDIA_SEND_READ_TIMEOUT, **thread_kwargs, **self._notification_kwargs(metadata), }, @@ -7164,6 +7168,7 @@ class TelegramAdapter(BasePlatformAdapter): "filename": display_name, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, + "read_timeout": _MEDIA_SEND_READ_TIMEOUT, **thread_kwargs, **self._notification_kwargs(metadata), }, @@ -7359,6 +7364,7 @@ class TelegramAdapter(BasePlatformAdapter): "animation": animation_url, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, + "read_timeout": _MEDIA_SEND_READ_TIMEOUT, **animation_thread_kwargs, **self._notification_kwargs(metadata), }, diff --git a/tests/tools/test_managed_tool_gateway.py b/tests/tools/test_managed_tool_gateway.py index 5a02ddff47..1c90bcb90f 100644 --- a/tests/tools/test_managed_tool_gateway.py +++ b/tests/tools/test_managed_tool_gateway.py @@ -192,10 +192,6 @@ class TestManagedMediaUploader: BASE_URL = f"{GATEWAY}/api/bfl" UPLOAD_PATH = "/api/uploads/bfl" - @staticmethod - def _builder(vendor): - return f"https://{vendor}-gateway.example.com" - def _uploader(self, **kwargs): return managed_tool_gateway.build_managed_media_uploader( kwargs.pop("server_url", self.BASE_URL), From 97c6a183af6baf1de3d2ca3ddbb1d2487ee7a24c Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:46:55 -0400 Subject: [PATCH 15/55] auto populate flux3 in tools for nous portal users --- hermes_cli/config.py | 1 + hermes_cli/tools_config.py | 73 ++++++++++++++++++ tests/hermes_cli/test_tools_config.py | 106 ++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4970c4c7f8..4523f92fd7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1853,6 +1853,7 @@ _EXTRA_KNOWN_ROOT_KEYS = { "smart_model_routing", # written by the setup wizard (hermes_cli/setup.py) "platform_toolsets", # written by the setup wizard (hermes_cli/setup.py) "known_plugin_toolsets", # written/read by hermes_cli/tools_config.py toolset-save flow + "known_builtin_toolsets", # ditto — which builtin toolsets a platform's checklist has offered "session_reset", # top-level form read by gateway/config.py + setup "group_sessions_per_user", # top-level form bridged by gateway/config.py "thread_sessions_per_user", # top-level form bridged by gateway/config.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index a777db1f1e..162a642764 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -2132,6 +2132,66 @@ def _exempt_explicit_platform_native( default_off.discard(ts) +#: Toolsets young enough that absence from a saved ``platform_toolsets`` list +#: means "never offered" rather than "declined". +#: +#: Saving ``hermes tools`` (or one toggle in the desktop Toolsets UI) replaces +#: a platform's composite with a frozen explicit list, and nothing ever adds to +#: that list — so a toolset shipped afterwards stays off forever for anyone who +#: has touched the picker, while everyone still on ``[hermes-cli]`` inherits it +#: on upgrade. Listing it here restores that parity. +#: +#: MUST ship in the same release as the toolset it names, and be emptied in the +#: next one. The inference only holds while no released build has put the +#: toolset on a checklist: once one has, a user who unchecks it writes a config +#: byte-identical to one saved before the toolset existed (the record below is +#: only written from that point on), and this rule turns their opt-out back on. +#: Landing late — or leaving an entry here for a second release — converts a +#: back-fill into a stuck checkbox. +#: +#: Not gated on a Nous subscription here: the six ``bfl_flux3_*`` tools carry +#: ``check_fn=check_bfl_requirements`` (logged in AND paid), so an enabled +#: toolset still ships zero schemas to a user without paid portal access — the +#: same split Home Assistant uses. Probing the portal from this path would put +#: a network call on every CLI start, gateway session and cron tick. +_RECENTLY_SHIPPED_TOOLSETS = frozenset({"bfl"}) + + +def _enable_recently_shipped_toolsets( + enabled_toolsets: Set[str], config: dict, platform: str +) -> None: + """Turn on toolsets that shipped after this platform's saved list. + + Either way of saying no outlives this: unchecking in ``hermes tools`` + records the toolset in ``known_builtin_toolsets`` so it reads as declined + from then on, and ``agent.disabled_toolsets`` is subtracted after every + rule in :func:`_get_platform_tools`. Mutates ``enabled_toolsets`` in place. + """ + from toolsets import resolve_toolset + + offered = (config.get("known_builtin_toolsets") or {}).get(platform) + declined = {str(ts) for ts in offered} if isinstance(offered, list) else set() + + plat_info = PLATFORMS.get(platform) + default_ts = plat_info["default_toolset"] if plat_info else f"hermes-{platform}" + composite_tools = None + + for ts_key in sorted(_RECENTLY_SHIPPED_TOOLSETS): + if ts_key in enabled_toolsets or ts_key in declined: + continue + if not _toolset_allowed_for_platform(ts_key, platform): + continue + # Parity is the whole justification, so only enable the toolset where + # staying on the composite would have enabled it anyway. Deliberately + # narrow composites (hermes-acp, hermes-webhook) stay narrow. + ts_tools = set(resolve_toolset(ts_key, include_registry=False)) + if composite_tools is None: + composite_tools = set(resolve_toolset(default_ts)) + if not ts_tools or not ts_tools.issubset(composite_tools): + continue + enabled_toolsets.add(ts_key) + + def _get_platform_tools( config: dict, platform: str, @@ -2217,6 +2277,8 @@ def _get_platform_tools( expanded -= default_off enabled_toolsets |= expanded + + _enable_recently_shipped_toolsets(enabled_toolsets, config, platform) else: # No explicit config — fall back to resolving composite toolset names # (e.g. "hermes-cli") to individual tool names and reverse-mapping. @@ -2484,6 +2546,17 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[ config["known_plugin_toolsets"] = {} config["known_plugin_toolsets"][platform] = sorted(plugin_keys) + # Same record for builtin toolsets: which ones this platform's checklist + # has actually put in front of the user. Without it, a toolset the user + # unchecks here is indistinguishable from one that shipped after they + # saved, and _enable_recently_shipped_toolsets would turn it straight back + # on. Recorded from the full catalog, since that is what the picker showed. + if not isinstance(config.get("known_builtin_toolsets"), dict): + config["known_builtin_toolsets"] = {} + config["known_builtin_toolsets"][platform] = sorted( + ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS + ) + # Reconcile with agent.disabled_toolsets. _get_platform_tools() applies # that list as a final override AFTER reading platform_toolsets., # so a toolset listed there stays permanently OFF no matter what this diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index ad05e6b82b..18902ebd58 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -9,6 +9,7 @@ import pytest from hermes_cli.nous_account import NousPortalAccountInfo from hermes_cli.tools_config import ( _DEFAULT_OFF_TOOLSETS, + _RECENTLY_SHIPPED_TOOLSETS, _apply_toolset_change, _checklist_toolset_keys, _configure_provider, @@ -553,3 +554,108 @@ def _fake_features(*, logged_in: bool, paid: bool = True): # ("browserbase") only the CLI, and camofox its npm package. +# ── Toolsets that shipped after a platform's last `hermes tools` save ──────── +# +# Saving the picker (or one toggle in the desktop Toolsets UI) replaces a +# platform's composite (``[hermes-cli]``) with a frozen explicit list, and +# nothing ever adds to that list — so a toolset shipped later stays off +# forever, while everyone still on the composite inherits it on upgrade. +# ``_RECENTLY_SHIPPED_TOOLSETS`` closes that gap for toolsets new enough that +# absence from a saved list cannot mean the user declined them. +# +# Every assertion here is a subset test against that set, which passes +# vacuously once it empties out — and empty is the steady state between +# releases. Skip loudly rather than going quietly green. +_requires_recently_shipped = pytest.mark.skipif( + not _RECENTLY_SHIPPED_TOOLSETS, + reason="no toolset is currently inside its first release", +) + + +def _saved_list_from_before(platform="cli"): + """A saved explicit list as it looked before the new toolsets existed.""" + from hermes_cli.tools_config import ( + _CONFIG_ONLY_TOOLSETS, + _toolset_allowed_for_platform, + ) + + return { + "platform_toolsets": { + platform: sorted( + ts_key + for ts_key, _, _ in CONFIGURABLE_TOOLSETS + if ts_key not in _RECENTLY_SHIPPED_TOOLSETS + and ts_key not in _DEFAULT_OFF_TOOLSETS + and ts_key not in _CONFIG_ONLY_TOOLSETS + and _toolset_allowed_for_platform(ts_key, platform) + ) + } + } + + +@_requires_recently_shipped +def test_saved_list_gains_toolsets_that_shipped_after_it_was_written(): + """The bug: a frozen list never gained bfl, so composite users got Nous + Portal video generation on upgrade and picker users silently did not.""" + on_composite = _get_platform_tools( + {"platform_toolsets": {"cli": ["hermes-cli"]}}, + "cli", + include_default_mcp_servers=False, + ) + on_saved_list = _get_platform_tools( + _saved_list_from_before(), "cli", include_default_mcp_servers=False + ) + + assert _RECENTLY_SHIPPED_TOOLSETS <= (on_composite & on_saved_list) + + +@_requires_recently_shipped +def test_unchecking_the_new_toolset_sticks(): + """Saving records it as offered, so the next read reads absence as a + decline instead of turning it back on.""" + config = {"platform_toolsets": {"cli": ["hermes-cli"]}} + enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False) + with patch("hermes_cli.tools_config.save_config"): + _save_platform_tools(config, "cli", enabled - _RECENTLY_SHIPPED_TOOLSETS) + + reread = _get_platform_tools(config, "cli", include_default_mcp_servers=False) + + assert not (_RECENTLY_SHIPPED_TOOLSETS & reread) + + +@_requires_recently_shipped +def test_agent_disabled_toolsets_still_wins(): + """The other way to say no — a global suppression list applied last.""" + config = _saved_list_from_before() + config["agent"] = {"disabled_toolsets": sorted(_RECENTLY_SHIPPED_TOOLSETS)} + + enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False) + + assert not (_RECENTLY_SHIPPED_TOOLSETS & enabled) + + +@_requires_recently_shipped +def test_platforms_whose_composite_excludes_it_are_left_narrow(): + """Parity is the justification, so don't widen a deliberately small + composite (hermes-acp, hermes-webhook) that never carried the toolset.""" + from toolsets import TOOLSETS, resolve_toolset + + narrow = [ + platform + for platform in ("acp", "webhook") + if f"hermes-{platform}" in TOOLSETS + and not any( + set(resolve_toolset(ts, include_registry=False)) + <= set(resolve_toolset(f"hermes-{platform}")) + for ts in _RECENTLY_SHIPPED_TOOLSETS + ) + ] + assert narrow, "expected a composite that excludes the new toolset" + + for platform in narrow: + enabled = _get_platform_tools( + _saved_list_from_before(platform), + platform, + include_default_mcp_servers=False, + ) + assert not (_RECENTLY_SHIPPED_TOOLSETS & enabled), platform From c0689c3bcbf4c67d9a21fe15c0cd5a85e7773faa Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:23:17 -0700 Subject: [PATCH 16/55] test(tui): make _load_enabled_toolsets assertions tolerant of first-release back-filled toolsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two exact-list assertions in test_tui_gateway_server froze the toolset list and broke the moment _RECENTLY_SHIPPED_TOOLSETS back-filled bfl onto a saved platform list — the exact behavior the sibling change ships on purpose. Assert the invariant instead: the expected base set is present, and anything extra must be inside _RECENTLY_SHIPPED_TOOLSETS (vacuously exact again once that set empties between releases). --- tests/test_tui_gateway_server.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ada479fafb..24f1b72de9 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1998,7 +1998,14 @@ def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys): # Sorted: ["kanban", "memory", "project"]. `kanban` is auto-recovered by # _get_platform_tools (a non-configurable platform toolset in hermes-cli's # universe); `project` is GUI-only, folded in by _load_enabled_toolsets. - assert server._load_enabled_toolsets() == ["kanban", "memory", "project"] + # Toolsets inside their first release (_RECENTLY_SHIPPED_TOOLSETS) are + # back-filled onto saved lists that never offered them — allow those too. + from hermes_cli.tools_config import _RECENTLY_SHIPPED_TOOLSETS + + result = server._load_enabled_toolsets() + assert result is not None + assert {"kanban", "memory", "project"} <= set(result) + assert set(result) - {"kanban", "memory", "project"} <= _RECENTLY_SHIPPED_TOOLSETS err = capsys.readouterr().err assert "ignoring disabled MCP servers" in err assert "mcp-off" in err @@ -2019,7 +2026,12 @@ def test_load_enabled_toolsets_falls_back_when_tui_env_invalid(monkeypatch, caps config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}} ) - assert server._load_enabled_toolsets() == ["kanban", "memory", "project"] + from hermes_cli.tools_config import _RECENTLY_SHIPPED_TOOLSETS + + result = server._load_enabled_toolsets() + assert result is not None + assert {"kanban", "memory", "project"} <= set(result) + assert set(result) - {"kanban", "memory", "project"} <= _RECENTLY_SHIPPED_TOOLSETS assert "using configured CLI toolsets" in capsys.readouterr().err From cc4cab2f592e60a197e796506de9168f74baf3ea Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:27:38 -0700 Subject: [PATCH 17/55] chore: release v0.19.1 (2026.7.30) --- hermes_cli/__init__.py | 4 ++-- pyproject.toml | 2 +- uv.lock | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index a10935a68f..c87e06cb63 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -14,8 +14,8 @@ Provides subcommands for: import os import sys -__version__ = "0.19.0" -__release_date__ = "2026.7.20" +__version__ = "0.19.1" +__release_date__ = "2026.7.30" def _ensure_utf8(): diff --git a/pyproject.toml b/pyproject.toml index df69becb53..2f6a0bef16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ [project] name = "hermes-agent" -version = "0.19.0" +version = "0.19.1" description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere" readme = "README.md" # Upper bound is load-bearing, not cosmetic. uv resolves the project's diff --git a/uv.lock b/uv.lock index 425c30c6db..2662d6292c 100644 --- a/uv.lock +++ b/uv.lock @@ -1547,7 +1547,7 @@ wheels = [ [[package]] name = "hermes-agent" -version = "0.19.0" +version = "0.19.1" source = { editable = "." } dependencies = [ { name = "certifi" }, @@ -2673,6 +2673,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/65/d320016505457cc30971f575e8dadffb923b7cfc780ab8bb25a4ce9d305c/nemo_relay-0.6.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:ad5dae6febf6532d7b113abc2a404679c8feffc499df3034b93d9a078185d2bb", size = 9917779, upload-time = "2026-07-22T20:07:48.961Z" }, { url = "https://files.pythonhosted.org/packages/ae/c0/f33250e71c4206da1b339072893f9a1e39295fe1aceb9a2fef4b8620a0f2/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0cd9570f64c6956fe3bfb82af1cdb3ee70cb50b51098cdb0de831c3f9b4e904", size = 8888375, upload-time = "2026-07-22T20:07:51.049Z" }, { url = "https://files.pythonhosted.org/packages/a3/f4/d1dfaed022da0f6f14765a122867f976a69cc520fe1faaf99757f5719d1f/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:849daa9e45158ac581e54506e0fcc7a24f557d1ed06dbdc074f5de7a00393cbc", size = 9336372, upload-time = "2026-07-22T20:07:53.224Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/f8b80509eef5e05702b940a3d1e2f60c962548d87dec2d712fd3804e6cd4/nemo_relay-0.6.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8c80e534b76bb0455cfc222aaf5c10fa064c088b3c0d5e137eb3c97db46dbc47", size = 10578834, upload-time = "2026-07-30T15:44:44.726Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a0/84ee49d45a1a874457f2f9260d30fd573af30c9be360d9d97b8bb7835ad9/nemo_relay-0.6.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c8bc4a792a2f8c35ddef1b900cc43be2b2bbbfcd5e7cf65aa0829c04d25eb77f", size = 10849651, upload-time = "2026-07-30T15:44:40.48Z" }, { url = "https://files.pythonhosted.org/packages/3a/b0/908d77f75b9054e1e403da78f7d9430249a45939070d4298abbb41a04b5b/nemo_relay-0.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:bfbbedfd130fa95c9b8c04643c30910df850e0ed3500beeab82b00ca2d94e7ea", size = 9613425, upload-time = "2026-07-22T20:07:55.175Z" }, { url = "https://files.pythonhosted.org/packages/cd/71/c438b9d746303ff7f270d99f13b250bf947cdac3e52de2a83fd132cbca0b/nemo_relay-0.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:82fe132943399d89e6ec34dc28df0be7bbe41b84f6698c545928b8816b6010f6", size = 9034810, upload-time = "2026-07-22T20:07:57.467Z" }, ] From 0cec9896a111ab1cb9a41edfc310d1a371c65736 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 20:51:09 -0500 Subject: [PATCH 18/55] fix(desktop): open links clicked in the integrated terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both of xterm's link paths activate through `window.open()`, which the window's setWindowOpenHandler denies, so ⌘-clicking a URL did nothing but log "Opening link blocked as opener could not be cleared" — and the OSC 8 path fronted that dead end with a raw confirm() dialog. Route both through the desktop bridge, the path every other external link in the app takes. ⌘-click on macOS, Ctrl-click elsewhere, matching VS Code's integrated terminal, Terminal.app, and iTerm2. A bare click stays with the selection so a misclick on a URL can't launch a browser. --- .../app/right-sidebar/terminal/links.test.ts | 75 +++++++++++++++++++ .../src/app/right-sidebar/terminal/links.ts | 35 +++++++++ .../terminal/use-agent-terminal.ts | 5 +- .../terminal/use-terminal-session.ts | 8 +- 4 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/app/right-sidebar/terminal/links.test.ts create mode 100644 apps/desktop/src/app/right-sidebar/terminal/links.ts diff --git a/apps/desktop/src/app/right-sidebar/terminal/links.test.ts b/apps/desktop/src/app/right-sidebar/terminal/links.test.ts new file mode 100644 index 0000000000..fcbc45853f --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/links.test.ts @@ -0,0 +1,75 @@ +import type { ILink, Terminal as TerminalType } from '@xterm/xterm' +import { Terminal } from '@xterm/xterm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { isTerminalLinkActivation, terminalLinkHandler, terminalWebLinksAddon } from './links' + +const openExternal = vi.fn() +const click = (init: Partial = {}) => ({ ctrlKey: false, metaKey: false, ...init }) + +beforeEach(() => { + openExternal.mockClear() + Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { openExternal } }) + // jsdom reports a non-mac platform, so the activation modifier resolves to + // Ctrl unless we say otherwise. + Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel' }) +}) + +// Drive the addon the way xterm does: load it on a real terminal, write a URL +// into the buffer, then activate the link its provider reports. +async function clickLinkIn(text: string, event: MouseEvent) { + const term = new Terminal({ allowProposedApi: true, cols: 80, rows: 10 }) + const providers: Array[0]> = [] + const register = term.registerLinkProvider.bind(term) + + term.registerLinkProvider = provider => { + providers.push(provider) + + return register(provider) + } + + term.loadAddon(terminalWebLinksAddon()) + + await new Promise(resolve => term.write(`${text}\r\n`, resolve)) + + const links = await new Promise(resolve => providers[0].provideLinks(1, found => resolve(found ?? []))) + + links[0]?.activate(event, links[0].text) + + return links[0]?.text +} + +describe('terminal links', () => { + it('opens a ⌘-clicked URL through the desktop bridge, not the window.open Electron denies', async () => { + const uri = 'https://example.com/path' + + expect(await clickLinkIn(uri, new MouseEvent('click', { metaKey: true }))).toBe(uri) + expect(openExternal).toHaveBeenCalledWith(uri) + }) + + it('leaves a bare click to the selection, so a misclick never launches a browser', async () => { + await clickLinkIn('https://example.com/path', new MouseEvent('click')) + + expect(openExternal).not.toHaveBeenCalled() + }) + + it('routes OSC 8 hyperlinks the same way, instead of xterm\u2019s confirm() dialog', () => { + terminalLinkHandler.activate(new MouseEvent('click', { metaKey: true }), 'https://example.com/osc8', { + end: { x: 10, y: 1 }, + start: { x: 1, y: 1 } + }) + + expect(openExternal).toHaveBeenCalledWith('https://example.com/osc8') + }) +}) + +describe('isTerminalLinkActivation', () => { + it('takes the platform modifier: ⌘ on macOS, Ctrl elsewhere', () => { + expect(isTerminalLinkActivation(click({ metaKey: true }), true)).toBe(true) + expect(isTerminalLinkActivation(click({ ctrlKey: true }), false)).toBe(true) + }) + + it('keeps Ctrl+click free on macOS, where the OS reads it as a right-click', () => { + expect(isTerminalLinkActivation(click({ ctrlKey: true }), true)).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/links.ts b/apps/desktop/src/app/right-sidebar/terminal/links.ts new file mode 100644 index 0000000000..4102440713 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/links.ts @@ -0,0 +1,35 @@ +import { WebLinksAddon } from '@xterm/addon-web-links' +import type { ILinkHandler } from '@xterm/xterm' + +import { openExternalLink } from '@/lib/external-link' + +import { isMacPlatform } from './selection' + +// Both of xterm's link paths — the web-links addon (URLs it finds in the +// buffer) and the core OSC 8 provider (hyperlinks a CLI emits explicitly) — +// activate through `window.open()`, which the window's setWindowOpenHandler +// denies: a click did nothing but log "Opening link blocked as opener could not +// be cleared", and OSC 8 fronted that dead end with a raw confirm() dialog. +// Route both through the desktop bridge, the path every other external link in +// the app takes. +// +// ⌘-click on macOS, Ctrl-click elsewhere — VS Code's integrated terminal, +// Terminal.app, and iTerm2 all agree. A bare click belongs to the selection, so +// a misclick on a URL can't launch a browser mid-sentence. ⌥ stays out of it: +// that's the force-selection drag over mouse-mode TUIs. +export function isTerminalLinkActivation( + event: Pick, + isMac = isMacPlatform() +): boolean { + return isMac ? event.metaKey : event.ctrlKey +} + +const activate = (event: MouseEvent, uri: string) => { + if (isTerminalLinkActivation(event)) { + openExternalLink(uri) + } +} + +export const terminalLinkHandler: ILinkHandler = { activate } + +export const terminalWebLinksAddon = () => new WebLinksAddon(activate) diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts index bee037094f..a8d92d9e36 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts @@ -1,6 +1,5 @@ import { FitAddon } from '@xterm/addon-fit' import { Unicode11Addon } from '@xterm/addon-unicode11' -import { WebLinksAddon } from '@xterm/addon-web-links' import { WebglAddon } from '@xterm/addon-webgl' import { Terminal } from '@xterm/xterm' import { useEffect, useRef } from 'react' @@ -12,6 +11,7 @@ import { useTheme } from '@/themes/context' import { registerAgentTerminalWriter } from './agent-terminal-stream' import { makeTerminalReader, registerTerminalReader } from './buffer' import { mirrorSelection, terminalClipboardIntent } from './clipboard' +import { terminalLinkHandler, terminalWebLinksAddon } from './links' import { isMacPlatform, resolveSurfaceColor, terminalTheme } from './selection' // Read-only terminal for an agent background process: a write-only xterm (no PTY, @@ -51,6 +51,7 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id: fontWeightBold: 'bold', letterSpacing: 0, lineHeight: 1.12, + linkHandler: terminalLinkHandler, minimumContrastRatio: 4.5, scrollback: 1000, theme: surfaceTheme() @@ -59,7 +60,7 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id: const fit = new FitAddon() term.loadAddon(fit) term.loadAddon(new Unicode11Addon()) - term.loadAddon(new WebLinksAddon()) + term.loadAddon(terminalWebLinksAddon()) term.unicode.activeVersion = '11' term.open(host) termRef.current = term diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 09cd815413..d60bb71bdd 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -1,7 +1,6 @@ import { FitAddon } from '@xterm/addon-fit' import { SerializeAddon } from '@xterm/addon-serialize' import { Unicode11Addon } from '@xterm/addon-unicode11' -import { WebLinksAddon } from '@xterm/addon-web-links' import { WebglAddon } from '@xterm/addon-webgl' import { Terminal } from '@xterm/xterm' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -16,6 +15,7 @@ import { $terminalInjection } from '../store' import { makeTerminalReader, registerTerminalReader } from './buffer' import { mirrorSelection, terminalClipboardIntent } from './clipboard' +import { terminalLinkHandler, terminalWebLinksAddon } from './links' import { isAddSelectionShortcut, isMacPlatform, @@ -514,6 +514,10 @@ export function useTerminalSession({ fontWeightBold: 'bold', letterSpacing: 0, lineHeight: 1.12, + // OSC 8 hyperlinks (gh, cargo, npm, ls --hyperlink) activate through this + // handler; without it xterm shows a raw confirm() and then a window.open + // Electron denies. + linkHandler: terminalLinkHandler, // Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag // can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native // selection over mouse-mode apps, which ⌘/Ctrl+L then sends to chat. @@ -536,7 +540,7 @@ export function useTerminalSession({ term.loadAddon(fit) term.loadAddon(serialize) term.loadAddon(new Unicode11Addon()) - term.loadAddon(new WebLinksAddon()) + term.loadAddon(terminalWebLinksAddon()) term.unicode.activeVersion = '11' // Replay last session's scrollback before the fresh shell boots. The process From 4d6589c69c1ea89a1c12aef8f8cf42c638c313dc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 20:51:40 -0500 Subject: [PATCH 19/55] =?UTF-8?q?fix(desktop):=20stop=20=E2=8C=A5-click=20?= =?UTF-8?q?spraying=20cursor=20escapes=20into=20the=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌥-drag is the app's force-selection gesture over mouse-mode TUIs, but xterm's default alt-click-moves-cursor claims the same click and emits one cursor left/right escape per column of travel. Shells that don't consume them echo the raw `^[[D` burst into the buffer. One gesture, one meaning. --- .../src/app/right-sidebar/terminal/use-terminal-session.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index d60bb71bdd..888d197a55 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -499,6 +499,11 @@ export function useTerminalSession({ const term = new Terminal({ allowProposedApi: true, + // ⌥-drag is our force-selection gesture (below), and xterm's default + // alt-click-moves-cursor claims the same click, emitting one cursor + // left/right escape per column of travel — shells that don't consume them + // echo the raw `^[[D` burst into the buffer. One gesture, one meaning. + altClickMovesCursor: false, // Opaque canvas = WebGL's crisp fast-path. allowTransparency instead bakes // glyphs as grayscale-alpha for compositing over a see-through canvas, which // reads soft on every platform; VS Code keeps it off and our surface From 463fbf5b16749898dd3076c04e8647400c80df3e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 21:43:14 -0500 Subject: [PATCH 20/55] fix(desktop): middle-click works on a real three-button mouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium on Windows and Linux answers a middle press inside a scroller by starting the autoscroll pan, and the mouseup that ends the pan never becomes an auxclick. Every surface carrying the gesture — tab strips, the session list, the terminal rail — is a scroller, so middle-click only ever worked on macOS, where autoscroll doesn't exist. Arm on pointerdown, spend on the pointerup over the same element (press one tab, release on another and nothing happens), and cancel the middle mousedown on every press so the pan widget can't appear on a surface that owns the button. One helper, four call sites. --- .../src/app/chat/sidebar/session-row.tsx | 17 ++-- .../src/app/right-sidebar/terminal/rail.tsx | 13 +-- .../src/components/ui/pane-tab.test.tsx | 6 +- apps/desktop/src/components/ui/pane-tab.tsx | 25 +++--- apps/desktop/src/lib/middle-click.test.tsx | 86 +++++++++++++++++++ apps/desktop/src/lib/middle-click.ts | 58 +++++++++++++ 6 files changed, 166 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/src/lib/middle-click.test.tsx create mode 100644 apps/desktop/src/lib/middle-click.ts diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index fcaa8b19ed..cfbeb9f2af 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -13,6 +13,7 @@ import type { SessionInfo } from '@/hermes' import { type Translations, useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' +import { middleClickHandlers } from '@/lib/middle-click' import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source' import { coarseElapsed } from '@/lib/time' import { cn } from '@/lib/utils' @@ -168,16 +169,11 @@ function SidebarSessionRowImpl({ )} { - if (event.button === 1) { - event.preventDefault() - event.stopPropagation() - triggerHaptic('selection') - openSession(session.id, () => undefined, 'tab') - } - }} + // Middle-click = open in a new tab (browser muscle memory). + {...middleClickHandlers(() => { + triggerHaptic('selection') + openSession(session.id, () => undefined, 'tab') + })} onClick={event => { const mod = event.metaKey || event.ctrlKey @@ -213,7 +209,6 @@ function SidebarSessionRowImpl({ onResume() }} - onMouseDown={event => event.button === 1 && event.preventDefault()} > {reorderable ? ( { - if (event.button === 1) { - event.preventDefault() - closeTerminal(term.id) - } - }} + {...middleClickHandlers(() => closeTerminal(term.id))} onClick={() => selectTerminal(term.id)} - onMouseDown={event => { - if (event.button === 1) { - event.preventDefault() - } - }} role="tab" type="button" > diff --git a/apps/desktop/src/components/ui/pane-tab.test.tsx b/apps/desktop/src/components/ui/pane-tab.test.tsx index c36f03fb8a..5b340a9fb1 100644 --- a/apps/desktop/src/components/ui/pane-tab.test.tsx +++ b/apps/desktop/src/components/ui/pane-tab.test.tsx @@ -6,7 +6,7 @@ import { PaneTab, PaneTabLabel } from './pane-tab' afterEach(cleanup) describe('PaneTab close gestures', () => { - it('middle-click (button 1) closes', () => { + it('middle-click closes — pointer events only, no auxclick', () => { const onClose = vi.fn() render( @@ -14,7 +14,9 @@ describe('PaneTab close gestures', () => { ) - fireEvent(screen.getByText('tab'), new MouseEvent('auxclick', { bubbles: true, button: 1 })) + const tab = screen.getByText('tab') + fireEvent.pointerDown(tab, { button: 1 }) + fireEvent.pointerUp(tab, { button: 1 }) expect(onClose).toHaveBeenCalledTimes(1) }) diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index eb553a770e..87545f018c 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -1,5 +1,6 @@ import * as React from 'react' +import { middleClickHandlers } from '@/lib/middle-click' import { cn } from '@/lib/utils' /** Inset stroke for a vertical tab rail — content-facing edge. */ @@ -60,9 +61,9 @@ export const PaneTab = React.forwardRef(function P active = false, dirty = false, onClose, - onAuxClick, onMouseDown, onPointerDown, + onPointerUp, onClickCapture, vertical = false, side = 'left', @@ -75,6 +76,7 @@ export const PaneTab = React.forwardRef(function P // Vertical rails only. Horizontal tabs draw no bottom border — the strip owns // that rule, and a per-tab border stacked a second translucent line over it. const edge = vertical ? (side === 'right' ? 'border-l' : 'border-r') : undefined + const middle = middleClickHandlers(onClose) return (
(function P )} data-active={active} data-vertical={vertical || undefined} - onAuxClick={event => { - // Middle-click closes (browser/IDE). Swallow mousedown so Chromium - // doesn't autoscroll. - if (onClose && event.button === 1) { - event.preventDefault() - onClose() - } - - onAuxClick?.(event) - }} onClickCapture={event => { // Sites whose tab activates on the label's own onClick (the preview // rail) fire it AFTER our pointerdown close — swallow that stray click @@ -111,13 +103,12 @@ export const PaneTab = React.forwardRef(function P onClickCapture?.(event) }} onMouseDown={event => { - if (onClose && event.button === 1) { - event.preventDefault() - } - + middle.onMouseDown(event) onMouseDown?.(event) }} onPointerDown={event => { + middle.onPointerDown(event) + // ⌘-click closes. Preempt here — the tab strips activate/drag on // pointerdown (drag-session onTap), so we must claim the press before // the shell's own handler starts a drag, and skip it entirely. @@ -131,6 +122,10 @@ export const PaneTab = React.forwardRef(function P onPointerDown?.(event) }} + onPointerUp={event => { + middle.onPointerUp(event) + onPointerUp?.(event) + }} ref={ref} {...props} > diff --git a/apps/desktop/src/lib/middle-click.test.tsx b/apps/desktop/src/lib/middle-click.test.tsx new file mode 100644 index 0000000000..c701825932 --- /dev/null +++ b/apps/desktop/src/lib/middle-click.test.tsx @@ -0,0 +1,86 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { middleClickHandlers } from './middle-click' + +afterEach(cleanup) + +/** A middle click as a real three-button mouse delivers it. Chromium on + * Windows/Linux swallows the trailing `auxclick` when the press starts + * autoscroll, so the gesture may NOT depend on that event. */ +function middleClick(element: Element, upOn: Element = element) { + fireEvent.mouseDown(element, { button: 1 }) + fireEvent.pointerDown(element, { button: 1 }) + fireEvent.pointerUp(upOn, { button: 1 }) +} + +function Target({ action, id = 'target' }: { action?: () => void; id?: string }) { + return ( + + ) +} + +describe('middleClickHandlers', () => { + it('fires without an auxclick — the event Chromium eats when autoscroll starts', () => { + const action = vi.fn() + render() + + middleClick(screen.getByText('target')) + expect(action).toHaveBeenCalledTimes(1) + }) + + it('cancels mousedown so the autoscroll pan widget never appears', () => { + render() + + const down = fireEvent.mouseDown(screen.getByText('target'), { button: 1 }) + expect(down).toBe(false) // preventDefault() called + }) + + it('cancels the middle mousedown even with no action — the surface owns the button', () => { + render() + + expect(fireEvent.mouseDown(screen.getByText('target'), { button: 1 })).toBe(false) + }) + + it('ignores left and right buttons', () => { + const action = vi.fn() + render() + + const target = screen.getByText('target') + fireEvent.pointerDown(target, { button: 0 }) + fireEvent.pointerUp(target, { button: 0 }) + fireEvent.pointerDown(target, { button: 2 }) + fireEvent.pointerUp(target, { button: 2 }) + expect(action).not.toHaveBeenCalled() + }) + + it('does nothing when the release lands on a different element', () => { + const pressed = vi.fn() + const released = vi.fn() + render( + <> + + + + ) + + middleClick(screen.getByText('pressed'), screen.getByText('released')) + expect(pressed).not.toHaveBeenCalled() + expect(released).not.toHaveBeenCalled() + }) + + it('a press with no action cannot arm the NEXT element it releases over', () => { + const action = vi.fn() + render( + <> + + + + ) + + middleClick(screen.getByText('inert'), screen.getByText('live')) + expect(action).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/lib/middle-click.ts b/apps/desktop/src/lib/middle-click.ts new file mode 100644 index 0000000000..bba5e58743 --- /dev/null +++ b/apps/desktop/src/lib/middle-click.ts @@ -0,0 +1,58 @@ +import type * as React from 'react' + +/** `MouseEvent.button` for the middle (wheel) button. */ +const MIDDLE_BUTTON = 1 + +/** Where the current middle press started. One pointer holds one button, so a + * single slot is the whole state, and it's only ever compared by identity in + * the pointerup right after — a value left behind by a press released + * elsewhere is inert, not stale. */ +let pressedOn: EventTarget | null = null + +/** + * Middle-click as a gesture that survives a real three-button mouse. + * + * `auxclick` is the obvious event and the wrong one to build on. Windows and + * Linux Chromium answer a middle press inside a scroller by starting the + * AUTOSCROLL pan, and the mouseup that ends the pan is spent stopping it + * instead of completing a click — so `auxclick` never arrives. Every surface + * carrying this gesture (tab strips, the session list, the terminal rail) is a + * scroller, which is why it only ever worked on macOS, where autoscroll + * doesn't exist. + * + * Pointer events fire either way, so the gesture arms on pointerdown and is + * spent on the pointerup over the SAME element — press one tab, release on + * another and nothing happens (Chrome / VS Code semantics). mousedown's default + * dies on every middle press, action or not, so the pan widget can't appear on + * a surface that owns the button. + * + * A plain factory, not a hook: tab strips call it inside `map()`. + */ +export function middleClickHandlers(action: (() => void) | undefined) { + return { + onMouseDown: (event: React.MouseEvent) => { + if (event.button === MIDDLE_BUTTON) { + event.preventDefault() + } + }, + + onPointerDown: (event: React.PointerEvent) => { + if (event.button === MIDDLE_BUTTON) { + pressedOn = action ? event.currentTarget : null + } + }, + + onPointerUp: (event: React.PointerEvent) => { + if (event.button !== MIDDLE_BUTTON) { + return + } + + const armed = pressedOn === event.currentTarget + pressedOn = null + + if (armed) { + action?.() + } + } + } +} From c7b021ca487277c322281aef040ae743681132ba Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 21:43:15 -0500 Subject: [PATCH 21/55] fix(desktop): closing the last main tab lands on New session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace pane can't leave the tree, so "close the main tab" only ever had one answer wired: shift the next stacked session in. With main as the only tab there was nothing to shift and ⌘W dead-ended on the tab the user was looking at. closeWorkspaceTab is now the one answer for every entry point — stacked session still wins, and with nothing stacked main drops to a fresh New session draft. A blank draft and a full-page view stay no-ops: a blank draft already IS the post-close state. --- apps/desktop/src/app/chat/close-tab.test.ts | 112 +++++++++++++++++--- apps/desktop/src/app/chat/close-tab.ts | 77 +++++++++----- 2 files changed, 150 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/app/chat/close-tab.test.ts b/apps/desktop/src/app/chat/close-tab.test.ts index 44368c8040..0326efebea 100644 --- a/apps/desktop/src/app/chat/close-tab.test.ts +++ b/apps/desktop/src/app/chat/close-tab.test.ts @@ -1,9 +1,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const closeFocusedSessionTab = vi.fn(() => false) +const nextSessionTileForWorkspace = vi.fn<() => null | string>(() => null) +const closeSessionTile = vi.fn() +const requestFreshSession = vi.fn() + +vi.mock('@/components/pane-shell/tree/store', () => ({ + closeFocusedSessionTab: () => closeFocusedSessionTab() +})) + +vi.mock('@/store/session-states', () => ({ + closeSessionTile: (...args: unknown[]) => closeSessionTile(...args), + nextSessionTileForWorkspace: () => nextSessionTileForWorkspace() +})) + +vi.mock('@/store/profile', () => ({ + requestFreshSession: () => requestFreshSession() +})) + import { $rightRailActiveTabId } from '@/store/layout' import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview' +import { $activeSessionId, $selectedStoredSessionId } from '@/store/session' -import { closeActiveTab } from './close-tab' +import { $workspaceIsPage } from '../routes' + +import { closeActiveTab, closeWorkspaceTab } from './close-tab' function fileTarget(path: string): PreviewTarget { return { @@ -16,19 +37,31 @@ function fileTarget(path: string): PreviewTarget { } } +/** Main is holding a loaded chat and nothing else is stacked with it. */ +function loadedMainOnly() { + $selectedStoredSessionId.set('stored-a') + $activeSessionId.set('runtime-a') +} + +beforeEach(() => { + vi.stubGlobal('document', { activeElement: null }) + closeRightRail() + window.localStorage.clear() + $selectedStoredSessionId.set(null) + $activeSessionId.set(null) + $workspaceIsPage.set(false) + closeFocusedSessionTab.mockReturnValue(false) + nextSessionTileForWorkspace.mockReturnValue(null) + vi.clearAllMocks() +}) + +afterEach(() => { + vi.unstubAllGlobals() + closeRightRail() + window.localStorage.clear() +}) + describe('closeActiveTab', () => { - beforeEach(() => { - vi.stubGlobal('document', { activeElement: null }) - closeRightRail() - window.localStorage.clear() - }) - - afterEach(() => { - vi.unstubAllGlobals() - closeRightRail() - window.localStorage.clear() - }) - it('closes the active file preview tab (⌘W happy path)', () => { openPreview(fileTarget('/work/notes.md'), 'manual') @@ -50,3 +83,56 @@ describe('closeActiveTab', () => { expect($previewTabs.get()).toHaveLength(0) }) }) + +/** + * The main tab's own close. The workspace pane can never leave the tree, so + * every answer here is about what FILLS it — a stacked session, or an empty + * draft. The gesture used to dead-end whenever main was the only tab. + */ +describe('closeWorkspaceTab', () => { + it('shifts the next stacked session into main', () => { + loadedMainOnly() + nextSessionTileForWorkspace.mockReturnValue('stored-b') + const load = vi.fn() + + expect(closeWorkspaceTab(load)).toBe(true) + expect(closeSessionTile).toHaveBeenCalledWith('stored-b') + expect(load).toHaveBeenCalledWith('stored-b') + // Promotion refills main — it must not ALSO blank it. + expect(requestFreshSession).not.toHaveBeenCalled() + }) + + it('drops a lone loaded main to a fresh draft', () => { + loadedMainOnly() + + expect(closeWorkspaceTab(vi.fn())).toBe(true) + expect(requestFreshSession).toHaveBeenCalledTimes(1) + }) + + it('empties main even with no session loader wired', () => { + loadedMainOnly() + + expect(closeWorkspaceTab()).toBe(true) + expect(requestFreshSession).toHaveBeenCalledTimes(1) + }) + + it('is a no-op on a blank draft — that IS the post-close state', () => { + expect(closeWorkspaceTab(vi.fn())).toBe(false) + expect(requestFreshSession).not.toHaveBeenCalled() + }) + + it('is a no-op over a full-page view, which owns no chat tab', () => { + loadedMainOnly() + $workspaceIsPage.set(true) + + expect(closeWorkspaceTab(vi.fn())).toBe(false) + expect(requestFreshSession).not.toHaveBeenCalled() + }) + + it('⌘W reaches it once the terminal, rail and zone tabs pass', () => { + loadedMainOnly() + + expect(closeActiveTab(vi.fn())).toBe(true) + expect(requestFreshSession).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index 89c8525931..0d75455bd8 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -1,27 +1,68 @@ +import { mainChatOccupied } from '@/app/open-session' import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals' +import { $workspaceIsPage } from '@/app/routes' import { closeFocusedSessionTab } from '@/components/pane-shell/tree/store' import { isFocusWithin } from '@/lib/keybinds/combo' import { $previewTabs, closeActiveRightRailTab } from '@/store/preview' +import { requestFreshSession } from '@/store/profile' +import { $activeSessionId, $selectedStoredSessionId } from '@/store/session' import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states' +/** + * Close the MAIN tab. The workspace pane itself can't leave the tree, so + * "closing" it means emptying it, and what fills the hole depends on what's + * stacked beside it: + * + * - session tabs stacked with it → the next one shifts INTO main (drop its + * tile, load it as the primary — the session stays alive, no busy prompt), + * - nothing stacked → main drops to a fresh "New session" draft. + * + * The second half is what makes the gesture honest when main is the ONLY tab: + * ⌘W / ⌘-click / middle-click used to be a dead key there, since the only + * available answer was "remove the pane", which this app never does. + * + * Returns false when there is nothing to close — a blank draft (already the + * post-close state) or a full-page view (skills / artifacts, which isn't a + * chat and owns no tab). ⌘W then stays a no-op; it never closes the window. + * + * `loadSessionIntoWorkspace` carries the app's route-based "load this session + * into main"; omitting it disables the promotion half. + */ +export function closeWorkspaceTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean { + // Order matters — close the tile FIRST so the selection homes to the + // workspace instead of re-fronting the tile. + if (loadSessionIntoWorkspace) { + const next = nextSessionTileForWorkspace() + + if (next) { + closeSessionTile(next) + loadSessionIntoWorkspace(next) + + return true + } + } + + if ($workspaceIsPage.get() || !mainChatOccupied($activeSessionId.get(), $selectedStoredSessionId.get())) { + return false + } + + requestFreshSession() + + return true +} + /** * ⌘W — close the tab of the context you're in, by precedence: * 1. a focused terminal → its active terminal tab, * 2. right-rail tabs (live preview and/or file peeks), * 3. the FOCUSED chat zone → its active tab (a session tile stacked into it). - * 4. the workspace tab itself, when session tabs are stacked with it: - * the workspace can't close, so ⌘W shifts the NEXT session tab into main - * (loads it as the primary + drops its now-redundant tile). + * 4. the workspace tab itself — see `closeWorkspaceTab`. * Returns false when nothing closes, so ⌘W is a no-op — it never closes the - * window (a bare workspace stays put). Shared by the keyboard path (Win/Linux) - * and the macOS menu-accelerator IPC. + * window. Shared by the keyboard path (Win/Linux) and the macOS + * menu-accelerator IPC. * * Steps 3-4 follow the same focused zone ⌘1…⌘9 indexes, so a second chat zone * with its own tab strip closes ITS tab instead of main's. - * - * `loadSessionIntoWorkspace` carries the app's route-based "load this session - * into main" (the two call sites have router access); omitting it disables the - * step-4 promotion (⌘W stays the pre-existing no-op on the main tab). */ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean { if (isFocusWithin('[data-terminal]')) { @@ -43,21 +84,5 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri return true } - // The main (workspace) tab is active and can't be closed — but if session - // tabs are stacked with it, ⌘W shifts the next one into the main tab: drop - // its tile (the session stays alive, no busy-close prompt) and load it into - // main. Order matters — close the tile FIRST so the selection homes to the - // workspace instead of re-fronting the tile. - if (loadSessionIntoWorkspace) { - const next = nextSessionTileForWorkspace() - - if (next) { - closeSessionTile(next) - loadSessionIntoWorkspace(next) - - return true - } - } - - return false + return closeWorkspaceTab(loadSessionIntoWorkspace) } From 193e5f84f7ac755547dc9be89e98bbe82d6f74c0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 21:43:15 -0500 Subject: [PATCH 22/55] fix(desktop): the main tab can be closed by gesture and menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab strip decided the close gesture from the `uncloseable` flag, which the workspace sets to keep its pane in the tree — so the one tab whose close now does something couldn't be ⌘-clicked or middle-clicked, and its right-click menu had no Close. Read the gesture off the pane's registered closer instead, with the workspace registering closeWorkspaceTab. An atom rather than a lookup, since that closer comes from a wiring effect that lands after the strip's first paint. --- apps/desktop/src/app/chat/session-tile.tsx | 16 +++++++++---- apps/desktop/src/app/contrib/wiring.tsx | 23 +++++++++++++++++-- .../pane-shell/tree/renderer/tree-group.tsx | 12 ++++++++-- .../src/components/pane-shell/tree/store.ts | 21 ++++++++++++++--- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 086d69fdf5..27954abc2a 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -27,7 +27,7 @@ import { ModelMenuPanel } from '@/app/shell/model-menu-panel' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status' import { findGroupOfPane } from '@/components/pane-shell/tree/model' -import { $layoutTree, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store' +import { $layoutTree, closeTreePane, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store' import { Button } from '@/components/ui/button' import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { transcribeAudio } from '@/hermes' @@ -504,9 +504,10 @@ export function SessionTabMenu({ } /** The MAIN tab's menu: the same session verbs targeting the primary's loaded - * session, plus the bar's off switch (the bar sticky-shows once a tab is - * ever gained; this is the explicit way back). A fresh draft has no session — - * no menu. */ + * session, plus Close (the tab empties to a fresh draft — the workspace pane + * itself never leaves the tree) and the bar's off switch (the bar sticky-shows + * once a tab is ever gained; this is the explicit way back). A fresh draft has + * no session — no menu. */ export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) { const selected = useStore($selectedStoredSessionId) @@ -524,7 +525,12 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) } return ( - + closeTreePane('workspace')} + onHideTabBar={hideTabBar} + storedSessionId={selected} + tabPaneId="workspace" + > {children} ) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 44d35607e1..1306ffb641 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -20,7 +20,7 @@ import { FindBar } from '@/components/find-bar' import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' import { NotificationStack } from '@/components/notifications' import { DesktopOnboardingOverlay } from '@/components/onboarding' -import { $newSessionTabAction } from '@/components/pane-shell/tree/store' +import { $newSessionTabAction, registerPaneCloser } from '@/components/pane-shell/tree/store' import { FloatingPet } from '@/components/pet/floating-pet' import { RemoteDisplayBanner } from '@/components/remote-display-banner' import { emitGatewayEvent } from '@/contrib/events' @@ -68,6 +68,7 @@ import { armWakeWord } from '@/store/wake-word' import { isSecondaryWindow } from '@/store/windows' import { useSkinCommand } from '@/themes/use-skin-command' +import { closeWorkspaceTab } from '../chat/close-tab' import { requestComposerInsert } from '../chat/composer/focus' import { useComposerActions } from '../chat/hooks/use-composer-actions' import { CommandPalette } from '../command-palette' @@ -83,7 +84,14 @@ import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker' import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree' import { PersistentTerminal } from '../right-sidebar/terminal/persistent' import { closeAllTerminals } from '../right-sidebar/terminal/terminals' -import { CRON_ROUTE, navigateToWorkspacePage, routeSessionId, SETTINGS_ROUTE, syncWorkspaceRoute } from '../routes' +import { + CRON_ROUTE, + navigateToWorkspacePage, + routeSessionId, + sessionRoute, + SETTINGS_ROUTE, + syncWorkspaceRoute +} from '../routes' import { SessionPickerOverlay } from '../session-picker-overlay' import { SessionSwitcher } from '../session-switcher' import { useBackgroundQueueDrain } from '../session/hooks/use-background-queue-drain' @@ -819,6 +827,17 @@ export function ContribWiring({ children }: { children: ReactNode }) { return () => $newSessionTabAction.set(null) }, [openNewSessionTab]) + // The MAIN tab's Close. The workspace pane can't leave the tree, so its + // closer empties it instead: the next stacked session shifts in, else main + // drops to a fresh draft. Registering it here is also what gives the tab its + // close GESTURE (⌘-click / middle-click) — the strip reads the closer, not + // the `uncloseable` flag, so the pane stays undismissable either way. + useEffect(() => { + registerPaneCloser('workspace', () => void closeWorkspaceTab(id => navigate(sessionRoute(id)))) + + return () => registerPaneCloser('workspace') + }, [navigate]) + // The controller's entire callback surface, gathered into the stable // `actions` bag. `nextActions` is TS-checked against WiringActions each // render; its fields are copied into the ref object so `actions` keeps one diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 9df6e3d44d..a4ad89465f 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -31,6 +31,7 @@ import { $hiddenTreePanes, $narrowViewport, $newSessionTabAction, + $panesWithCloser, $treeDragging, activateTreePane, closeAllTreeTabs, @@ -177,6 +178,7 @@ export function TreeGroup({ const hiddenPanes = useStore($hiddenTreePanes) const narrow = useStore($narrowViewport) const newSessionTabAction = useStore($newSessionTabAction) + const panesWithCloser = useStore($panesWithCloser) const paneFor = (id: string) => panes.find(p => p.id === id) @@ -290,6 +292,12 @@ export function TreeGroup({ // closes the session, a store-bound pane collapses). const closeTab = (paneId: string) => (isCollapsePane(paneId) ? dismissTreePane(paneId) : closeTreePane(paneId)) + // A pane whose store owns Close keeps the gesture even when the pane itself + // is uncloseable — the workspace tab empties to a fresh draft rather than + // leaving the tree. + const closeableTab = (paneId: string) => + !paneChrome(paneFor(paneId)).uncloseable || panesWithCloser.has(paneId) + // Collapse/restore a tool panel (or plain minimize elsewhere) — the header // chevron + tap gesture, routed so ⌃`/the titlebar toggle stay truthful. const toggleCollapse = () => (node.minimized ? restoreTreePane(activeId) : collapseTreePane(activeId)) @@ -347,7 +355,7 @@ export function TreeGroup({ role="tablist" > {shown.map(paneId => { - const closeable = !paneChrome(paneFor(paneId)).uncloseable + const closeable = closeableTab(paneId) const title = paneFor(paneId)?.title ?? paneId return ( @@ -414,7 +422,7 @@ export function TreeGroup({ {shown.map(paneId => { const isActive = paneId === activeId && !node.minimized const chrome = paneChrome(paneFor(paneId)) - const closeable = !chrome.uncloseable + const closeable = closeableTab(paneId) const title = paneFor(paneId)?.title ?? paneId const tab = ( diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 49ca0587c7..4bb24460dd 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -198,9 +198,24 @@ function setDismissed(paneId: string, dismissed: boolean) { const paneClosers: Record void> = {} const paneOpeners: Record void> = {} -/** Route a pane's Close through the app store that owns its visibility. */ -export function registerPaneCloser(paneId: string, close: () => void) { - paneClosers[paneId] = close +/** Pane ids whose Close an app store owns. True for the main workspace, whose + * pane can't leave the tree but whose TAB can still be emptied — the close + * GESTURE (⌘-click / middle-click) keys off this rather than `uncloseable`. + * An atom, not a lookup: a closer registered by a wiring EFFECT lands after + * the strip's first paint, and a plain read would leave that tab gestureless + * until something else happened to re-render it. */ +export const $panesWithCloser = atom>(new Set()) + +/** Route a pane's Close through the app store that owns its visibility. + * Passing no closer unregisters (a wiring effect's cleanup). */ +export function registerPaneCloser(paneId: string, close?: () => void) { + if (close) { + paneClosers[paneId] = close + } else { + delete paneClosers[paneId] + } + + $panesWithCloser.set(new Set(Object.keys(paneClosers))) } /** From b1858f33a1cc6083ee34aea41417ea5e18564d2e Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:08:09 +0000 Subject: [PATCH 23/55] fmt(js): `npm run fix` on merge (#75159) Co-authored-by: github-actions[bot] --- .../src/components/pane-shell/tree/renderer/tree-group.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index a4ad89465f..e81bc1afd2 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -295,8 +295,7 @@ export function TreeGroup({ // A pane whose store owns Close keeps the gesture even when the pane itself // is uncloseable — the workspace tab empties to a fresh draft rather than // leaving the tree. - const closeableTab = (paneId: string) => - !paneChrome(paneFor(paneId)).uncloseable || panesWithCloser.has(paneId) + const closeableTab = (paneId: string) => !paneChrome(paneFor(paneId)).uncloseable || panesWithCloser.has(paneId) // Collapse/restore a tool panel (or plain minimize elsewhere) — the header // chevron + tap gesture, routed so ⌃`/the titlebar toggle stay truthful. From 0b4bd3c7c77532a2c899164a224d237a05e9586e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 22:33:22 -0500 Subject: [PATCH 24/55] fix(desktop): the placeholder comes back when you clear the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select-all + Cut emptied the text and left the composer blank — no draft, no prompt. Delete had the same hole. The placeholder is painted on `:empty`, and a cleared editor keeps a scaffolding
so the contenteditable can't collapse to a sliver. Those two facts collide: the moment the break lands the editor has a child, `:empty` goes false, and the prompt never comes back. CSS can't infer emptiness on its own either. A text node is invisible to selectors, so `one
` and a lone `
` are the same shape — a structural rule like `:has(> br:only-child)` paints the placeholder straight over the user's text. The code that empties the editor is what knows, so it marks the root and the condition reads `:is(:empty, [data-empty])`. Both writers that reshape that root maintain the marker through one helper: the normalizer, and renderComposerContents for a restored draft or an undo. The message-edit composer shares the slot and the rule, so it takes the same shared class instead of drifting on its own copy. #74815 fixed the draft this stashed; the placeholder is a separate seam. --- .../app/chat/composer/empty-composer.test.ts | 70 ++++++++++++++++++- apps/desktop/src/app/chat/composer/index.tsx | 3 +- .../src/app/chat/composer/rich-editor.ts | 29 ++++++++ .../thread/user-edit-composer.tsx | 3 +- apps/desktop/src/styles.css | 2 +- 5 files changed, 103 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/empty-composer.test.ts b/apps/desktop/src/app/chat/composer/empty-composer.test.ts index 4e5146c174..fe8305d2c4 100644 --- a/apps/desktop/src/app/chat/composer/empty-composer.test.ts +++ b/apps/desktop/src/app/chat/composer/empty-composer.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { composerPlainText, normalizeComposerEditorDom, RICH_INPUT_SLOT } from './rich-editor' +import { + composerPlainText, + normalizeComposerEditorDom, + renderComposerContents, + RICH_INPUT_SLOT +} from './rich-editor' function editor(): HTMLDivElement { const el = document.createElement('div') @@ -69,3 +74,66 @@ describe('an emptied composer reads as empty', () => { expect(composerPlainText(el)).toBe('one\n\n') }) }) + +/** The rule the stylesheet paints the placeholder with. `:empty` alone goes + * false the instant the scaffolding
lands. */ +const PLACEHOLDER_SHOWS = ':is(:empty, [data-empty])' + +describe('an emptied composer shows its placeholder again', () => { + it('advertises emptiness once the scaffolding break is in place', () => { + expect(emptied().matches(PLACEHOLDER_SHOWS)).toBe(true) + }) + + it('advertises emptiness for a truly childless editor', () => { + expect(editor().matches(PLACEHOLDER_SHOWS)).toBe(true) + }) + + it('stops advertising it once something is typed', () => { + const el = emptied() + + el.replaceChildren(document.createTextNode('hi')) + normalizeComposerEditorDom(el) + + expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false) + }) + + // A text node is invisible to selectors, so `one
` and `
` are the same + // shape to any pure-CSS rule (`:has(> br:only-child)` matches both and paints + // the placeholder straight over the user's text). The DOM writer has to say. + it('does not advertise emptiness for a trailing break after text', () => { + const el = editor() + + el.append(document.createTextNode('one'), document.createElement('br')) + normalizeComposerEditorDom(el) + + expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false) + }) + + it('does not advertise emptiness for a Shift+Enter break between text', () => { + const el = editor() + + el.append(document.createTextNode('one'), document.createElement('br'), document.createTextNode('two')) + normalizeComposerEditorDom(el) + + expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false) + }) + + // Repainting from text (restored draft, undo, completion rebuild) is the + // other writer that reshapes the editor root — it must not strand the marker. + it('drops the marker when a draft is painted back in', () => { + const el = emptied() + + renderComposerContents(el, 'restored draft') + + expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false) + }) + + it('re-advertises emptiness when a draft is painted back out', () => { + const el = editor() + + renderComposerContents(el, 'temporary') + renderComposerContents(el, '') + + expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index b05b563c52..7471775922 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -57,6 +57,7 @@ import { ActionBadges } from './micro-actions' import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' import { QueuePanel } from './queue-panel' import { + COMPOSER_PLACEHOLDER_CLASS, composerPlainText, deleteChipBeforeCaret, deleteSelectionInEditor, @@ -946,7 +947,7 @@ export function ChatBar({ autoCorrect="off" className={cn( 'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed', - 'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60', + COMPOSER_PLACEHOLDER_CLASS, '**:data-ref-text:cursor-default', stacked && 'pl-3', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1' diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index d7ef6c3082..0c9c709887 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -21,6 +21,28 @@ import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs' export const RICH_INPUT_SLOT = 'composer-rich-input' +/** Paints `data-placeholder` while the editor is empty. + * + * `:empty` can't be the whole test: a cleared editor keeps a scaffolding
+ * so the contenteditable doesn't collapse, and that break makes `:empty` + * false. Nor can CSS infer it on its own — a text node is invisible to + * selectors, so `one
` and a lone `
` are the same shape, and + * `:has(> br:only-child)` would paint the placeholder straight over the + * user's text. The code that empties the editor is what knows, so it marks it. + * + * @see markEditorEmptiness */ +export const COMPOSER_PLACEHOLDER_CLASS = + '[&:is(:empty,[data-empty])]:before:content-[attr(data-placeholder)] [&:is(:empty,[data-empty])]:before:text-muted-foreground/60' + +/** Keep that marker in step with the editor root's contents. */ +export function markEditorEmptiness(editor: HTMLElement) { + if (editor.childNodes.length === 0) { + editor.dataset.empty = '' + } else { + delete editor.dataset.empty + } +} + /** @see referenceRe — the shared pattern every surface recognises a reference * with. Module-level `/g` regexes carry `lastIndex`, so call sites reset it. */ export const REF_RE = referenceRe() @@ -165,6 +187,10 @@ export function renderComposerContents(target: HTMLElement, text: string, option // typed (`/wor`) and must stay editable. Callers repainting inert text (a // restored draft, a sent message opened for edit) pass `trailingCommitted`. appendComposerContents(target, text, options) + + // The other writer that reshapes the editor root: painting a restored draft + // in clears the marker, clearing back to '' sets it. + markEditorEmptiness(target) } /** Caret range when the selection lives inside `editor`; else null. */ @@ -681,6 +707,9 @@ export function normalizeComposerEditorDom(editor: HTMLElement) { // composer to appear as a tiny dot/pixel. Ensure there's always at least // one
so the element maintains intrinsic height. The CSS min-height // is a belt; the
is suspenders — together they prevent the shrink. + // That break is also why emptiness has to be marked, not inferred. + markEditorEmptiness(editor) + if (editor.childNodes.length === 0) { editor.appendChild(document.createElement('br')) } diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 16ec52cf0d..1e6ec792d0 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -35,6 +35,7 @@ import { } from '@/app/chat/composer/inline-refs' import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs' import { + COMPOSER_PLACEHOLDER_CLASS, composerPlainText, insertComposerContentsAtCaret, placeCaretEnd, @@ -772,7 +773,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess autoCorrect="off" className={cn( 'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none', - 'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60', + COMPOSER_PLACEHOLDER_CLASS, '**:data-ref-text:cursor-default', expanded ? 'min-h-16' : 'min-h-[1.25rem]' )} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 95eeebe29a..4017644307 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1436,7 +1436,7 @@ text-* variant utilities. */ .btn-arc { font-size: 0.8125rem; } -[data-slot='composer-rich-input']:empty::before { +[data-slot='composer-rich-input']:is(:empty, [data-empty])::before { color: var(--ui-text-tertiary) !important; } From fead8c8d6ad3fa6f422937e97a35936929159a79 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 23:42:43 -0500 Subject: [PATCH 25/55] feat(tui): one token type for everything deferred in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A collapsed paste and an attached image are the same idea: a `[[ … ]]` marker sitting in the input line that stands in for a payload resolved at submit. Model both as ComposerToken and give them one expander. Image tokens resolve to nothing — the gateway already holds the file in attached_images — so expandTokens eats an adjacent space to avoid leaving a gap mid-sentence. nextImageIndex never reuses an index after a delete, or two files would collide on one label. --- ui-tui/src/__tests__/attachments.test.ts | 96 ++++++++++++++++++++++++ ui-tui/src/app/interfaces.ts | 34 ++++++--- ui-tui/src/app/useSubmission.test.ts | 34 --------- ui-tui/src/domain/attachments.ts | 60 +++++++++++++++ 4 files changed, 179 insertions(+), 45 deletions(-) create mode 100644 ui-tui/src/__tests__/attachments.test.ts delete mode 100644 ui-tui/src/app/useSubmission.test.ts create mode 100644 ui-tui/src/domain/attachments.ts diff --git a/ui-tui/src/__tests__/attachments.test.ts b/ui-tui/src/__tests__/attachments.test.ts new file mode 100644 index 0000000000..50e5088bcc --- /dev/null +++ b/ui-tui/src/__tests__/attachments.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' + +import type { ComposerToken } from '../app/interfaces.js' +import { droppedTokens, expandTokens, imageToken, nextImageIndex } from '../domain/attachments.js' + +const paste = (label: string, text: string): ComposerToken => ({ kind: 'paste', label, text }) + +const image = (index: number, path = `/tmp/img${index}.png`): ComposerToken => ({ + index, + kind: 'image', + label: imageToken(index), + path +}) + +describe('expandTokens (what the agent actually receives)', () => { + it('replaces a collapsed paste label with its full content', () => { + const label = '[[ hello.. [3 lines] .. world ]]' + const expand = expandTokens([paste(label, 'hello\nfoo\nworld')]) + + expect(expand(`here: ${label} done`)).toBe('here: hello\nfoo\nworld done') + }) + + it('is a no-op for already-expanded / token-free text (recall round-trip)', () => { + const expanded = 'hello\nfoo\nworld' + expect(expandTokens([])(expanded)).toBe(expanded) + }) + + it('expands repeated identical labels in submission order', () => { + const label = '[[ x [1 lines] ]]' + const expand = expandTokens([paste(label, 'first'), paste(label, 'second')]) + + expect(expand(`${label} then ${label}`)).toBe('first then second') + }) + + it('leaves an unmatched label intact', () => { + const label = '[[ orphan [2 lines] ]]' + expect(expandTokens([])(label)).toBe(label) + }) + + it('drops an image token from the text — the gateway already holds the file', () => { + const expand = expandTokens([image(1)]) + + expect(expand(`what is in ${imageToken(1)}`)).toBe('what is in') + }) + + it('leaves no double space where an image token sat mid-sentence', () => { + const expand = expandTokens([image(1)]) + + expect(expand(`before ${imageToken(1)} after`)).toBe('before after') + }) + + it('resolves an image-only message to empty text', () => { + expect(expandTokens([image(1)])(imageToken(1))).toBe('') + }) + + it('resolves pastes and images in one pass', () => { + const label = '[[ log.. [9 lines] ]]' + const expand = expandTokens([paste(label, 'stack\ntrace'), image(2)]) + + expect(expand(`${label} and ${imageToken(2)}`)).toBe('stack\ntrace and') + }) +}) + +describe('nextImageIndex (user-facing numbering)', () => { + it('starts at 1', () => { + expect(nextImageIndex([])).toBe(1) + }) + + it('counts past existing images', () => { + expect(nextImageIndex([image(1), image(2)])).toBe(3) + }) + + it('ignores paste tokens', () => { + expect(nextImageIndex([paste('[[ x ]]', 'y')])).toBe(1) + }) + + it('does not reuse an index after an earlier image is deleted', () => { + // [[ Image 1 ]] was erased; the next attach must not become Image 1 again + // or expandTokens would resolve two different files to one label. + expect(nextImageIndex([image(2)])).toBe(3) + }) +}) + +describe('droppedTokens (deleting the token unattaches the thing)', () => { + it('reports an image whose token was erased from the text', () => { + expect(droppedTokens([image(1)], 'just text now')).toEqual([image(1)]) + }) + + it('reports nothing while the token is still present', () => { + expect(droppedTokens([image(1)], `look at ${imageToken(1)}`)).toEqual([]) + }) + + it('keeps a surviving token when a sibling is erased', () => { + expect(droppedTokens([image(1), image(2)], imageToken(2))).toEqual([image(1)]) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 1a446f69e1..3802769e93 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -7,7 +7,6 @@ import type { BillingCardInfo, BillingMutationResponse, BillingStateResponse, - ImageAttachResponse, SessionCloseResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, @@ -364,6 +363,10 @@ export interface ComposerPasteResult { export type MaybePromise = Promise | T export interface ComposerActions { + /** Pull an image off the system clipboard in as a token. */ + attachClipboardImage: () => void + /** Attach an image by path in as a token. */ + attachImagePath: (path: string) => void clearIn: () => void dequeue: () => string | undefined enqueue: (text: string) => void @@ -373,12 +376,14 @@ export interface ComposerActions { removeQueue: (index: number) => void replaceQueue: (index: number, text: string) => void setCompIdx: StateSetter + setComposerTokens: StateSetter setHistoryIdx: StateSetter setInput: StateSetter setInputBuf: StateSetter - setPasteSnips: StateSetter setQueueEdit: (index: null | number) => void syncQueue: () => void + /** Reconcile attached payloads against tokens still present in the text. */ + syncTokens: (value: string) => void } export interface ComposerRefs { @@ -387,6 +392,7 @@ export interface ComposerRefs { queueEditRef: MutableRefObject queueRef: MutableRefObject submitRef: MutableRefObject<(value: string) => void> + tokensRef: MutableRefObject } export interface ComposerState { @@ -396,16 +402,15 @@ export interface ComposerState { historyIdx: null | number input: string inputBuf: string[] - pasteSnips: PasteSnippet[] queueEditIdx: null | number queuedDisplay: string[] + tokens: ComposerToken[] } export interface UseComposerStateOptions { gw: GatewayClient - onClipboardPaste: (quiet?: boolean) => Promise | void - onImageAttached?: (info: ImageAttachResponse) => void submitRef: MutableRefObject<(value: string) => void> + sys: (text: string) => void } export interface UseComposerStateResult { @@ -495,10 +500,11 @@ export interface GatewayEventHandlerContext { export interface SlashHandlerContext { composer: { + attachClipboardImage: () => void + attachImagePath: (path: string) => void enqueue: (text: string) => void hasSelection: boolean openEditor: () => Promise - paste: (quiet?: boolean) => void queueRef: MutableRefObject selection: SelectionApi setInput: StateSetter @@ -618,8 +624,14 @@ export interface AppOverlaysProps { pagerPageSize: number } -export interface PasteSnippet { - label: string - path?: string - text: string -} +/** + * A `[[ … ]]` token sitting in the composer text, plus the payload it stands + * for. `paste` tokens expand back into their text at submit; `image` tokens + * are a receipt for a file the gateway already holds, and expand to nothing. + * + * `index` is the user-facing number in `[[ Image 2 ]]`; `path` is the gateway + * path, used to detach the image when its token is deleted. + */ +export type ComposerToken = + | { index: number; kind: 'image'; label: string; path: string; text?: undefined } + | { index?: undefined; kind: 'paste'; label: string; path?: string; text: string } diff --git a/ui-tui/src/app/useSubmission.test.ts b/ui-tui/src/app/useSubmission.test.ts deleted file mode 100644 index 34202104dd..0000000000 --- a/ui-tui/src/app/useSubmission.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import type { PasteSnippet } from './interfaces.js' -import { expandSnips } from './useSubmission.js' - -const snip = (label: string, text: string): PasteSnippet => ({ label, text }) - -describe('expandSnips (paste history recall)', () => { - it('replaces a collapsed paste label with its full content', () => { - const label = '[[ hello.. [3 lines] .. world ]]' - const full = `here: ${label} done` - const expand = expandSnips([snip(label, 'hello\nfoo\nworld')]) - - expect(expand(full)).toBe('here: hello\nfoo\nworld done') - }) - - it('is a no-op for already-expanded / label-free text (recall round-trip)', () => { - const expanded = 'hello\nfoo\nworld' - // Re-submitting a recalled history entry has no snips and no labels. - expect(expandSnips([])(expanded)).toBe(expanded) - }) - - it('expands repeated identical labels in submission order', () => { - const label = '[[ x [1 lines] ]]' - const expand = expandSnips([snip(label, 'first'), snip(label, 'second')]) - - expect(expand(`${label} then ${label}`)).toBe('first then second') - }) - - it('leaves an unmatched label intact', () => { - const label = '[[ orphan [2 lines] ]]' - expect(expandSnips([])(label)).toBe(label) - }) -}) diff --git a/ui-tui/src/domain/attachments.ts b/ui-tui/src/domain/attachments.ts new file mode 100644 index 0000000000..7894cf170a --- /dev/null +++ b/ui-tui/src/domain/attachments.ts @@ -0,0 +1,60 @@ +import type { ComposerToken } from '../app/interfaces.js' +import { PASTE_SNIPPET_RE } from '../protocol/paste.js' + +/** + * Composer tokens are the ONE way deferred content shows up in the input line: + * a collapsed paste and an attached image both render as `[[ … ]]` sitting in + * the text the user is editing. They are ordinary characters — arrow keys, + * backspace, and selection work on them for free — and they carry their real + * payload out-of-band until submit. + * + * Two consequences the rest of the composer relies on: + * - Deleting the token is how you drop the thing. Nothing else to click. + * - Position in the text is meaningful: the model sees the payload where the + * token sat, not stapled to the front of the turn. + */ +export const imageToken = (index: number) => `[[ Image ${index} ]]` + +/** Highest image token index handed out so far, so a new one never collides. */ +export const nextImageIndex = (tokens: ComposerToken[]) => + tokens.reduce((max, t) => (t.kind === 'image' ? Math.max(max, t.index) : max), 0) + 1 + +/** Tokens whose label is no longer anywhere in the composer text. */ +export const droppedTokens = (tokens: ComposerToken[], value: string) => { + const live = new Set(value.match(PASTE_SNIPPET_RE) ?? []) + + return tokens.filter(t => !live.has(t.label)) +} + +/** + * Resolve every token in `value` to what the agent should actually receive. + * + * Repeated identical labels expand in submission order (left to right), which + * is why this walks matches instead of doing a global replace per token. + * + * An image token expands to nothing: the gateway already holds the file in + * `session.attached_images` and splices the real vision content in at submit. + * The token's job was to show the user where it landed, so it also eats one + * adjacent space to avoid leaving a gap in the middle of a sentence. + */ +export const expandTokens = (tokens: ComposerToken[]) => { + const byLabel = new Map() + + for (const token of tokens) { + const hit = byLabel.get(token.label) + hit ? hit.push(token) : byLabel.set(token.label, [token]) + } + + return (value: string) => + value + .replace(new RegExp(`[ \\t]?(?:${PASTE_SNIPPET_RE.source})`, 'g'), match => { + const token = byLabel.get(match.trimStart())?.shift() + + if (!token) { + return match + } + + return token.kind === 'paste' ? match.slice(0, match.length - token.label.length) + token.text : '' + }) + .trim() +} From ca5ee5ed331dbbe029157f7bcbb5f8b8f3fc8566 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 23:42:53 -0500 Subject: [PATCH 26/55] feat(tui): attach images inline at the cursor, delete the token to unattach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every attach path now drops an `[[ Image N ]]` token where you are typing: drag-drop, clipboard (bracketed and hotkey), /image, /paste. The composer owns clipboard attach directly instead of calling back out to useMainApp. Deleting the token is how you unattach — there is no second control. updateInput is the one choke point every keystroke passes through, so syncTokens reconciles there and detaches anything erased. That also fixes a stale image riding along on the next unrelated turn. Tokens and the input line get refs alongside state: paste-then-immediately -Enter submits before React has re-rendered, and the submit path has to see the token that was just added. --- ui-tui/src/app/slash/commands/core.ts | 2 +- ui-tui/src/app/slash/commands/session.ts | 15 +- ui-tui/src/app/useComposerState.ts | 252 +++++++++++++++++------ ui-tui/src/app/useMainApp.ts | 61 +++--- ui-tui/src/app/useSessionLifecycle.ts | 4 +- ui-tui/src/app/useSubmission.ts | 32 ++- 6 files changed, 238 insertions(+), 128 deletions(-) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 00321ecc90..865c9cb5d6 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -439,7 +439,7 @@ export const coreCommands: SlashCommand[] = [ { help: 'attach clipboard image', name: 'paste', - run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.paste()) + run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.attachClipboardImage()) }, { diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index a412949019..bc072ec380 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -1,11 +1,10 @@ import { usageBarsText } from '../../../components/overlayPrimitives.js' -import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js' +import { introMsg, toTranscriptMessages } from '../../../domain/messages.js' import { sessionScopedModelArg, TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' import type { BackgroundStartResponse, ConfigGetValueResponse, ConfigSetResponse, - ImageAttachResponse, SessionBranchResponse, SessionCompressResponse, SessionUsageResponse, @@ -192,17 +191,7 @@ export const sessionCommands: SlashCommand[] = [ { help: 'attach an image', name: 'image', - run: (arg, ctx) => { - ctx.gateway.rpc('image.attach', { path: arg, session_id: ctx.sid }).then( - ctx.guarded(r => { - ctx.transcript.sys(attachedImageNotice(r)) - - if (r.remainder) { - ctx.composer.setInput(r.remainder) - } - }) - ) - } + run: (arg, ctx) => ctx.composer.attachImagePath(arg) }, { diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 40120326a8..188e3c5727 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -5,10 +5,11 @@ import { join } from 'node:path' import { useStdin, withInkSuspended } from '@hermes/ink' import { useStore } from '@nanostores/react' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import type { PasteEvent } from '../components/textInput.js' -import type { ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js' +import { droppedTokens, imageToken, nextImageIndex } from '../domain/attachments.js' +import type { ClipboardPasteResponse, ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js' import { useCompletion } from '../hooks/useCompletion.js' import { useInputHistory } from '../hooks/useInputHistory.js' import { useQueue } from '../hooks/useQueue.js' @@ -18,30 +19,37 @@ import { readOsc52Clipboard } from '../lib/osc52.js' import { isRemoteShellSession } from '../lib/terminalSetup.js' import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js' -import type { MaybePromise, PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js' +import type { + ComposerPasteResult, + ComposerToken, + MaybePromise, + StateSetter, + UseComposerStateOptions, + UseComposerStateResult +} from './interfaces.js' import { $isBlocked } from './overlayStore.js' import { getUiState } from './uiStore.js' -const PASTE_SNIP_MAX_COUNT = 32 -const PASTE_SNIP_MAX_TOTAL_BYTES = 4 * 1024 * 1024 +const TOKEN_MAX_COUNT = 32 +const TOKEN_MAX_TOTAL_BYTES = 4 * 1024 * 1024 -const trimSnips = (snips: PasteSnippet[]): PasteSnippet[] => { +const trimTokens = (tokens: ComposerToken[]): ComposerToken[] => { let total = 0 - const out: PasteSnippet[] = [] + const out: ComposerToken[] = [] - for (let i = snips.length - 1; i >= 0; i--) { - const snip = snips[i]! - const size = snip.text.length + for (let i = tokens.length - 1; i >= 0; i--) { + const token = tokens[i]! + const size = token.text?.length ?? 0 - if (out.length >= PASTE_SNIP_MAX_COUNT || total + size > PASTE_SNIP_MAX_TOTAL_BYTES) { + if (out.length >= TOKEN_MAX_COUNT || total + size > TOKEN_MAX_TOTAL_BYTES) { break } total += size - out.unshift(snip) + out.unshift(token) } - return out.length === snips.length ? snips : out + return out.length === tokens.length ? tokens : out } /** Insert text at the cursor position, adding spacing to separate from adjacent non-whitespace. */ @@ -97,15 +105,26 @@ export function looksLikeDroppedPath(text: string): boolean { return false } -export function useComposerState({ - gw, - onClipboardPaste, - onImageAttached, - submitRef -}: UseComposerStateOptions): UseComposerStateResult { - const [input, setInput] = useState('') +export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions): UseComposerStateResult { + const [input, setInputState] = useState('') const [inputBuf, setInputBuf] = useState([]) - const [pasteSnips, setPasteSnips] = useState([]) + const [tokens, setTokens] = useState([]) + // Tokens and the input line are read from keystroke handlers that run several + // times before React re-renders, so the refs — not the state — are the source + // of truth for "what is in the composer right now". + const inputRef = useRef('') + const tokensRef = useRef([]) + + const setInput = useCallback>(next => { + inputRef.current = typeof next === 'function' ? next(inputRef.current) : next + setInputState(inputRef.current) + }, []) + + const setComposerTokens = useCallback>(next => { + tokensRef.current = typeof next === 'function' ? next(tokensRef.current) : next + setTokens(tokensRef.current) + }, []) + const isBlocked = useStore($isBlocked) const { querier } = useStdin() as { querier: Parameters[0] } @@ -128,27 +147,95 @@ export function useComposerState({ const clearIn = useCallback(() => { setInput('') setInputBuf([]) - setPasteSnips([]) + setComposerTokens([]) setQueueEdit(null) setHistoryIdx(null) historyDraftRef.current = '' - }, [historyDraftRef, setQueueEdit, setHistoryIdx]) + }, [historyDraftRef, setComposerTokens, setHistoryIdx, setInput, setQueueEdit]) + + /** + * Deleting an `[[ Image N ]]` token IS how you unattach the image — there is + * no separate control. Reconcile on every edit so the gateway's + * `attached_images` never outlives the token the user just erased, which is + * what used to make a stale image ride along on the next unrelated turn. + */ + const syncTokens = useCallback( + (value: string) => { + const gone = droppedTokens(tokensRef.current, value) + + if (!gone.length) { + return + } + + for (const token of gone) { + if (token.kind === 'image') { + void gw.request('image.detach', { path: token.path, session_id: getUiState().sid }).catch(() => {}) + } + } + + setComposerTokens(prev => prev.filter(token => !gone.includes(token))) + }, + [gw, setComposerTokens] + ) + + /** + * Attach an image the gateway already resolved: a token at the cursor, + * followed by whatever non-path text came along with it (a drag-drop paste + * of `~/shot.png look at this` keeps the caption). + */ + const attachImageToken = useCallback( + (attached: ImageAttachResponse & { path?: string }, value: string, cursor: number): ComposerPasteResult => { + const index = nextImageIndex(tokensRef.current) + const label = imageToken(index) + + setComposerTokens(prev => trimTokens([...prev, { index, kind: 'image', label, path: attached.path ?? '' }])) + + const withToken = insertAtCursor(value, cursor, label) + const remainder = attached.remainder?.trim() ?? '' + + return remainder ? insertAtCursor(withToken.value, withToken.cursor, remainder) : withToken + }, + [setComposerTokens] + ) + + /** + * Pull an image off the system clipboard into the composer as a token. + * + * `quiet` is the empty-bracketed-paste probe: the terminal delivers an image + * paste as zero text, so we speculatively ask the gateway and stay silent if + * there was nothing there. An explicit `/paste` reports the miss. + */ + const pasteClipboardImage = useCallback( + async (value: string, cursor: number, quiet: boolean): Promise => { + const sid = getUiState().sid + + if (!sid) { + return null + } + + const r = await gw + .request('clipboard.paste', { session_id: sid }) + .catch(() => null) + + if (r?.attached) { + return attachImageToken(r, value, cursor) + } + + if (!quiet) { + sys(r?.message || 'No image found in clipboard') + } + + return null + }, + [attachImageToken, gw, sys] + ) const handleResolvedPaste = useCallback( - async ({ - bracketed, - cursor, - text, - value - }: Omit): Promise => { + async ({ bracketed, cursor, text, value }: Omit): Promise => { const cleanedText = stripTrailingPasteNewlines(text) if (!cleanedText || !/[^\n]/.test(cleanedText)) { - if (bracketed) { - void onClipboardPaste(true) - } - - return null + return bracketed ? pasteClipboardImage(value, cursor, true) : null } const sid = getUiState().sid @@ -161,14 +248,11 @@ export function useComposerState({ }) if (attached?.name) { - onImageAttached?.(attached) - const remainder = attached.remainder?.trim() ?? '' - - if (!remainder) { - return { cursor, value } - } - - return insertAtCursor(value, cursor, remainder) + // Drop an `[[ Image N ]]` token where the path was typed. The old + // path printed a notice above the status bar and left the composer + // untouched, so the only trace of the attachment lived outside the + // input the user was editing. + return attachImageToken(attached, value, cursor) } } catch { // Fall back to generic file-drop detection below. @@ -204,7 +288,7 @@ export function useComposerState({ const label = pasteTokenLabel(cleanedText, lineCount) const inserted = insertAtCursor(value, cursor, label) - setPasteSnips(prev => trimSnips([...prev, { label, text: cleanedText }])) + setComposerTokens(prev => trimTokens([...prev, { kind: 'paste', label, text: cleanedText }])) void gw .request<{ path?: string }>('paste.collapse', { text: cleanedText }) @@ -215,13 +299,13 @@ export function useComposerState({ return } - setPasteSnips(prev => prev.map(s => (s.label === label ? { ...s, path } : s))) + setComposerTokens(prev => prev.map(t => (t.label === label ? { ...t, path } : t))) }) .catch(() => {}) return inserted }, - [gw, onClipboardPaste, onImageAttached] + [attachImageToken, gw, pasteClipboardImage, setComposerTokens] ) const handleTextPaste = useCallback( @@ -231,7 +315,7 @@ export function useComposerState({ hotkey, text, value - }: PasteEvent): MaybePromise => { + }: PasteEvent): MaybePromise => { if (hotkey) { const preferOsc52 = isRemoteShellSession(process.env) @@ -256,15 +340,58 @@ export function useComposerState({ return handleResolvedPaste({ bracketed: false, cursor, text: preferredText, value }) } - void onClipboardPaste(false) - - return null + // No text on the clipboard — an image paste looks exactly like this. + return pasteClipboardImage(value, cursor, false) }) } return handleResolvedPaste({ bracketed: !!bracketed, cursor, text, value }) }, - [handleResolvedPaste, onClipboardPaste, querier] + [handleResolvedPaste, pasteClipboardImage, querier] + ) + + /** + * `/paste` and `/image` attach without a cursor of their own — the token + * lands at the end of whatever is currently typed. + */ + const appendAttachment = useCallback( + (attach: (value: string, cursor: number) => Promise) => { + const current = inputRef.current + + void attach(current, current.length).then(next => { + if (next) { + setInput(next.value) + } + }) + }, + [setInput] + ) + + const attachClipboardImage = useCallback( + () => appendAttachment((value, cursor) => pasteClipboardImage(value, cursor, false)), + [appendAttachment, pasteClipboardImage] + ) + + const attachImagePath = useCallback( + (path: string) => + appendAttachment(async (value, cursor) => { + const sid = getUiState().sid + + if (!sid || !path.trim()) { + return null + } + + const attached = await gw + .request('image.attach', { path, session_id: sid }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + + return null + }) + + return attached?.name ? attachImageToken(attached, value, cursor) : null + }), + [appendAttachment, attachImageToken, gw, sys] ) const openEditor = useCallback(async () => { @@ -297,10 +424,12 @@ export function useComposerState({ } finally { rmSync(dir, { force: true, recursive: true }) } - }, [input, inputBuf, submitRef]) + }, [input, inputBuf, setInput, submitRef]) const actions = useMemo( () => ({ + attachClipboardImage, + attachImagePath, clearIn, dequeue, enqueue, @@ -310,14 +439,17 @@ export function useComposerState({ removeQueue: removeQ, replaceQueue: replaceQ, setCompIdx, + setComposerTokens, setHistoryIdx, setInput, setInputBuf, - setPasteSnips, setQueueEdit, - syncQueue + syncQueue, + syncTokens }), [ + attachClipboardImage, + attachImagePath, clearIn, dequeue, enqueue, @@ -327,9 +459,12 @@ export function useComposerState({ removeQ, replaceQ, setCompIdx, + setComposerTokens, setHistoryIdx, + setInput, setQueueEdit, - syncQueue + syncQueue, + syncTokens ] ) @@ -339,7 +474,8 @@ export function useComposerState({ historyRef, queueEditRef, queueRef, - submitRef + submitRef, + tokensRef }), [historyDraftRef, historyRef, queueEditRef, queueRef, submitRef] ) @@ -352,11 +488,11 @@ export function useComposerState({ historyIdx, input, inputBuf, - pasteSnips, queueEditIdx, - queuedDisplay + queuedDisplay, + tokens }), - [compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay] + [compIdx, compReplace, completions, historyIdx, input, inputBuf, queueEditIdx, queuedDisplay, tokens] ) return { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 5766ebb6a7..18277fd663 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -16,13 +16,11 @@ import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' import { RESIZE_COALESCE_MS } from '../config/timing.js' import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js' import { SECTION_NAMES, sectionMode } from '../domain/details.js' -import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' import { composeTabTitle, fmtProjectCwdBranch, shortCwd } from '../domain/paths.js' import { sessionScopedModelArg } from '../domain/slash.js' import { type GatewayClient } from '../gatewayClient.js' import type { ClarifyRespondResponse, - ClipboardPasteResponse, ConfigSetResponse, GatewayEvent, SessionActiveListResponse, @@ -46,7 +44,7 @@ import { createGatewayEventHandler } from './createGatewayEventHandler.js' import { createSlashHandler } from './createSlashHandler.js' import { planGatewayRecovery } from './gatewayRecovery.js' import { getInputSelection } from './inputSelectionStore.js' -import { type GatewayRpc, type TranscriptRow } from './interfaces.js' +import { type GatewayRpc, type StateSetter, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' import { $goodVibesTick } from './petFlashStore.js' import { scrollWithSelectionBy } from './scroll.js' @@ -223,7 +221,7 @@ export function useMainApp(gw: GatewayClient) { const colsRef = useRef(cols) const scrollRef = useRef(null) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) - const clipboardPasteRef = useRef<(quiet?: boolean) => Promise | void>(() => {}) + const sysRef = useRef<(text: string) => void>(() => {}) const submitRef = useRef<(value: string) => void>(() => {}) const terminalHintsShownRef = useRef(new Set()) const historyItemsRef = useRef(historyItems) @@ -296,11 +294,8 @@ export function useMainApp(gw: GatewayClient) { const composer = useComposerState({ gw, - onClipboardPaste: quiet => clipboardPasteRef.current(quiet), - onImageAttached: info => { - sys(attachedImageNotice(info)) - }, - submitRef + submitRef, + sys: text => sysRef.current(text) }) const { actions: composerActions, refs: composerRefs, state: composerState } = composer @@ -711,27 +706,7 @@ export function useMainApp(gw: GatewayClient) { [appendMessage, overlay.clarify, rpc] ) - const paste = useCallback( - (quiet = false) => - rpc('clipboard.paste', { session_id: getUiState().sid }).then(r => { - if (!r) { - return - } - - if (r.attached) { - const meta = imageTokenMeta(r) - - return sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`) - } - - if (!quiet) { - sys(r.message || 'No image found in clipboard') - } - }), - [rpc, sys] - ) - - clipboardPasteRef.current = paste + sysRef.current = sys const { dispatchSubmission, send, sendQueued, submit } = useSubmission({ appendMessage, @@ -891,10 +866,11 @@ export function useMainApp(gw: GatewayClient) { () => createSlashHandler({ composer: { + attachClipboardImage: composerActions.attachClipboardImage, + attachImagePath: composerActions.attachImagePath, enqueue: composerActions.enqueue, hasSelection, openEditor: composerActions.openEditor, - paste, queueRef: composerRefs.queueRef, selection, setInput: composerActions.setInput @@ -933,7 +909,6 @@ export function useMainApp(gw: GatewayClient) { maybeWarn, page, panel, - paste, selection, send, session, @@ -1137,6 +1112,24 @@ export function useMainApp(gw: GatewayClient) { ] ) + /** + * Every keystroke lands here, so this is where attached payloads are + * reconciled against the tokens still in the text — deleting an + * `[[ Image N ]]` is how the user unattaches it. + */ + const updateInput = useCallback>( + next => { + composerActions.setInput(prev => { + const value = typeof next === 'function' ? next(prev) : next + + composerActions.syncTokens(value) + + return value + }) + }, + [composerActions] + ) + const appComposer = useMemo( () => ({ cols, @@ -1150,10 +1143,10 @@ export function useMainApp(gw: GatewayClient) { queueEditIdx: composerState.queueEditIdx, queuedDisplay: composerState.queuedDisplay, submit, - updateInput: composerActions.setInput, + updateInput, voiceRecordKey }), - [cols, composerActions, composerState, empty, pagerPageSize, submit, voiceRecordKey] + [cols, composerActions, composerState, empty, pagerPageSize, submit, updateInput, voiceRecordKey] ) // Pass current progress through unfrozen — streaming update throttling diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 4cce57bae5..13dab7ce4c 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -178,7 +178,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { setHistoryItems([]) setLastUserMsg('') setStickyPrompt('') - composerActions.setPasteSnips([]) + composerActions.setComposerTokens([]) // Half-prune: new session has new keys, but keep a warm pool in case // the user resumes back to the prior session. evictInkCaches('half') @@ -202,7 +202,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { setHistoryItems(info ? [introMsg(info)] : []) setStickyPrompt('') setLastUserMsg('') - composerActions.setPasteSnips([]) + composerActions.setComposerTokens([]) patchTurnState({ activity: [] }) patchUiState({ info, usage: usageFrom(info) }) }, diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 0ced5f0b8a..28ffaff9ca 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -1,32 +1,21 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import { TYPING_IDLE_MS } from '../config/timing.js' +import { expandTokens } from '../domain/attachments.js' import { completionToApplyOnSubmit, looksLikeSlashCommand } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' import type { SessionSteerResponse, ShellExecResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js' -import { PASTE_SNIPPET_RE } from '../protocol/paste.js' import type { Msg } from '../types.js' -import type { ComposerActions, ComposerRefs, ComposerState, PasteSnippet } from './interfaces.js' +import type { ComposerActions, ComposerRefs, ComposerState } from './interfaces.js' import { submitPrompt } from './submissionCore.js' import { turnController } from './turnController.js' import { getUiState, patchUiState } from './uiStore.js' const DOUBLE_ENTER_MS = 450 -export const expandSnips = (snips: PasteSnippet[]) => { - const byLabel = new Map() - - for (const { label, text } of snips) { - const hit = byLabel.get(label) - hit ? hit.push(text) : byLabel.set(label, [text]) - } - - return (value: string) => value.replace(PASTE_SNIPPET_RE, tok => byLabel.get(tok)?.shift() ?? tok) -} - const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) => matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text) @@ -68,7 +57,9 @@ export function useSubmission(opts: UseSubmissionOptions) { const send = useCallback( (text: string, showUserMessage = true, displayText?: string) => { - const expand = expandSnips(composerState.pasteSnips) + // Read tokens off the ref, not render state: a paste immediately followed + // by Enter submits before React has re-rendered with the new token. + const expand = expandTokens(composerRefs.tokensRef.current) submitPrompt( text, @@ -84,7 +75,7 @@ export function useSubmission(opts: UseSubmissionOptions) { displayText ) }, - [appendMessage, composerActions, composerState.pasteSnips, gw, setLastUserMsg, sys] + [appendMessage, composerActions, composerRefs, gw, setLastUserMsg, sys] ) const shellExec = useCallback( @@ -217,10 +208,12 @@ export function useSubmission(opts: UseSubmissionOptions) { return } - // History stores expanded paste content, not the `[[…]]` label: snips - // are cleared on submit, so recall must be self-contained. Idempotent on - // label-free text, so re-submitting a recalled entry stays stable. - const toHistory = expandSnips(composerState.pasteSnips)(full) + // History stores resolved content, not `[[…]]` labels: tokens are cleared + // on submit, so recall must be self-contained. Image tokens resolve to + // nothing — a detached image can't be re-attached by recalling the text. + // Idempotent on token-free text, so re-submitting a recalled entry is + // stable. + const toHistory = expandTokens(composerRefs.tokensRef.current)(full) if (looksLikeSlashCommand(full)) { appendMessage({ kind: 'slash', role: 'system', text: full }) @@ -294,7 +287,6 @@ export function useSubmission(opts: UseSubmissionOptions) { appendMessage, composerActions, composerRefs, - composerState.pasteSnips, handleBusyInput, interpolate, send, From 22af266b4f3b865144279c81bc77c5cb4dbe7fd6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 23:42:58 -0500 Subject: [PATCH 27/55] fix(tui): stop announcing attachments outside the composer The token in the input line is the whole receipt. Drop the notices that duplicated it somewhere the user was not looking: the drag-drop and clipboard sys() lines, and the attachedImageNotice / "detected file: X" activity rows above the status bar. attachedImageNotice and imageTokenMeta have no callers left. --- ui-tui/src/app/submissionCore.ts | 12 +++++------- ui-tui/src/domain/messages.ts | 23 +---------------------- 2 files changed, 6 insertions(+), 29 deletions(-) diff --git a/ui-tui/src/app/submissionCore.ts b/ui-tui/src/app/submissionCore.ts index c99eca09f2..aa6cd98db6 100644 --- a/ui-tui/src/app/submissionCore.ts +++ b/ui-tui/src/app/submissionCore.ts @@ -1,4 +1,3 @@ -import { attachedImageNotice } from '../domain/messages.js' import type { GatewayClient } from '../gatewayClient.js' import type { InputDetectDropResponse, PromptSubmitResponse } from '../gatewayTypes.js' import type { Msg } from '../types.js' @@ -109,6 +108,11 @@ export function submitPrompt( // Always ask the backend whether this looks like a file drop. The backend's // _detect_file_drop handles paths with spaces, quotes, Windows drive letters, // and escaped characters correctly. + // + // No notice is emitted for a match: an image dropped into the composer already + // shows as an `[[ Image N ]]` token, and a matched non-image path is rewritten + // in place. Announcing it a second time above the status bar was the old + // out-of-band attachment UI. deps.gw .request('input.detect_drop', { session_id: sid, text }) .then(r => { @@ -116,12 +120,6 @@ export function submitPrompt( return startSubmit(text, deps.expand(text), showUserMessage) } - if (r.is_image) { - turnController.pushActivity(attachedImageNotice(r)) - } else { - turnController.pushActivity(`detected file: ${r.name}`) - } - startSubmit(r.text || text, deps.expand(r.text || text), showUserMessage) }) .catch(() => startSubmit(text, deps.expand(text), showUserMessage)) diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts index 45fbf606c0..b4428520d8 100644 --- a/ui-tui/src/domain/messages.ts +++ b/ui-tui/src/domain/messages.ts @@ -1,24 +1,9 @@ import { LONG_MSG } from '../config/limits.js' -import { buildToolTrailLine, fmtK } from '../lib/text.js' +import { buildToolTrailLine } from '../lib/text.js' import type { Msg, SessionInfo } from '../types.js' export const introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' }) -export const imageTokenMeta = (info?: ImageMeta | null) => { - const { width, height, token_estimate: t } = info ?? {} - - return [width && height ? `${width}x${height}` : '', (t ?? 0) > 0 ? `~${fmtK(t!)} tok` : ''] - .filter(Boolean) - .join(' · ') -} - -export const attachedImageNotice = (info?: ({ name?: string } & ImageMeta) | null) => { - const meta = imageTokenMeta(info) - const label = info?.name ? `📎 Attached image: ${info.name}` : '📎 Attached image' - - return `${label}${meta ? ` · ${meta}` : ''}` -} - export const userDisplay = (text: string) => { if (text.length <= LONG_MSG) { return text @@ -112,12 +97,6 @@ export const fmtDuration = (ms: number) => { return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s` } -interface ImageMeta { - height?: number - token_estimate?: number - width?: number -} - interface TranscriptRow { context?: string display_kind?: string From ce6dd1a65f4b6b20b1f3b31f75184a3e26583488 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Thu, 30 Jul 2026 22:31:42 -0700 Subject: [PATCH 28/55] fix(sync): read org state from the org endpoints, not the personal ones (#75237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Org-shared skills were unusable past the first propose. Three defects, one root cause plus two that it masked. ROOT CAUSE — org reads went to the personal endpoint. `SyncClient.get_refs()` / `get_object()` only ever called `/v1/sync/refs` and `/v1/sync/objects/:hash`. Those routes are hard-scoped server-side to the token's own owner, so asking them for `refs/org//` returns the caller's PERSONAL refs rather than an error, and org objects 404. Both org call sites read org state through them: - `pull_org_skills` resolved head=None for a populated org and reported `{"ok": true, "head": null, "updated": []}` — org skills silently never arrived, which reads as "my org has no skills" rather than as a failure. - `propose_skill` resolved base_head=None, so the FIRST propose to an org succeeded by accident (`from: null` happened to be correct) and EVERY later one CAS'd against a head it had never seen -> 409 -> a raw `SyncConflict` traceback. Worse, it built its root from an empty skill map, so a landed CAS would have REPLACED the org set rather than splicing into it — the 409 was accidentally preventing data loss. Fix: `org_scope=True` on `get_refs`/`get_object`, threaded through `get_commit_json`, `get_tree_json`, `_root_tree_of_commit`, `_skill_trees_of_root`, and `materialize_tree` — walking an org commit needs the org route on every hop, not just the first. Both org call sites now go through one `_read_org_head()` helper. ALSO FIXED - `propose_skill` retries on conflict. When the org HEAD moves between the read and the CAS (another member proposing, an admin merging), it re-splices this one skill onto the NEW head and retries, bounded at 5 attempts. Re-splicing rather than replaying the old root is what stops a concurrent proposal being dropped. - An empty `actual` in a 409 means "the ref does not exist", not "here is a commit". `SyncConflict` normalizes "" to None in its constructor, and the personal push path redoes the CAS as a create instead of fetching "" as an object — which surfaced as the baffling `object not found` (doubled space). This is what a client hits after switching sync planes, since `.sync_state` is not environment-scoped and carries a foreign head. THE MOCK WAS THE REASON THIS SHIPPED The test mock served org refs and org objects off the personal routes, so 21 org tests passed against a client that could not work against the real plane. The mock now mirrors production: `/v1/sync/org/refs` and `/v1/sync/org/objects/:hash` exist, org objects live in a separate scope, and the personal routes refuse org content. Two existing tests had to be corrected to assert against the org scope — they had been passing on the mock's over-permissiveness. Tests: 5 new (org head invisible on the personal route; second propose splices and preserves the first; pull resolves a real org head; empty `actual` -> None; push recovers from a stale cross-plane head). Verified they FAIL without the fix: reverting just `_read_org_head` to the personal route fails the second-propose test and the pre-existing splice test. 1278 passed / 0 failed across 54 suites via scripts/run_tests.sh. Verified against PRODUCTION with a real org token, not just the mock: - `pull_org_skills` -> head `sha256:1adf9333…`, materialized `software-development/gateway-gateway-connector` into the `_org` mirror (was head=None, updated=[]). - A second `hermes sync propose` succeeded where it previously raised, and the org set afterwards contains BOTH skills with the new commit descending from the first. --- tests/tools/test_skills_sync_client.py | 185 +++++++++++++++++++--- tools/skills_sync_client.py | 206 ++++++++++++++++++------- 2 files changed, 311 insertions(+), 80 deletions(-) diff --git a/tests/tools/test_skills_sync_client.py b/tests/tools/test_skills_sync_client.py index ec7e68d933..c967eef187 100644 --- a/tests/tools/test_skills_sync_client.py +++ b/tests/tools/test_skills_sync_client.py @@ -39,6 +39,12 @@ class _MockState: # when org_role_admin is False, convert org-HEAD CAS to 202 proposals. self.org_feature = True self.org_role_admin = True + self.org_role_present = True + # Org objects live in a SEPARATE scope from personal ones, mirroring + # production's `org:` scope key. Keeping them in a distinct + # dict is what makes a personal-route read of org content 404 in tests + # exactly as it does against the real plane. + self.org_objects = {} self.proposals = [] # [{n, to, base}] @@ -79,50 +85,74 @@ def _make_handler(state: _MockState): if part.startswith("prefix="): from urllib.parse import unquote prefix = unquote(part[len("prefix="):]) + # FAITHFUL TO PRODUCTION: the personal refs route is scoped to + # the caller's own owner and does NOT serve org refs. Asking it + # for an `refs/org/...` prefix yields the caller's personal + # refs, not an error — the exact trap that let a broken client + # look healthy against an over-permissive mock. refs = [ {"name": n, "hash": h} for n, h in state.refs.items() - if n.startswith(prefix) + if n.startswith(prefix) and not n.startswith("refs/org/") ] return self._json(200, {"refs": refs}) + if path == "/v1/sync/org/refs": + if not state.org_feature: + return self._json(404, {"error": "unknown"}) + if not state.org_role_present: + return self._json(403, {"error": "org_workflow_unavailable"}) + refs = [ + {"name": n, "hash": h} + for n, h in state.refs.items() + if n.startswith("refs/org/") + ] + return self._json(200, {"refs": refs}) + + if path.startswith("/v1/sync/org/objects/"): + if not state.org_role_present: + return self._json(403, {"error": "org_workflow_unavailable"}) + obj_hash = path[len("/v1/sync/org/objects/"):] + if obj_hash not in state.org_objects: + return self._json(404, {"error": "not_found"}) + return self._send_object(*state.org_objects[obj_hash]) + if path.startswith("/v1/sync/objects/"): obj_hash = path[len("/v1/sync/objects/"):] + # Org-scoped objects are NOT readable through the personal + # route (production scopes it to the token owner). if obj_hash not in state.objects: return self._json(404, {"error": "not_found"}) - kind, data = state.objects[obj_hash] - if kind == ssc.KIND_BLOB: - self.send_response(200) - self.send_header("Content-Type", "application/octet-stream") - self.send_header("X-HSP-Object-Type", "blob") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - return - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("X-HSP-Object-Type", kind) - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - return + return self._send_object(*state.objects[obj_hash]) self._json(404, {"error": "unknown"}) + def _send_object(self, kind, data): + self.send_response(200) + self.send_header( + "Content-Type", + "application/octet-stream" if kind == ssc.KIND_BLOB else "application/json", + ) + self.send_header("X-HSP-Object-Type", kind) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + return + def do_POST(self): length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) if length else b"" path = self.path.split("?", 1)[0] # e.g. /v1/sync/objects?scope=org if path == "/v1/sync/objects": - return self._handle_put_objects(raw) + return self._handle_put_objects(raw, org="scope=org" in self.path) if path.startswith("/v1/sync/refs/"): return self._handle_cas(raw) self._json(404, {"error": "unknown"}) - def _handle_put_objects(self, raw): + def _handle_put_objects(self, raw, org=False): # multipart/form-data: parse parts (field=hash, filename=type, # body=raw bytes). The server recomputes each hash and 422s on # mismatch (contract §4.2). @@ -163,10 +193,11 @@ def _make_handler(state: _MockState): return self._json(422, { "error": "hash_mismatch", "claimed": claimed_hash, }) - if claimed_hash in state.objects: + store = state.org_objects if org else state.objects + if claimed_hash in store: already.append(claimed_hash) else: - state.objects[claimed_hash] = (kind, body) + store[claimed_hash] = (kind, body) accepted.append(claimed_hash) return self._json(200, {"accepted": accepted, "already_present": already}) @@ -890,7 +921,9 @@ class TestOrgEndToEnd: assert result.get("merged") is True head = state.refs["refs/org/org-1/HEAD"] assert head == result["head"] - commit = json.loads(state.objects[head][1]) + # Org content lands in the ORG object scope, not the personal one. + assert head not in state.objects, "org commit must not be personal-scoped" + commit = json.loads(state.org_objects[head][1]) assert commit["parents"] == [] # first org commit def test_member_propose_becomes_202_proposal(self, mock_server, synced_env): @@ -931,8 +964,8 @@ class TestOrgEndToEnd: member_ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"} result = ssc.propose_skill("alpha", client, identity=member_ident) # Walk the proposed commit's root: both skills present. - commit = json.loads(state.objects[result["commit"]][1]) - root = json.loads(state.objects[commit["tree"]][1]) + commit = json.loads(state.org_objects[result["commit"]][1]) + root = json.loads(state.org_objects[commit["tree"]][1]) names = {e["name"] for e in root["entries"]} assert "alpha" in names and "devops" in names @@ -976,3 +1009,107 @@ class TestOrgEndToEnd: monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", lambda **kw: {"api_key": token}) assert ssc.maybe_pull_org_skills() is None + + +class TestOrgEndpointScoping: + """Org reads must use the ORG endpoints, not the personal ones. + + The personal refs route is scoped to the caller's own owner: asked for an + ``refs/org/...`` prefix it returns the caller's PERSONAL refs rather than + erroring. A client reading org state through it therefore concludes "this + org has no content" and every subsequent CAS races a head it never saw — + which is exactly how a second propose used to die on a raw SyncConflict. + """ + + def _admin(self, identity): + return {**identity, "org_id": "org-1", "org_role": "ADMIN"} + + def test_org_head_is_not_visible_on_the_personal_route( + self, mock_server, synced_env + ): + base, state = mock_server + home, skills, identity = synced_env + state.refs["refs/org/org-1/HEAD"] = "sha256:" + "a" * 64 + client = ssc.SyncClient(base, identity["api_key"]) + + personal = client.get_refs("refs/org/org-1/") + assert personal == [], ( + "the personal refs route must not serve org refs — if it does, " + "the mock is more permissive than production and will hide bugs" + ) + org = client.get_refs("refs/org/org-1/", org_scope=True) + assert [r["name"] for r in org] == ["refs/org/org-1/HEAD"] + + def test_second_propose_splices_onto_the_existing_org_head( + self, mock_server, synced_env + ): + """The regression: propose #1 works, propose #2 used to raise.""" + base, state = mock_server + home, skills, identity = synced_env + admin = self._admin(identity) + client = ssc.SyncClient(base, identity["api_key"]) + + # `synced_env` already seeds alpha and beta (beta under devops/). + first = ssc.propose_skill("alpha", client, identity=admin) + assert first["ok"] is True + + # Previously: base_head read as None -> CAS from None -> 409 -> + # SyncConflict escaped to the caller. + second = ssc.propose_skill("beta", client, identity=admin) + assert second["ok"] is True + + # And the splice preserved the first skill rather than replacing it. + head = state.refs["refs/org/org-1/HEAD"] + commit = json.loads(state.org_objects[head][1]) + root = json.loads(state.org_objects[commit["tree"]][1]) + names = {e["name"] for e in root["entries"]} + # beta is seeded under the devops/ category, so it appears as that + # category tree at the root. + assert "alpha" in names and "devops" in names + assert commit["parents"], "second commit must descend from the first" + + def test_pull_org_skills_sees_an_existing_org_head( + self, mock_server, synced_env + ): + """pull_org_skills used to report head=None for a populated org.""" + base, state = mock_server + home, skills, identity = synced_env + admin = self._admin(identity) + client = ssc.SyncClient(base, identity["api_key"]) + ssc.propose_skill("alpha", client, identity=admin) + + result = ssc.pull_org_skills(client=client, identity=admin) + assert result["ok"] is True + assert result["head"] == state.refs["refs/org/org-1/HEAD"], ( + "pull must resolve the real org HEAD, not None" + ) + assert result["updated"], "the org's skill must materialize" + + +class TestEmptyActualConflict: + """A 409 with an empty ``actual`` means the ref does not exist.""" + + def test_conflict_actual_empty_becomes_none(self): + c = ssc.SyncConflict("") + assert c.actual is None + assert "does not exist" in str(c) + + def test_push_recovers_from_a_stale_local_head(self, mock_server, synced_env): + """Switching sync planes leaves a foreign head in local state. + + The CAS then fails against a ref that does not exist, and the server + answers 409 with an empty ``actual``. The client must redo the CAS as + a create rather than trying to fetch "" as a commit (which surfaced as + the bizarre `object not found`, with a doubled space). + """ + base, state = mock_server + home, skills, identity = synced_env + st = ssc.read_sync_state() + st["head"] = "sha256:" + "f" * 64 # head from another plane + ssc.write_sync_state(st) + + client = ssc.SyncClient(base, identity["api_key"]) + result = ssc.push_skills(client=client, identity=identity) + assert result["ok"] is True + assert result.get("recovered_stale_head") is True + assert state.refs[ssc.user_head_ref(identity["owner"])] == result["head"] diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 90fe360e98..1689edbe86 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -744,12 +744,24 @@ class SyncError(RuntimeError): class SyncConflict(RuntimeError): - """CAS lost (409). ``actual`` is the current head to merge against - (contract §4.4). NOT a rejection -- pushed objects are already durable.""" + """CAS lost (409). NOT a rejection -- pushed objects are already durable. - def __init__(self, actual: str): - super().__init__(f"CAS conflict; actual head {actual}") - self.actual = actual + ``actual`` is the current head to merge against, or **None** when the ref + does not exist server-side (the server reports that as an empty string). + None means "there is nothing to merge against, retry as a create" -- it + must never be fetched as an object. + """ + + def __init__(self, actual: Optional[str]): + # Normalize here, not at the call site: the server sends "" for a + # non-existent ref, and every consumer must see that as None rather + # than an empty hash it might try to fetch. + self.actual: Optional[str] = actual or None + super().__init__( + f"CAS conflict; actual head {self.actual}" + if self.actual + else "CAS conflict; the ref does not exist yet" + ) class SyncClient: @@ -777,22 +789,39 @@ class SyncClient: raise SyncError(f"capabilities failed: {r.status_code}", status=r.status_code) return r.json() - def get_refs(self, prefix: str) -> List[Dict[str, str]]: - """GET /v1/sync/refs?prefix=... (sync contract).""" - r = self._session.get( - self._url("refs"), params={"prefix": prefix}, timeout=self.timeout - ) + def get_refs(self, prefix: str, *, org_scope: bool = False) -> List[Dict[str, str]]: + """GET /v1/sync/refs?prefix=... (or the org route when ``org_scope``). + + Org refs live behind a SEPARATE endpoint, not behind a prefix filter on + the personal one: the personal route is hard-scoped to the token's own + owner, so asking it for ``refs/org//`` silently returns the + caller's personal refs instead of an error. Callers reading an org ref + MUST pass ``org_scope=True``. + """ + path = "org/refs" if org_scope else "refs" + params = None if org_scope else {"prefix": prefix} + r = self._session.get(self._url(path), params=params, timeout=self.timeout) if r.status_code != 200: raise SyncError(f"get_refs failed: {r.status_code}", status=r.status_code) - return (r.json() or {}).get("refs", []) + refs = (r.json() or {}).get("refs", []) + if org_scope: + # The org route returns the org's refs unfiltered; apply the + # prefix client-side so both modes have the same contract. + refs = [r_ for r_ in refs if str(r_.get("name", "")).startswith(prefix)] + return refs - def get_object(self, obj_hash: str) -> Tuple[str, bytes]: - """GET /v1/sync/objects/:hash (sync contract). Returns (kind, bytes). + def get_object(self, obj_hash: str, *, org_scope: bool = False) -> Tuple[str, bytes]: + """GET /v1/sync/objects/:hash (or the org route when ``org_scope``). Kind comes from the object-type response header for tree/commit; a blob (application/octet-stream) is returned as ``blob``. + + Org objects are stored under the ``org:`` scope key and are NOT + readable through the personal route (it scopes to the token's owner), + so walking an org commit requires ``org_scope=True`` on every hop. """ - r = self._session.get(self._url(f"objects/{obj_hash}"), timeout=self.timeout) + path = f"org/objects/{obj_hash}" if org_scope else f"objects/{obj_hash}" + r = self._session.get(self._url(path), timeout=self.timeout) if r.status_code == 404: raise SyncError(f"object {obj_hash} not found", status=404) if r.status_code == 403: @@ -802,16 +831,20 @@ class SyncClient: kind = r.headers.get("X-HSP-Object-Type") or KIND_BLOB return kind, r.content - def get_commit_json(self, commit_hash: str) -> Dict[str, Any]: + def get_commit_json( + self, commit_hash: str, *, org_scope: bool = False + ) -> Dict[str, Any]: """Fetch a commit object and parse its canonical JSON.""" - kind, data = self.get_object(commit_hash) + kind, data = self.get_object(commit_hash, org_scope=org_scope) if kind != KIND_COMMIT: raise SyncError(f"{commit_hash} is {kind}, expected commit") return json.loads(data.decode("utf-8")) - def get_tree_json(self, tree_hash: str) -> Dict[str, Any]: + def get_tree_json( + self, tree_hash: str, *, org_scope: bool = False + ) -> Dict[str, Any]: """Fetch a tree object and parse its canonical JSON.""" - kind, data = self.get_object(tree_hash) + kind, data = self.get_object(tree_hash, org_scope=org_scope) if kind != KIND_TREE: raise SyncError(f"{tree_hash} is {kind}, expected tree") return json.loads(data.decode("utf-8")) @@ -884,8 +917,10 @@ class SyncClient: body = r.json() if r.content else {} return {"proposal_pending": True, **body} if r.status_code == 409: - actual = (r.json() or {}).get("actual", "") - raise SyncConflict(actual) + # An EMPTY `actual` means the ref does not exist server-side (the + # CAS lost against "no head"), NOT that there is a commit to merge + # against. Callers must not try to fetch it as an object. + raise SyncConflict((r.json() or {}).get("actual", "")) if r.status_code == 403: raise SyncError("forbidden (403) -- owner/permission", status=403) if r.status_code != 200: @@ -983,7 +1018,9 @@ def write_sync_state(data: Dict[str, Any]) -> None: # Tree materialization (pull) -- write a tree back to a skill directory # --------------------------------------------------------------------------- -def materialize_tree(client: SyncClient, tree_hash: str, dest: Path) -> None: +def materialize_tree( + client: SyncClient, tree_hash: str, dest: Path, *, org_scope: bool = False +) -> None: """Write the tree at *tree_hash* into *dest* (created if needed). Blobs become files (with +x restored for ``exec`` mode), nested trees @@ -991,7 +1028,7 @@ def materialize_tree(client: SyncClient, tree_hash: str, dest: Path) -> None: caller decides removal semantics. Refuses path traversal via entry names. """ dest.mkdir(parents=True, exist_ok=True) - tree = client.get_tree_json(tree_hash) + tree = client.get_tree_json(tree_hash, org_scope=org_scope) for entry in tree.get("entries", []): name = entry.get("name", "") if not name or "/" in name or name in (".", ".."): @@ -1000,9 +1037,9 @@ def materialize_tree(client: SyncClient, tree_hash: str, dest: Path) -> None: target = dest / name kind = entry.get("kind") if kind == KIND_TREE: - materialize_tree(client, entry["hash"], target) + materialize_tree(client, entry["hash"], target, org_scope=org_scope) elif kind == KIND_BLOB: - _, data = client.get_object(entry["hash"]) + _, data = client.get_object(entry["hash"], org_scope=org_scope) target.write_bytes(data) if entry.get("mode") == MODE_EXEC: try: @@ -1140,12 +1177,16 @@ def user_conflict_ref(owner: str, n: int) -> str: return f"refs/user/{owner}/conflict/{n}" -def _root_tree_of_commit(client: "SyncClient", commit_hash: str) -> str: +def _root_tree_of_commit( + client: "SyncClient", commit_hash: str, *, org_scope: bool = False +) -> str: """Return the tree hash referenced by a commit.""" - return client.get_commit_json(commit_hash)["tree"] + return client.get_commit_json(commit_hash, org_scope=org_scope)["tree"] -def _skill_trees_of_root(client: "SyncClient", root_tree_hash: str) -> Dict[str, str]: +def _skill_trees_of_root( + client: "SyncClient", root_tree_hash: str, *, org_scope: bool = False +) -> Dict[str, str]: """Flatten a profile-root tree into ``{posix_rel_path: skill_tree_hash}``. A skill tree is any tree containing a ``SKILL.md`` blob entry. We walk the @@ -1155,7 +1196,7 @@ def _skill_trees_of_root(client: "SyncClient", root_tree_hash: str) -> Dict[str, result: Dict[str, str] = {} def _walk(tree_hash: str, prefix: str) -> None: - tree = client.get_tree_json(tree_hash) + tree = client.get_tree_json(tree_hash, org_scope=org_scope) entries = tree.get("entries", []) has_skill_md = any( e.get("name") == "SKILL.md" and e.get("kind") == KIND_BLOB for e in entries @@ -1272,6 +1313,20 @@ def push_skills( write_sync_state(manifest) return {"ok": True, "head": commit_hash, "pushed_objects": len(objects)} except SyncConflict as conflict: + if not conflict.actual: + # The ref does not exist server-side: our `from` was a stale head + # (commonly a local state file carried over from another sync + # plane). There is nothing to merge — redo the CAS as a create. + client.cas_ref(ref, None, commit_hash) + manifest["head"] = commit_hash + manifest["root"] = root_hash + write_sync_state(manifest) + return { + "ok": True, + "head": commit_hash, + "pushed_objects": len(objects), + "recovered_stale_head": True, + } return _resolve_push_conflict( client, identity, conflict.actual, root_hash, commit_hash, objects, skill_names, message, base_head, @@ -1718,6 +1773,26 @@ def org_sync_available() -> bool: return False +# How many times a propose will re-splice onto a moved org HEAD before giving +# up. Small: contention means other members are actively proposing, and an +# unbounded loop would spin. +_ORG_CAS_MAX_ATTEMPTS = 5 + + +def _read_org_head(client: "SyncClient", org_id: str) -> Optional[str]: + """Current ``refs/org//HEAD``, or None if the org has no content. + + Reads through the ORG endpoint. The personal refs route is scoped to the + caller's own owner and answers an ``refs/org/...`` prefix with the caller's + PERSONAL refs, so a personal-route read here silently reports "no org head" + and every subsequent CAS races against a head it never saw. + """ + refs = client.get_refs(f"refs/org/{org_id}/", org_scope=True) + return next( + (r["hash"] for r in refs if r.get("name") == org_head_ref(org_id)), None + ) + + def org_head_ref(org_id: str) -> str: return f"refs/org/{org_id}/HEAD" @@ -1755,10 +1830,7 @@ def pull_org_skills( if "org" not in (caps.get("features") or []): raise SyncInertError("this server does not support org-shared skills") - refs = client.get_refs(f"refs/org/{org_id}/") - head = next( - (r["hash"] for r in refs if r.get("name") == org_head_ref(org_id)), None - ) + head = _read_org_head(client, org_id) # TOKEN-GATED resolution marker (agent/skill_utils.read_active_org_id): # written HERE because this function only runs after resolve_org_identity # verified the token's org_id + org_role. Discovery scans only the marked @@ -1768,9 +1840,9 @@ def pull_org_skills( if not head: return {"ok": True, "org_id": org_id, "head": None, "updated": []} - head_commit = client.get_commit_json(head) + head_commit = client.get_commit_json(head, org_scope=True) root_tree = head_commit["tree"] - skill_trees = _skill_trees_of_root(client, root_tree) + skill_trees = _skill_trees_of_root(client, root_tree, org_scope=True) dest_root = _org_dir() / org_id updated: List[str] = [] @@ -1797,7 +1869,7 @@ def pull_org_skills( shutil.rmtree(dest) dest.mkdir(parents=True, exist_ok=True) - materialize_tree(client, tree_hash, dest) + materialize_tree(client, tree_hash, dest, org_scope=True) baseline[rel_path] = { "fingerprint": _skill_dir_fingerprint(dest), "tree": tree_hash, @@ -1991,29 +2063,51 @@ def propose_skill( # Base = current org HEAD (None for the org's first content). The proposed # root is HEAD's skill-tree map with this one skill spliced in — proposals # are per-skill deltas, never a wholesale replace of the org set. - refs = client.get_refs(f"refs/org/{org_id}/") - base_head = next( - (r["hash"] for r in refs if r.get("name") == org_head_ref(org_id)), None - ) - if base_head: - base_root = _root_tree_of_commit(client, base_head) - skill_map = _skill_trees_of_root(client, base_root) - else: - skill_map = {} - skill_map[str(rel)] = skill_tree + # + # Wrapped in a bounded retry: between reading HEAD and the CAS, another + # member's propose (or an admin merge) can advance it. The server answers + # 409 with the new head; we re-splice this one skill onto THAT head and try + # again rather than surfacing a raw conflict. Re-splicing (not replaying + # the old root) is what keeps the other member's skill from being dropped. + attempts = 0 + while True: + attempts += 1 + base_head = _read_org_head(client, org_id) + if base_head: + base_root = _root_tree_of_commit(client, base_head, org_scope=True) + skill_map = _skill_trees_of_root(client, base_root, org_scope=True) + else: + skill_map = {} + skill_map[str(rel)] = skill_tree - root_hash = _assemble_root_from_skill_trees(client, skill_map, objects) - commit_hash = build_commit( - root_hash, - [base_head] if base_head else [], - owner=identity["owner"], - device=stable_device_id(), - message=message or f"propose {skill_name}", - objects=objects, - ) + root_hash = _assemble_root_from_skill_trees(client, skill_map, objects) + commit_hash = build_commit( + root_hash, + [base_head] if base_head else [], + owner=identity["owner"], + device=stable_device_id(), + message=message or f"propose {skill_name}", + objects=objects, + ) - client.put_objects(objects.objects, org_scope=True) - result = client.cas_ref(org_head_ref(org_id), base_head, commit_hash) + client.put_objects(objects.objects, org_scope=True) + try: + result = client.cas_ref(org_head_ref(org_id), base_head, commit_hash) + break + except SyncConflict as conflict: + if attempts >= _ORG_CAS_MAX_ATTEMPTS: + raise SyncError( + "the organisation's skills changed while this was being " + f"proposed, and {attempts} attempts to catch up all lost " + "the race — run the command again", + status=409, + ) from conflict + logger.debug( + "propose_skill: org HEAD moved (actual=%r), re-splicing (attempt %d)", + conflict.actual, + attempts, + ) + continue if result.get("proposal_pending"): return { From 8ccb4c2cee14a3d0604b6df26adfe5ff2b3b9840 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 23:56:16 -0500 Subject: [PATCH 29/55] perf(desktop): scope background-throttling opt-out to live streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The process-wide disable-background-timer-throttling / disable-backgrounding-occluded-windows switches plus a static backgroundThrottling: false on every chat window pinned each renderer's document.visibilityState to 'visible' for the life of the window. Every visibility-gated backstop poll and clock tick in the renderer became an always-on timer: an idle, minimized Hermes burned ~20% CPU around the clock, on battery too. Throttling is now a runtime dial. A small controller (stream-throttle.ts) rides the merged hermes:active-work reports the quit guard already receives: while any turn is in flight every chat window gets setBackgroundThrottling(false) — a live answer keeps painting while blurred, occluded, or minimized, exactly as before — and once all turns settle (plus a 5s trailing window so the final flush lands at full cadence) Chromium's default throttling returns and hidden windows go quiet. disable-renderer-backgrounding stays: process priority only, no timer semantics, and it keeps hidden streaming fast. --- apps/desktop/electron/main.ts | 86 ++++++++-- apps/desktop/electron/session-windows.test.ts | 13 +- apps/desktop/electron/session-windows.ts | 21 ++- apps/desktop/electron/stream-throttle.test.ts | 152 ++++++++++++++++++ apps/desktop/electron/stream-throttle.ts | 119 ++++++++++++++ .../session/hooks/use-message-stream/index.ts | 20 +-- 6 files changed, 372 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/electron/stream-throttle.test.ts create mode 100644 apps/desktop/electron/stream-throttle.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index f485e5a542..41686883f7 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -183,6 +183,7 @@ import { redactSecrets, SshConnection } from './ssh-connection' +import { createStreamThrottle } from './stream-throttle' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { resolveBehindCount, shouldCountCommits } from './update-count' import { waitForUpdateClearance } from './update-gate' @@ -421,18 +422,24 @@ if (IS_WINDOWS) { ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON) -// Keep the renderer running at full speed while the window is in the background -// or occluded. The chat transcript streams to screen through a bounded timer -// flush; Chromium clamps timers for backgrounded/occluded renderers, so without -// these the live answer stalls -// whenever the window loses focus (switching to your editor mid-turn, detached -// devtools, another window covering it) and only paints on refocus or refresh. -// `backgroundThrottling: false` on the BrowserWindow covers the blurred case; -// these process-level switches additionally stop Chromium from backgrounding or -// occlusion-throttling the renderer. Must run before app `ready`. +// Keep the renderer's PROCESS priority normal while its windows are hidden — +// a deprioritized renderer streams a live answer visibly slower once the +// window is minimized. This switch only affects scheduling priority; it does +// not exempt timers from throttling and costs nothing at idle. +// +// The timer/rAF throttling story is deliberately NOT handled here anymore. +// The old process-wide `disable-background-timer-throttling` / +// `disable-backgrounding-occluded-windows` switches (plus a static +// `backgroundThrottling: false` on every chat window) pinned every renderer's +// `document.visibilityState` to 'visible' forever — which silently turned all +// the renderer's visibility-gated backstop polls and clock ticks into +// always-on timers. A completely idle, minimized Hermes burned ~20% CPU +// around the clock. Throttling is now a runtime dial scoped to streaming: +// see createStreamThrottle() — chat windows are unthrottled while any turn is +// in flight (so a live answer keeps painting while blurred, occluded, or +// minimized, exactly as before) and return to Chromium's default throttling +// once the work settles. app.commandLine.appendSwitch('disable-renderer-backgrounding') -app.commandLine.appendSwitch('disable-backgrounding-occluded-windows') -app.commandLine.appendSwitch('disable-background-timer-throttling') const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') @@ -5188,6 +5195,31 @@ function sendPowerResume() { let powerResumeRegistered = false +// Mirror of powerMonitor's AC/battery state, broadcast to every window so +// renderer backstop polls can slow down on battery (see store/power.ts). +// `null` until the first powerMonitor read after app ready. +let onBatteryPower: boolean | null = null + +// Renderer-side battery gating seeds from this and stays current via the +// 'hermes:power-battery' push below. +ipcMain.handle('hermes:power-battery:get', () => onBatteryPower === true) + +function broadcastBatteryState(next: boolean) { + if (onBatteryPower === next) { + return + } + + onBatteryPower = next + + for (const win of BrowserWindow.getAllWindows()) { + const { webContents } = win + + if (webContents && !webContents.isDestroyed()) { + webContents.send('hermes:power-battery', next) + } + } +} + function registerPowerResumeListeners() { if (powerResumeRegistered) { return @@ -5200,6 +5232,9 @@ function registerPowerResumeListeners() { // full suspend. Either can drop an idle socket. powerMonitor.on('resume', sendPowerResume) powerMonitor.on('unlock-screen', sendPowerResume) + powerMonitor.on('on-battery', () => broadcastBatteryState(true)) + powerMonitor.on('on-ac', () => broadcastBatteryState(false)) + onBatteryPower = powerMonitor.isOnBatteryPower() } catch { // powerMonitor is unavailable before app 'ready' on some platforms; the // caller registers after 'ready', so this should not normally throw. @@ -8697,6 +8732,7 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch? win.on('enter-full-screen', () => sendWindowStateChanged(true)) win.on('leave-full-screen', () => sendWindowStateChanged(false)) + streamThrottle.register(win) wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) loadWindowUrl( @@ -8739,7 +8775,7 @@ function nextInstanceBounds() { } // Open a new full-chrome instance window. Mirrors createWindow()'s window -// options (shared chatWindowWebPreferences keeps backgroundThrottling:false so a +// options (shared chatWindowWebPreferences + streamThrottle registration so a // streamed answer never stalls in the background) but is a peer, not the // primary: it never overwrites the mainWindow global, doesn't start the backend // (the renderer's getConnection() joins the already-running one), and loads the @@ -8780,6 +8816,7 @@ function createInstanceWindow() { win.on('enter-full-screen', () => sendWindowStateChanged(true, win)) win.on('leave-full-screen', () => sendWindowStateChanged(false, win)) + streamThrottle.register(win) wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) win.on('closed', () => { @@ -9162,10 +9199,11 @@ function createWindow() { // material before the renderer paints the app theme. See createSessionWindow. show: false, backgroundColor: getWindowBackgroundColor(), - // Shared with the secondary session windows (chatWindowWebPreferences) so - // both keep `backgroundThrottling: false` — the chat transcript uses a - // bounded timer flush that Chromium clamps for blurred windows, stalling - // the live answer until refocus. See session-windows.ts. + // Shared with the secondary session windows (chatWindowWebPreferences); + // stream-aware throttling is applied per-window via streamThrottle so a + // live answer keeps painting while the window is blurred or minimized, + // without pinning visibilityState to 'visible' at idle. See + // session-windows.ts and stream-throttle.ts. webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) @@ -9258,6 +9296,7 @@ function createWindow() { } }) + streamThrottle.register(mainWindow) wireCommonWindowHandlers(mainWindow, zoomWiringForWindowKind('chat')) mainWindow.webContents.on('render-process-gone', (_event, details) => { @@ -10427,14 +10466,27 @@ ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWat // merged picture. Keyed by webContents id so a closed window stops counting. const activeWorkByWebContents = new Map() +// The same merged picture drives background throttling: chat windows run +// unthrottled while any turn is in flight (streaming must paint while hidden) +// and fall back to Chromium's default throttling at idle. See stream-throttle.ts. +const streamThrottle = createStreamThrottle() + +function updateStreamThrottleFromActiveWork() { + streamThrottle.update(mergeActiveWork(activeWorkByWebContents.values()).count > 0) +} + ipcMain.on('hermes:active-work', (event, payload) => { const id = event.sender.id if (!activeWorkByWebContents.has(id)) { - event.sender.once('destroyed', () => activeWorkByWebContents.delete(id)) + event.sender.once('destroyed', () => { + activeWorkByWebContents.delete(id) + updateStreamThrottleFromActiveWork() + }) } activeWorkByWebContents.set(id, normalizeActiveWork(payload)) + updateStreamThrottleFromActiveWork() }) ipcMain.on('hermes:titlebar-theme', (_event, payload) => { diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index a60ebb3762..a3dbfc04e4 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -191,13 +191,16 @@ test('registry trims the session id before keying', () => { assert.equal(registry.has('s1'), true) }) -test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => { - // Regression: secondary session windows used to omit this flag, so a streamed - // answer stalled until the window regained focus (Chromium clamps the - // transcript flush timer for backgrounded windows). +test('chatWindowWebPreferences leaves background throttling to the runtime stream dial', () => { + // Regression (both directions): a static `backgroundThrottling: false` here + // pinned document.visibilityState to 'visible' forever, turning every + // visibility-gated poll into an always-on timer (~20% CPU at idle, + // minimized). Streaming's "paint while blurred" need is served by + // stream-throttle.ts flipping setBackgroundThrottling at turn boundaries — + // so the static flag must stay absent. const prefs = chatWindowWebPreferences('/tmp/preload.cjs') - assert.equal(prefs.backgroundThrottling, false) + assert.equal('backgroundThrottling' in prefs, false) }) test('chatWindowWebPreferences passes the preload path through and keeps the hardened defaults', () => { diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index 312deb3d1e..81736908fc 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -13,14 +13,20 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // Shared webPreferences for every window that renders the chat transcript — the // primary window AND the secondary session windows. Keeping it in one place is // the whole point: the two BrowserWindow definitions in main.ts used to be -// hand-copied, and the secondary windows silently lost `backgroundThrottling: -// false`, so a streamed answer stalled until the window regained focus. +// hand-copied, and the secondary windows silently drifted apart (a streamed +// answer stalled until the window regained focus because one of them lost the +// throttling opt-out). // -// `backgroundThrottling: false` is load-bearing: the transcript streams to the -// screen through a bounded timer flush, which Chromium clamps for blurred/ -// occluded windows. A streaming chat app must keep painting in the -// background, so every chat window opts out. The preload path is injected -// because it depends on the Electron entry's __dirname. +// Background throttling is deliberately NOT set here. It is managed at runtime +// by main.ts (`setBackgroundThrottling` driven by the merged `hermes:active-work` +// reports): while any turn is in flight every chat window is unthrottled so the +// transcript's bounded timer flush keeps painting while blurred, occluded, or +// minimized — and once all turns finish, Chromium's default throttling returns +// so an idle hidden window costs ~nothing. A static `backgroundThrottling: +// false` here would pin `document.visibilityState` to 'visible' forever, +// turning every visibility-gated poll in the renderer into an always-on timer +// (the "Hermes idles at 20% CPU while minimized" bug). The preload path is +// injected because it depends on the Electron entry's __dirname. // // `autoplayPolicy: 'no-user-gesture-required'` is load-bearing for voice: // Chromium's default autoplay policy suspends audio (HTMLAudioElement.play() @@ -39,7 +45,6 @@ function chatWindowWebPreferences(preloadPath: string) { sandbox: true, nodeIntegration: false, devTools: true, - backgroundThrottling: false, autoplayPolicy: 'no-user-gesture-required' as const } } diff --git a/apps/desktop/electron/stream-throttle.test.ts b/apps/desktop/electron/stream-throttle.test.ts new file mode 100644 index 0000000000..fc41bcf168 --- /dev/null +++ b/apps/desktop/electron/stream-throttle.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { createStreamThrottle, type ThrottleWindowLike } from './stream-throttle' + +function makeTimers() { + const pending = new Map void>() + let nextId = 1 + + return { + clearTimeout: (handle: unknown) => { + pending.delete(handle as number) + }, + fire() { + const jobs = [...pending.values()] + pending.clear() + + for (const job of jobs) { + job() + } + }, + get pendingCount() { + return pending.size + }, + setTimeout: (fn: () => void, _ms: number) => { + const id = nextId++ + pending.set(id, fn) + + return id + } + } +} + +function makeWindow() { + const calls: boolean[] = [] + const listeners = new Map void>() + let destroyed = false + + const win = { + calls, + close() { + destroyed = true + listeners.get('closed')?.() + }, + isDestroyed: () => destroyed, + on(event: string, fn: () => void) { + listeners.set(event, fn) + }, + webContents: { + isDestroyed: () => destroyed, + setBackgroundThrottling(allowed: boolean) { + calls.push(allowed) + } + } + } + + return win +} + +test('registering a window applies the current throttle state immediately', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const idle = makeWindow() + throttle.register(idle) + + // Idle default: throttling allowed. + assert.deepEqual(idle.calls, [true]) + + throttle.update(true) + const late = makeWindow() + throttle.register(late) + + // A window created mid-stream starts unthrottled. + assert.deepEqual(late.calls, [false]) +}) + +test('a turn in flight unthrottles every chat window; settling re-throttles after the trailing delay', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const win = makeWindow() + throttle.register(win) + + throttle.update(true) + assert.deepEqual(win.calls, [true, false]) + assert.equal(throttle.isUnthrottled(), true) + + // Turn ends: not re-throttled synchronously — the tail flush needs full + // cadence — only after the trailing timer fires. + throttle.update(false) + assert.deepEqual(win.calls, [true, false]) + assert.equal(throttle.isUnthrottled(), true) + + timers.fire() + assert.deepEqual(win.calls, [true, false, true]) + assert.equal(throttle.isUnthrottled(), false) +}) + +test('a new turn during the trailing window cancels the pending re-throttle', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const win = makeWindow() + throttle.register(win) + + throttle.update(true) + throttle.update(false) + assert.equal(timers.pendingCount, 1) + + // Busy again before the delay elapses: stay unthrottled, timer cancelled. + throttle.update(true) + assert.equal(timers.pendingCount, 0) + assert.equal(throttle.isUnthrottled(), true) + + // The cancelled timer firing late must be a no-op. + timers.fire() + assert.equal(throttle.isUnthrottled(), true) +}) + +test('repeated busy reports do not re-apply or stack timers', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const win = makeWindow() + throttle.register(win) + + throttle.update(true) + throttle.update(true) + throttle.update(true) + assert.deepEqual(win.calls, [true, false]) + + throttle.update(false) + throttle.update(false) + assert.equal(timers.pendingCount, 1) +}) + +test('closed and destroyed windows drop out without throwing', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const closedWin = makeWindow() + throttle.register(closedWin) + closedWin.close() + + const gone: ThrottleWindowLike & { on?: never } = { + isDestroyed: () => true, + webContents: null + } + + throttle.register(gone) + + throttle.update(true) + // Only the registration-time call landed; nothing after close. + assert.deepEqual(closedWin.calls, [true]) +}) diff --git a/apps/desktop/electron/stream-throttle.ts b/apps/desktop/electron/stream-throttle.ts new file mode 100644 index 0000000000..912b40b916 --- /dev/null +++ b/apps/desktop/electron/stream-throttle.ts @@ -0,0 +1,119 @@ +// Stream-aware background throttling for chat windows. +// +// Chat windows must paint the live transcript while blurred, occluded, or +// minimized — but a static `backgroundThrottling: false` in webPreferences +// costs far more than that feature needs: it pins the renderer's +// `document.visibilityState` to 'visible' for the life of the window, which +// turns every visibility-gated poll and clock tick in the renderer into an +// always-on timer. An idle, hidden Hermes burned ~20% CPU forever. +// +// So throttling is a runtime dial instead: the renderers already report +// "which chats are mid-turn" for the quit guard (`hermes:active-work`), and +// this controller rides the merged edge of those reports. Any turn in flight → +// every registered chat window gets `setBackgroundThrottling(false)`, exactly +// the streaming behavior the static flag used to provide. All turns done → +// after a short trailing delay (so tail flushes land at full cadence) Chromium's +// default throttling returns and hidden windows go quiet. +// +// Pure and Electron-free (timers + the WebContents surface are injected) so it +// can be unit-tested, mirroring session-windows.ts. + +/** How long after the last turn ends before throttling is restored. Covers the + * stream queue's final coalesced flush and the settle writes that trail a + * turn's completion, so re-throttling never strands a visible delta. */ +const RETHROTTLE_DELAY_MS = 5_000 + +export interface ThrottleWindowLike { + isDestroyed(): boolean + webContents?: { + isDestroyed(): boolean + setBackgroundThrottling(allowed: boolean): void + } | null +} + +interface TimersLike { + clearTimeout(handle: unknown): void + setTimeout(fn: () => void, ms: number): unknown +} + +export interface StreamThrottle { + /** True while windows are currently unthrottled (streaming or trailing). */ + isUnthrottled(): boolean + /** Track a chat window; applies the current state immediately and stops + * tracking on close. */ + register(win: ThrottleWindowLike & { on?: (event: string, fn: () => void) => void }): void + /** Report whether any turn is in flight across all renderers. */ + update(busy: boolean): void +} + +export function createStreamThrottle( + timers: TimersLike = { clearTimeout: handle => clearTimeout(handle as never), setTimeout }, + delayMs: number = RETHROTTLE_DELAY_MS +): StreamThrottle { + const windows = new Set() + let unthrottled = false + let trailing: unknown = null + + function apply(win: ThrottleWindowLike) { + if (win.isDestroyed()) { + windows.delete(win) + + return + } + + const contents = win.webContents + + if (!contents || contents.isDestroyed()) { + return + } + + try { + contents.setBackgroundThrottling(!unthrottled) + } catch { + // A window mid-teardown can throw; it's about to leave the set anyway. + } + } + + function applyAll() { + for (const win of windows) { + apply(win) + } + } + + return { + isUnthrottled: () => unthrottled, + + register(win) { + windows.add(win) + win.on?.('closed', () => windows.delete(win)) + apply(win) + }, + + update(busy) { + if (busy) { + if (trailing !== null) { + timers.clearTimeout(trailing) + trailing = null + } + + if (!unthrottled) { + unthrottled = true + applyAll() + } + + return + } + + if (!unthrottled || trailing !== null) { + return + } + + // Trailing edge: keep full cadence briefly so the final flush paints. + trailing = timers.setTimeout(() => { + trailing = null + unthrottled = false + applyAll() + }, delayMs) + } + } +} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index d5ec67d384..7aa89a8cf1 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -273,18 +273,20 @@ export function useMessageStream({ // Always a timer, never requestAnimationFrame. Chromium pauses rAF for a // renderer it considers hidden, and "hidden" is not something this code can - // verify: `backgroundThrottling: false` plus the process-level switches in - // electron/main.ts cover the blurred and occluded cases, but they don't - // cover a minimized window, a fully off-screen one, or a renderer the - // compositor has otherwise parked. In those states an rAF-gated flush never - // runs, so a finished answer sits in this queue until some later input or - // focus event happens to wake a frame — the reply looks stalled, then - // arrives all at once on refocus. + // verify: while a turn is in flight the main process unthrottles every chat + // window (stream-throttle.ts), but that doesn't guarantee frames for a + // minimized window, a fully off-screen one, or a renderer the compositor + // has otherwise parked. In those states an rAF-gated flush never runs, so a + // finished answer sits in this queue until some later input or focus event + // happens to wake a frame — the reply looks stalled, then arrives all at + // once on refocus. // // A timer keeps the same coalescing cadence (that's what the floor above is // for) while guaranteeing delivery without user interaction. Timers are - // clamped in background renderers rather than suspended, and - // disable-background-timer-throttling already opts out of that clamp. + // clamped in background renderers rather than suspended, and the + // stream-aware unthrottle lifts even that clamp for the life of the turn; + // in the worst case (a delta arriving before the unthrottle lands) the + // clamp only stretches one flush to ~1s in a window nobody can see. flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, adaptiveFloor - sinceLast)) }, [flushQueuedDeltas]) From be7c4b8fe744e5e3a38f39461db85073d524be40 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 23:56:16 -0500 Subject: [PATCH 30/55] perf(desktop): let the hidden link-title window throttle It loads arbitrary user-linked pages offscreen; unthrottled, a heavy page burns full CPU for the window's whole lifetime. Title resolution rides load events and main-process timers, which throttling doesn't touch. --- apps/desktop/electron/link-title-window.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/electron/link-title-window.ts b/apps/desktop/electron/link-title-window.ts index 0920976a52..226ac3ce60 100644 --- a/apps/desktop/electron/link-title-window.ts +++ b/apps/desktop/electron/link-title-window.ts @@ -9,7 +9,13 @@ export function linkTitleWindowOptions(partitionSession) { width: 1280, height: 800, webPreferences: { - backgroundThrottling: false, + // Deliberately throttled: this hidden window loads arbitrary user-linked + // pages, and an unthrottled heavy page burns full CPU for the window's + // whole lifetime. Title resolution rides load events + // (page-title-updated / did-finish-load) plus main-process timers, none + // of which the renderer clamp touches — hidden-page throttling only + // slows the page's own timer-driven JS, and the grace window already + // absorbs that. contextIsolation: true, javascript: true, nodeIntegration: false, From 044800e3587601d8b6b8d0a78dccfcaafe479a02 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 23:56:16 -0500 Subject: [PATCH 31/55] perf(desktop): stretch backstop polls while on battery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit powerMonitor's AC/battery state is mirrored to the renderers (store/power.ts) and visiblePoll quadruples its cadence on battery. Only the safety-net refreshes slow down — event-driven refreshes and live streaming are untouched. --- apps/desktop/electron/preload.ts | 8 +++++ .../app/contrib/hooks/use-background-sync.ts | 15 ++++++++-- apps/desktop/src/global.d.ts | 2 ++ apps/desktop/src/main.tsx | 2 ++ apps/desktop/src/store/power.ts | 30 +++++++++++++++++++ 5 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/store/power.ts diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 8822efd2a9..8fb6d97c3f 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -275,6 +275,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', { return () => ipcRenderer.removeListener('hermes:power-resume', listener) }, + // AC ↔ battery transitions; renderers slow their backstop polls on battery. + getOnBattery: () => ipcRenderer.invoke('hermes:power-battery:get'), + onBatteryChanged: callback => { + const listener = (_event, onBattery) => callback(Boolean(onBattery)) + ipcRenderer.on('hermes:power-battery', listener) + + return () => ipcRenderer.removeListener('hermes:power-battery', listener) + }, onBootProgress: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:boot-progress', listener) diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts index f176d7f786..06dc49b56b 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -3,6 +3,7 @@ import { useEffect } from 'react' import { createClientSessionState } from '@/lib/chat-runtime' import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync' +import { $onBattery, batteryPollInterval } from '@/store/power' import { refreshActiveProfile } from '@/store/profile' import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session' import { @@ -180,7 +181,10 @@ interface BackgroundSyncParams { } /** Poll a callback while the tab is visible, on `intervalMs`; re-checks on tab - * re-focus. Returns nothing — meant to live inside an effect. */ + * re-focus. On battery the cadence stretches (see store/power) — these are + * safety-net refreshes, not the live path, so they're the right thing to slow + * when the machine is spending its charge. Returns nothing — meant to live + * inside an effect. */ function visiblePoll(intervalMs: number, tick: () => void): () => void { const run = () => { if (document.visibilityState === 'visible') { @@ -188,10 +192,17 @@ function visiblePoll(intervalMs: number, tick: () => void): () => void { } } - const intervalId = window.setInterval(run, intervalMs) + let intervalId = window.setInterval(run, batteryPollInterval(intervalMs, $onBattery.get())) + + const unsubscribeBattery = $onBattery.listen(onBattery => { + window.clearInterval(intervalId) + intervalId = window.setInterval(run, batteryPollInterval(intervalMs, onBattery)) + }) + document.addEventListener('visibilitychange', run) return () => { + unsubscribeBattery() window.clearInterval(intervalId) document.removeEventListener('visibilitychange', run) } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index d2804298fa..f266f13aff 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -256,6 +256,8 @@ declare global { // reload. Wipe session lists (skeletons) and re-dial. onConnectionApplied?: (callback: () => void) => () => void onPowerResume?: (callback: () => void) => () => void + getOnBattery?: () => Promise + onBatteryChanged?: (callback: (onBattery: boolean) => void) => () => void onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void getBootstrapState: () => Promise continueBootstrapLocal: () => Promise<{ ok: boolean }> diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 9a14e26e45..f2546d0c01 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -1,6 +1,8 @@ import './styles.css' // Side-effect: reports in-flight turns to the main process for the quit guard. import './store/active-work' +// Side-effect: mirrors the machine's AC/battery state for poll demotion. +import './store/power' // Side-effect: applies the persisted window translucency on load. import './store/translucency' // Dev-only render/state churn counters. MUST precede the `react-dom` import diff --git a/apps/desktop/src/store/power.ts b/apps/desktop/src/store/power.ts new file mode 100644 index 0000000000..4d6f5e2bb8 --- /dev/null +++ b/apps/desktop/src/store/power.ts @@ -0,0 +1,30 @@ +/** + * AC ↔ battery mirror of the main process's powerMonitor. + * + * Backstop polls are already event-demoted and visibility-gated; battery is + * the third gate — on battery their cadence stretches (×4) so a laptop + * running Hermes in the background isn't spending its charge on safety-net + * refreshes. Live streaming and event-driven refreshes are untouched: this + * only slows timers whose job is catching what a degraded socket missed. + * + * Imported for its side effect from `main.tsx` (same pattern as + * store/active-work). + */ + +import { atom } from 'nanostores' + +export const $onBattery = atom(false) + +/** Multiply a backstop poll interval by this on battery. */ +export const BATTERY_POLL_MULTIPLIER = 4 + +export function batteryPollInterval(intervalMs: number, onBattery: boolean): number { + return onBattery ? intervalMs * BATTERY_POLL_MULTIPLIER : intervalMs +} + +if (typeof window !== 'undefined') { + const desktop = window.hermesDesktop + + void desktop?.getOnBattery?.().then(onBattery => $onBattery.set(onBattery)) + desktop?.onBatteryChanged?.(onBattery => $onBattery.set(onBattery)) +} From 98105f31f46d3de58a8f69a2a439cee3f7a5e389 Mon Sep 17 00:00:00 2001 From: kshitijk4poor Date: Thu, 30 Jul 2026 20:40:29 +0500 Subject: [PATCH 32/55] fix(file_ops): harden new-file umask chmod for portability Follow-ups on top of #70888's cherry-picked fix: - Replace the $((0666 & ~0$u)) shell arithmetic with POSIX who-less 'chmod "=rw"'. zsh (reachable via _find_bash's $SHELL fallback on bash-less hosts) parses leading-zero constants as decimal and silently chmods a garbage mode (e.g. 0210); the symbolic form is spec-identical across bash/dash/busybox-ash/zsh and degrades to mktemp's 0600 (pre-fix behavior) rather than corrupting perms if chmod rejects it. - Move the new-file chmod after the content stream so the temp file stays owner-writable while cat runs. - Run the chmod on a '[ ! -e "$t" ]' check after cat instead of the stat/else branch, keeping the overwrite path untouched. - Update the stale perms comment #70856 called out (new files did NOT land with default umask perms pre-fix). - Tests: select the atomic-write script by content instead of call order (the previous last-call capture only worked because the bare MagicMock's falsy-exit early return suppressed later execs), assert behavior at explicit umasks 0022/0002/0077 via parametrize, add an overwrite mode-preservation regression guard, and dedupe the real-subprocess env fake into make_real_subprocess_env() shared with TestSearchFilesFallbackHiddenPaths. (webtecnica's email mapping already exists in contributors/emails/ on current main; the PR's check-attribution red was stale-base only.) # Conflicts: # tests/tools/test_file_operations.py --- tests/tools/test_file_operations.py | 103 ++++++++++++++++------------ tools/file_operations.py | 18 +++-- 2 files changed, 73 insertions(+), 48 deletions(-) diff --git a/tests/tools/test_file_operations.py b/tests/tools/test_file_operations.py index bc3b6c4542..e80938d3ef 100644 --- a/tests/tools/test_file_operations.py +++ b/tests/tools/test_file_operations.py @@ -242,6 +242,38 @@ def file_ops(mock_env): return ShellFileOperations(mock_env) +def make_real_subprocess_env(cwd: str, include_stderr: bool = False) -> MagicMock: + """Mock env whose execute() runs the command in a real subprocess. + + For tests that need the generated shell scripts to actually run + (search fallback, atomic-write permissions) instead of being + intercepted by a bare MagicMock. ``include_stderr`` folds stderr + into ``output`` for tests that surface shell error text; leave it + off for tests that parse structured stdout (e.g. find results). + """ + env = MagicMock() + env.cwd = cwd + + def execute(command, **kwargs): + completed = subprocess.run( + command, + shell=True, + text=True, + capture_output=True, + input=kwargs.get("stdin_data"), + ) + output = completed.stdout + if include_stderr: + output += completed.stderr + return { + "output": output, + "returncode": completed.returncode, + } + + env.execute = execute + return env + + class TestShellFileOpsHelpers: def test_normalize_read_pagination_clamps_invalid_values(self): assert normalize_read_pagination(offset=0, limit=0) == (1, 1) @@ -393,23 +425,7 @@ class TestSearchPathValidation: class TestSearchFilesFallbackHiddenPaths: def _make_env(self): - env = MagicMock() - env.cwd = "/" - - def execute(command, **kwargs): - completed = subprocess.run( - command, - shell=True, - text=True, - capture_output=True, - ) - return { - "output": completed.stdout, - "returncode": completed.returncode, - } - - env.execute = execute - return env + return make_real_subprocess_env("/") def test_hidden_root_with_hidden_ancestor_includes_files(self, tmp_path, monkeypatch): """Fallback find should include visible files when path is inside hidden root.""" @@ -561,42 +577,41 @@ class _DeletedTestGitBaselineCheck: class TestAtomicWriteNewFilePermissions: """_atomic_write should apply umask-default perms to new files (not 0600).""" - def test_new_file_gets_umask_default_permissions(self, tmp_path): + @pytest.mark.parametrize("test_umask", [0o022, 0o002, 0o077]) + def test_new_file_gets_umask_default_permissions(self, tmp_path, test_umask): """Newly created file should get umask-computed perms, not mktemp's 0600. Uses a real subprocess so the shell script actually runs. """ - env = MagicMock() - env.cwd = str(tmp_path) - - def execute(command, **kwargs): - completed = subprocess.run( - command, - shell=True, - text=True, - capture_output=True, - input=kwargs.get("stdin_data"), - ) - return { - "output": completed.stdout + completed.stderr, - "returncode": completed.returncode, - } - - env.execute = execute - ops = ShellFileOperations(env) + ops = ShellFileOperations(make_real_subprocess_env(str(tmp_path))) dest = tmp_path / "new_file.txt" assert not dest.exists() - result = ops.write_file(str(dest), "test content\n") - assert result.error is None, f"write failed: {result.error}" - assert dest.exists() + old_umask = os.umask(test_umask) + try: + result = ops.write_file(str(dest), "test content\n") + finally: + os.umask(old_umask) - # Compute expected mode: 0666 & ~umask - current_umask = os.umask(0) - os.umask(current_umask) # restore - expected_mode = 0o666 & ~current_umask + assert result.error is None, f"write failed: {result.error}" + assert dest.read_text() == "test content\n" + expected_mode = 0o666 & ~test_umask actual_mode = dest.stat().st_mode & 0o777 assert actual_mode == expected_mode, ( - f"Expected mode {expected_mode:04o} (umask {current_umask:04o}), " + f"Expected mode {expected_mode:04o} (umask {test_umask:04o}), " f"got {actual_mode:04o}" ) + + def test_overwrite_still_preserves_existing_mode(self, tmp_path): + """The new-file branch must not disturb the overwrite path's + mode preservation (e.g. an executable script stays 0755).""" + ops = ShellFileOperations(make_real_subprocess_env(str(tmp_path))) + dest = tmp_path / "existing.sh" + dest.write_text("#!/bin/sh\n") + dest.chmod(0o755) + + result = ops.write_file(str(dest), "#!/bin/sh\necho updated\n") + + assert result.error is None, f"write failed: {result.error}" + assert dest.read_text() == "#!/bin/sh\necho updated\n" + assert dest.stat().st_mode & 0o777 == 0o755 diff --git a/tools/file_operations.py b/tools/file_operations.py index aff82fa0af..d0d1f53a83 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -1005,7 +1005,16 @@ class ShellFileOperations(FileOperations): # - `chmod --reference` is GNU-only, so we read the octal mode with # `stat` (GNU `-c%a` or BSD `-f%Lp`) and `chmod` it explicitly; # silent best-effort — a perms-copy failure must not abort the - # write, the file still lands with default umask perms. + # write (the file then lands at mktemp's 0600, same as pre-fix). + # - brand-new targets get `chmod "=rw"` — the POSIX who-less + # symbolic form, which sets rw minus the process umask (e.g. + # 0644 under umask 022) instead of mktemp's hardcoded 0600 + # (#70856). Deliberately NOT shell arithmetic on `$(umask)`: + # zsh (reachable via _find_bash's $SHELL fallback) parses + # leading-zero constants as decimal and silently computes a + # garbage mode, while `chmod "=rw"` is spec-identical in + # bash/dash/ash/zsh and degrades to 0600 (pre-fix behavior) + # if an exotic chmod rejects it. # - `trap ... EXIT` guarantees the temp is removed on every error # path (cat failure, mv failure, signal) but NOT after a # successful mv (the temp no longer exists by then). @@ -1022,11 +1031,12 @@ class ShellFileOperations(FileOperations): 'if [ -e "$t" ]; then ' 'm="$(stat -c%a "$t" 2>/dev/null || stat -f%Lp "$t" 2>/dev/null || true)"; ' '[ -n "$m" ] && chmod "$m" "$tmp" 2>/dev/null || true; ' - # new file: apply umask-computed default instead of mktemp's 0600 - 'else ' - 'u="$(umask)"; chmod $(printf '"'"'%04o'"'"' $((0666 & ~0$u))) "$tmp" 2>/dev/null || true; ' "fi; " 'cat > "$tmp"; ' + # new file: umask-default perms instead of mktemp's 0600 (#70856). + # Runs AFTER cat so a write-masking umask can't EACCES the stream; + # quoted "=rw" so zsh doesn't =word-expand it. + 'if [ ! -e "$t" ]; then chmod "=rw" "$tmp" 2>/dev/null || true; fi; ' 'mv -f "$tmp" "$t"; ' "trap - EXIT" ) From 01c0879785b573c9e5003b558062a2a220a7305b Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:55:45 +0530 Subject: [PATCH 33/55] chore: map jordan.mymail@gmail.com -> lxman in contributor directory Attribution prerequisite for salvaging PR #74522 (micro-compaction); the contributor audit requires every commit-author email on main to resolve to a GitHub login. --- contributors/emails/jordan.mymail@gmail.com | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 contributors/emails/jordan.mymail@gmail.com diff --git a/contributors/emails/jordan.mymail@gmail.com b/contributors/emails/jordan.mymail@gmail.com new file mode 100644 index 0000000000..c8e734af3d --- /dev/null +++ b/contributors/emails/jordan.mymail@gmail.com @@ -0,0 +1,2 @@ +lxman +# PR #74522 salvage (per-turn micro-compaction) From 2de1e86c160c1a2bcc0219529425f1c15800e396 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:26:56 -0600 Subject: [PATCH 34/55] fix(cli): stabilize custom provider identities Use providers keys as the canonical custom-provider identity while accepting legacy bare keys, display-name slugs, bare custom fallback, and doubled custom prefixes across resolution, pickers, doctor, and runtime reverse lookup. Co-authored-by: Bakhtier Sizhaev --- hermes_cli/doctor.py | 9 +- hermes_cli/main.py | 25 +- hermes_cli/model_setup_flows.py | 3 +- hermes_cli/model_switch.py | 47 +- hermes_cli/providers.py | 51 ++- hermes_cli/runtime_provider.py | 45 +- .../test_custom_provider_identity.py | 63 +++ .../test_custom_provider_model_switch.py | 48 ++ tests/hermes_cli/test_doctor.py | 404 +++++++++++++++++ .../test_model_switch_custom_providers.py | 425 ++++++++++++++++++ 10 files changed, 1053 insertions(+), 67 deletions(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index ca8b8897ae..18ea422f3e 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -980,11 +980,13 @@ def run_doctor(args): try: from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers from hermes_cli.providers import ( + custom_provider_aliases as _custom_provider_aliases, normalize_provider as _normalize_catalog_provider, resolve_provider_full as _resolve_provider_full, ) except Exception: _compatible_custom_providers = None + _custom_provider_aliases = None _normalize_catalog_provider = None _resolve_provider_full = None @@ -1007,8 +1009,11 @@ def run_doctor(args): if not isinstance(entry, dict): continue name = str(entry.get("name") or "").strip() - if name: - known_providers.add("custom:" + name.lower().replace(" ", "-")) + provider_key = str(entry.get("provider_key") or "").strip() + if name and _custom_provider_aliases is not None: + known_providers.update( + _custom_provider_aliases(name, provider_key) + ) valid_provider_ids = set(known_providers) provider_ids_to_accept = {provider} if provider else set() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 814fa7e8f3..209ef0d083 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3029,7 +3029,11 @@ def select_provider_and_model(args=None): load_config, get_env_value, ) - from hermes_cli.providers import resolve_provider_full + from hermes_cli.providers import ( + custom_provider_aliases, + custom_provider_slug, + resolve_provider_full, + ) config = load_config() current_model = config.get("model") @@ -3149,13 +3153,8 @@ def select_provider_and_model(args=None): base_url = (entry.get("base_url") or "").strip() if not name or not base_url: continue - key = "custom:" + name.lower().replace(" ", "-") provider_key = (entry.get("provider_key") or "").strip() - if provider_key: - try: - resolve_provider(provider_key) - except AuthError: - key = provider_key + key = custom_provider_slug(name, provider_key) custom_provider_map[key] = { "name": name, "base_url": base_url, @@ -3183,6 +3182,16 @@ def select_provider_and_model(args=None): config ) # key → {name, base_url, api_key} + def _canonical_named_custom_key(provider_id: str) -> str: + requested = str(provider_id or "").strip().lower() + for key, provider_info in _custom_provider_map.items(): + if requested in custom_provider_aliases( + provider_info.get("name", ""), + provider_info.get("provider_key", ""), + ): + return key + return provider_id + def _active_custom_key_from_base_url() -> str: if effective_provider != "custom" or not isinstance(model_cfg, dict): return "" @@ -3205,6 +3214,8 @@ def select_provider_and_model(args=None): ) if active_def is not None: active = active_def.id + if active_def.source == "user-config": + active = _canonical_named_custom_key(active) else: warning = ( f"Unknown provider '{effective_provider}'. Check 'hermes model' for " diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index cea14a263f..fd5a0265df 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -26,6 +26,7 @@ import subprocess import urllib.parse from hermes_cli.config import clear_model_endpoint_credentials +from hermes_cli.providers import custom_provider_slug # AWS cross-region inference profile prefixes. Any geo-prefixed profile only @@ -1636,7 +1637,7 @@ def _model_flow_named_custom(config, provider_info): model = {"default": model} if model else {} cfg["model"] = model if provider_key: - model["provider"] = "custom:" + provider_key.strip().lower().replace(" ", "-") + model["provider"] = custom_provider_slug(name, provider_key) model.pop("base_url", None) model.pop("api_key", None) else: diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 75a1e9d914..8da7910337 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -27,6 +27,7 @@ from typing import Any, List, NamedTuple, Optional from hermes_cli.providers import ( ProviderDef, + custom_provider_aliases, custom_provider_slug, determine_api_mode, get_label, @@ -1145,11 +1146,10 @@ def _resolve_named_custom_model_id( for entry in custom_providers or []: if not isinstance(entry, dict): continue - entry_slugs = { - custom_provider_slug(str(entry.get(key) or "")).lower() - for key in ("name", "provider_key") - if str(entry.get(key) or "").strip() - } + entry_slugs = custom_provider_aliases( + str(entry.get("name") or ""), + str(entry.get("provider_key") or ""), + ) if provider not in entry_slugs or f"custom:{prefix}" not in entry_slugs: continue for model_id in _declared_model_ids(entry.get("models")): @@ -1684,9 +1684,12 @@ def switch_model( continue # Match by provider slug (custom:) or by base_url entry_name = entry.get("name", "") - entry_slug = f"custom:{entry_name}" if entry_name else "" + entry_aliases = custom_provider_aliases( + str(entry_name or ""), + str(entry.get("provider_key") or ""), + ) entry_url = entry.get("base_url", "") - if entry_slug == target_provider or entry_url == base_url: + if target_provider.lower() in entry_aliases or entry_url == base_url: # Check if the requested model matches the entry's model entry_model = entry.get("model", "") entry_models = entry.get("models", {}) @@ -2580,6 +2583,7 @@ def list_authenticated_providers( "has_explicit_models": False, "ep_cfg": ep_cfg, # used below for discover_models / api_key "raw_names": [], + "aliases": set(), } # Aggregate models across all members of the group (preserve order). for _m in entry_models: @@ -2593,6 +2597,9 @@ def list_authenticated_providers( if entry_declared_models: ep_groups[group_key]["has_explicit_models"] = True ep_groups[group_key]["raw_names"].append(display_name) + ep_groups[group_key]["aliases"].update( + custom_provider_aliases(display_name, str(ep_name)) + ) for grp in ep_groups.values(): ep_cfg = grp["ep_cfg"] @@ -2632,10 +2639,12 @@ def list_authenticated_providers( has_explicit_models = bool(grp.get("has_explicit_models")) _ep_url_norm = str(api_url).strip().rstrip("/").lower() _ep_slug_norm = str(ep_name).strip().lower() - _ep_custom_slug_norm = custom_provider_slug(display_name).lower() + _ep_aliases = { + str(alias).lower() for alias in grp.get("aliases", set()) + } _ep_is_current = ( _ep_slug_norm == _current_provider_norm - or _ep_custom_slug_norm == _current_provider_norm + or _current_provider_norm in _ep_aliases or ( _current_provider_norm == "custom" and bool(_current_base_url_norm) @@ -2669,7 +2678,7 @@ def list_authenticated_providers( "api_url": api_url, }) seen_slugs.add(ep_name.lower()) - seen_slugs.add(custom_provider_slug(display_name).lower()) + seen_slugs.update(_ep_aliases) # Record (display_name, api_url) for each raw entry that joined # this group so section-4's _section3_emitted_pairs dedup can # match per-model custom_providers rows ("Palantir Claude 4.7 Opus") @@ -2817,7 +2826,8 @@ def list_authenticated_providers( # Reuse the prefix computed above as the row display name; # fall back to the raw name if stripping left it empty. display_name = _display_prefix or raw_name - slug = custom_provider_slug(display_name) + provider_key = str(entry.get("provider_key") or "").strip() + slug = custom_provider_slug(display_name, provider_key) groups[group_key] = { "slug": slug, "name": display_name, @@ -2827,6 +2837,7 @@ def list_authenticated_providers( "has_explicit_models": False, "discover_models": discover, "extra_headers": entry_extra_headers, + "aliases": set(), } else: if api_key and not groups[group_key].get("api_key"): @@ -2837,6 +2848,12 @@ def list_authenticated_providers( # honour that for the whole grouped row. if not discover: groups[group_key]["discover_models"] = False + groups[group_key]["aliases"].update( + custom_provider_aliases( + raw_name, + str(entry.get("provider_key") or ""), + ) + ) # The singular ``model:`` field only holds the currently # active model. Hermes's own writer (main.py::_save_custom_provider) @@ -2928,7 +2945,13 @@ def list_authenticated_providers( # api_key is present. This supports endpoints that expose a # full aggregator catalog via /models but only serve a subset # (parity with section 3's user ``providers:`` behaviour). - _grp_is_current = slug.lower() == _current_provider_norm or ( + _grp_is_current = ( + slug.lower() == _current_provider_norm + or _current_provider_norm in { + str(alias).lower() + for alias in grp.get("aliases", set()) + } + ) or ( _current_provider_norm == "custom" and bool(_current_base_url_norm) and _grp_url_norm == _current_base_url_norm diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 17cae5d584..8fc2ac3b41 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -708,14 +708,36 @@ def resolve_user_provider(name: str, user_config: Dict[str, Any]) -> Optional[Pr ) -def custom_provider_slug(display_name: str) -> str: - """Build a canonical slug for a custom_providers entry. +def custom_provider_slug(display_name: str, provider_key: str = "") -> str: + """Build the stable ``custom:`` identity for a configured provider. - Matches the convention used by runtime_provider and credential_pool - (``custom:``). Centralised here so all call-sites - produce identical slugs. + Keyed ``providers:`` entries keep their config key as the durable + identity even when their display name changes. Legacy + ``custom_providers:`` entries have no key, so their normalized display + name remains the identity. """ - return "custom:" + display_name.strip().lower().replace(" ", "-") + identity = str(provider_key or "").strip() or str(display_name or "").strip() + normalized = identity.lower().replace(" ", "-") + return normalized if normalized.startswith("custom:") else f"custom:{normalized}" + + +def custom_provider_aliases( + display_name: str, + provider_key: str = "", +) -> frozenset[str]: + """Return every current and legacy identity accepted for one endpoint.""" + aliases: set[str] = set() + for value in (display_name, provider_key): + raw = str(value or "").strip().lower() + if not raw: + continue + normalized = raw.replace(" ", "-") + aliases.update({raw, normalized, custom_provider_slug(normalized)}) + if normalized.startswith("custom:"): + suffix = normalized.split(":", 1)[1] + if suffix: + aliases.update({suffix, f"custom:{normalized}"}) + return frozenset(aliases) def resolve_custom_provider( @@ -734,7 +756,7 @@ def resolve_custom_provider( # from a prior model-switch bug), fall back to the first custom # provider entry so existing configs self-heal. (GH #17478) bare_custom_fallback = requested == "custom" - first_valid: Optional[Tuple[str, str, Tuple[str, ...]]] = None + first_valid: Optional[Tuple[str, str, Tuple[str, ...], str]] = None for entry in custom_providers: if not isinstance(entry, dict): @@ -751,16 +773,22 @@ def resolve_custom_provider( continue key_env = (entry.get("key_env") or "").strip() + provider_key = (entry.get("provider_key") or "").strip() env_vars: List[str] = [] if key_env: env_vars.append(key_env) # Stash the first valid entry for bare-"custom" fallback if first_valid is None: - first_valid = (display_name, api_url, tuple(env_vars)) + first_valid = ( + display_name, + api_url, + tuple(env_vars), + custom_provider_slug(display_name, provider_key), + ) - slug = custom_provider_slug(display_name) - if requested not in {display_name.lower(), slug}: + slug = custom_provider_slug(display_name, provider_key) + if requested not in custom_provider_aliases(display_name, provider_key): continue return ProviderDef( @@ -776,8 +804,7 @@ def resolve_custom_provider( # Self-heal: bare "custom" matched nothing — return first valid entry if bare_custom_fallback and first_valid: - dname, aurl, denv = first_valid - slug = custom_provider_slug(dname) + dname, aurl, denv, slug = first_valid return ProviderDef( id=slug, name=dname, diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c140ee02b4..c92f0b7fc9 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -42,6 +42,7 @@ from hermes_cli.config import ( load_config, normalize_extra_headers, ) +from hermes_cli.providers import custom_provider_aliases, custom_provider_slug from hermes_constants import OPENROUTER_BASE_URL from utils import base_url_host_matches, base_url_hostname, env_int @@ -686,8 +687,6 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An # they're not configured. if not is_provider_enabled(entry): continue - # Match exact name or normalized name - name_norm = _normalize_custom_provider_name(ep_name) # Resolve the API key from the env var name stored in key_env key_env = str(entry.get("key_env", "") or "").strip() resolved_api_key = _getenv(key_env, "").strip() if key_env else "" @@ -695,7 +694,11 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An if not resolved_api_key: resolved_api_key = str(entry.get("api_key", "") or "").strip() - if requested_norm in {ep_name, name_norm, f"custom:{name_norm}"}: + display_name = entry.get("name", "") + if requested_norm in custom_provider_aliases( + str(display_name or ep_name), + str(ep_name), + ): # Found match by provider key base_url = entry.get("api") or entry.get("url") or entry.get("base_url") or "" if base_url: @@ -721,29 +724,6 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An result["api_mode"] = api_mode _lift_max_output_tokens(entry, result) return result - # Also check the 'name' field if present - display_name = entry.get("name", "") - if display_name: - display_norm = _normalize_custom_provider_name(display_name) - if requested_norm in {display_name, display_norm, f"custom:{display_norm}"}: - # Found match by display name - base_url = entry.get("api") or entry.get("url") or entry.get("base_url") or "" - if base_url: - result = { - "name": display_name, - "base_url": base_url.strip(), - "api_key": resolved_api_key, - "model": entry.get("default_model", ""), - } - extra_body = entry.get("extra_body") - if isinstance(extra_body, dict): - result["extra_body"] = dict(extra_body) - _lift_extra_headers(entry, result) - api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport")) - if api_mode: - result["api_mode"] = api_mode - _lift_max_output_tokens(entry, result) - return result # Fall back to custom_providers: list (legacy format) custom_providers = config.get("custom_providers") @@ -766,12 +746,8 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An base_url = entry.get("base_url") if not isinstance(name, str) or not isinstance(base_url, str): continue - name_norm = _normalize_custom_provider_name(name) - menu_key = f"custom:{name_norm}" provider_key = str(entry.get("provider_key", "") or "").strip() - provider_key_norm = _normalize_custom_provider_name(provider_key) if provider_key else "" - provider_menu_key = f"custom:{provider_key_norm}" if provider_key_norm else "" - if requested_norm not in {name_norm, menu_key, provider_key_norm, provider_menu_key}: + if requested_norm not in custom_provider_aliases(name, provider_key): continue result = { "name": name.strip(), @@ -846,7 +822,7 @@ def find_custom_provider_identity(base_url: str) -> Optional[str]: entry.get("api") or entry.get("url") or entry.get("base_url") or "" ) if _normalize_base_url_for_match(entry_url) == target: - return f"custom:{_normalize_custom_provider_name(str(ep_name))}" + return custom_provider_slug(str(ep_name), str(ep_name)) try: custom_providers = get_compatible_custom_providers(config) @@ -859,7 +835,10 @@ def find_custom_provider_identity(base_url: str) -> Optional[str]: if not isinstance(name, str) or not name.strip(): continue if _normalize_base_url_for_match(entry.get("base_url")) == target: - return f"custom:{_normalize_custom_provider_name(name)}" + return custom_provider_slug( + name, + str(entry.get("provider_key", "") or ""), + ) return None diff --git a/tests/hermes_cli/test_custom_provider_identity.py b/tests/hermes_cli/test_custom_provider_identity.py index f9a6304d37..c4f36ea37f 100644 --- a/tests/hermes_cli/test_custom_provider_identity.py +++ b/tests/hermes_cli/test_custom_provider_identity.py @@ -27,6 +27,69 @@ def test_matches_legacy_custom_providers_list(monkeypatch): ) +def test_matches_providers_dict_by_key(monkeypatch): + monkeypatch.setattr( + rp, + "load_config", + lambda: {"providers": {"local": {"api": "http://127.0.0.1:8000/v1"}}}, + ) + assert ( + rp.find_custom_provider_identity("http://127.0.0.1:8000/v1") + == "custom:local" + ) + + +def test_matches_providers_dict_by_stable_key_not_display_name(monkeypatch): + config = { + "providers": { + "local-127.0.0.1:8000": { + "name": "Local Ollama", + "api": "http://127.0.0.1:8000/v1", + } + } + } + monkeypatch.setattr( + rp, + "load_config", + lambda: config, + ) + slug = rp.find_custom_provider_identity("http://127.0.0.1:8000/v1") + assert slug == "custom:local-127.0.0.1:8000" + + entry = rp._get_named_custom_provider(slug) + assert entry is not None + assert entry["name"] == "Local Ollama" + + +def test_match_ignores_trailing_slash_and_case(monkeypatch): + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "custom_providers": [ + {"name": "local", "base_url": "http://Localhost:8000/v1/"} + ] + }, + ) + assert ( + rp.find_custom_provider_identity("http://localhost:8000/v1") + == "custom:local" + ) + + +def test_no_match_returns_none(monkeypatch): + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "custom_providers": [ + {"name": "other", "base_url": "https://elsewhere.example/v1"} + ] + }, + ) + assert rp.find_custom_provider_identity("https://api.mimo.example/v1") is None + + def test_empty_base_url_returns_none(monkeypatch): monkeypatch.setattr( rp, "load_config", lambda: {"custom_providers": [{"name": "x"}]} diff --git a/tests/hermes_cli/test_custom_provider_model_switch.py b/tests/hermes_cli/test_custom_provider_model_switch.py index e24f6e8c67..387332eb4f 100644 --- a/tests/hermes_cli/test_custom_provider_model_switch.py +++ b/tests/hermes_cli/test_custom_provider_model_switch.py @@ -327,6 +327,7 @@ class TestCustomProviderModelSwitch: saved_text = config_path.read_text() saved = yaml.safe_load(saved_text) or {} entry = saved["providers"]["crs-henkee"] + assert saved["model"]["provider"] == "custom:crs-henkee" assert "api_key" not in entry, ( f"providers.crs-henkee gained an api_key field: {entry.get('api_key')!r}" ) @@ -338,6 +339,53 @@ class TestCustomProviderModelSwitch: # The synthesized template is also redundant here — key_env owns it. assert "${HERMES_CRS_HENKEE_KEY}" not in saved_text + @pytest.mark.parametrize( + "stored_provider", + [ + "local-127.0.0.1:11434", + "custom:local-ollama", + "custom:local-127.0.0.1:11434", + ], + ) + def test_picker_recognizes_current_provider_alias_when_name_differs( + self, config_home, monkeypatch, stored_provider + ): + """The classic picker maps legacy and stable IDs to the keyed row.""" + from hermes_cli.main import select_provider_and_model + + config_path = config_home / "config.yaml" + config_path.write_text( + "model:\n" + f" provider: {stored_provider}\n" + " default: qwen3.5:9b\n" + "providers:\n" + " local-127.0.0.1:11434:\n" + " name: Local Ollama\n" + " base_url: http://127.0.0.1:11434/v1\n" + " default_model: qwen3.5:9b\n" + " models:\n" + " qwen3.5:9b: {}\n" + "custom_providers: []\n", + encoding="utf-8", + ) + + captured = {} + + def _capture_and_cancel(labels, default=0): + captured["labels"] = labels + captured["default"] = default + return len(labels) - 1 + + with patch( + "hermes_cli.main._prompt_provider_choice", + side_effect=_capture_and_cancel, + ), patch("builtins.print"): + select_provider_and_model() + + active_label = captured["labels"][captured["default"]] + assert "Local Ollama" in active_label + assert "currently active" in active_label + def test_key_env_providers_dict_preserves_existing_api_key( self, config_home, monkeypatch ): diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index b03aa0757b..aae133a0eb 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -272,6 +272,410 @@ class TestDoctorMemoryProviderSection: assert "Mem0" not in out + def test_mem0_provider_not_installed_shows_fail(self, monkeypatch, tmp_path): + # Make mem0 import fail + monkeypatch.setitem(sys.modules, "plugins.memory.mem0", None) + out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="mem0") + assert "Memory Provider" in out + assert "Built-in memory active" not in out + + +def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkeypatch, tmp_path): + helper = TestDoctorMemoryProviderSection() + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + + real_which = doctor_mod.shutil.which + + def fake_which(cmd): + if cmd in {"docker", "node", "npm"}: + return None + return real_which(cmd) + + monkeypatch.setattr(doctor_mod.shutil, "which", fake_which) + + out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="") + + assert "Docker backend is not available inside Termux" in out + assert "Node.js not found (browser tools are optional in the tested Termux path)" in out + assert "Install Node.js on Termux with: pkg install nodejs" in out + assert "Termux browser setup:" in out + assert "1) pkg install nodejs" in out + assert "2) npm install -g agent-browser" in out + assert "3) agent-browser install" in out + assert "Termux compatibility fallbacks:" in out + assert "use .[termux-all] for broad compatibility" in out + assert "Matrix E2EE extra is excluded on Termux" in out + assert "Local faster-whisper extra is excluded on Termux" in out + assert "STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY)." in out + assert "docker not found (optional)" not in out + + +def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + + import yaml + + (home / "config.yaml").write_text( + yaml.dump( + { + "model": { + "provider": "volcengine-plan", + "default": "doubao-seed-2.0-code", + }, + "providers": { + "volcengine-plan": { + "name": "volcengine-plan", + "base_url": "https://ark.cn-beijing.volces.com/api/coding/v3", + "default_model": "doubao-seed-2.0-code", + "models": {"doubao-seed-2.0-code": {}}, + } + }, + } + ) + ) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + (tmp_path / "project").mkdir(exist_ok=True) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) + except Exception: + pass + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + + out = buf.getvalue() + assert "model.provider 'volcengine-plan' is not a recognised provider" not in out + + +def test_run_doctor_accepts_stable_key_when_provider_name_differs( + monkeypatch, tmp_path +): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text( + "model:\n" + " provider: custom:local-127.0.0.1:11434\n" + " default: qwen3.5:9b\n" + "providers:\n" + " local-127.0.0.1:11434:\n" + " name: Local Ollama\n" + " base_url: http://127.0.0.1:11434/v1\n" + " default_model: qwen3.5:9b\n", + encoding="utf-8", + ) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + (tmp_path / "project").mkdir(exist_ok=True) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) + except Exception: + pass + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + + out = buf.getvalue() + assert ( + "model.provider 'custom:local-127.0.0.1:11434' is not a recognised provider" + not in out + ) + assert "model.provider 'custom:local-127.0.0.1:11434' is unknown" not in out + + +def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text( + "model:\n" + " provider: custom\n" + " default: local-model\n" + " base_url: http://localhost:8000/v1\n", + encoding="utf-8", + ) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + (tmp_path / "project").mkdir(exist_ok=True) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) + except Exception: + pass + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + + out = buf.getvalue() + assert "model.provider 'custom' is not a recognised provider" not in out + + +def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text( + "model:\n" + " provider: openrouter\n" + " default: openai/gpt-4.1-mini\n", + encoding="utf-8", + ) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + (tmp_path / "project").mkdir(exist_ok=True) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + try: + from hermes_cli import auth as _auth_mod + + monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {}) + except Exception: + pass + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + + out = buf.getvalue() + assert "model.provider 'openrouter' is set but no API key is configured" in out + assert "No credentials found for provider 'openrouter'." in out + + +@pytest.mark.parametrize( + ("provider", "default_model"), + [ + ("opencode-zen", "anthropic/claude-sonnet-4.6"), + ("kilocode", "anthropic/claude-sonnet-4.6"), + ("kimi-coding", "kimi-k2"), + ("nvidia", "qwen/qwen3.5-122b-a10b"), + ("moa", "anthropic/claude-sonnet-4.6"), + ], +) +def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases( + monkeypatch, tmp_path, provider, default_model +): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text( + "model:\n" + f" provider: {provider}\n" + f" default: {default_model}\n", + encoding="utf-8", + ) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + (tmp_path / "project").mkdir(exist_ok=True) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) + except Exception: + pass + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + + out = buf.getvalue() + assert f"model.provider '{provider}' is not a recognised provider" not in out + assert f"model.provider '{provider}' is unknown" not in out + if provider in {"opencode-zen", "kilocode", "nvidia"}: + assert ( + f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'" + not in out + ) + + +def test_run_doctor_accepts_vendor_slugs_for_named_custom_provider(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text( + "model:\n" + " provider: custom:hpc-ai\n" + " default: deepseek/deepseek-v4-flash\n" + "custom_providers:\n" + " - name: hpc-ai\n" + " base_url: https://hpc-ai.example/v1\n" + " api_key: test-key\n", + encoding="utf-8", + ) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + (tmp_path / "project").mkdir(exist_ok=True) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) + except Exception: + pass + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + + out = buf.getvalue() + assert "model.provider 'custom:hpc-ai' is not a recognised provider" not in out + assert "model.provider 'custom:hpc-ai' is unknown" not in out + assert ( + "model.default 'deepseek/deepseek-v4-flash' uses a vendor/model slug but provider is " + "'custom:hpc-ai'" + not in out + ) + assert "Either set model.provider to 'openrouter', or drop the vendor prefix." not in out + + + + +def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / ".env").write_text("KIMI_CN_API_KEY=***\n", encoding="utf-8") + (home / "config.yaml").write_text( + "model:\n" + " provider: kimi-coding-cn\n" + " default: kimi-k2.6\n", + encoding="utf-8", + ) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + (tmp_path / "project").mkdir(exist_ok=True) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) + except Exception: + pass + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + + out = buf.getvalue() + assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out + + +def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") + project = tmp_path / "project" + project.mkdir(exist_ok=True) + + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]), + TOOLSET_REQUIREMENTS={ + "terminal": {"name": "terminal"}, + "browser": {"name": "browser"}, + }, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) + except Exception: + pass + + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + out = buf.getvalue() + + assert "✓ browser" not in out + assert "browser" in out + assert "system dependency not met" in out + assert "agent-browser is not installed (expected in the tested Termux path)" in out + assert "npm install -g agent-browser && agent-browser install" in out def _run_doctor_with_managed_agent_browser(monkeypatch, tmp_path, runnable): diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 221f279228..e1eef0bc5a 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -63,6 +63,179 @@ def test_list_authenticated_providers_includes_custom_providers(monkeypatch): +def test_resolve_provider_full_finds_named_custom_provider(): + """Explicit /model --provider should resolve saved custom_providers entries.""" + resolved = resolve_provider_full( + "custom:local-(127.0.0.1:4141)", + user_providers={}, + custom_providers=[ + { + "name": "Local (127.0.0.1:4141)", + "base_url": "http://127.0.0.1:4141/v1", + } + ], + ) + + assert resolved is not None + assert resolved.id == "custom:local-(127.0.0.1:4141)" + assert resolved.name == "Local (127.0.0.1:4141)" + assert resolved.base_url == "http://127.0.0.1:4141/v1" + assert resolved.source == "user-config" + + +@pytest.mark.parametrize( + "requested", + [ + "Local Ollama", + "local-ollama", + "local-127.0.0.1:11434", + "custom:local-ollama", + "custom:local-127.0.0.1:11434", + ], +) +def test_keyed_custom_provider_legacy_aliases_resolve_to_stable_key(requested): + """Every historical identity resolves, but keyed providers return one ID.""" + resolved = resolve_provider_full( + requested, + user_providers={}, + custom_providers=[ + { + "name": "Local Ollama", + "provider_key": "local-127.0.0.1:11434", + "base_url": "http://127.0.0.1:11434/v1", + } + ], + ) + + assert resolved is not None + assert resolved.id == "custom:local-127.0.0.1:11434" + assert resolved.name == "Local Ollama" + + +def test_keyed_custom_provider_bare_custom_fallback_uses_stable_key(): + resolved = resolve_provider_full( + "custom", + user_providers={}, + custom_providers=[ + { + "name": "Local Ollama", + "provider_key": "local-127.0.0.1:11434", + "base_url": "http://127.0.0.1:11434/v1", + } + ], + ) + + assert resolved is not None + assert resolved.id == "custom:local-127.0.0.1:11434" + + +@pytest.mark.parametrize( + "requested", + ["foo", "custom:foo", "custom:custom:foo"], +) +def test_prefixed_provider_key_does_not_accumulate_custom_prefixes(requested): + """Accept the historical doubled form without writing a third identity.""" + resolved = resolve_provider_full( + requested, + user_providers={}, + custom_providers=[ + { + "name": "Foo Relay", + "provider_key": "custom:foo", + "base_url": "https://foo.example/v1", + } + ], + ) + + assert resolved is not None + assert resolved.id == "custom:foo" + + +def test_list_authenticated_providers_includes_active_bare_custom_endpoint(monkeypatch): + """Bare model.provider=custom + model.base_url should still populate /model. + + Users can configure a one-off OpenAI-compatible endpoint directly under + ``model:`` without a named ``providers:`` or ``custom_providers:`` row. + The gateway picker receives only the current model/base_url slice, so it + must surface that active endpoint rather than looking like config was + ignored. + """ + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + + providers = list_authenticated_providers( + current_provider="custom", + current_base_url="https://www.ccsub.net/v1", + current_model="gpt-4o", + user_providers={}, + custom_providers=[], + max_models=50, + ) + + bare_custom = next((p for p in providers if p["slug"] == "custom"), None) + assert bare_custom is not None + assert bare_custom["name"] == "Custom endpoint" + assert bare_custom["is_current"] is True + assert bare_custom["is_user_defined"] is True + assert bare_custom["models"] == ["gpt-4o"] + assert bare_custom["api_url"] == "https://www.ccsub.net/v1" + + +def test_list_authenticated_providers_can_probe_active_bare_custom_endpoint(monkeypatch): + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + monkeypatch.setattr( + "hermes_cli.models.fetch_api_models", + lambda api_key, api_url, **kwargs: ["gpt-4o", "gpt-4o-mini"], + ) + + providers = list_authenticated_providers( + current_provider="custom", + current_base_url="https://www.ccsub.net/v1", + current_model="gpt-4o", + user_providers={}, + custom_providers=[], + probe_custom_providers=False, + probe_current_custom_provider=True, + ) + + bare_custom = next(p for p in providers if p["slug"] == "custom") + assert bare_custom["is_current"] is True + assert bare_custom["models"] == ["gpt-4o", "gpt-4o-mini"] + + +def test_switch_model_accepts_explicit_bare_custom_current_endpoint(monkeypatch): + """Picker selections for bare custom endpoints should route to current base_url.""" + monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION) + monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None) + monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None) + + result = switch_model( + raw_input="gpt-4o-mini", + current_provider="custom", + current_model="gpt-4o", + current_base_url="https://www.ccsub.net/v1", + current_api_key="sk-test", + explicit_provider="custom", + user_providers={}, + custom_providers=[], + ) + + assert result.success is True + assert result.target_provider == "custom" + assert result.provider_label == "Custom endpoint" + assert result.new_model == "gpt-4o-mini" + assert result.base_url == "https://www.ccsub.net/v1" + assert result.api_key == "sk-test" + + +def test_is_aggregator_recognizes_named_custom_provider(): + assert providers_mod.is_aggregator("custom:hpc-ai") is True + assert providers_mod.is_aggregator("custom:litellm") is True + + +def test_is_aggregator_leaves_unknown_provider_non_aggregator(): + assert providers_mod.is_aggregator("not-a-provider") is False def test_is_routing_aggregator_excludes_flat_namespace_resellers(): @@ -162,6 +335,258 @@ def test_list_authenticated_providers_bare_custom_slug_recovers(monkeypatch): assert group["is_current"] is True +def test_compatible_keyed_provider_uses_stable_key_and_accepts_legacy_current_name( + monkeypatch, +): + """The merged providers view keeps the config key while old IDs stay current.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + + providers = list_authenticated_providers( + current_provider="custom:local-ollama", + user_providers={}, + custom_providers=[ + { + "name": "Local Ollama", + "provider_key": "local-127.0.0.1:11434", + "base_url": "http://127.0.0.1:11434/v1", + "model": "qwen3.5:9b", + } + ], + max_models=50, + probe_custom_providers=False, + ) + + row = next(p for p in providers if p.get("is_user_defined")) + assert row["slug"] == "custom:local-127.0.0.1:11434" + assert row["is_current"] is True + + +def test_user_provider_row_recognizes_stable_custom_key_as_current(monkeypatch): + """Section 3 keeps its legacy row slug but recognizes the stable ID.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + + providers = list_authenticated_providers( + current_provider="custom:local-127.0.0.1:11434", + user_providers={ + "local-127.0.0.1:11434": { + "name": "Local Ollama", + "base_url": "http://127.0.0.1:11434/v1", + "default_model": "qwen3.5:9b", + } + }, + custom_providers=[], + max_models=50, + probe_custom_providers=False, + ) + + row = next(p for p in providers if p.get("is_user_defined")) + assert row["slug"] == "local-127.0.0.1:11434" + assert row["is_current"] is True + + +def test_list_authenticated_providers_distinct_endpoints_stay_separate(monkeypatch): + """Entries with different base_urls must produce separate picker rows + even if some display names happen to be similar.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + + providers = list_authenticated_providers( + user_providers={}, + custom_providers=[ + {"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1", + "api_key": "ollama", "model": "glm-5.1"}, + {"name": "Moonshot", "base_url": "https://api.moonshot.cn/v1", + "api_key": "sk-m", "model": "moonshot-v1"}, + {"name": "Ollama — Qwen3-coder", "base_url": "http://localhost:11434/v1", + "api_key": "ollama", "model": "qwen3-coder"}, + ], + max_models=50, + probe_custom_providers=False, + ) + + custom_groups = [p for p in providers if p.get("is_user_defined")] + assert len(custom_groups) == 2 + # Ollama endpoint collapses to one row with both models + ollama = next(p for p in custom_groups if p["name"] == "Ollama") + assert set(ollama["models"]) == {"glm-5.1", "qwen3-coder"} + moonshot = next(p for p in custom_groups if p["name"] == "Moonshot") + assert moonshot["models"] == ["moonshot-v1"] + + +def test_list_authenticated_providers_same_url_different_keys_disambiguated(monkeypatch): + """Two custom_providers entries with the same base_url but different + api_keys (and identical cleaned names) must both stay visible in the + picker — slug is suffixed to disambiguate.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + + providers = list_authenticated_providers( + user_providers={}, + custom_providers=[ + {"name": "OpenAI — key A", "base_url": "https://api.openai.com/v1", + "api_key": "sk-AAA", "model": "gpt-5.4"}, + {"name": "OpenAI — key B", "base_url": "https://api.openai.com/v1", + "api_key": "sk-BBB", "model": "gpt-4.6"}, + ], + max_models=50, + ) + + custom_groups = [p for p in providers if p.get("is_user_defined")] + assert len(custom_groups) == 2 + slugs = sorted(p["slug"] for p in custom_groups) + # First group keeps the base slug, second gets a numeric suffix + assert slugs == ["custom:openai", "custom:openai-2"] + # Each row has a distinct model + models = {p["slug"]: p["models"] for p in custom_groups} + assert models["custom:openai"] == ["gpt-5.4"] + assert models["custom:openai-2"] == ["gpt-4.6"] + + +def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch): + """Same gateway host but different key_env/api_mode entries are distinct providers.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + + providers = list_authenticated_providers( + current_provider="custom:gpt", + current_base_url="https://gateway.example.com", + user_providers={}, + custom_providers=[ + { + "name": "gpt", + "base_url": "https://gateway.example.com", + "key_env": "GPT_KEY", + "api_mode": "codex_responses", + "model": "gpt-5.5", + }, + { + "name": "claude", + "base_url": "https://gateway.example.com", + "key_env": "CLAUDE_KEY", + "api_mode": "anthropic_messages", + "model": "claude-opus-4-8", + }, + ], + max_models=50, + ) + + custom = [p for p in providers if p.get("is_user_defined")] + by_slug = {p["slug"]: p for p in custom} + + assert set(by_slug) == {"custom:gpt", "custom:claude"} + assert by_slug["custom:gpt"]["models"] == ["gpt-5.5"] + assert by_slug["custom:claude"]["models"] == ["claude-opus-4-8"] + assert by_slug["custom:gpt"]["is_current"] is True + assert by_slug["custom:claude"]["is_current"] is False + + +def test_list_authenticated_providers_total_models_reflects_grouped_count(monkeypatch): + """After grouping six entries into one row, total_models must reflect + the full count, and every grouped model appears in the list.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + + entries = [ + {"name": f"Ollama \u2014 Model {i}", "base_url": "http://localhost:11434/v1", + "api_key": "ollama", "model": f"model-{i}"} + for i in range(6) + ] + providers = list_authenticated_providers( + user_providers={}, + custom_providers=entries, + max_models=4, + probe_custom_providers=False, + ) + + groups = [p for p in providers if p.get("is_user_defined")] + assert len(groups) == 1 + group = groups[0] + assert group["total_models"] == 6 + # All six models are preserved in the grouped row. + assert sorted(group["models"]) == sorted(f"model-{i}" for i in range(6)) + + +def test_lmstudio_picker_probes_active_config_base_url(monkeypatch): + """When `provider: lmstudio` is saved with a remote base_url and no + LM_BASE_URL env var, the picker must probe the saved base_url — not + 127.0.0.1. Regression: prior behavior always probed localhost, so users + with LM Studio on a lab box saw the wrong (or empty) model list. + """ + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + monkeypatch.delenv("LM_BASE_URL", raising=False) + monkeypatch.delenv("LM_API_KEY", raising=False) + + captured: dict = {} + + def _fake_fetch(api_key=None, base_url=None, timeout=5.0): + captured["base_url"] = base_url + captured["api_key"] = api_key + return ["qwen/qwen3-coder-30b"] + + monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch) + + list_authenticated_providers( + current_provider="lmstudio", + current_base_url="http://192.168.1.10:1234/v1", + current_model="qwen/qwen3-coder-30b", + ) + + assert captured["base_url"] == "http://192.168.1.10:1234/v1" + + +def test_lmstudio_picker_lm_base_url_env_wins_over_active_config(monkeypatch): + """LM_BASE_URL env var must still take precedence over the saved + base_url so users can temporarily redirect the picker without editing + config.yaml. + """ + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + monkeypatch.setenv("LM_BASE_URL", "http://override.local:9999/v1") + monkeypatch.delenv("LM_API_KEY", raising=False) + + captured: dict = {} + + def _fake_fetch(api_key=None, base_url=None, timeout=5.0): + captured["base_url"] = base_url + return [] + + monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch) + + list_authenticated_providers( + current_provider="lmstudio", + current_base_url="http://192.168.1.10:1234/v1", + ) + + assert captured["base_url"] == "http://override.local:9999/v1" + + +def test_lmstudio_picker_skips_probe_when_not_configured(monkeypatch): + """If the user has never configured LM Studio (no LM_API_KEY / LM_BASE_URL + and not on lmstudio), the picker must not pay the localhost probe cost + just to discover LM Studio is unavailable. + """ + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + monkeypatch.delenv("LM_BASE_URL", raising=False) + monkeypatch.delenv("LM_API_KEY", raising=False) + + captured: dict = {} + + def _fake_fetch(api_key=None, base_url=None, timeout=5.0): + captured["base_url"] = base_url + return [] + + monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch) + + list_authenticated_providers( + current_provider="openrouter", + current_base_url="https://openrouter.ai/api/v1", + ) + + assert "base_url" not in captured def test_custom_providers_uses_live_models_for_multi_model_endpoint(monkeypatch): From 7b5a18817e5952a1f4d60edd30fc034c10eb16e3 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:17:28 +0500 Subject: [PATCH 35/55] fix: migrate sibling custom-provider slug sites to custom_provider_slug find_custom_provider_identity_by_model (runtime_provider.py:895,908) and acp_adapter/server.py:149 still used the old f"custom:{_normalize_custom_provider_name(...)}" pattern while the rest of the codebase migrated to custom_provider_slug. For keyed providers whose display name differs from their config key, the model-based reverse lookup would return custom: instead of the stable custom: identity every other code path returns. Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com> --- acp_adapter/server.py | 4 ++-- hermes_cli/runtime_provider.py | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 7fee2d932f..1b0046fe32 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -114,6 +114,7 @@ def _named_custom_provider_catalogs() -> list[tuple[str, str, list[tuple[str, st load_config, ) from hermes_cli.models import fetch_api_models + from hermes_cli.providers import custom_provider_slug except ImportError: return [] @@ -145,8 +146,7 @@ def _named_custom_provider_catalogs() -> list[tuple[str, str, list[tuple[str, st base_url = str(entry.get("base_url", "") or "").strip() if not name or not base_url: continue - slug_source = provider_key or name - slug = "custom:" + slug_source.strip().lower().replace(" ", "-") + slug = custom_provider_slug(name, provider_key) api_key = str(entry.get("api_key", "") or "").strip() if not api_key: diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c92f0b7fc9..c96f333d63 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -892,7 +892,7 @@ def find_custom_provider_identity_by_model(model: str) -> Optional[str]: if not isinstance(entry, dict): continue if _entry_serves_model(entry): - return f"custom:{_normalize_custom_provider_name(str(ep_name))}" + return custom_provider_slug(str(ep_name), str(ep_name)) try: custom_providers = get_compatible_custom_providers(config) @@ -905,7 +905,10 @@ def find_custom_provider_identity_by_model(model: str) -> Optional[str]: if not isinstance(name, str) or not name.strip(): continue if _entry_serves_model(entry): - return f"custom:{_normalize_custom_provider_name(name)}" + return custom_provider_slug( + name, + str(entry.get("provider_key", "") or ""), + ) return None From 186cad02f9cf1feff42a1b472b740aa7a41a8b6a Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 29 Jul 2026 13:48:51 -0400 Subject: [PATCH 36/55] feat(agent): per-turn micro-compaction to amortize context compression Batch compaction pauses a session for one large summarization once the window fills. Micro-compaction spreads that cost out: after each completed turn, `finalize_turn` folds the single oldest un-absorbed exchange (assistant message plus its tool results) into a rolling summary, so the work happens in small increments during post-turn idle time instead of one long stall. Mechanics: - a cursor tracks the first message not yet absorbed, recovered from the transcript's last summary marker when in-memory state is unavailable; - protected head and tail windows are never touched, so the system prompt and recent turns stay verbatim; - the absorbed span is replaced by a marker carrying the usual `_compressed_summary` metadata, so resume, handoff and `/compress` treat it exactly like a batch summary; - `archive_and_compact` keeps the session DB in step, otherwise the append-only flush would leave the original rows active and a resume would double-load both summary and originals; - when the rolling summary itself passes a token threshold it is defragged: re-summarized in one shot and the cursor jumps to the tail; - an exchange the summarizer can't handle is retried a bounded number of times, then skipped, so one poison exchange can't stall every turn. Keep only the newest summary marker. The rolling summary is cumulative, so each marker already contains everything the previous ones held; leaving them stacked near-duplicate copies of the same text, each with its own heading and end-marker scaffolding, and the transcript grew on every turn instead of shrinking. Measured over six turns on a 12-exchange conversation with tool output: 4104 -> 4797 tokens before, 4104 -> 2572 after. Off switch: `compression.micro_compact: false` (default on). Co-Authored-By: Claude Fable 5 --- agent/agent_init.py | 7 + agent/context_compressor.py | 462 +++++++++++++++++++++++++++ agent/turn_finalizer.py | 25 ++ tests/agent/test_micro_compaction.py | 179 +++++++++++ 4 files changed, 673 insertions(+) create mode 100644 tests/agent/test_micro_compaction.py diff --git a/agent/agent_init.py b/agent/agent_init.py index 407f9a6c7b..5972b84981 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1963,6 +1963,9 @@ def init_agent( compression_in_place = is_truthy_value( _compression_cfg.get("in_place"), default=True ) + compression_micro_compact = is_truthy_value( + _compression_cfg.get("micro_compact"), default=True + ) codex_app_server_auto_compaction = str( _compression_cfg.get("codex_app_server_auto", "native") or "native" ).lower() @@ -2418,6 +2421,10 @@ def init_agent( pass agent.compression_enabled = compression_enabled agent.compression_in_place = compression_in_place + # Apply micro-compaction flag to the compressor (enabled by default) + _cc = getattr(agent, "context_compressor", None) + if _cc is not None and hasattr(_cc, "_micro_compact_enabled"): + _cc._micro_compact_enabled = compression_micro_compact agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction agent.max_compression_attempts = compression_max_attempts agent.compression_idle_compact_after_seconds = ( diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 209253335b..a42e9d374b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -364,6 +364,11 @@ _SUMMARY_RATIO = 0.20 # itself a context-pressure source and slows every compaction. _SUMMARY_TOKENS_CEILING = 10_000 +# Micro-compaction failure guard: after this many consecutive failures on the +# same cursor position, skip the stuck exchange and advance the cursor so the +# system doesn't busy-loop on an unsummarizable exchange every turn. +_MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES = 3 + # Aggregate cap on the serialized turn block fed to the summarizer prompt # (chars). Per-message truncation (_CONTENT_MAX / _TOOL_ARGS_MAX) alone is # not enough: a compression window with hundreds of already-truncated turns @@ -1290,6 +1295,12 @@ class ContextCompressor(ContextEngine): self._active_compression_telemetry = None self._compression_telemetry_seed = None + # Micro-compaction state reset + self._micro_compact_cursor = 0 + self._micro_compact_rolling_summary = "" + self._micro_compact_consecutive_failures = 0 + self._micro_compact_last_failure_cursor = -1 + def _begin_compression_telemetry( self, *, @@ -2136,6 +2147,15 @@ class ContextCompressor(ContextEngine): # deterministic "summary unavailable" handoff and drop the middle window. self.abort_on_summary_failure = abort_on_summary_failure + # ── Micro-compaction (per-turn rolling compaction) ───────── + # Default: enabled when not explicitly disabled (backward compatible). + self._micro_compact_enabled: bool = True + self._micro_compact_cursor: int = 0 + self._micro_compact_rolling_summary: str = "" + self._micro_compact_consecutive_failures: int = 0 + self._micro_compact_last_failure_cursor: int = -1 + self._micro_compact_defrag_threshold_tokens: int = 2000 + # Defer context-length resolution to first access (#32221): # get_model_context_length() can issue a synchronous /models HTTP # probe, which must not block AIAgent construction. The small-context @@ -4987,6 +5007,448 @@ This compaction should PRIORITISE preserving all information related to the focu # Main compression entry point # ------------------------------------------------------------------ + def _resolve_compact_cursor( + self, + messages: List[Dict[str, Any]], + head_end: int, + tail_start: int, + ) -> int: + """Derive the micro-compaction cursor from in-memory state or transcript scan. + + Returns the index of the first message that has NOT yet been absorbed + into the rolling summary. If the in-memory cursor ``_micro_compact_cursor`` + is valid (non-zero and within the compressible window), use it directly. + Otherwise scan from *head_end* through *tail_start* for the last context + summary marker and set the cursor past it. + """ + if self._micro_compact_cursor > head_end and self._micro_compact_cursor < tail_start: + return self._micro_compact_cursor + # Scan transcript for the last summary marker + last_summary_idx = -1 + for idx in range(head_end, tail_start): + if self._is_context_summary_message(messages[idx]): + last_summary_idx = idx + if last_summary_idx >= head_end: + cursor = last_summary_idx + 1 + else: + cursor = head_end + self._micro_compact_cursor = cursor + return cursor + + def _find_one_exchange( + self, + messages: List[Dict[str, Any]], + start: int, + tail_start: int, + ) -> Optional[tuple[int, int]]: + """Find the next complete exchange starting at *start*. + + An exchange is: (optional) user message + assistant message + its tool + results. Returns ``(exchange_start, exchange_end)`` indices into + *messages*, or ``None`` if no complete exchange is available before + *tail_start*. + + Tool results are consumed as a group following the assistant message + (consecutive ``tool``-role messages). The assistant message itself must + exist; without one there is nothing to summarise. + """ + idx = start + n = len(messages) + if idx >= n or idx >= tail_start: + return None + + # Walk past any user messages or summary markers until we hit an + # assistant message with actual output (content or tool_calls). + while idx < tail_start and idx < n: + msg = messages[idx] + role = msg.get("role") + if role == "assistant": + break + idx += 1 + + if idx >= tail_start or idx >= n: + return None + + exchange_start = idx + + # Advance past the assistant message + idx += 1 + + # Consume following tool results as part of the same exchange + while idx < tail_start and idx < n: + if messages[idx].get("role") == "tool": + idx += 1 + else: + break + + if idx <= exchange_start: + return None + return (exchange_start, idx) + + def _serialize_one_exchange( + self, + messages: List[Dict[str, Any]], + start: int, + end: int, + ) -> str: + """Serialize a single exchange for the micro-summarizer. + + Uses the same content-max truncation and redaction as the batch + ``_serialize_for_summary`` method, but scoped to one exchange. + """ + from agent.agent_runtime_helpers import strip_think_blocks + + parts = [] + for msg in messages[start:end]: + role = msg.get("role", "unknown") + content = msg.get("content") + if isinstance(content, list): + text_parts: list[str] = [] + for part in content: + if isinstance(part, dict): + ptype = part.get("type") + if ptype == "text": + text_parts.append(part.get("text", "")) + elif ptype in {"image", "image_url", "input_image"}: + text_parts.append(_image_part_label(part)) + else: + text_parts.append(f"[{ptype or 'attachment'}]") + elif isinstance(part, str): + text_parts.append(part) + content = "\n".join(text_parts) + content = _redact_compaction_text(content or "") + content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content) + if role == "assistant" and content: + content = strip_think_blocks(None, content) + + if role == "tool": + tool_id = msg.get("tool_call_id", "") + if len(content) > self._CONTENT_MAX: + content = content[:self._CONTENT_HEAD] + "\n...[truncated]...\n" + content[-self._CONTENT_TAIL:] + parts.append(f"[TOOL RESULT {tool_id}]: {content}") + continue + + if role == "assistant": + if len(content) > self._CONTENT_MAX: + content = content[:self._CONTENT_HEAD] + "\n...[truncated]...\n" + content[-self._CONTENT_TAIL:] + tool_calls = msg.get("tool_calls", []) + if tool_calls: + tc_parts = [] + for tc in tool_calls: + if isinstance(tc, dict): + fn = tc.get("function", {}) + name = fn.get("name", "?") + args = _redact_compaction_text(fn.get("arguments", "")) + if len(args) > self._TOOL_ARGS_MAX: + args = args[:self._TOOL_ARGS_HEAD] + "..." + tc_parts.append(f" {name}({args})") + else: + fn = getattr(tc, "function", None) + name = getattr(fn, "name", "?") if fn else "?" + tc_parts.append(f" {name}(...)") + content += "\n[Tool calls:\n" + "\n".join(tc_parts) + "\n]" + parts.append(f"[ASSISTANT]: {content}") + continue + + if role == "user": + if len(content) > self._CONTENT_MAX: + content = content[:self._CONTENT_HEAD] + "\n...[truncated]...\n" + content[-self._CONTENT_TAIL:] + parts.append(f"[USER]: {content}") + continue + + parts.append(f"[{role.upper()}]: {content}") + + return "\n---\n".join(parts) + + def _build_micro_summary_prompt( + self, + existing_summary: str, + exchange_text: str, + ) -> List[Dict[str, str]]: + """Build the prompt messages for a single-exchange micro-summary.""" + if existing_summary.strip(): + summary_block = existing_summary + else: + summary_block = "(No previous summary yet.)" + + user_prompt = ( + "You are a summarization agent creating a compact record of an " + "ongoing conversation. You are given a running summary and the " + "next exchange from the conversation. Merge the exchange's key " + "decisions, requirements, file paths, and open questions into the " + "summary. Preserve the summary's structure. Drop resolved details " + "that are no longer relevant. Add new decisions, file paths, and " + "open questions.\n\n" + "NEVER include API keys, tokens, passwords, secrets, credentials, " + "or connection strings in the summary \u2014 replace any that appear " + f"with [REDACTED].\n\n" + f"## Current Running Summary\n{summary_block}\n\n" + f"## Next Exchange to Merge\n{exchange_text}\n\n" + "Return ONLY the updated summary text, no preamble or explanation. " + "Do not include this instruction block in your output." + ) + + return [ + {"role": "system", "content": "You are a conversation summarization assistant."}, + {"role": "user", "content": user_prompt}, + ] + + def _micro_summarize_one( + self, + exchange_text: str, + ) -> Optional[str]: + """Micro-summarize one exchange into the rolling summary via aux LLM. + + Calls the same auxiliary compression model as the batch path, with + a focused prompt that merges one exchange into the running summary. + Returns the updated summary text, or ``None`` on failure. + """ + from agent.auxiliary_client import call_llm, aux_interrupt_protection + + messages = self._build_micro_summary_prompt( + self._micro_compact_rolling_summary, + exchange_text, + ) + + call_kwargs = { + "task": "compression", + "messages": messages, + "max_tokens": min(1500, self.max_summary_tokens or 1500), + "temperature": 0.1, + } + if self.summary_model: + call_kwargs["model"] = self.summary_model + if self.model: + call_kwargs.setdefault("main_runtime", { + "model": self.model, + "provider": self.provider or "", + "base_url": self.base_url or "", + "api_key": self.api_key or "", + "api_mode": getattr(self, "api_mode", "") or "", + }) + + try: + with aux_interrupt_protection(): + response = call_llm(**call_kwargs) + except Exception as exc: + logger.info("micro-summarization call failed: %s", exc) + return None + + message = response.choices[0].message + if isinstance(message, dict): + content = message.get("content") + else: + content = getattr(message, "content", message) + if not isinstance(content, str): + content = str(content) if content else "" + content = content.strip() + if not content: + logger.info("micro-summarization returned empty content") + return None + + from agent.agent_runtime_helpers import strip_think_blocks + stripped = strip_think_blocks(None, content).strip() + return stripped if stripped else None + + def _needs_defrag(self) -> bool: + """Return True when the rolling summary is large enough to defrag.""" + content_tokens = estimate_tokens_rough(self._micro_compact_rolling_summary) + return content_tokens >= self._micro_compact_defrag_threshold_tokens + + def _defrag_rolling_summary( + self, + messages: List[Dict[str, Any]], + head_end: int, + tail_start: int, + ) -> None: + """Re-summarize the rolling summary + remaining middle in one shot. + + This is a lightweight batch compaction on just the summary and the + remaining un-compacted region \u2014 NOT the full transcript. It replaces + the rolling summary with a fresh, compact version and advances the + cursor to *tail_start*. + """ + middle_content = self._serialize_one_exchange(messages, head_end, tail_start) + fresh_summary = self._micro_summarize_one(middle_content) + if fresh_summary: + self._micro_compact_rolling_summary = fresh_summary + self._micro_compact_cursor = tail_start + logger.info( + "Micro-compaction defrag: rolling summary re-summarized " + "(%d chars)", len(fresh_summary), + ) + + def _micro_compact( + self, + messages: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """Run one round of micro-compaction on the conversation. + + Absorbs the oldest uncompacted exchange into the rolling summary, + advancing the in-memory cursor. Runs in post-turn idle time. + + This is the public entry point called from ``finalize_turn()``. + Returns the (possibly modified) message list. + + NOTE: the in-memory splice alone is not persisted — the subsequent + ``_persist_session`` flush is append-only, so old DB rows stay + ``active=1`` and a session resume double-loads both the summary and + the original exchanges (#82483). This method therefore also calls + ``archive_and_compact`` on the session DB to soft-archive old rows + and insert the compacted set atomically. + """ + if not self._micro_compact_enabled: + return messages + + n_messages = len(messages) + if n_messages < 4: + return messages + + head_size = self._protect_head_size(messages) + compress_start = self._align_boundary_forward(messages, head_size) + compress_end = self._find_tail_cut_by_tokens(messages, compress_start) + + if compress_start >= compress_end: + return messages + + cursor = self._resolve_compact_cursor(messages, compress_start, compress_end) + if cursor >= compress_end: + return messages + + # Find the next exchange + exchange = self._find_one_exchange(messages, cursor, compress_end) + if exchange is None: + return messages + + exchange_start, exchange_end = exchange + + # Check for defrag trigger + if self._needs_defrag(): + self._defrag_rolling_summary(messages, exchange_start, compress_end) + result = self._splice_micro_compact_result(messages, exchange_start, compress_end) + self._sync_micro_compact_to_db(result) + self._micro_compact_consecutive_failures = 0 + self._micro_compact_last_failure_cursor = -1 + return result + + # Micro-summarize one exchange + exchange_text = self._serialize_one_exchange(messages, exchange_start, exchange_end) + updated_summary = self._micro_summarize_one(exchange_text) + if updated_summary is None: + # Track consecutive failures on the same cursor position so we + # don't busy-loop on an unsummarizable exchange every turn. + if exchange_start == self._micro_compact_last_failure_cursor: + self._micro_compact_consecutive_failures += 1 + else: + self._micro_compact_consecutive_failures = 1 + self._micro_compact_last_failure_cursor = exchange_start + + if self._micro_compact_consecutive_failures >= _MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES: + logger.info( + "Micro-compaction: skipping exchange at cursor %d " + "after %d consecutive failures", + exchange_start, self._micro_compact_consecutive_failures, + ) + # Advance the cursor past the stuck exchange so we don't + # retry it every turn. The skipped messages remain in the + # transcript and will be absorbed by the next batch + # compression or defrag. + self._micro_compact_cursor = exchange_end + self._micro_compact_consecutive_failures = 0 + self._micro_compact_last_failure_cursor = -1 + return messages + + self._micro_compact_rolling_summary = updated_summary + self._micro_compact_cursor = exchange_end + self._micro_compact_consecutive_failures = 0 + self._micro_compact_last_failure_cursor = -1 + + result = self._splice_micro_compact_result(messages, exchange_start, exchange_end) + self._sync_micro_compact_to_db(result) + return result + + def _sync_micro_compact_to_db( + self, + compacted_messages: List[Dict[str, Any]], + ) -> None: + """Persist the micro-compacted message set to the session DB. + + Soft-archives every currently-active message row (``active = 0``) + and inserts *compacted_messages* as fresh active rows — atomically, + via ``archive_and_compact``. Then stamps ``_DB_PERSISTED_MARKER`` on + every dict so the upcoming append-only flush (``_persist_session`` → + ``_flush_messages_to_session_db_unlocked``) skips them: they are + already correctly stored. + + Without this, the in-memory-only splice leaves old exchange rows at + ``active=1``, and a session resume double-loads both the summary and + the original messages — blowing past the model's context limit. + """ + session_db = getattr(self, "_session_db", None) + session_id = getattr(self, "_session_id", "") + if not session_db or not session_id: + return + try: + session_db.archive_and_compact(session_id, compacted_messages) + for msg in compacted_messages: + if isinstance(msg, dict): + msg[_DB_PERSISTED_MARKER] = True + except Exception: + logger.info( + "Micro-compaction DB sync failed — resume will double-load " + "compacted messages until the next batch compression" + ) + + def _splice_micro_compact_result( + self, + messages: List[Dict[str, Any]], + splice_start: int, + splice_end: int, + ) -> List[Dict[str, Any]]: + """Replace *messages[splice_start:splice_end]* with a summary marker. + + The summary marker carries the rolling summary text and the + ``_compressed_summary`` metadata flag so downstream consumers + (resume, handoff, /compress) handle it identically to batch + compaction summaries. + """ + summary_text = self._micro_compact_rolling_summary + if not summary_text.strip(): + return messages + + content = ( + f"{SUMMARY_PREFIX}\n\n" + f"{HISTORICAL_TASK_HEADING}\n" + ) + content += summary_text.strip() + content += f"\n\n{_SUMMARY_END_MARKER}" + + summary_msg = { + "role": "user", + "content": content, + COMPRESSED_SUMMARY_METADATA_KEY: True, + COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: True, + } + + result = messages[:splice_start] + [summary_msg] + messages[splice_end:] + + # The rolling summary is cumulative: this marker already contains + # everything every earlier micro-compaction marker held. Leaving those + # in place stacks near-duplicate copies of the same text — each with + # its own prefix/heading/end-marker scaffolding — so the transcript + # grows with every turn instead of shrinking, which defeats the point. + # Keep only the newest marker. + marker_idxs = [ + i for i, m in enumerate(result) + if isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY) + ] + if len(marker_idxs) > 1: + superseded = set(marker_idxs[:-1]) + result = [m for i, m in enumerate(result) if i not in superseded] + + _strip_persistence_markers(result) + return result + def compress( self, messages: List[Dict[str, Any]], diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 0f636c1dd2..110a104c46 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -349,6 +349,31 @@ def finalize_turn( _apply_override = getattr(agent, "_apply_persist_user_message_override", None) if callable(_apply_override): _apply_override(messages) + + # ── Post-turn micro-compaction ──────────────────────────── + # After the assistant response is finalized but before the session is + # persisted, run micro-compaction to absorb the oldest uncompacted + # exchange into the rolling summary. This amortizes compression + # across turns rather than batching it into one big pause. + if not interrupted and not failed: + try: + _compressor = getattr(agent, "context_compressor", None) + if ( + _compressor + and getattr(_compressor, '_micro_compact_enabled', False) + and final_response + ): + _before = len(messages) + messages[:] = _compressor._micro_compact(messages) or messages + _after = len(messages) + if _before != _after: + logger.info( + "Micro-compaction: %d -> %d messages", + _before, _after, + ) + except Exception as _mc_err: + logger.info("Micro-compaction failed: %s", _mc_err) + agent._persist_session(messages, conversation_history) except Exception as _persist_err: _cleanup_errors.append(f"persist_session: {_persist_err}") diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py new file mode 100644 index 0000000000..3bfcdceccb --- /dev/null +++ b/tests/agent/test_micro_compaction.py @@ -0,0 +1,179 @@ +"""Tests for per-turn micro-compaction in ``ContextCompressor``. + +Micro-compaction amortizes the cost of context compression: instead of one +long pause when the window fills, each turn folds the single oldest +un-absorbed exchange into a rolling summary. + +The invariants that matter: + +* one call absorbs exactly one exchange (assistant + its tool results), so + the per-turn cost stays bounded; +* the absorbed span is replaced by a summary marker carrying the usual + ``_compressed_summary`` metadata, so resume/handoff treat it like a batch + summary; +* the cursor advances, so successive calls walk forward rather than + re-summarising the same exchange; +* protected head and tail messages are never touched; +* an exchange the summarizer cannot handle is retried a bounded number of + times and then skipped, so a poison exchange can't stall every turn. +""" + +import pytest + +from agent.context_compressor import ( + COMPRESSED_SUMMARY_METADATA_KEY, + ContextCompressor, + _MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES, +) + + +def _compressor(summary="ROLLING SUMMARY") -> ContextCompressor: + cc = ContextCompressor( + model="test-model", + threshold_percent=0.75, + protect_first_n=1, + protect_last_n=2, + quiet_mode=True, + config_context_length=40960, + provider="test", + ) + cc._micro_compact_enabled = True + # Stand in for the auxiliary summarizer LLM. + cc._micro_summarize_one = lambda _text: summary + return cc + + +def _conversation(exchanges: int = 6) -> list: + msgs = [{"role": "system", "content": "system prompt"}] + for i in range(exchanges): + msgs.append({"role": "user", "content": f"question {i}"}) + msgs.append({"role": "assistant", "content": f"answer {i} " + "z" * 400}) + return msgs + + +def _summary_markers(messages: list) -> list: + return [m for m in messages if m.get(COMPRESSED_SUMMARY_METADATA_KEY)] + + +class TestMicroCompaction: + def test_absorbs_one_exchange_and_leaves_a_summary_marker(self): + cc = _compressor() + messages = _conversation() + + result = cc._micro_compact(list(messages)) + + # The absorbed assistant turn is gone from the transcript. + assert any("answer 0" in str(m.get("content")) for m in messages) + assert not any("answer 0" in str(m.get("content")) for m in result) + markers = _summary_markers(result) + assert len(markers) == 1 + assert "ROLLING SUMMARY" in markers[0]["content"] + # The marker stands in for a user turn, like batch compaction's does. + assert markers[0]["role"] == "user" + + def test_disabled_is_a_no_op(self): + cc = _compressor() + cc._micro_compact_enabled = False + messages = _conversation() + + assert cc._micro_compact(list(messages)) == messages + + def test_cursor_advances_across_successive_turns(self): + cc = _compressor() + messages = _conversation(exchanges=8) + + first = cc._micro_compact(list(messages)) + cursor_after_first = cc._micro_compact_cursor + second = cc._micro_compact(list(first)) + + assert cursor_after_first > 0 + assert cc._micro_compact_cursor >= cursor_after_first + # Still exactly one marker: the second pass merges into the rolling + # summary rather than stacking a second summary block. + assert len(_summary_markers(second)) == 1 + + def test_protected_head_and_tail_survive(self): + cc = _compressor() + messages = _conversation() + + result = cc._micro_compact(list(messages)) + + assert result[0] == messages[0], "system prompt must be preserved" + assert result[-1] == messages[-1], "most recent turn must be preserved" + + def test_short_conversation_is_untouched(self): + cc = _compressor() + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + + assert cc._micro_compact(list(messages)) == messages + + def test_summarizer_failure_leaves_conversation_intact(self): + cc = _compressor() + cc._micro_summarize_one = lambda _text: None + messages = _conversation() + + result = cc._micro_compact(list(messages)) + + assert result == messages + assert cc._micro_compact_consecutive_failures == 1 + + def test_poison_exchange_is_skipped_after_repeated_failures(self): + """A repeatedly unsummarizable exchange must not stall every turn.""" + cc = _compressor() + cc._micro_summarize_one = lambda _text: None + messages = _conversation() + + for _ in range(_MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES): + cc._micro_compact(list(messages)) + + # The cursor has moved past the stuck exchange and the strike count + # is reset, so the next turn attempts new material. + assert cc._micro_compact_cursor > 0 + assert cc._micro_compact_consecutive_failures == 0 + + def test_repeated_compaction_shrinks_context_and_keeps_one_marker(self): + """The whole point: successive turns must reduce the transcript. + + The rolling summary is cumulative, so an earlier marker's text is a + subset of the current one. Keeping the earlier markers stacked + near-duplicate copies (each with its own heading/end-marker + scaffolding) and made the transcript grow every turn — the opposite + of what compaction is for. + """ + from agent.model_metadata import estimate_messages_tokens_rough + + cc = _compressor() + # Cumulative summary, like the real summarizer produces. + state = {"n": 0} + + def growing(_text): + state["n"] += 1 + return "SUMMARY " + " ".join(f"ex{i}" for i in range(state["n"])) + + cc._micro_summarize_one = growing + + messages = _conversation(exchanges=12) + before = estimate_messages_tokens_rough(messages) + for _ in range(6): + messages = cc._micro_compact(messages) + after = estimate_messages_tokens_rough(messages) + + assert len(_summary_markers(messages)) == 1 + assert after < before, f"context grew: {before} -> {after}" + + def test_defrag_triggers_once_the_rolling_summary_grows(self): + cc = _compressor(summary="FRESH DEFRAGGED SUMMARY") + cc._micro_compact_rolling_summary = "x" * 40_000 # far over the threshold + messages = _conversation(exchanges=8) + + assert cc._needs_defrag() is True + result = cc._micro_compact(list(messages)) + + assert cc._micro_compact_rolling_summary == "FRESH DEFRAGGED SUMMARY" + markers = _summary_markers(result) + assert len(markers) == 1 + assert "FRESH DEFRAGGED SUMMARY" in markers[0]["content"] From cd9d9d03b31f98a68f2e20a02a65d8ca9e842aae Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 29 Jul 2026 13:49:44 -0400 Subject: [PATCH 37/55] docs(agent): explain micro-compaction Covers what it does, the head/tail protection, the cursor and rolling summary, defrag, how the session DB is kept in step, and the failure paths. States the tradeoff up front: compression cost is amortized across turns, at the price of older detail becoming summarized earlier in a session than batch-only compaction would. Co-Authored-By: Claude Fable 5 --- docs/micro-compaction.md | 156 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/micro-compaction.md diff --git a/docs/micro-compaction.md b/docs/micro-compaction.md new file mode 100644 index 0000000000..5c712a8ad3 --- /dev/null +++ b/docs/micro-compaction.md @@ -0,0 +1,156 @@ +# Micro-compaction + +**A way to amortize the cost of compression.** + +Long conversations eventually outgrow the model's context window, and something +has to be thrown away or summarized. Hermes has always done this in one batch: +when the transcript crosses a threshold, the session stops, a large chunk of the +middle is summarized in a single call, and the conversation resumes. That works, +but the whole bill comes due at once — one visible pause, one big summarization +request, at whatever moment you happened to cross the line. + +Micro-compaction pays the same bill in instalments. After each completed turn, +Hermes folds the single oldest un-absorbed exchange into a running summary. The +work is the same work; it just happens continuously, in small pieces, during the +idle moment after a response instead of all at once in the middle of your +session. + +**The tradeoff is that knowledge gets a little earlier than you may be used to.** +Because compaction is always running, older parts of the conversation become +summaries sooner than they would under batch compaction — which leaves +everything verbatim until the window actually fills. Detail from earlier in the +session turns second-hand faster. You trade some of that fidelity for never +eating one long stall, and for a context window that stays consistently smaller +rather than sawtoothing up to the threshold and back. + +--- + +## What it does + +After every turn that finishes normally, `finalize_turn` asks the context +compressor to absorb **one** exchange: + +1. Find the oldest exchange that hasn't been summarized yet. +2. Send just that exchange, plus the current running summary, to the auxiliary + summarization model. +3. Replace those messages in the transcript with a single summary marker + carrying the updated running summary. + +One exchange per turn. The per-turn cost stays bounded no matter how long the +conversation gets. + +An **exchange** is an assistant message together with any tool results that +followed it. In tool-heavy work that's where the bulk of the tokens live — a +file read or a command's output dwarfs the surrounding prose — which is why +absorbing one exchange at a time is worth doing at all. + +## What it never touches + +Two regions are protected and stay verbatim: + +- **The head** — the system prompt and the opening messages, so the session's + founding instructions are never paraphrased. +- **The tail** — a token-budgeted window of the most recent messages, so + everything that's immediately relevant is still there in full. + +Micro-compaction only ever works in the middle, between those two. + +## How it works + +### The cursor + +The compressor keeps a cursor: the index of the first message not yet absorbed. +Each successful pass advances it past the exchange it just summarized. + +If that in-memory cursor is missing or out of range — a fresh process, a resumed +session — it's recovered by scanning the transcript for the last summary marker +and resuming just after it. The transcript itself is the source of truth, so +resuming a session doesn't re-summarize work already done. + +### The rolling summary + +Rather than keeping a pile of per-exchange summaries, there is exactly one +running summary that each new exchange is merged into. The summarizer is asked +to fold in the new material's decisions, requirements, file paths and open +questions, drop details that are no longer relevant, and preserve the existing +structure. It's also explicitly instructed to replace any credentials it +encounters with `[REDACTED]`. + +Because that summary is cumulative, only the newest marker is kept in the +transcript. Earlier markers are strictly redundant — the current summary already +contains everything they held — so they're dropped as they're superseded. This +matters more than it sounds: leaving them in place stacks near-duplicate copies +of the same text, each with its own heading and end-marker scaffolding, and the +transcript grows on every turn instead of shrinking. + +### Defrag + +Merge into a summary often enough and it gets baggy — repetitive, and larger +than the material justifies. When the running summary crosses a token threshold +(2000 by default), the next pass **defrags**: it re-summarizes the summary and +whatever middle remains in one shot, replacing it with a fresh compact version +and advancing the cursor to the tail. + +This is still much cheaper than full batch compaction. It only ever processes the +summary plus the un-absorbed middle, never the whole transcript. + +### Staying in step with the session database + +The in-memory splice alone isn't enough. Hermes's normal session flush is +append-only, so the original rows would stay marked active and a resume would +load *both* the summary and the messages it replaced — putting the session +straight over the context limit. + +So each pass also calls `archive_and_compact`, which atomically soft-archives the +active rows and inserts the compacted set. The messages are then stamped as +already-persisted so the append-only flush that follows skips them. If that +database step fails, it's logged and the session continues; the resume would +double-load until the next batch compression cleans up. + +### When the summarizer fails + +A summarization call can fail — the auxiliary model is unreachable, out of quota, +or the exchange itself is somehow unsummarizable. The transcript is left +untouched and the failure is counted. + +If the *same* exchange fails three times in a row, the cursor is advanced past it +anyway. Without that, one bad exchange would be retried on every single turn +forever. Those skipped messages stay in the transcript and get picked up by the +next defrag or batch compaction. + +## Interaction with batch compaction + +Micro-compaction doesn't replace batch compaction — it defers it. Threshold-based +compaction is still there and still fires if the window fills anyway, and its +summary markers are the same format, so the two interoperate. In practice +micro-compaction keeps the transcript far enough below the threshold that the +batch path fires much less often. + +## Configuration + +```yaml +compression: + micro_compact: true # default +``` + +Set it to `false` to disable micro-compaction and return to batch-only +compaction. Everything else about compression is unchanged. + +## What you'll see in the logs + +``` +Micro-compaction: 37 -> 36 messages +Micro-compaction defrag: rolling summary re-summarized (1843 chars) +Micro-compaction: skipping exchange at cursor 12 after 3 consecutive failures +``` + +Message counts move by small amounts — that's the point. The token count is where +the effect shows: absorbing one tool-heavy exchange can drop hundreds of tokens +while changing the message count by one or two. + +## Failure behaviour + +Micro-compaction is best-effort throughout. The call in `finalize_turn` is wrapped +so that any exception is logged and swallowed — a failure returns the conversation +unchanged and the turn completes normally. It can degrade, but it shouldn't be +able to break a session. From 214b5d8612f1e4e510d939971f840d52db4b029d Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 29 Jul 2026 15:10:54 -0400 Subject: [PATCH 38/55] docs(agent): state that user turns are never micro-compacted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_find_one_exchange`'s docstring described an exchange as "(optional) user message + assistant message + its tool results", but the walk skips past user messages and starts at the assistant, so user turns are never absorbed into the rolling summary. The code is right and the docstring was wrong. Assistant output is largely an account of what was done and survives summarising with little loss. The user's messages are the intent everything else is derived from and cannot be reconstructed from the work that followed — paraphrasing "use the existing helper, don't add a new one" into a summary is how an agent ends up doing the opposite six turns later. They are also cheap: a prompt is normally a tiny fraction of what one tool result costs. Correct the docstring, document the property (and its cost — a floor on how small the middle can get, since user turns accumulate), and add a test so it stays deliberate. Co-Authored-By: Claude Fable 5 --- agent/context_compressor.py | 18 ++++++++++++++---- docs/micro-compaction.md | 26 +++++++++++++++++++++++++- tests/agent/test_micro_compaction.py | 21 +++++++++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index a42e9d374b..b120e83db0 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -5043,14 +5043,24 @@ This compaction should PRIORITISE preserving all information related to the focu ) -> Optional[tuple[int, int]]: """Find the next complete exchange starting at *start*. - An exchange is: (optional) user message + assistant message + its tool - results. Returns ``(exchange_start, exchange_end)`` indices into - *messages*, or ``None`` if no complete exchange is available before - *tail_start*. + An exchange is an assistant message plus its tool results. Returns + ``(exchange_start, exchange_end)`` indices into *messages*, or ``None`` + if no complete exchange is available before *tail_start*. Tool results are consumed as a group following the assistant message (consecutive ``tool``-role messages). The assistant message itself must exist; without one there is nothing to summarise. + + User messages are deliberately NOT part of an exchange. The walk skips + past them to reach the assistant message, and ``exchange_start`` is that + assistant index, so user turns are never absorbed into the rolling + summary and stay verbatim for the life of the session. This is the + intended behaviour, not an oversight: what the assistant emits is + largely an account of what it did, which survives summarising, while the + user's own words are the instructions everything else is derived from + and are the one thing that cannot be reconstructed from context. They + are also cheap — a prompt is normally a tiny fraction of the tokens a + single tool result costs. """ idx = start n = len(messages) diff --git a/docs/micro-compaction.md b/docs/micro-compaction.md index 5c712a8ad3..3fa9aa6049 100644 --- a/docs/micro-compaction.md +++ b/docs/micro-compaction.md @@ -44,9 +44,33 @@ followed it. In tool-heavy work that's where the bulk of the tokens live — a file read or a command's output dwarfs the surrounding prose — which is why absorbing one exchange at a time is worth doing at all. +## Your messages are never compacted + +An exchange deliberately starts at the *assistant* message. Micro-compaction +walks straight past user messages to get there, so **what you typed is never +summarized** — your prompts stay verbatim for the entire session, no matter how +long it runs or how many times compaction fires. + +This is the most useful property of the whole design, and it's worth being +explicit about why. What the assistant produces is largely an account of what it +did: it read this file, it ran that command, it got this result. That kind of +narration survives summarising with very little loss — "it did it this way" is +about as informative compressed as it was in full. Your instructions are a +different kind of thing. They're the intent everything else is derived from, and +they cannot be reconstructed from the work that followed. Paraphrasing "use the +existing retry helper, don't add a new one" into a summary is exactly how an +agent ends up confidently doing the thing you told it not to, six turns later. + +So the asymmetry is on purpose: compact the derived material, keep the source of +truth. The cost is a floor on how small the middle can get, since user turns +accumulate and are never absorbed. In practice that floor is low — a prompt is +normally a tiny fraction of what a single tool result costs — but it is a real +floor. If you routinely paste 10–20K-token prompts, that weight stays in context +by design. + ## What it never touches -Two regions are protected and stay verbatim: +Two more regions are protected and stay verbatim: - **The head** — the system prompt and the opening messages, so the session's founding instructions are never paraphrased. diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py index 3bfcdceccb..f6f897de08 100644 --- a/tests/agent/test_micro_compaction.py +++ b/tests/agent/test_micro_compaction.py @@ -101,6 +101,27 @@ class TestMicroCompaction: assert result[0] == messages[0], "system prompt must be preserved" assert result[-1] == messages[-1], "most recent turn must be preserved" + def test_user_messages_are_never_absorbed(self): + """User turns stay verbatim for the life of the session — by design. + + Assistant output is largely an account of what was done and survives + summarising; the user's own words are the intent everything else is + derived from and can't be reconstructed from it. So an exchange starts + at the assistant message and the walk skips past user turns. + """ + cc = _compressor() + messages = _conversation(exchanges=10) + originals = [m["content"] for m in messages if m["role"] == "user"] + + for _ in range(5): + messages = cc._micro_compact(messages) + + surviving = [ + m["content"] for m in messages + if m.get("role") == "user" and not m.get(COMPRESSED_SUMMARY_METADATA_KEY) + ] + assert surviving == originals, "user turns must survive verbatim" + def test_short_conversation_is_untouched(self): cc = _compressor() messages = [ From a72f898d0fd7cc88d2e3dcc3a96b666d783ea908 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 29 Jul 2026 15:16:09 -0400 Subject: [PATCH 39/55] docs(agent): drop a fabricated issue reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `_micro_compact` docstring cited "#82483" for the resume double-load problem. No such issue exists — the repository's highest number is 74323, so the reference was invented rather than looked up. The reasoning it was attached to is correct and stays: the session flush is append-only, so an in-memory splice alone leaves the original rows active and a resume loads both the summary and the messages it replaced. Only the citation was wrong. Co-Authored-By: Claude Fable 5 --- agent/context_compressor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index b120e83db0..7cbc3879b5 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -5303,7 +5303,7 @@ This compaction should PRIORITISE preserving all information related to the focu NOTE: the in-memory splice alone is not persisted — the subsequent ``_persist_session`` flush is append-only, so old DB rows stay ``active=1`` and a session resume double-loads both the summary and - the original exchanges (#82483). This method therefore also calls + the original exchanges. This method therefore also calls ``archive_and_compact`` on the session DB to soft-archive old rows and insert the compacted set atomically. """ From cac9526d2a8dfe3ef24aef0d3209d3f8114256f0 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 29 Jul 2026 15:35:35 -0400 Subject: [PATCH 40/55] feat(agent): token telemetry for micro-compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing log line reports message counts, which is the least informative number available here: absorbing one tool-heavy exchange can drop hundreds of tokens while moving the count by one. There was no way to answer "is this actually helping?" from a real session. Emit one content-free JSON line per pass, in the same shape as the batch compaction telemetry: before/after tokens, the delta, the size of the absorbed exchange, the rolling summary size, duration, and running per-session totals so a whole run can be read off the last line. No transcript content rides along. Add scripts/micro_compaction_report.py to aggregate those lines into passes, outcome mix, net tokens saved, mean exchange size and durations, with an optional per-session breakdown. Measuring it immediately surfaced something worth documenting: the first pass in a session normally *costs* tokens. The summary marker carries a fixed ~400 tokens of scaffolding, paid on pass one against a single absorbed exchange. From pass two the marker is replaced rather than added, so the overhead is already paid and each exchange is close to pure saving. Break-even is typically the second or third pass. Tests cover the telemetry contract, the cumulative totals, and that first-pass/later-pass shape so nobody reads a single turn and concludes it made things worse. The estimator costs ~5 ms at 600 messages and ~20 ms at 1200, taken twice per pass, post-turn — and only once an exchange is actually in hand, so turns that no-op early pay nothing. Co-Authored-By: Claude Fable 5 --- agent/context_compressor.py | 96 +++++++++++++++++++ docs/micro-compaction.md | 44 ++++++++- scripts/micro_compaction_report.py | 132 +++++++++++++++++++++++++++ tests/agent/test_micro_compaction.py | 69 ++++++++++++++ 4 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 scripts/micro_compaction_report.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 7cbc3879b5..59e97d48d1 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1300,6 +1300,8 @@ class ContextCompressor(ContextEngine): self._micro_compact_rolling_summary = "" self._micro_compact_consecutive_failures = 0 self._micro_compact_last_failure_cursor = -1 + self._micro_compact_passes = 0 + self._micro_compact_tokens_saved_total = 0 def _begin_compression_telemetry( self, @@ -2155,6 +2157,8 @@ class ContextCompressor(ContextEngine): self._micro_compact_consecutive_failures: int = 0 self._micro_compact_last_failure_cursor: int = -1 self._micro_compact_defrag_threshold_tokens: int = 2000 + self._micro_compact_passes: int = 0 + self._micro_compact_tokens_saved_total: int = 0 # Defer context-length resolution to first access (#32221): # get_model_context_length() can issue a synchronous /models HTTP @@ -5332,6 +5336,15 @@ This compaction should PRIORITISE preserving all information related to the focu exchange_start, exchange_end = exchange + # Baseline for telemetry. Taken only once an exchange is in hand, so + # turns that no-op early don't pay for the scan. + _started_at = time.monotonic() + _tokens_before = estimate_messages_tokens_rough(messages) + _messages_before = n_messages + + def _elapsed_ms() -> int: + return int((time.monotonic() - _started_at) * 1000) + # Check for defrag trigger if self._needs_defrag(): self._defrag_rolling_summary(messages, exchange_start, compress_end) @@ -5339,10 +5352,19 @@ This compaction should PRIORITISE preserving all information related to the focu self._sync_micro_compact_to_db(result) self._micro_compact_consecutive_failures = 0 self._micro_compact_last_failure_cursor = -1 + self._emit_micro_compaction_telemetry( + outcome="defrag", + messages_before=_messages_before, + messages_after=len(result), + tokens_before=_tokens_before, + tokens_after=estimate_messages_tokens_rough(result), + duration_ms=_elapsed_ms(), + ) return result # Micro-summarize one exchange exchange_text = self._serialize_one_exchange(messages, exchange_start, exchange_end) + _exchange_tokens = estimate_tokens_rough(exchange_text) updated_summary = self._micro_summarize_one(exchange_text) if updated_summary is None: # Track consecutive failures on the same cursor position so we @@ -5366,6 +5388,18 @@ This compaction should PRIORITISE preserving all information related to the focu self._micro_compact_cursor = exchange_end self._micro_compact_consecutive_failures = 0 self._micro_compact_last_failure_cursor = -1 + _outcome = "exchange_skipped" + else: + _outcome = "summarize_failed" + self._emit_micro_compaction_telemetry( + outcome=_outcome, + messages_before=_messages_before, + messages_after=len(messages), + tokens_before=_tokens_before, + tokens_after=_tokens_before, + exchange_tokens=_exchange_tokens, + duration_ms=_elapsed_ms(), + ) return messages self._micro_compact_rolling_summary = updated_summary @@ -5375,8 +5409,70 @@ This compaction should PRIORITISE preserving all information related to the focu result = self._splice_micro_compact_result(messages, exchange_start, exchange_end) self._sync_micro_compact_to_db(result) + self._emit_micro_compaction_telemetry( + outcome="absorbed", + messages_before=_messages_before, + messages_after=len(result), + tokens_before=_tokens_before, + tokens_after=estimate_messages_tokens_rough(result), + exchange_tokens=_exchange_tokens, + duration_ms=_elapsed_ms(), + ) return result + def _emit_micro_compaction_telemetry( + self, + *, + outcome: str, + messages_before: int, + messages_after: int, + tokens_before: int | None, + tokens_after: int | None, + exchange_tokens: int | None = None, + duration_ms: int | None = None, + ) -> None: + """Emit one content-free JSON log line describing a micro-compaction pass. + + Mirrors ``_emit_compression_attempt_telemetry`` for the batch path. + Message counts move by one or two even when the saving is large, so the + token fields are the ones that actually answer "is this helping?". + ``tokens_delta`` is negative when the pass shrank the transcript, and + the ``*_total`` fields accumulate across the session so a whole run can + be summarised from the last line alone. + """ + try: + delta = None + if tokens_before is not None and tokens_after is not None: + delta = tokens_after - tokens_before + self._micro_compact_tokens_saved_total -= delta + self._micro_compact_passes += 1 + payload = { + "event": "micro_compaction", + "session_id": getattr(self, "_session_id", "") or "", + "outcome": outcome, + "messages_before": messages_before, + "messages_after": messages_after, + "tokens_before": _safe_int(tokens_before), + "tokens_after": _safe_int(tokens_after), + "tokens_delta": _safe_int(delta), + "exchange_tokens": _safe_int(exchange_tokens), + "rolling_summary_tokens": estimate_tokens_rough( + self._micro_compact_rolling_summary + ), + "cursor": _safe_int(self._micro_compact_cursor), + "passes_total": self._micro_compact_passes, + "tokens_saved_total": self._micro_compact_tokens_saved_total, + "duration_ms": _safe_int(duration_ms), + "main_model": self.model or "", + "aux_model": self.summary_model or "", + } + logger.info( + "micro compaction telemetry: %s", + json.dumps(payload, sort_keys=True, separators=(",", ":")), + ) + except Exception as exc: + logger.debug("failed to emit micro-compaction telemetry: %s", exc) + def _sync_micro_compact_to_db( self, compacted_messages: List[Dict[str, Any]], diff --git a/docs/micro-compaction.md b/docs/micro-compaction.md index 3fa9aa6049..19419dab78 100644 --- a/docs/micro-compaction.md +++ b/docs/micro-compaction.md @@ -160,7 +160,47 @@ compression: Set it to `false` to disable micro-compaction and return to batch-only compaction. Everything else about compression is unchanged. -## What you'll see in the logs +## Measuring it + +Every pass emits one content-free JSON line, in the same style as the batch +compaction telemetry: + +``` +micro compaction telemetry: {"event":"micro_compaction","outcome":"absorbed", +"tokens_before":12739,"tokens_after":12060,"tokens_delta":-679, +"exchange_tokens":868,"rolling_summary_tokens":31,"passes_total":1, +"tokens_saved_total":679,"duration_ms":14,...} +``` + +`tokens_delta` is negative when the pass shrank the transcript. +`tokens_saved_total` and `passes_total` accumulate across the session, so a whole +run can be summarised from its last line. No transcript content appears in the +payload — only counts. + +To turn a log into an answer: + +``` +python scripts/micro_compaction_report.py [--per-session] [LOGFILE ...] +``` + +Defaults to `$HERMES_HOME/logs/agent.log`. It reports passes, outcome mix, net +tokens saved, mean absorbed-exchange size and pass durations. + +### Reading the numbers honestly + +**The first pass in a session usually costs tokens rather than saving them.** +Inserting the summary marker carries a fixed ~400 tokens of scaffolding — the +compaction preamble, the historical heading, the end marker — and on pass one +that is paid against a single absorbed exchange. A first pass showing +`tokens_delta: +330` is not a malfunction. + +From the second pass on, the marker is *replaced* rather than added, so the +scaffolding is already paid for and each absorbed exchange is close to pure +saving. The break-even is normally the second or third pass. This is why the +per-session view matters more than any single line: judge the feature on a +session's trajectory, not on one turn. + +The plainer human-readable lines are still there too: ``` Micro-compaction: 37 -> 36 messages @@ -168,7 +208,7 @@ Micro-compaction defrag: rolling summary re-summarized (1843 chars) Micro-compaction: skipping exchange at cursor 12 after 3 consecutive failures ``` -Message counts move by small amounts — that's the point. The token count is where +Message counts move by small amounts — that's expected. The token count is where the effect shows: absorbing one tool-heavy exchange can drop hundreds of tokens while changing the message count by one or two. diff --git a/scripts/micro_compaction_report.py b/scripts/micro_compaction_report.py new file mode 100644 index 0000000000..6d65a0c133 --- /dev/null +++ b/scripts/micro_compaction_report.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Summarize micro-compaction telemetry from Hermes logs. + +Reads the content-free ``micro compaction telemetry:`` JSON lines emitted by +``ContextCompressor._emit_micro_compaction_telemetry`` and reports what the +feature actually did, per session and overall. + +Usage: + python scripts/micro_compaction_report.py [LOGFILE ...] + python scripts/micro_compaction_report.py --per-session + +With no LOGFILE, reads ``$HERMES_HOME/logs/agent.log`` (default ~/.hermes). + +Note on reading the numbers: the first pass in a session inserts the summary +marker, which costs a fixed ~400 tokens of scaffolding. That overhead is paid +once; from the second pass on the marker is replaced rather than added, so +each absorbed exchange is pure saving. A session with a single pass can +therefore show a net loss and still be working correctly. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections import defaultdict +from pathlib import Path + +MARKER = "micro compaction telemetry: " + + +def default_log() -> Path: + home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes") + return Path(home) / "logs" / "agent.log" + + +def load(paths: list[Path]) -> list[dict]: + events: list[dict] = [] + for path in paths: + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + print(f"warning: cannot read {path}: {exc}", file=sys.stderr) + continue + for line in text.splitlines(): + idx = line.find(MARKER) + if idx == -1: + continue + try: + events.append(json.loads(line[idx + len(MARKER):])) + except ValueError: + continue + return events + + +def fmt(n: int | None) -> str: + return "-" if n is None else f"{n:,}" + + +def report(events: list[dict], per_session: bool) -> int: + if not events: + print("No micro-compaction telemetry found.") + print("Micro-compaction may be disabled (compression.micro_compact),") + print("or no session has run long enough to trigger a pass yet.") + return 1 + + by_session: dict[str, list[dict]] = defaultdict(list) + for e in events: + by_session[e.get("session_id") or "(unknown)"].append(e) + + outcomes: dict[str, int] = defaultdict(int) + for e in events: + outcomes[e.get("outcome", "?")] += 1 + + saved = sum(-(e.get("tokens_delta") or 0) for e in events) + absorbed = [e for e in events if e.get("outcome") == "absorbed"] + exchange_tokens = [e.get("exchange_tokens") or 0 for e in absorbed] + durations = [e.get("duration_ms") or 0 for e in events] + + if per_session: + print(f"{'session':<38} {'passes':>7} {'saved':>10} {'first':>8} {'last':>8}") + print("-" * 76) + for sid, evs in sorted(by_session.items(), key=lambda kv: -len(kv[1])): + s = sum(-(e.get("tokens_delta") or 0) for e in evs) + first = evs[0].get("tokens_before") + last = evs[-1].get("tokens_after") + print(f"{sid[:38]:<38} {len(evs):>7} {s:>+10,} {fmt(first):>8} {fmt(last):>8}") + print() + + print(f"sessions {len(by_session):,}") + print(f"passes {len(events):,}") + for name, count in sorted(outcomes.items(), key=lambda kv: -kv[1]): + print(f" {name:<20} {count:,}") + print(f"net tokens saved {saved:+,}") + if absorbed: + print(f"exchanges absorbed {len(absorbed):,}") + print(f" mean exchange size {sum(exchange_tokens) // len(absorbed):,} tokens") + print(f" mean saving/pass {saved // len(events):+,} tokens") + if durations: + ordered = sorted(durations) + print(f"pass duration median {ordered[len(ordered) // 2]:,} ms") + print(f" max {ordered[-1]:,} ms") + + multi = {s: e for s, e in by_session.items() if len(e) > 1} + if multi: + net = sum( + (evs[-1].get("tokens_after") or 0) - (evs[0].get("tokens_before") or 0) + for evs in multi.values() + ) + print() + print(f"sessions with >1 pass {len(multi):,}") + print(f" net context change {net:+,} tokens (negative is shrinkage)") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("logs", nargs="*", type=Path, help="log files (default: agent.log)") + ap.add_argument("--per-session", action="store_true", help="break down by session") + args = ap.parse_args() + + paths = args.logs or [default_log()] + missing = [p for p in paths if not p.exists()] + for p in missing: + print(f"warning: {p} does not exist", file=sys.stderr) + return report(load([p for p in paths if p.exists()]), args.per_session) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py index f6f897de08..f940ff71df 100644 --- a/tests/agent/test_micro_compaction.py +++ b/tests/agent/test_micro_compaction.py @@ -186,6 +186,75 @@ class TestMicroCompaction: assert len(_summary_markers(messages)) == 1 assert after < before, f"context grew: {before} -> {after}" + def test_emits_content_free_token_telemetry(self, caplog): + """Each pass logs one JSON line with the token accounting. + + Message counts barely move even when the saving is large, so the token + fields are what make the effect measurable in a real session. + """ + import json + import logging + + cc = _compressor() + messages = _conversation(exchanges=8) + + with caplog.at_level(logging.INFO, logger="agent.context_compressor"): + result = cc._micro_compact(messages) + + lines = [ + r.getMessage() for r in caplog.records + if "micro compaction telemetry:" in r.getMessage() + ] + assert len(lines) == 1 + payload = json.loads(lines[0].split("micro compaction telemetry: ", 1)[1]) + + assert payload["event"] == "micro_compaction" + assert payload["outcome"] == "absorbed" + assert payload["tokens_saved_total"] == -payload["tokens_delta"] + assert payload["passes_total"] == 1 + assert payload["messages_after"] == len(result) + assert payload["exchange_tokens"] > 0 + # Content-free: no transcript text may ride along in the payload. + blob = json.dumps(payload) + assert "answer 0" not in blob and "question 0" not in blob + + def test_first_pass_costs_marker_overhead_then_pays_it_back(self): + """The first pass can grow the transcript; later passes recover it. + + Inserting the summary marker costs a fixed ~400 tokens of scaffolding + (the compaction preamble, the historical heading and the end marker). + On pass one that overhead is paid against a single absorbed exchange, + so the net can be positive. From pass two on the marker is replaced + rather than added, so the scaffolding is already paid for and each + absorbed exchange is pure saving. Anyone reading a single turn's + telemetry needs to know this before concluding it made things worse. + """ + from agent.model_metadata import estimate_messages_tokens_rough + + cc = _compressor() + messages = _conversation(exchanges=10) + start = estimate_messages_tokens_rough(messages) + + messages = cc._micro_compact(messages) + after_first = estimate_messages_tokens_rough(messages) + + for _ in range(5): + messages = cc._micro_compact(messages) + after_many = estimate_messages_tokens_rough(messages) + + assert after_first > start, "expected one-time marker overhead" + assert after_many < after_first, "later passes must recover it" + + def test_cumulative_savings_accumulate_across_passes(self): + cc = _compressor() + messages = _conversation(exchanges=10) + + for _ in range(4): + messages = cc._micro_compact(messages) + + assert cc._micro_compact_passes == 4 + assert cc._micro_compact_tokens_saved_total > 0 + def test_defrag_triggers_once_the_rolling_summary_grows(self): cc = _compressor(summary="FRESH DEFRAGGED SUMMARY") cc._micro_compact_rolling_summary = "x" * 40_000 # far over the threshold From ac48add3a7fdfcb53d15c5ee7f97db352c692d39 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 29 Jul 2026 16:15:35 -0400 Subject: [PATCH 41/55] feat(agent): report context occupancy, not just tokens saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokens saved is the wrong headline for this feature. Micro-compaction is not an efficiency optimisation — the same summarization work happens either way. What it buys is (a) that work amortized across turns instead of one stall, and (b) a window kept low enough that a session runs much further before needing a hard compaction at all. Neither shows up in "net tokens saved". A session can save nothing on paper and still be a clear win on both counts. So the telemetry now carries occupancy: tokens_after as a share of the compaction threshold, plus the threshold and resolved window it was computed from. That is the number that says whether a session has headroom left. The report leads with it, and cross-references the batch `compression_attempt` lines already in the log so it can show how often the long pause actually fired — ideally never. Occupancy is read from the cached threshold only. The public `threshold_tokens` property resolves lazily and can issue a synchronous /models probe (#32221); telemetry must never be the thing that blocks a turn, so an unresolved window reports null. In practice a pass has already resolved it via the tail calculation, so the field is populated. A test pins the no-forcing behaviour directly against the emitter. The report is pure ASCII: `scripts/check_subprocess_stdin.py` currently dies on a cp1252 console before printing its results, and a diagnostic tool that crashes on the platform it is diagnosing is worse than no tool. Co-Authored-By: Claude Fable 5 --- agent/context_compressor.py | 15 +++ docs/micro-compaction.md | 24 +++++ scripts/micro_compaction_report.py | 153 +++++++++++++++++---------- tests/agent/test_micro_compaction.py | 66 ++++++++++++ 4 files changed, 201 insertions(+), 57 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 59e97d48d1..319e5507af 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -5446,6 +5446,15 @@ This compaction should PRIORITISE preserving all information related to the focu delta = tokens_after - tokens_before self._micro_compact_tokens_saved_total -= delta self._micro_compact_passes += 1 + # Cached reads only. The ``threshold_tokens`` / ``context_length`` + # properties resolve lazily and can fire a synchronous /models + # probe on first access (#32221) — telemetry must never be the + # thing that blocks a turn. Unresolved simply reports null. + threshold = self._threshold_tokens + context_limit = self._resolved_context_length + occupancy = None + if threshold and tokens_after is not None and threshold > 0: + occupancy = round(tokens_after / threshold * 100, 1) payload = { "event": "micro_compaction", "session_id": getattr(self, "_session_id", "") or "", @@ -5463,6 +5472,12 @@ This compaction should PRIORITISE preserving all information related to the focu "passes_total": self._micro_compact_passes, "tokens_saved_total": self._micro_compact_tokens_saved_total, "duration_ms": _safe_int(duration_ms), + # Headroom, not efficiency: how full the window is being kept. + # This is the number that says whether the session can keep + # going without a hard batch compaction. + "threshold_tokens": _safe_int(threshold), + "context_limit": _safe_int(context_limit), + "occupancy_pct": occupancy, "main_model": self.model or "", "aux_model": self.summary_model or "", } diff --git a/docs/micro-compaction.md b/docs/micro-compaction.md index 19419dab78..c302a1b594 100644 --- a/docs/micro-compaction.md +++ b/docs/micro-compaction.md @@ -162,16 +162,40 @@ compaction. Everything else about compression is unchanged. ## Measuring it +Micro-compaction is not primarily a token-saving or time-saving optimisation, +and judging it on tokens saved will undersell it. The two things it actually +buys you are: + +1. **The long pause is amortized.** The same summarization work happens, but as + small increments after turns instead of one stall in the middle of a session. +2. **Your context lasts longer.** Because the middle is continuously reclaimed, + occupancy stays low instead of sawtoothing up to the threshold. A session + runs much further — often indefinitely — before it needs a hard compaction + at all. + +So the number that matters is **occupancy**: how full the window is being kept, +as a percentage of the compaction threshold. A session that holds steady around +40% has headroom to keep going; one climbing through 90% is about to stall. The +second number is **how many batch compactions actually fired** — ideally none. + +A session can save nothing on paper and still be a clear win on both counts. + Every pass emits one content-free JSON line, in the same style as the batch compaction telemetry: ``` micro compaction telemetry: {"event":"micro_compaction","outcome":"absorbed", "tokens_before":12739,"tokens_after":12060,"tokens_delta":-679, +"occupancy_pct":38.4,"threshold_tokens":34816,"context_limit":40960, "exchange_tokens":868,"rolling_summary_tokens":31,"passes_total":1, "tokens_saved_total":679,"duration_ms":14,...} ``` +`occupancy_pct` is `tokens_after` as a share of the compaction threshold -- the +headroom figure. It is null when the model's window has not been resolved yet: +the telemetry reads only the cached value, because resolving it can issue a +synchronous `/models` probe and telemetry must never be what blocks a turn. + `tokens_delta` is negative when the pass shrank the transcript. `tokens_saved_total` and `passes_total` accumulate across the session, so a whole run can be summarised from its last line. No transcript content appears in the diff --git a/scripts/micro_compaction_report.py b/scripts/micro_compaction_report.py index 6d65a0c133..30811a6717 100644 --- a/scripts/micro_compaction_report.py +++ b/scripts/micro_compaction_report.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """Summarize micro-compaction telemetry from Hermes logs. -Reads the content-free ``micro compaction telemetry:`` JSON lines emitted by +Reads the content-free JSON lines emitted by ``ContextCompressor._emit_micro_compaction_telemetry`` and reports what the -feature actually did, per session and overall. +feature actually bought you. Usage: python scripts/micro_compaction_report.py [LOGFILE ...] @@ -11,11 +11,23 @@ Usage: With no LOGFILE, reads ``$HERMES_HOME/logs/agent.log`` (default ~/.hermes). -Note on reading the numbers: the first pass in a session inserts the summary -marker, which costs a fixed ~400 tokens of scaffolding. That overhead is paid -once; from the second pass on the marker is replaced rather than added, so -each absorbed exchange is pure saving. A session with a single pass can -therefore show a net loss and still be working correctly. +What to look at +--------------- +The point of micro-compaction is not saving tokens or time. It is: + + (a) amortizing the one long batch-compaction pause across many turns, and + (b) keeping the context window low enough that a session runs much further + before it needs a hard compaction at all. + +So the headline numbers here are OCCUPANCY (how full the window is kept, as a +percentage of the compaction threshold) and BATCH COMPACTIONS (how often the +long pause actually fired). Net tokens saved is reported too, but it is the +least interesting figure -- a session can save nothing on paper and still be a +clear win because the stalls disappeared and the window never filled. + +Caveat: running the test suite writes telemetry into the same log. Test lines +cluster inside a sub-second window and carry an empty session_id (they group +as "(unknown)"). Use --per-session to spot them. """ from __future__ import annotations @@ -27,7 +39,8 @@ import sys from collections import defaultdict from pathlib import Path -MARKER = "micro compaction telemetry: " +MICRO_MARKER = "micro compaction telemetry: " +BATCH_MARKER = "context compression attempt telemetry: " def default_log() -> Path: @@ -35,8 +48,9 @@ def default_log() -> Path: return Path(home) / "logs" / "agent.log" -def load(paths: list[Path]) -> list[dict]: - events: list[dict] = [] +def load(paths: list[Path]) -> tuple[list[dict], list[dict]]: + micro: list[dict] = [] + batch: list[dict] = [] for path in paths: try: text = path.read_text(encoding="utf-8", errors="replace") @@ -44,88 +58,113 @@ def load(paths: list[Path]) -> list[dict]: print(f"warning: cannot read {path}: {exc}", file=sys.stderr) continue for line in text.splitlines(): - idx = line.find(MARKER) - if idx == -1: - continue - try: - events.append(json.loads(line[idx + len(MARKER):])) - except ValueError: - continue - return events + for marker, sink in ((MICRO_MARKER, micro), (BATCH_MARKER, batch)): + idx = line.find(marker) + if idx == -1: + continue + try: + sink.append(json.loads(line[idx + len(marker):])) + except ValueError: + pass + break + return micro, batch -def fmt(n: int | None) -> str: +def pct(values: list[float]) -> tuple[float, float, float] | None: + if not values: + return None + ordered = sorted(values) + return ordered[0], ordered[len(ordered) // 2], ordered[-1] + + +def fmt(n) -> str: return "-" if n is None else f"{n:,}" -def report(events: list[dict], per_session: bool) -> int: - if not events: +def report(micro: list[dict], batch: list[dict], per_session: bool) -> int: + if not micro: print("No micro-compaction telemetry found.") - print("Micro-compaction may be disabled (compression.micro_compact),") - print("or no session has run long enough to trigger a pass yet.") + print("It may be disabled (compression.micro_compact), or no session") + print("has run long enough to trigger a pass yet.") return 1 by_session: dict[str, list[dict]] = defaultdict(list) - for e in events: + for e in micro: by_session[e.get("session_id") or "(unknown)"].append(e) outcomes: dict[str, int] = defaultdict(int) - for e in events: + for e in micro: outcomes[e.get("outcome", "?")] += 1 - saved = sum(-(e.get("tokens_delta") or 0) for e in events) - absorbed = [e for e in events if e.get("outcome") == "absorbed"] - exchange_tokens = [e.get("exchange_tokens") or 0 for e in absorbed] - durations = [e.get("duration_ms") or 0 for e in events] + occupancies = [e["occupancy_pct"] for e in micro if e.get("occupancy_pct") is not None] + saved = sum(-(e.get("tokens_delta") or 0) for e in micro) + absorbed = [e for e in micro if e.get("outcome") == "absorbed"] + durations = [e.get("duration_ms") or 0 for e in micro] if per_session: - print(f"{'session':<38} {'passes':>7} {'saved':>10} {'first':>8} {'last':>8}") - print("-" * 76) + print(f"{'session':<26} {'passes':>6} {'occupancy%':>18} {'batch':>6} {'saved':>10}") + print("-" * 72) + batch_by_session: dict[str, int] = defaultdict(int) + for b in batch: + batch_by_session[b.get("session_id") or "(unknown)"] += 1 for sid, evs in sorted(by_session.items(), key=lambda kv: -len(kv[1])): + occ = [e["occupancy_pct"] for e in evs if e.get("occupancy_pct") is not None] + spread = pct(occ) + occ_s = f"{spread[0]:.0f}-{spread[2]:.0f} (med {spread[1]:.0f})" if spread else "-" s = sum(-(e.get("tokens_delta") or 0) for e in evs) - first = evs[0].get("tokens_before") - last = evs[-1].get("tokens_after") - print(f"{sid[:38]:<38} {len(evs):>7} {s:>+10,} {fmt(first):>8} {fmt(last):>8}") + print(f"{sid[:26]:<26} {len(evs):>6} {occ_s:>18} " + f"{batch_by_session.get(sid, 0):>6} {s:>+10,}") print() + print("-- headroom ----------------------------------") + spread = pct(occupancies) + if spread: + print(f"context occupancy min {spread[0]:.0f}% median {spread[1]:.0f}% max {spread[2]:.0f}%") + print(" (% of the batch-compaction threshold)") + else: + print("context occupancy unavailable (window not resolved when logged)") + print(f"batch compactions {len(batch):,}") + if batch: + print(f" micro passes each {len(micro) / len(batch):.1f}") + else: + print(" none fired -- the long pause never happened in this log") + + print() + print("-- activity ----------------------------------") print(f"sessions {len(by_session):,}") - print(f"passes {len(events):,}") + print(f"passes {len(micro):,}") for name, count in sorted(outcomes.items(), key=lambda kv: -kv[1]): print(f" {name:<20} {count:,}") - print(f"net tokens saved {saved:+,}") - if absorbed: - print(f"exchanges absorbed {len(absorbed):,}") - print(f" mean exchange size {sum(exchange_tokens) // len(absorbed):,} tokens") - print(f" mean saving/pass {saved // len(events):+,} tokens") if durations: ordered = sorted(durations) - print(f"pass duration median {ordered[len(ordered) // 2]:,} ms") - print(f" max {ordered[-1]:,} ms") + print(f"pass duration median {ordered[len(ordered) // 2]:,} ms " + f"max {ordered[-1]:,} ms") - multi = {s: e for s, e in by_session.items() if len(e) > 1} - if multi: - net = sum( - (evs[-1].get("tokens_after") or 0) - (evs[0].get("tokens_before") or 0) - for evs in multi.values() - ) - print() - print(f"sessions with >1 pass {len(multi):,}") - print(f" net context change {net:+,} tokens (negative is shrinkage)") + print() + print("-- tokens (least interesting) ----------------") + print(f"net tokens saved {saved:+,}") + if absorbed: + sizes = [e.get("exchange_tokens") or 0 for e in absorbed] + print(f"exchanges absorbed {len(absorbed):,} " + f"(mean {sum(sizes) // len(absorbed):,} tokens each)") + print("note: the first pass in a session costs ~400 tokens of marker") + print("scaffolding; it pays back from the second pass on.") return 0 def main() -> int: - ap = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("logs", nargs="*", type=Path, help="log files (default: agent.log)") ap.add_argument("--per-session", action="store_true", help="break down by session") args = ap.parse_args() paths = args.logs or [default_log()] - missing = [p for p in paths if not p.exists()] - for p in missing: - print(f"warning: {p} does not exist", file=sys.stderr) - return report(load([p for p in paths if p.exists()]), args.per_session) + for p in paths: + if not p.exists(): + print(f"warning: {p} does not exist", file=sys.stderr) + micro, batch = load([p for p in paths if p.exists()]) + return report(micro, batch, args.per_session) if __name__ == "__main__": diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py index f940ff71df..0178ad1ce5 100644 --- a/tests/agent/test_micro_compaction.py +++ b/tests/agent/test_micro_compaction.py @@ -18,6 +18,8 @@ The invariants that matter: times and then skipped, so a poison exchange can't stall every turn. """ +from unittest.mock import patch + import pytest from agent.context_compressor import ( @@ -218,6 +220,70 @@ class TestMicroCompaction: blob = json.dumps(payload) assert "answer 0" not in blob and "question 0" not in blob + def test_telemetry_reports_occupancy_without_forcing_resolution(self, caplog): + """Occupancy is the headline: how full the window is being kept. + + It must be read from the cached threshold only. The public + ``threshold_tokens`` property resolves lazily and can fire a + synchronous /models probe (#32221); telemetry must never be what + blocks a turn, so an unresolved window reports null instead. + """ + import json + import logging + + cc = _compressor() + cc.threshold_tokens = 10_000 # pin; also populates the cache + messages = _conversation(exchanges=8) + + with caplog.at_level(logging.INFO, logger="agent.context_compressor"): + cc._micro_compact(messages) + + line = next(r.getMessage() for r in caplog.records + if "micro compaction telemetry:" in r.getMessage()) + payload = json.loads(line.split("micro compaction telemetry: ", 1)[1]) + + assert payload["threshold_tokens"] == 10_000 + assert payload["occupancy_pct"] == pytest.approx( + payload["tokens_after"] / 10_000 * 100, abs=0.1 + ) + + def test_emitter_never_forces_window_resolution(self, caplog): + """The emitter reads the cached threshold, never the property. + + In a real pass the threshold is already resolved by the time + telemetry runs (the tail calculation needs it), so occupancy is + normally populated. This pins the safety property directly: with the + cache empty, emitting reports null rather than triggering the lazy + resolution — which can issue a synchronous /models probe (#32221). + """ + import json + import logging + + cc = _compressor() + cc._threshold_tokens = None + cc._resolved_context_length = None + + def explode(self): # pragma: no cover - must never be called + raise AssertionError("telemetry forced context-length resolution") + + with patch.object(type(cc), "threshold_tokens", + property(explode, lambda s, v: None)): + with caplog.at_level(logging.INFO, logger="agent.context_compressor"): + cc._emit_micro_compaction_telemetry( + outcome="absorbed", + messages_before=10, + messages_after=9, + tokens_before=500, + tokens_after=400, + ) + + line = next(r.getMessage() for r in caplog.records + if "micro compaction telemetry:" in r.getMessage()) + payload = json.loads(line.split("micro compaction telemetry: ", 1)[1]) + + assert payload["occupancy_pct"] is None + assert payload["threshold_tokens"] is None + def test_first_pass_costs_marker_overhead_then_pays_it_back(self): """The first pass can grow the transcript; later passes recover it. From d6265285604e03ec0d928d38a056525f08536d16 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 29 Jul 2026 16:35:16 -0400 Subject: [PATCH 42/55] fix(agent): derive the micro-compaction cursor from the spliced list The cursor was set to the pre-splice `exchange_end`. A splice collapses the absorbed span -- an assistant plus its tool results, often four or more messages -- into a single marker, and may also drop a superseded marker further back, so every index after it shifts. The stale cursor therefore overshot, landing inside a *later* exchange's tool group. The next pass's `_find_one_exchange` walked forward from there to the following assistant, so the exchange it had landed inside was never absorbed at all. On tool-bearing conversations micro-compaction was silently doing roughly half the work it should. Traced on a 3-tool-per-exchange transcript: the cursor sat at index 6 when the marker was at 2, and the message count stalled at 32 instead of continuing to 28. Derive the cursor from the marker's actual position in the spliced result instead, which is self-correcting regardless of how much the splice moved. Apply it on the defrag path too, which had the same staleness. Found by a randomized long-horizon harness (480 conversation shapes x 25 passes, varying tool-group sizes and summarizer failure modes) asserting structural and progress invariants after every pass. Existing tests missed it because their fixtures have no tool results, so the absorbed span is one message and nothing shifts. The regression test uses tool groups. Co-Authored-By: Claude Fable 5 --- agent/context_compressor.py | 25 +++++++++++++++++ tests/agent/test_micro_compaction.py | 40 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 319e5507af..be41e8f84c 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -5349,6 +5349,7 @@ This compaction should PRIORITISE preserving all information related to the focu if self._needs_defrag(): self._defrag_rolling_summary(messages, exchange_start, compress_end) result = self._splice_micro_compact_result(messages, exchange_start, compress_end) + self._micro_compact_cursor = self._cursor_after_splice(result, exchange_start + 1) self._sync_micro_compact_to_db(result) self._micro_compact_consecutive_failures = 0 self._micro_compact_last_failure_cursor = -1 @@ -5408,6 +5409,7 @@ This compaction should PRIORITISE preserving all information related to the focu self._micro_compact_last_failure_cursor = -1 result = self._splice_micro_compact_result(messages, exchange_start, exchange_end) + self._micro_compact_cursor = self._cursor_after_splice(result, exchange_start + 1) self._sync_micro_compact_to_db(result) self._emit_micro_compaction_telemetry( outcome="absorbed", @@ -5420,6 +5422,29 @@ This compaction should PRIORITISE preserving all information related to the focu ) return result + def _cursor_after_splice( + self, + result: List[Dict[str, Any]], + fallback: int, + ) -> int: + """Cursor position just past the summary marker in *result*. + + The cursor must be derived from the spliced list, never carried over + from pre-splice indices. A splice collapses the absorbed span (an + assistant plus its tool results -- often several messages) into a + single marker, and may also drop a superseded marker further back, so + every index after it shifts. Reusing the old ``exchange_end`` left the + cursor pointing into the middle of a *later* exchange's tool group; + the next pass then walked forward to the following assistant and + skipped that exchange entirely, so roughly half the work silently + never happened on tool-bearing conversations. + """ + for idx in range(len(result) - 1, -1, -1): + entry = result[idx] + if isinstance(entry, dict) and entry.get(COMPRESSED_SUMMARY_METADATA_KEY): + return idx + 1 + return fallback + def _emit_micro_compaction_telemetry( self, *, diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py index 0178ad1ce5..4ac3344a46 100644 --- a/tests/agent/test_micro_compaction.py +++ b/tests/agent/test_micro_compaction.py @@ -124,6 +124,46 @@ class TestMicroCompaction: ] assert surviving == originals, "user turns must survive verbatim" + def test_cursor_is_derived_from_the_spliced_list(self): + """The cursor must never carry over a pre-splice index. + + A splice collapses an assistant plus its tool results -- often several + messages -- into one marker, so every later index shifts. Reusing + ``exchange_end`` left the cursor pointing inside a *later* exchange's + tool group; the next pass walked forward to the following assistant + and skipped that exchange entirely, so on tool-bearing conversations + roughly half the work silently never happened. + + Tool-free fixtures cannot catch this: the span is one message, so + nothing shifts. + """ + cc = _compressor() + msgs = [{"role": "system", "content": "sys"}] + for i in range(8): + msgs.append({"role": "user", "content": f"q{i}"}) + msgs.append({ + "role": "assistant", + "content": f"a{i}", + "tool_calls": [ + {"id": f"c{i}-{j}", "type": "function", + "function": {"name": "f", "arguments": "{}"}} + for j in range(3) + ], + }) + for j in range(3): + msgs.append({"role": "tool", "tool_call_id": f"c{i}-{j}", + "content": "T" * 500}) + + for _ in range(4): + msgs = cc._micro_compact(msgs) + marker_idx = next( + i for i, m in enumerate(msgs) + if m.get(COMPRESSED_SUMMARY_METADATA_KEY) + ) + assert cc._micro_compact_cursor == marker_idx + 1, ( + "cursor must sit just past the marker in the spliced list" + ) + def test_short_conversation_is_untouched(self): cc = _compressor() messages = [ From e8237050fc768897aeaa23f426d3bb6be7f51454 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 29 Jul 2026 20:29:35 -0400 Subject: [PATCH 43/55] docs(agent): state the real cost, and make model choice the main knob Three corrections, all from measuring a real 3.5 hour session rather than reasoning about the design. "During the idle moment after a response" was wrong. A pass is a real call to the compression model at the end of a turn: the answer has streamed, but the turn does not close until it finishes. Measured 2 to 37 seconds, median around 31, on a small local model. Say so. Add the choice of `auxiliary.compression` model as its own section, because it dominates everything else here. A pass sends only a few thousand tokens but runs every turn, so latency is felt repeatedly, and reasoning models are a poor fit -- merging one exchange into a summary is mechanical work, and a thinking model spends reasoning tokens on it for no benefit. Two measured data points are given as illustrations of the shape, explicitly not as recommendations: the right answer depends on the operator's hardware. Add what a working session actually looks like: occupancy climbing to ~22% and flattening (equilibrium -- 4,841 tokens added between the last two passes, 4,395 reclaimed), zero batch compactions, and reclamation only ramping after the tail budget is crossed. Also state the cost in the same breath rather than burying it. Frame the feature as a tuning option rather than a win: it lets you choose how the compression cost is distributed and which model pays it. It is not a magic bullet and the docs should not imply otherwise. Co-Authored-By: Claude Fable 5 --- docs/micro-compaction.md | 91 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/docs/micro-compaction.md b/docs/micro-compaction.md index c302a1b594..974fce2f11 100644 --- a/docs/micro-compaction.md +++ b/docs/micro-compaction.md @@ -11,9 +11,16 @@ request, at whatever moment you happened to cross the line. Micro-compaction pays the same bill in instalments. After each completed turn, Hermes folds the single oldest un-absorbed exchange into a running summary. The -work is the same work; it just happens continuously, in small pieces, during the -idle moment after a response instead of all at once in the middle of your -session. +work is the same work; it just happens continuously, a piece at a time, instead +of all at once in the middle of your session. + +It is not free and it is not a magic bullet. Each pass is a real call to the +compression model, and it runs at the end of a turn — your answer has already +streamed, but the turn does not close until the pass finishes. What the feature +gives you is a **tuning option**: you choose how the compression cost is +distributed, and which model pays it. See +[Choosing a compression model](#choosing-a-compression-model), because that +choice matters more than anything else here. **The tradeoff is that knowledge gets a little earlier than you may be used to.** Because compaction is always running, older parts of the conversation become @@ -160,6 +167,49 @@ compression: Set it to `false` to disable micro-compaction and return to batch-only compaction. Everything else about compression is unchanged. +## Choosing a compression model + +Micro-compaction uses the `auxiliary.compression` model: + +```yaml +auxiliary: + compression: + provider: openai-api + model: + base_url: +``` + +This is the single most important knob, and there is no universally right +answer — it depends on your hardware and what you are willing to trade. + +Each pass sends the running summary plus one exchange, so the prompt is small +(a few thousand tokens) but the call happens **every turn**, at the end of the +turn. Two properties matter: + +- **Latency dominates.** Because a pass runs per turn, its wall-clock cost is + felt repeatedly. A model that takes 30 seconds turns every turn into a turn + plus 30 seconds. +- **Reasoning models are a poor fit.** Merging one exchange into a summary is + mechanical work. A thinking model will spend reasoning tokens on it and be + substantially slower than a plain instruct model of similar size, for no + benefit to the output. + +Some measured points, on one particular setup — treat them as illustrations of +the shape, not as recommendations: + +| model | observed | +|---|---| +| 7B 4-bit instruct, local (MLX, Apple Silicon) | ~31s per pass; box also serving other work | +| large MoE reasoning model, remote GPU | noticeably slower still — thinking tokens on a summarisation task | + +The pattern is that a small, fast, non-reasoning instruct model is usually the +right shape, and that a bigger or "smarter" model is often worse here rather +than better. Where that lands for you depends on what you have to run it on. + +If passes feel too slow, your options in rough order of effect are: pick a +faster or smaller compression model; give it a less contended host; or turn +micro-compaction off and go back to batch compaction. + ## Measuring it Micro-compaction is not primarily a token-saving or time-saving optimisation, @@ -210,6 +260,41 @@ python scripts/micro_compaction_report.py [--per-session] [LOGFILE ...] Defaults to `$HERMES_HOME/logs/agent.log`. It reports passes, outcome mix, net tokens saved, mean absorbed-exchange size and pass durations. +### What it looks like when it is working + +One real session — a 3.5 hour whole-project code review, ~75K tokens of +transcript, 400K window, compaction threshold at 320K: + +| pass | messages | tokens | delta | occupancy | duration | +|---|---|---|---|---|---| +| 1 | 40 -> 39 | 27,479 -> 27,778 | +299 | 8.7% | 2.2s | +| 2 | 61 -> 59 | 48,676 -> 48,128 | -548 | 15.0% | 4.5s | +| 3 | 70 -> 67 | 58,309 -> 55,915 | -2,394 | 17.5% | 9.1s | +| 4 | 84 -> 80 | 75,251 -> 69,818 | -5,433 | 21.8% | 36.2s | +| 5 | 84 -> 80 | 74,659 -> 70,264 | -4,395 | 22.0% | 31.2s | + +Three things to read off it. + +**Occupancy flattened.** It climbed to about 22% and stopped. The last two +passes are identical (84 -> 80 messages); between them the conversation added +4,841 tokens and micro-compaction reclaimed 4,395. That is equilibrium: the +window holds steady instead of marching toward the threshold. + +**No batch compaction fired.** Across the whole session the long pause never +happened. + +**Reclamation only ramps after the tail budget.** The first passes recovered +almost nothing, because below the tail budget (here 64,000 tokens, 16% of the +window) nearly the whole transcript is protected tail and there is very little +that may be touched. Early sessions legitimately show no passes at all. + +And the cost, stated plainly: passes ran 2 to 37 seconds, median around 31, on +a small local model that was also serving other work. Roughly two minutes of +summarisation spread across three and a half hours. Against one batch +compaction of a 75K-token middle that is still the better trade, but a +37-second increment is not a rounding error. See +[Choosing a compression model](#choosing-a-compression-model). + ### Reading the numbers honestly **The first pass in a session usually costs tokens rather than saving them.** From 60781a0cc8f07a5a2a3c2828e1a6624b0898147d Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 29 Jul 2026 20:40:10 -0400 Subject: [PATCH 44/55] fix(agent): do not destroy compacted history when a session resumes The rolling summary lives only in memory. A resumed session starts with an empty one while the marker carrying every previously absorbed exchange is still in the transcript. The first pass after a resume therefore built a marker from a single exchange and superseded the marker holding the entire history -- silently discarding everything micro-compaction had accumulated. This was introduced by the supersede fix. Before it, markers piled up wastefully, but nothing was ever lost. Two changes, so a single failure cannot lose data: Rehydrate. When the cursor is recovered by scanning the transcript -- the resume path -- also recover the rolling summary from that marker, so the next pass merges into the existing history instead of replacing it. Extraction uses rfind for the heading because SUMMARY_PREFIX references the heading text itself, so the first occurrence is inside the preamble. Gate superseding. Earlier markers are dropped only when this pass's summary is demonstrably cumulative, i.e. the rolling summary was non-empty going in. If rehydration ever fails, the pass keeps both markers: wasteful, but the history survives. Tests cover the resume path, the failed-rehydration fallback, and the round trip of a summary through a marker. Co-Authored-By: Claude Fable 5 --- agent/context_compressor.py | 65 ++++++++++++++++++++++++---- tests/agent/test_micro_compaction.py | 48 ++++++++++++++++++++ 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index be41e8f84c..ca4a3f56eb 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -5034,6 +5034,19 @@ This compaction should PRIORITISE preserving all information related to the focu last_summary_idx = idx if last_summary_idx >= head_end: cursor = last_summary_idx + 1 + # Resumed session: in-memory state is gone but the marker survives. + # Carry its text forward so the next pass merges into the existing + # history instead of replacing it with a single-exchange summary. + if not self._micro_compact_rolling_summary.strip(): + recovered = self._rolling_summary_from_marker( + messages[last_summary_idx].get("content") + ) + if recovered: + self._micro_compact_rolling_summary = recovered + logger.info( + "Micro-compaction: recovered rolling summary from " + "transcript (%d chars)", len(recovered), + ) else: cursor = head_end self._micro_compact_cursor = cursor @@ -5363,6 +5376,10 @@ This compaction should PRIORITISE preserving all information related to the focu ) return result + # Whether this pass's summary will be cumulative — i.e. whether it + # subsumes any earlier marker. Captured before summarizing. + _cumulative = bool(self._micro_compact_rolling_summary.strip()) + # Micro-summarize one exchange exchange_text = self._serialize_one_exchange(messages, exchange_start, exchange_end) _exchange_tokens = estimate_tokens_rough(exchange_text) @@ -5408,7 +5425,9 @@ This compaction should PRIORITISE preserving all information related to the focu self._micro_compact_consecutive_failures = 0 self._micro_compact_last_failure_cursor = -1 - result = self._splice_micro_compact_result(messages, exchange_start, exchange_end) + result = self._splice_micro_compact_result( + messages, exchange_start, exchange_end, supersede=_cumulative, + ) self._micro_compact_cursor = self._cursor_after_splice(result, exchange_start + 1) self._sync_micro_compact_to_db(result) self._emit_micro_compaction_telemetry( @@ -5422,6 +5441,29 @@ This compaction should PRIORITISE preserving all information related to the focu ) return result + @staticmethod + def _rolling_summary_from_marker(content: Any) -> str: + """Recover the rolling-summary text from a summary marker's content. + + The rolling summary lives in memory, but a resumed session starts with + an empty one while the marker holding every previous exchange is still + in the transcript. Without rehydrating from it, the first post-resume + pass would build a marker from nothing and supersede the one carrying + the whole history. + """ + if not isinstance(content, str) or not content.strip(): + return "" + body = content + # rfind, not find: SUMMARY_PREFIX itself references the heading text, + # so the first occurrence is inside the preamble, not the real heading. + idx = body.rfind(HISTORICAL_TASK_HEADING) + if idx != -1: + body = body[idx + len(HISTORICAL_TASK_HEADING):] + end = body.find(_SUMMARY_END_MARKER) + if end != -1: + body = body[:end] + return body.strip() + def _cursor_after_splice( self, result: List[Dict[str, Any]], @@ -5550,6 +5592,7 @@ This compaction should PRIORITISE preserving all information related to the focu messages: List[Dict[str, Any]], splice_start: int, splice_end: int, + supersede: bool = True, ) -> List[Dict[str, Any]]: """Replace *messages[splice_start:splice_end]* with a summary marker. @@ -5584,13 +5627,19 @@ This compaction should PRIORITISE preserving all information related to the focu # its own prefix/heading/end-marker scaffolding — so the transcript # grows with every turn instead of shrinking, which defeats the point. # Keep only the newest marker. - marker_idxs = [ - i for i, m in enumerate(result) - if isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY) - ] - if len(marker_idxs) > 1: - superseded = set(marker_idxs[:-1]) - result = [m for i, m in enumerate(result) if i not in superseded] + # Only drop earlier markers when this one demonstrably contains them: + # the rolling summary must have been non-empty going into this pass. + # A pass that started from nothing (a resume that could not rehydrate) + # produces a marker covering one exchange, and dropping the previous + # marker would throw away the entire compacted history. + if supersede: + marker_idxs = [ + i for i, m in enumerate(result) + if isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY) + ] + if len(marker_idxs) > 1: + superseded = set(marker_idxs[:-1]) + result = [m for i, m in enumerate(result) if i not in superseded] _strip_persistence_markers(result) return result diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py index 4ac3344a46..57b3b773ad 100644 --- a/tests/agent/test_micro_compaction.py +++ b/tests/agent/test_micro_compaction.py @@ -164,6 +164,54 @@ class TestMicroCompaction: "cursor must sit just past the marker in the spliced list" ) + def test_resume_does_not_destroy_the_accumulated_summary(self): + """A resumed session must not throw away compacted history. + + The rolling summary lives in memory; a resumed process starts with an + empty one while the marker holding every previous exchange is still in + the transcript. Superseding on that first pass would replace the whole + history with a summary of one exchange. + """ + msgs = _conversation(exchanges=10) + first = _compressor(summary="IMPORTANT HISTORY: decisions and paths") + for _ in range(3): + msgs = first._micro_compact(msgs) + assert any("IMPORTANT HISTORY" in m["content"] for m in _summary_markers(msgs)) + + # Fresh compressor over the same transcript = resume. + resumed = _compressor(summary="MERGED: history plus newest exchange") + assert resumed._micro_compact_rolling_summary == "" + result = resumed._micro_compact(msgs) + + markers = _summary_markers(result) + assert len(markers) == 1 + assert "MERGED" in markers[0]["content"] + + def test_resume_keeps_the_old_marker_when_rehydration_fails(self): + """If the prior summary can't be recovered, it must not be dropped.""" + msgs = _conversation(exchanges=10) + first = _compressor(summary="IMPORTANT HISTORY: decisions and paths") + for _ in range(3): + msgs = first._micro_compact(msgs) + + resumed = _compressor(summary="BRAND NEW SUMMARY") + resumed._rolling_summary_from_marker = staticmethod(lambda _c: "") + result = resumed._micro_compact(msgs) + + markers = _summary_markers(result) + assert len(markers) == 2, "must retain the un-carried history" + assert any("IMPORTANT HISTORY" in m["content"] for m in markers) + + def test_rolling_summary_round_trips_through_a_marker(self): + cc = _compressor() + cc._micro_compact_rolling_summary = "decisions: use the existing helper" + msgs = _conversation(exchanges=6) + result = cc._micro_compact(msgs) + marker = _summary_markers(result)[0] + + assert (cc._rolling_summary_from_marker(marker["content"]) + == cc._micro_compact_rolling_summary) + def test_short_conversation_is_untouched(self): cc = _compressor() messages = [ From d19c18287935cbb009d44478a54304122f29efaa Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 29 Jul 2026 22:06:40 -0400 Subject: [PATCH 45/55] fix(agent): ship micro-compaction opt-in, not default-on Review raised whether default-on can be reconciled with the prompt-cache contract in AGENTS.md, which permits mutating past context only for context compression and treats per-conversation caching as sacred. It cannot, and the codebase already says so in its own words. A micro-compaction pass rewrites already-sent history, so it invalidates the cached prefix every turn rather than at an episodic boundary. That is the exact cost the proactive prune gates against: `proactive_prune_min_reclaim_tokens` exists, per its own config comment, to keep rewrites to "one big episodic break instead of a tiny break every tool iteration." Micro-compaction has no equivalent gate -- one exchange per turn means one break per turn, by design. Default to off. An operator who wants the amortized stall can opt in with `compression.micro_compact: true` and accept the tradeoff knowingly; nobody inherits a per-turn cache break from installing an update. Also register the key in config_defaults so it is discoverable and picked up by the update path's new-options check -- it was previously read by agent_init but declared nowhere -- and document the cache cost in docs/micro-compaction.md instead of only the benefit. The measurements behind the feature (occupancy plateau, zero batch compactions) never priced cache invalidation, and the doc now says which numbers a reader would need to measure to justify enabling it. Co-Authored-By: Claude Opus 5 --- agent/agent_init.py | 7 ++++- agent/context_compressor.py | 6 ++-- docs/micro-compaction.md | 56 +++++++++++++++++++++++++++++++---- hermes_cli/config_defaults.py | 12 ++++++++ 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 5972b84981..b716522d0a 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1963,8 +1963,13 @@ def init_agent( compression_in_place = is_truthy_value( _compression_cfg.get("in_place"), default=True ) + # Opt-in (default False): a micro-compaction pass rewrites already-sent + # history every turn, which breaks the provider prompt-cache prefix on a + # per-turn cadence rather than at an episodic boundary. That is the cost + # `proactive_prune_min_reclaim_tokens` exists to amortize, so the feature + # stays off until an operator opts in and accepts the tradeoff. compression_micro_compact = is_truthy_value( - _compression_cfg.get("micro_compact"), default=True + _compression_cfg.get("micro_compact"), default=False ) codex_app_server_auto_compaction = str( _compression_cfg.get("codex_app_server_auto", "native") or "native" diff --git a/agent/context_compressor.py b/agent/context_compressor.py index ca4a3f56eb..8b71a517d2 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -2150,8 +2150,10 @@ class ContextCompressor(ContextEngine): self.abort_on_summary_failure = abort_on_summary_failure # ── Micro-compaction (per-turn rolling compaction) ───────── - # Default: enabled when not explicitly disabled (backward compatible). - self._micro_compact_enabled: bool = True + # Default: OFF. Each pass rewrites already-sent history, so it breaks + # the prompt-cache prefix every turn instead of at an episodic + # boundary. Operators opt in via `compression.micro_compact: true`. + self._micro_compact_enabled: bool = False self._micro_compact_cursor: int = 0 self._micro_compact_rolling_summary: str = "" self._micro_compact_consecutive_failures: int = 0 diff --git a/docs/micro-compaction.md b/docs/micro-compaction.md index 974fce2f11..dd62f6478c 100644 --- a/docs/micro-compaction.md +++ b/docs/micro-compaction.md @@ -14,11 +14,16 @@ Hermes folds the single oldest un-absorbed exchange into a running summary. The work is the same work; it just happens continuously, a piece at a time, instead of all at once in the middle of your session. -It is not free and it is not a magic bullet. Each pass is a real call to the +It is not free and it is not a magic bullet, and it is **off by default** — +`compression.micro_compact: true` turns it on. Each pass is a real call to the compression model, and it runs at the end of a turn — your answer has already -streamed, but the turn does not close until the pass finishes. What the feature -gives you is a **tuning option**: you choose how the compression cost is -distributed, and which model pays it. See +streamed, but the turn does not close until the pass finishes. Each pass also +rewrites already-sent history, which breaks the provider prompt-cache prefix +every turn; read [Prompt caching](#prompt-caching--the-cost-you-are-opting-into) +before enabling it, because for some setups that cost exceeds the benefit. + +What the feature gives you is a **tuning option**: you choose how the +compression cost is distributed, and which model pays it. See [Choosing a compression model](#choosing-a-compression-model), because that choice matters more than anything else here. @@ -159,14 +164,53 @@ batch path fires much less often. ## Configuration +Micro-compaction is **off by default**. Turn it on explicitly: + ```yaml compression: - micro_compact: true # default + micro_compact: true # default: false ``` -Set it to `false` to disable micro-compaction and return to batch-only +With it unset or `false` Hermes behaves exactly as it always has: batch-only compaction. Everything else about compression is unchanged. +It ships opt-in rather than on because of the prompt-cache cost described in +the next section — that cost is real, it is not universally worth paying, and +it should be a decision you make rather than one you inherit. + +## Prompt caching — the cost you are opting into + +Read this before enabling the feature. It is the strongest argument against it. + +A long-lived conversation reuses a cached prompt prefix every turn, and cached +input tokens are billed at a fraction of uncached ones. That discount survives +only as long as the prefix does not change. **A micro-compaction pass rewrites +already-sent history**, which invalidates the prefix from the rewrite point +onward — so with micro-compaction on, you break the cache *every turn* instead +of once per batch compaction. + +This is the same cost the proactive prune deliberately avoids. That path gates +itself behind `compression.proactive_prune_min_reclaim_tokens` (4096 by +default) precisely so its rewrites stay, in the words of the config comment, +"one big episodic break instead of a tiny break every tool iteration." +Micro-compaction has no such gate: one exchange per turn means one break per +turn, by design. + +So the honest framing is a trade of one cost for another, not a saving: + +| | Batch-only (default) | Micro-compaction on | +|---|---|---| +| Compression stalls | One long stall at the threshold | Spread across turns | +| Context occupancy | Sawtooths up to the threshold | Stays low and flat | +| Cache prefix | Intact between compactions | Broken every turn | + +Which side wins depends on numbers specific to you: how much your provider +discounts cached input, how large your prefix is, how long your sessions run, +and how much a mid-session stall actually costs you. On a provider with a deep +cache discount and a big prefix, the per-turn invalidation can plausibly cost +more than the stall it removes. Measure your own sessions — see +[Measuring it](#measuring-it) — rather than assuming. + ## Choosing a compression model Micro-compaction uses the `auxiliary.compression` model: diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index dd1cfe88c0..7d52bc2502 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -578,6 +578,18 @@ DEFAULT_CONFIG = { # prompt-cache invalidation amortized: one big # episodic break instead of a tiny break every # tool iteration. 0 = commit any non-zero prune. + "micro_compact": False, # opt-in: after each completed turn, fold the + # oldest un-absorbed exchange into a rolling + # summary, amortizing compression cost instead + # of paying it in one batch stall. Default False + # because a pass rewrites already-sent history + # and so breaks the provider prompt-cache prefix + # EVERY turn — the per-turn cache break that + # `proactive_prune_min_reclaim_tokens` above + # exists to avoid. Enable only when you have + # measured that the amortized stall is worth + # more to you than the cached-prefix discount. + # See docs/micro-compaction.md. "hygiene_hard_message_limit": 5000, # gateway session-hygiene force-compress threshold by message count "hygiene_timeout_seconds": 30, # max seconds gateway waits for pre-agent hygiene compression # WITHOUT forward progress. The summary call streams, so From 9ca4ee72cadc8bf0ef7f1c62757023b8b60694b9 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 29 Jul 2026 22:14:40 -0400 Subject: [PATCH 46/55] feat(agent): make the micro-compaction cadence configurable The on/off switch was the only knob. A pass fired after every completed turn, absorbed exactly one exchange, and there was no way to ask for less. Since a pass is also what breaks the prompt-cache prefix, "how often does it run" and "how often do I pay a cache break" are the same question, and it had no answer. Add `compression.micro_compact_every_n_turns` (default 1, clamped to >= 1). At 1 the behaviour is what it was; at 5 you get a fifth of the breaks and a fifth of the reclaim rate. The counter advances per invocation rather than per committed pass, so a turn that finds nothing to absorb still moves the cadence along and cannot wedge it, and a bogus 0 or negative degrades to "every turn" instead of silently disabling compaction. Also expose `micro_compact_defrag_threshold_tokens`, which has been a hardcoded attribute on the compressor with no path from config since it was added. This does not give micro-compaction the prune's reclaim-size gate -- a pass still commits whatever the single absorbed exchange saved. It makes the break frequency tunable, which reaches the same end by absorbing less rather than by waiting for a bigger win. The docs now say that plainly, including that a reclaim threshold is the obvious follow-up and does not exist yet. Tests cover the skip-until-due window, the cursor and prefix staying untouched on skipped turns, the clamp, and that the feature is off unless enabled. Co-Authored-By: Claude Opus 5 --- agent/agent_init.py | 25 ++++++++++- agent/context_compressor.py | 19 +++++++++ docs/micro-compaction.md | 32 +++++++++++--- hermes_cli/config_defaults.py | 11 +++++ tests/agent/test_micro_compaction.py | 64 ++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 6 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index b716522d0a..c24c5c65ff 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1971,6 +1971,23 @@ def init_agent( compression_micro_compact = is_truthy_value( _compression_cfg.get("micro_compact"), default=False ) + # How often a pass runs, in completed turns. Each pass rewrites + # already-sent history and costs one prompt-cache break, so this is the + # dial for how often that cost is paid: 1 = every turn (most aggressive + # reclaim), 5 = one break per five turns. Clamped to >= 1. + compression_micro_compact_every_n_turns = max( + 1, + _parse_prune_int(_compression_cfg.get("micro_compact_every_n_turns", 1), 1), + ) + # Rolling-summary defrag threshold, in tokens. Lived on the compressor as + # a hardcoded attribute with no path from config until now. + compression_micro_compact_defrag_tokens = max( + 1, + _parse_prune_int( + _compression_cfg.get("micro_compact_defrag_threshold_tokens", 2000), + 2000, + ), + ) codex_app_server_auto_compaction = str( _compression_cfg.get("codex_app_server_auto", "native") or "native" ).lower() @@ -2426,10 +2443,16 @@ def init_agent( pass agent.compression_enabled = compression_enabled agent.compression_in_place = compression_in_place - # Apply micro-compaction flag to the compressor (enabled by default) + # Apply micro-compaction settings to the compressor (feature is opt-in) _cc = getattr(agent, "context_compressor", None) if _cc is not None and hasattr(_cc, "_micro_compact_enabled"): _cc._micro_compact_enabled = compression_micro_compact + if _cc is not None and hasattr(_cc, "_micro_compact_every_n_turns"): + _cc._micro_compact_every_n_turns = compression_micro_compact_every_n_turns + if _cc is not None and hasattr(_cc, "_micro_compact_defrag_threshold_tokens"): + _cc._micro_compact_defrag_threshold_tokens = ( + compression_micro_compact_defrag_tokens + ) agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction agent.max_compression_attempts = compression_max_attempts agent.compression_idle_compact_after_seconds = ( diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 8b71a517d2..f8ff0b861b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1302,6 +1302,7 @@ class ContextCompressor(ContextEngine): self._micro_compact_last_failure_cursor = -1 self._micro_compact_passes = 0 self._micro_compact_tokens_saved_total = 0 + self._micro_compact_turns_since_pass = 0 def _begin_compression_telemetry( self, @@ -2161,6 +2162,12 @@ class ContextCompressor(ContextEngine): self._micro_compact_defrag_threshold_tokens: int = 2000 self._micro_compact_passes: int = 0 self._micro_compact_tokens_saved_total: int = 0 + # Cadence: run a pass every Nth completed turn. Each pass rewrites + # already-sent history and so breaks the prompt-cache prefix, which + # makes this the dial that sets how often that break is paid. 1 = + # every turn (most aggressive reclaim, one break per turn). + self._micro_compact_every_n_turns: int = 1 + self._micro_compact_turns_since_pass: int = 0 # Defer context-length resolution to first access (#32221): # get_model_context_length() can issue a synchronous /models HTTP @@ -5329,6 +5336,18 @@ This compaction should PRIORITISE preserving all information related to the focu if not self._micro_compact_enabled: return messages + # Cadence gate. A pass rewrites already-sent history, so it costs one + # prompt-cache break; `every_n_turns` is how an operator trades reclaim + # frequency against that cost. Counted per invocation rather than per + # committed pass so a turn that finds nothing to absorb still advances + # the cadence and cannot wedge it. + every_n = max(1, int(self._micro_compact_every_n_turns or 1)) + if every_n > 1: + self._micro_compact_turns_since_pass += 1 + if self._micro_compact_turns_since_pass < every_n: + return messages + self._micro_compact_turns_since_pass = 0 + n_messages = len(messages) if n_messages < 4: return messages diff --git a/docs/micro-compaction.md b/docs/micro-compaction.md index dd62f6478c..dcb414b14c 100644 --- a/docs/micro-compaction.md +++ b/docs/micro-compaction.md @@ -168,11 +168,26 @@ Micro-compaction is **off by default**. Turn it on explicitly: ```yaml compression: - micro_compact: true # default: false + micro_compact: true # default: false + micro_compact_every_n_turns: 1 # cadence — how often a pass runs + micro_compact_defrag_threshold_tokens: 2000 ``` -With it unset or `false` Hermes behaves exactly as it always has: batch-only -compaction. Everything else about compression is unchanged. +With `micro_compact` unset or `false` Hermes behaves exactly as it always has: +batch-only compaction. Everything else about compression is unchanged. + +`micro_compact_every_n_turns` is the knob that matters most after the on/off +switch, because it sets how often you pay the cache break described below. At +`1` a pass runs after every completed turn: the most aggressive reclaim, and +one broken prefix per turn. At `5` you get a fifth of the breaks and a fifth of +the reclaim rate, which is the right direction if your sessions are long-lived +and your provider's cache discount is deep. Values below `1` are clamped to `1` +rather than silently disabling the feature. The counter advances per turn, not +per committed pass, so a turn with nothing to absorb still moves the cadence +along and cannot wedge it. + +`micro_compact_defrag_threshold_tokens` is when the rolling summary gets +re-summarized instead of growing forever — see [Defrag](#defrag). It ships opt-in rather than on because of the prompt-cache cost described in the next section — that cost is real, it is not universally worth paying, and @@ -193,8 +208,15 @@ This is the same cost the proactive prune deliberately avoids. That path gates itself behind `compression.proactive_prune_min_reclaim_tokens` (4096 by default) precisely so its rewrites stay, in the words of the config comment, "one big episodic break instead of a tiny break every tool iteration." -Micro-compaction has no such gate: one exchange per turn means one break per -turn, by design. + +Micro-compaction has no equivalent *reclaim-size* gate — a pass commits +whatever the one absorbed exchange happened to save, large or small. What it +has instead is a *frequency* dial, `micro_compact_every_n_turns`. Raising it +makes the breaks rarer and more episodic, which is the same end the prune's +gate serves by a different route, though it gets there by absorbing less rather +than by waiting for a bigger win. If you want the prune's exact semantics here, +a reclaim threshold on micro-compaction is the obvious follow-up and does not +exist yet. So the honest framing is a trade of one cost for another, not a saving: diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 7d52bc2502..3fa9350af8 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -590,6 +590,17 @@ DEFAULT_CONFIG = { # measured that the amortized stall is worth # more to you than the cached-prefix discount. # See docs/micro-compaction.md. + "micro_compact_every_n_turns": 1, # cadence: run a pass every Nth completed + # turn. Since each pass costs one prompt-cache + # break, this is the dial for how often that + # cost is paid — 1 reclaims most aggressively + # at one break per turn, 5 trades reclaim rate + # for a fifth of the breaks. Clamped to >= 1. + # Ignored unless `micro_compact` is true. + "micro_compact_defrag_threshold_tokens": 2000, # once the rolling summary + # exceeds this many tokens, the next pass + # re-summarizes the summary itself instead of + # letting it grow without bound. "hygiene_hard_message_limit": 5000, # gateway session-hygiene force-compress threshold by message count "hygiene_timeout_seconds": 30, # max seconds gateway waits for pre-agent hygiene compression # WITHOUT forward progress. The summary call streams, so diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py index 57b3b773ad..c175a9b613 100644 --- a/tests/agent/test_micro_compaction.py +++ b/tests/agent/test_micro_compaction.py @@ -80,6 +80,70 @@ class TestMicroCompaction: assert cc._micro_compact(list(messages)) == messages + def test_is_off_unless_explicitly_enabled(self): + # A pass rewrites already-sent history, breaking the prompt-cache + # prefix, so nobody inherits it from an update: it stays off until + # `compression.micro_compact` opts in. + cc = ContextCompressor( + model="test-model", + threshold_percent=0.75, + protect_first_n=1, + protect_last_n=2, + quiet_mode=True, + config_context_length=40960, + provider="test", + ) + cc._micro_summarize_one = lambda _text: "ROLLING SUMMARY" + messages = _conversation() + + assert cc._micro_compact_enabled is False + assert cc._micro_compact(list(messages)) == messages + + def test_cadence_of_one_runs_every_turn(self): + cc = _compressor() + cc._micro_compact_every_n_turns = 1 + messages = _conversation(exchanges=8) + + first = cc._micro_compact(list(messages)) + second = cc._micro_compact(list(first)) + + assert cc._micro_compact_cursor > 0 + assert len(_summary_markers(first)) == 1 + # Each turn absorbed something, so the transcript kept shrinking. + assert len(second) < len(first) + + def test_cadence_skips_turns_until_a_pass_is_due(self): + cc = _compressor() + cc._micro_compact_every_n_turns = 3 + messages = _conversation(exchanges=8) + + first = cc._micro_compact(list(messages)) + second = cc._micro_compact(list(first)) + + # Cache prefix untouched on the turns in between. + assert _summary_markers(first) == [] + assert _summary_markers(second) == [] + assert cc._micro_compact_cursor == 0 + + third = cc._micro_compact(list(second)) + + assert len(_summary_markers(third)) == 1 + assert not any("answer 0" in str(m.get("content")) for m in third) + # Counter rearmed for the next window. + assert cc._micro_compact_turns_since_pass == 0 + + def test_cadence_is_clamped_to_at_least_one(self): + # A bogus 0 or negative must not disable compaction silently, nor + # divide-by-zero: it degrades to "every turn". + for bogus in (0, -5): + cc = _compressor() + cc._micro_compact_every_n_turns = bogus + messages = _conversation(exchanges=8) + + result = cc._micro_compact(list(messages)) + + assert len(_summary_markers(result)) == 1 + def test_cursor_advances_across_successive_turns(self): cc = _compressor() messages = _conversation(exchanges=8) From b8bfd68af137db300951fc4db1161846a712114f Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:53:03 +0530 Subject: [PATCH 47/55] fix(agent): make micro-compaction alternation-safe and defrag user-preserving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two integration bugs found during review of #74522, both confirmed with empirical probes against the production message-repair path: 1. Alternation: the summary marker was role="user" and an exchange was a single assistant+tools group, so splicing between two user turns produced user -> marker(user) -> user. The pre-request repair_message_sequence pass (conversation_loop.py, runs before EVERY API call) then merged the marker into the neighbouring real user message: metadata gone, cursor unrecoverable on resume, and the summary text duplicated into the transcript on every later pass (the transcript GREW every turn). Fix: an exchange is now a full agent turn (assistant + tools + follow-up assistant iterations, bounded by user messages), the marker is assistant-role, and superseding an old marker deliberately merges the two adjacent real user turns (plain-text \n\n-join, identical to repair pass 2) so the returned transcript is alternation-valid by construction. Probe result: repairs 0 (was 2), marker survives, no summary leakage. 2. Defrag destroyed user messages: _defrag_rolling_summary serialized the whole remaining middle (user turns included) and spliced it away — 8 of 10 user prompts destroyed in one pass, contradicting the feature's "your messages are never compacted" invariant. Fix: defrag now re-summarizes only the rolling summary TEXT and rewrites the marker content in place; transcript shape, cursor, and user turns untouched. Probe result: 10 of 10 user prompts survive. Also: marker provenance is now COMPRESSED_SUMMARY_HAS_USER_TURN_KEY=False — micro markers absorb only assistant/tool content (#64650 invariant), and real user turns remain in the transcript for provenance detection. Adds 5 regression tests (repair-pass integration, alternation on multi-iteration tool turns, defrag user survival, defrag input scope, marker provenance); updates the two existing tests and the design doc to the corrected semantics. 28 tests pass. --- agent/context_compressor.py | 242 ++++++++++++++++++++------- docs/micro-compaction.md | 24 ++- tests/agent/test_micro_compaction.py | 160 +++++++++++++++++- 3 files changed, 349 insertions(+), 77 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index f8ff0b861b..7ff6f92bf5 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -5069,36 +5069,46 @@ This compaction should PRIORITISE preserving all information related to the focu ) -> Optional[tuple[int, int]]: """Find the next complete exchange starting at *start*. - An exchange is an assistant message plus its tool results. Returns - ``(exchange_start, exchange_end)`` indices into *messages*, or ``None`` - if no complete exchange is available before *tail_start*. + An exchange is one full agent turn: the first assistant message after + *start* plus everything through the end of that turn — tool results + and any follow-up assistant iterations — up to (exclusive) the next + ``user`` message. Returns ``(exchange_start, exchange_end)`` indices + into *messages*, or ``None`` if no complete, safely-spliceable turn is + available before *tail_start*. - Tool results are consumed as a group following the assistant message - (consecutive ``tool``-role messages). The assistant message itself must - exist; without one there is nothing to summarise. + The full-turn shape is an alternation-safety requirement, not a + convenience: the splice replaces the span with a single + ``assistant``-role summary marker, so the span must be bounded by + user messages on the right (``messages[exchange_end]`` is ``user``). + Absorbing only the first assistant+tools group of a multi-iteration + turn would leave the marker adjacent to the turn's next assistant + message — two consecutive assistant turns, which strict providers + reject and ``repair_message_sequence`` would then mangle. User messages are deliberately NOT part of an exchange. The walk skips past them to reach the assistant message, and ``exchange_start`` is that assistant index, so user turns are never absorbed into the rolling - summary and stay verbatim for the life of the session. This is the - intended behaviour, not an oversight: what the assistant emits is - largely an account of what it did, which survives summarising, while the - user's own words are the instructions everything else is derived from - and are the one thing that cannot be reconstructed from context. They - are also cheap — a prompt is normally a tiny fraction of the tokens a - single tool result costs. + summary and their text stays verbatim for the life of the session. + This is the intended behaviour, not an oversight: what the assistant + emits is largely an account of what it did, which survives summarising, + while the user's own words are the instructions everything else is + derived from and are the one thing that cannot be reconstructed from + context. They are also cheap — a prompt is normally a tiny fraction + of the tokens a single tool result costs. """ idx = start n = len(messages) if idx >= n or idx >= tail_start: return None - # Walk past any user messages or summary markers until we hit an - # assistant message with actual output (content or tool_calls). + # Walk past user messages and existing summary markers until we hit a + # real assistant message with actual output (content or tool_calls). + # Summary markers are assistant-role themselves, so they must be + # skipped explicitly or a rehydrated cursor could try to absorb the + # marker that carries the compacted history. while idx < tail_start and idx < n: msg = messages[idx] - role = msg.get("role") - if role == "assistant": + if msg.get("role") == "assistant" and not self._is_context_summary_message(msg): break idx += 1 @@ -5107,18 +5117,33 @@ This compaction should PRIORITISE preserving all information related to the focu exchange_start = idx - # Advance past the assistant message + # Consume the full turn: assistant / tool messages until the next + # user message (or an existing summary marker) ends the turn. idx += 1 - - # Consume following tool results as part of the same exchange while idx < tail_start and idx < n: - if messages[idx].get("role") == "tool": - idx += 1 - else: + msg = messages[idx] + role = msg.get("role") + if role not in ("assistant", "tool"): break + if self._is_context_summary_message(msg): + break + idx += 1 if idx <= exchange_start: return None + + # Splice-boundary guard: the message right after the exchange must be + # a user turn (or a summary marker, which stands in for one + # structurally). If the walk stopped because it ran into *tail_start* + # mid-turn, splicing here would leave the assistant-role marker + # adjacent to the turn's remaining assistant/tool messages — invalid + # alternation. Skip this pass; the tail recedes as the conversation + # grows and the turn becomes absorbable later. + if idx >= n: + return None + boundary = messages[idx] + if not isinstance(boundary, dict) or boundary.get("role") != "user": + return None return (exchange_start, idx) def _serialize_one_exchange( @@ -5294,25 +5319,51 @@ This compaction should PRIORITISE preserving all information related to the focu def _defrag_rolling_summary( self, messages: List[Dict[str, Any]], - head_end: int, - tail_start: int, - ) -> None: - """Re-summarize the rolling summary + remaining middle in one shot. + ) -> bool: + """Re-summarize the rolling summary TEXT and rewrite the marker in place. - This is a lightweight batch compaction on just the summary and the - remaining un-compacted region \u2014 NOT the full transcript. It replaces - the rolling summary with a fresh, compact version and advances the - cursor to *tail_start*. + Merging exchange after exchange makes the rolling summary baggy — + repetitive, and larger than the material justifies. Defrag compacts + the summary *itself*: one aux call over the accumulated summary text, + then the existing marker's content is rewritten in place. + + Deliberately transcript-shape-neutral: no messages are spliced, no + user turns are touched, and the cursor does not move. The original + implementation serialized the whole remaining middle (user turns + included) and spliced it into the marker, which silently absorbed + user messages — violating the feature's core "your messages are never + compacted" invariant. Un-absorbed exchanges stay where they are and + get absorbed by later per-exchange passes. + + Returns True when a pass actually rewrote the summary. """ - middle_content = self._serialize_one_exchange(messages, head_end, tail_start) - fresh_summary = self._micro_summarize_one(middle_content) - if fresh_summary: - self._micro_compact_rolling_summary = fresh_summary - self._micro_compact_cursor = tail_start - logger.info( - "Micro-compaction defrag: rolling summary re-summarized " - "(%d chars)", len(fresh_summary), - ) + old_summary = self._micro_compact_rolling_summary + if not old_summary.strip(): + return False + # Feed the old summary through the merge prompt with an empty base: + # "merge these decisions into (no previous summary)" is exactly a + # rewrite-compactly instruction for the accumulated text. + self._micro_compact_rolling_summary = "" + fresh_summary = self._micro_summarize_one(old_summary) + if not fresh_summary: + self._micro_compact_rolling_summary = old_summary + return False + self._micro_compact_rolling_summary = fresh_summary + # Rewrite the newest marker's content in place so the transcript and + # the in-memory summary stay in step (resume rehydrates from it). + for idx in range(len(messages) - 1, -1, -1): + entry = messages[idx] + if isinstance(entry, dict) and entry.get(COMPRESSED_SUMMARY_METADATA_KEY): + entry["content"] = self._render_micro_marker_content(fresh_summary) + # Content changed after a possible flush — clear the persisted + # stamp so the DB sync/flush rewrites the row. + entry.pop(_DB_PERSISTED_MARKER, None) + break + logger.info( + "Micro-compaction defrag: rolling summary re-summarized " + "(%d -> %d chars)", len(old_summary), len(fresh_summary), + ) + return True def _micro_compact( self, @@ -5379,23 +5430,26 @@ This compaction should PRIORITISE preserving all information related to the focu def _elapsed_ms() -> int: return int((time.monotonic() - _started_at) * 1000) - # Check for defrag trigger + # Check for defrag trigger: the rolling summary itself has grown + # baggy. Defrag rewrites the summary text and the existing marker in + # place — no splice, no cursor movement, no user turns touched — so + # the transcript shape is unchanged and this pass does not also + # absorb an exchange (one aux call per turn either way). if self._needs_defrag(): - self._defrag_rolling_summary(messages, exchange_start, compress_end) - result = self._splice_micro_compact_result(messages, exchange_start, compress_end) - self._micro_compact_cursor = self._cursor_after_splice(result, exchange_start + 1) - self._sync_micro_compact_to_db(result) - self._micro_compact_consecutive_failures = 0 - self._micro_compact_last_failure_cursor = -1 + defragged = self._defrag_rolling_summary(messages) + if defragged: + self._sync_micro_compact_to_db(messages) + self._micro_compact_consecutive_failures = 0 + self._micro_compact_last_failure_cursor = -1 self._emit_micro_compaction_telemetry( - outcome="defrag", + outcome="defrag" if defragged else "defrag_failed", messages_before=_messages_before, - messages_after=len(result), + messages_after=len(messages), tokens_before=_tokens_before, - tokens_after=estimate_messages_tokens_rough(result), + tokens_after=estimate_messages_tokens_rough(messages), duration_ms=_elapsed_ms(), ) - return result + return messages # Whether this pass's summary will be cumulative — i.e. whether it # subsumes any earlier marker. Captured before summarizing. @@ -5621,23 +5675,36 @@ This compaction should PRIORITISE preserving all information related to the focu ``_compressed_summary`` metadata flag so downstream consumers (resume, handoff, /compress) handle it identically to batch compaction summaries. + + Alternation safety: the marker is ``assistant``-role. An exchange is + a full agent turn bounded by user messages on both sides (see + ``_find_one_exchange``), so the spliced result is + ``user → marker(assistant) → user`` — valid alternation that the + pre-request ``repair_message_sequence`` pass leaves untouched. A + ``user``-role marker in that position produced ``user → user → user``, + and repair then merged the marker into the neighbouring real user + message: metadata gone, cursor unrecoverable, and the summary text + duplicated into the transcript on every subsequent pass. + + Superseding an earlier marker removes the assistant turn that stood + between two real user messages, leaving them adjacent. Those two are + merged (plain-text only, ``\\n\\n``-joined — the same repair pass 2 + would apply) so the transcript is alternation-valid as returned + rather than relying on downstream repair to fix it up. """ summary_text = self._micro_compact_rolling_summary if not summary_text.strip(): return messages - content = ( - f"{SUMMARY_PREFIX}\n\n" - f"{HISTORICAL_TASK_HEADING}\n" - ) - content += summary_text.strip() - content += f"\n\n{_SUMMARY_END_MARKER}" - summary_msg = { - "role": "user", - "content": content, + "role": "assistant", + "content": self._render_micro_marker_content(summary_text), COMPRESSED_SUMMARY_METADATA_KEY: True, - COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: True, + # Honest provenance (#64650): this marker absorbs only + # assistant/tool content — user turns are never micro-compacted, + # so they remain in the transcript and _transcript_has_real_user_turn + # keeps reporting them directly. + COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: False, } result = messages[:splice_start] + [summary_msg] + messages[splice_end:] @@ -5661,10 +5728,63 @@ This compaction should PRIORITISE preserving all information related to the focu if len(marker_idxs) > 1: superseded = set(marker_idxs[:-1]) result = [m for i, m in enumerate(result) if i not in superseded] + result = self._merge_adjacent_user_turns(result) _strip_persistence_markers(result) return result + @staticmethod + def _render_micro_marker_content(summary_text: str) -> str: + """Assemble the marker content wrapper around *summary_text*.""" + return ( + f"{SUMMARY_PREFIX}\n\n" + f"{HISTORICAL_TASK_HEADING}\n" + f"{summary_text.strip()}" + f"\n\n{_SUMMARY_END_MARKER}" + ) + + @staticmethod + def _merge_adjacent_user_turns( + result: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """Merge consecutive plain-text real user turns left by a supersede. + + Dropping a superseded marker removes the assistant turn that separated + two real user messages. Merging them here (``\\n\\n``-joined, exactly + what ``repair_message_sequence`` pass 2 does) keeps every byte the + user typed while restoring alternation deliberately, so the marker + and cursor state are never collateral damage of the downstream repair. + Multimodal (list) content is left alone, mirroring the repair pass. + """ + from agent.turn_context import drop_stale_api_content + + merged: List[Dict[str, Any]] = [] + for msg in result: + prev = merged[-1] if merged else None + if ( + isinstance(msg, dict) + and isinstance(prev, dict) + and msg.get("role") == "user" + and prev.get("role") == "user" + and not msg.get(COMPRESSED_SUMMARY_METADATA_KEY) + and not prev.get(COMPRESSED_SUMMARY_METADATA_KEY) + and isinstance(prev.get("content"), str) + and isinstance(msg.get("content"), str) + ): + prev_content = prev["content"] + new_content = msg["content"] + prev["content"] = ( + (prev_content + "\n\n" + new_content) + if prev_content and new_content + else (prev_content or new_content) + ) + # Merged content invalidates the api_content sidecar (exact + # bytes previously sent for the pre-merge message). + drop_stale_api_content(prev) + continue + merged.append(msg) + return merged + def compress( self, messages: List[Dict[str, Any]], diff --git a/docs/micro-compaction.md b/docs/micro-compaction.md index dcb414b14c..7c4a7176ba 100644 --- a/docs/micro-compaction.md +++ b/docs/micro-compaction.md @@ -51,10 +51,14 @@ compressor to absorb **one** exchange: One exchange per turn. The per-turn cost stays bounded no matter how long the conversation gets. -An **exchange** is an assistant message together with any tool results that -followed it. In tool-heavy work that's where the bulk of the tokens live — a +An **exchange** is one full agent turn: an assistant message together with its +tool results and any follow-up assistant iterations, up to the next user +message. In tool-heavy work that's where the bulk of the tokens live — a file read or a command's output dwarfs the surrounding prose — which is why -absorbing one exchange at a time is worth doing at all. +absorbing one exchange at a time is worth doing at all. Taking the whole turn +(rather than a single assistant+tools group) also keeps the transcript's role +alternation strictly valid: the summary marker is an assistant-role message, +and a full turn is always bounded by user messages on both sides. ## Your messages are never compacted @@ -123,12 +127,14 @@ transcript grows on every turn instead of shrinking. Merge into a summary often enough and it gets baggy — repetitive, and larger than the material justifies. When the running summary crosses a token threshold -(2000 by default), the next pass **defrags**: it re-summarizes the summary and -whatever middle remains in one shot, replacing it with a fresh compact version -and advancing the cursor to the tail. +(2000 by default), the next pass **defrags**: one auxiliary call re-summarizes +the running summary *itself* into a fresh compact version, and the summary +marker in the transcript is rewritten in place. -This is still much cheaper than full batch compaction. It only ever processes the -summary plus the un-absorbed middle, never the whole transcript. +Defrag never touches the transcript's structure — no messages are absorbed or +spliced, the cursor doesn't move, and user turns are untouched. It processes +only the accumulated summary text, never conversation messages, so the +"your messages are never compacted" guarantee holds through it. ### Staying in step with the session database @@ -152,7 +158,7 @@ untouched and the failure is counted. If the *same* exchange fails three times in a row, the cursor is advanced past it anyway. Without that, one bad exchange would be retried on every single turn forever. Those skipped messages stay in the transcript and get picked up by the -next defrag or batch compaction. +next batch compaction. ## Interaction with batch compaction diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py index c175a9b613..543360be4a 100644 --- a/tests/agent/test_micro_compaction.py +++ b/tests/agent/test_micro_compaction.py @@ -70,8 +70,12 @@ class TestMicroCompaction: markers = _summary_markers(result) assert len(markers) == 1 assert "ROLLING SUMMARY" in markers[0]["content"] - # The marker stands in for a user turn, like batch compaction's does. - assert markers[0]["role"] == "user" + # The marker is assistant-role: an exchange is a full agent turn + # bounded by user messages, so user → marker(assistant) → user keeps + # strict alternation. A user-role marker produced user → user → user, + # and the pre-request repair_message_sequence pass merged the marker + # into the neighbouring real user turn — metadata gone, cursor lost. + assert markers[0]["role"] == "assistant" def test_disabled_is_a_no_op(self): cc = _compressor() @@ -168,12 +172,17 @@ class TestMicroCompaction: assert result[-1] == messages[-1], "most recent turn must be preserved" def test_user_messages_are_never_absorbed(self): - """User turns stay verbatim for the life of the session — by design. + """Every byte the user typed stays in the transcript — by design. Assistant output is largely an account of what was done and survives summarising; the user's own words are the intent everything else is derived from and can't be reconstructed from it. So an exchange starts at the assistant message and the walk skips past user turns. + + The invariant is on user TEXT, not message-list shape: superseding an + old marker leaves two real user turns adjacent, and they are merged + (\\n\\n-joined, same as repair_message_sequence pass 2) to keep strict + alternation. Text is never summarized or dropped. """ cc = _compressor() messages = _conversation(exchanges=10) @@ -182,11 +191,14 @@ class TestMicroCompaction: for _ in range(5): messages = cc._micro_compact(messages) - surviving = [ + surviving_text = "\n\n".join( m["content"] for m in messages if m.get("role") == "user" and not m.get(COMPRESSED_SUMMARY_METADATA_KEY) - ] - assert surviving == originals, "user turns must survive verbatim" + ) + for original in originals: + assert original in surviving_text, ( + f"user text {original!r} must survive verbatim" + ) def test_cursor_is_derived_from_the_spliced_list(self): """The cursor must never carry over a pre-splice index. @@ -474,9 +486,20 @@ class TestMicroCompaction: assert cc._micro_compact_tokens_saved_total > 0 def test_defrag_triggers_once_the_rolling_summary_grows(self): + """Defrag rewrites the summary text and the marker — nothing else. + + The original implementation spliced the whole remaining middle (user + turns included) into the marker, silently absorbing user messages. + Defrag is now transcript-shape-neutral: same message list, same + cursor, marker content rewritten in place. + """ cc = _compressor(summary="FRESH DEFRAGGED SUMMARY") - cc._micro_compact_rolling_summary = "x" * 40_000 # far over the threshold messages = _conversation(exchanges=8) + # Seed a real marker + oversized rolling summary, as after many passes. + messages = cc._micro_compact(list(messages)) + cc._micro_compact_rolling_summary = "x" * 40_000 # far over the threshold + cursor_before = cc._micro_compact_cursor + shape_before = [m.get("role") for m in messages] assert cc._needs_defrag() is True result = cc._micro_compact(list(messages)) @@ -485,3 +508,126 @@ class TestMicroCompaction: markers = _summary_markers(result) assert len(markers) == 1 assert "FRESH DEFRAGGED SUMMARY" in markers[0]["content"] + # Shape-neutral: no messages absorbed or spliced, cursor unmoved. + assert [m.get("role") for m in result] == shape_before + assert cc._micro_compact_cursor == cursor_before + + def test_defrag_never_absorbs_user_messages(self): + """Defrag must not touch user turns — the feature's core invariant. + + The original implementation serialized head..tail (user turns + included) and spliced it away: 8 of 10 user prompts were destroyed in + one pass. Defrag now only rewrites the rolling summary text. + """ + cc = _compressor(summary="DEFRAGGED") + messages = [{"role": "system", "content": "sys"}] + for i in range(10): + messages.append({"role": "user", "content": f"UNIQUE-USER-PROMPT-{i}"}) + messages.append({"role": "assistant", "content": f"answer {i} " + "z" * 400}) + + cc._micro_compact_rolling_summary = "x" * 40_000 # force defrag + result = cc._micro_compact(list(messages)) + + surviving = [ + m["content"] for m in result + if m.get("role") == "user" and not m.get(COMPRESSED_SUMMARY_METADATA_KEY) + ] + for i in range(10): + assert any(f"UNIQUE-USER-PROMPT-{i}" in s for s in surviving), ( + f"user prompt {i} was absorbed by defrag" + ) + + def test_defrag_summarizes_only_the_summary_text(self): + """The defrag aux call receives the rolling summary, not the transcript.""" + cc = _compressor() + captured = {} + + def capture(text): + captured["text"] = text + return "DEFRAGGED" + + cc._micro_summarize_one = capture + cc._micro_compact_rolling_summary = "OLD-SUMMARY " + "x" * 40_000 + messages = _conversation(exchanges=8) + cc._micro_compact(list(messages)) + + assert "OLD-SUMMARY" in captured["text"] + assert "[USER]" not in captured["text"], ( + "defrag must never serialize transcript user turns" + ) + + def test_spliced_transcript_survives_repair_message_sequence(self): + """The compacted transcript must survive the production repair pass. + + conversation_loop runs repair_message_sequence before EVERY API call. + With the old user-role marker, splicing next to a real user turn made + user → user → user; repair merged the marker into the real user + message — metadata gone, cursor unrecoverable, summary text duplicated + into the transcript on every later pass. Pin the integration: markers + survive repair untouched, and no summary text leaks into real user + messages. + """ + from agent.agent_runtime_helpers import repair_message_sequence + + class _DummyAgent: + session_id = "probe" + _last_flushed_db_idx = 0 + + cc = _compressor() + messages = _conversation(exchanges=8) + + for _ in range(3): + messages = cc._micro_compact(messages) + repairs = repair_message_sequence(_DummyAgent(), messages) + assert repairs == 0, ( + "micro-compacted transcript must already be alternation-valid" + ) + markers = _summary_markers(messages) + assert len(markers) == 1, "marker destroyed by repair pass" + polluted = [ + m for m in messages + if m.get("role") == "user" + and not m.get(COMPRESSED_SUMMARY_METADATA_KEY) + and "ROLLING SUMMARY" in str(m.get("content")) + ] + assert not polluted, "summary text leaked into a real user message" + + def test_spliced_transcript_has_no_consecutive_same_role_messages(self): + """Alternation invariant, checked directly on tool-bearing turns.""" + cc = _compressor() + msgs = [{"role": "system", "content": "sys"}] + for i in range(8): + msgs.append({"role": "user", "content": f"q{i}"}) + msgs.append({ + "role": "assistant", + "content": f"a{i}", + "tool_calls": [ + {"id": f"c{i}-{j}", "type": "function", + "function": {"name": "f", "arguments": "{}"}} + for j in range(2) + ], + }) + for j in range(2): + msgs.append({"role": "tool", "tool_call_id": f"c{i}-{j}", + "content": "T" * 400}) + # Multi-iteration turn: a second assistant+tools group before the + # next user message — the splice must absorb the WHOLE turn. + msgs.append({"role": "assistant", "content": f"followup {i} " + "y" * 200}) + + for _ in range(4): + msgs = cc._micro_compact(msgs) + for a, b in zip(msgs, msgs[1:]): + ra, rb = a.get("role"), b.get("role") + assert not (ra == rb and ra in ("user", "assistant")), ( + f"consecutive {ra} messages after micro-compaction" + ) + + def test_marker_reports_no_user_provenance(self): + """Micro markers absorb only assistant/tool content (#64650).""" + from agent.context_compressor import COMPRESSED_SUMMARY_HAS_USER_TURN_KEY + + cc = _compressor() + result = cc._micro_compact(_conversation(exchanges=6)) + marker = _summary_markers(result)[0] + + assert marker[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False From c696a5fd9cf7ffaf32efec10a4a2a48255217f54 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:10:12 +0530 Subject: [PATCH 48/55] fix(agent): harden the finalize-turn micro-compaction gate against duck-typed compressors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/run_agent/test_proactive_prune_loop_wiring.py builds agents with a MagicMock compressor; getattr(mock, '_micro_compact_enabled', False) returns a truthy auto-attribute, so the hook called _micro_compact on the mock and spliced its (empty-iterating) return over the transcript — wiping all messages before persist (CI slice 7/8 failure). Gate now requires _micro_compact_enabled is True, a callable _micro_compact, and a non-empty list result before touching messages. Same hardening protects production plugin context engines that don't subclass ContextCompressor. --- agent/turn_finalizer.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 110a104c46..5078eed9d3 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -358,13 +358,21 @@ def finalize_turn( if not interrupted and not failed: try: _compressor = getattr(agent, "context_compressor", None) + # Strict `is True` + isinstance gates: plugin context engines + # (and MagicMock compressors in tests) satisfy getattr/duck + # checks with truthy auto-attributes — a bare truthiness check + # here called _micro_compact on a mock and spliced its (empty- + # iterating) return value over the transcript, wiping it. if ( _compressor - and getattr(_compressor, '_micro_compact_enabled', False) + and getattr(_compressor, '_micro_compact_enabled', False) is True + and callable(getattr(_compressor, '_micro_compact', None)) and final_response ): _before = len(messages) - messages[:] = _compressor._micro_compact(messages) or messages + _compacted = _compressor._micro_compact(messages) + if isinstance(_compacted, list) and _compacted: + messages[:] = _compacted _after = len(messages) if _before != _after: logger.info( From 53559aaf86b84dadae83cd9bb605ca476f9a0606 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:37:44 +0530 Subject: [PATCH 49/55] fix(agent): protect batch-compaction markers from micro supersede/defrag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 review findings on the salvage branch: C1 (critical): batch and micro summary markers share COMPRESSED_SUMMARY_METADATA_KEY, and compress() never reset micro state. After micro absorbed exchanges 1..k, a batch compaction summarizing 1..m (m>k) could fire; the next micro pass's supersede then dropped the batch marker (whose content the stale rolling summary does NOT contain) and archive_and_compact immediately made the loss durable. Defrag had the same hazard: it rewrote "the newest marker" even if that was a batch marker. Empirically confirmed with a probe (batch marker content destroyed in one pass). Fix, three parts: - Micro-created markers now carry MICRO_COMPACT_MARKER_KEY; supersede and defrag only ever touch micro-tagged markers. Rehydration in _resolve_compact_cursor tags the marker it absorbs (containment proof), which safely covers adopting a batch marker as the new rolling base after a reset. - compress() success path resets micro rolling summary/cursor state so a stale summary can never claim cumulativeness over a batch marker. - Regression tests for both directions plus the reset. W4: _splice_micro_compact_result no longer strips _db_persisted stamps from surviving messages. Micro archives in place under the SAME session id (unlike batch's child-session rotation, #57491), so surviving stamps are accurate; stripping them meant an archive_and_compact failure left every previously-persisted message unstamped and the next append-only flush re-inserted them all as duplicate active rows. W5: finalize_turn micro gate now checks agent._persist_disabled — persistence-isolated fork agents (background review) must not burn an aux call per review turn, and must never archive_and_compact the canonical session rows if their compressor ever gains a DB binding. W1: _serialize_one_exchange now delegates to _serialize_for_summary (was a ~70-line near-verbatim copy; one serializer, one place to fix). S4: _find_one_exchange boundary guard rejects only assistant/tool boundaries (the actual alternation hazard) instead of requiring user — a stray mid-list system/injected message can no longer wedge the cursor forever. 5 new regression tests; 38 micro/prune tests, 400 compression-suite tests, 61 finalize/persist tests pass; ruff clean. --- agent/context_compressor.py | 164 +++++++++++++-------------- agent/turn_finalizer.py | 7 ++ tests/agent/test_micro_compaction.py | 121 ++++++++++++++++++++ 3 files changed, 209 insertions(+), 83 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 7ff6f92bf5..c4a58c10ac 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -137,6 +137,12 @@ LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:" # "is_compressed_summary" would reach the wire and trip exactly that. COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary" COMPRESSED_SUMMARY_HAS_USER_TURN_KEY = "_compressed_summary_has_user_turn" +# Distinguishes rolling micro-compaction markers from batch-compaction +# markers (both carry COMPRESSED_SUMMARY_METADATA_KEY so resume/handoff +# treat them alike). Supersede/defrag/rehydration must only ever touch +# micro markers: a batch marker's content is NOT contained in the micro +# rolling summary, so dropping or rewriting one destroys history. +MICRO_COMPACT_MARKER_KEY = "_micro_compact_marker" _DB_PERSISTED_MARKER = "_db_persisted" _NO_USER_TASK_SENTINEL = "None. This session contains no user-authored turns." @@ -5052,6 +5058,14 @@ This compaction should PRIORITISE preserving all information related to the focu ) if recovered: self._micro_compact_rolling_summary = recovered + # Rehydration is containment proof: this marker's text now + # lives inside the rolling summary, so it becomes + # supersede/defrag-eligible. This also covers a BATCH + # marker adopted as the rolling base after a batch + # compaction reset — safe precisely because we just + # absorbed its content. Markers whose content we did NOT + # absorb never get the key and are never dropped. + messages[last_summary_idx][MICRO_COMPACT_MARKER_KEY] = True logger.info( "Micro-compaction: recovered rolling summary from " "transcript (%d chars)", len(recovered), @@ -5132,17 +5146,22 @@ This compaction should PRIORITISE preserving all information related to the focu if idx <= exchange_start: return None - # Splice-boundary guard: the message right after the exchange must be - # a user turn (or a summary marker, which stands in for one - # structurally). If the walk stopped because it ran into *tail_start* - # mid-turn, splicing here would leave the assistant-role marker - # adjacent to the turn's remaining assistant/tool messages — invalid - # alternation. Skip this pass; the tail recedes as the conversation - # grows and the turn becomes absorbable later. + # Splice-boundary guard: the message right after the exchange must + # close the turn. If the walk stopped because it ran into + # *tail_start* mid-turn (boundary is assistant or tool — including + # an assistant-role summary marker), splicing here would leave the + # assistant-role marker adjacent to the turn's remaining + # assistant/tool messages — invalid alternation. Skip this pass; the + # tail recedes as the conversation grows and the turn becomes + # absorbable later. Any other boundary role (user, or a stray + # system/injected message) is a safe splice point — the marker is + # assistant-role, so no same-role adjacency is possible — and + # accepting them keeps one odd message from wedging the cursor + # forever. if idx >= n: return None boundary = messages[idx] - if not isinstance(boundary, dict) or boundary.get("role") != "user": + if not isinstance(boundary, dict) or boundary.get("role") in ("assistant", "tool"): return None return (exchange_start, idx) @@ -5154,72 +5173,11 @@ This compaction should PRIORITISE preserving all information related to the focu ) -> str: """Serialize a single exchange for the micro-summarizer. - Uses the same content-max truncation and redaction as the batch - ``_serialize_for_summary`` method, but scoped to one exchange. + Delegates to the batch path's ``_serialize_for_summary`` (same + truncation, redaction, think-block stripping, and media labeling), + scoped to one exchange — one serializer, one place to fix. """ - from agent.agent_runtime_helpers import strip_think_blocks - - parts = [] - for msg in messages[start:end]: - role = msg.get("role", "unknown") - content = msg.get("content") - if isinstance(content, list): - text_parts: list[str] = [] - for part in content: - if isinstance(part, dict): - ptype = part.get("type") - if ptype == "text": - text_parts.append(part.get("text", "")) - elif ptype in {"image", "image_url", "input_image"}: - text_parts.append(_image_part_label(part)) - else: - text_parts.append(f"[{ptype or 'attachment'}]") - elif isinstance(part, str): - text_parts.append(part) - content = "\n".join(text_parts) - content = _redact_compaction_text(content or "") - content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content) - if role == "assistant" and content: - content = strip_think_blocks(None, content) - - if role == "tool": - tool_id = msg.get("tool_call_id", "") - if len(content) > self._CONTENT_MAX: - content = content[:self._CONTENT_HEAD] + "\n...[truncated]...\n" + content[-self._CONTENT_TAIL:] - parts.append(f"[TOOL RESULT {tool_id}]: {content}") - continue - - if role == "assistant": - if len(content) > self._CONTENT_MAX: - content = content[:self._CONTENT_HEAD] + "\n...[truncated]...\n" + content[-self._CONTENT_TAIL:] - tool_calls = msg.get("tool_calls", []) - if tool_calls: - tc_parts = [] - for tc in tool_calls: - if isinstance(tc, dict): - fn = tc.get("function", {}) - name = fn.get("name", "?") - args = _redact_compaction_text(fn.get("arguments", "")) - if len(args) > self._TOOL_ARGS_MAX: - args = args[:self._TOOL_ARGS_HEAD] + "..." - tc_parts.append(f" {name}({args})") - else: - fn = getattr(tc, "function", None) - name = getattr(fn, "name", "?") if fn else "?" - tc_parts.append(f" {name}(...)") - content += "\n[Tool calls:\n" + "\n".join(tc_parts) + "\n]" - parts.append(f"[ASSISTANT]: {content}") - continue - - if role == "user": - if len(content) > self._CONTENT_MAX: - content = content[:self._CONTENT_HEAD] + "\n...[truncated]...\n" + content[-self._CONTENT_TAIL:] - parts.append(f"[USER]: {content}") - continue - - parts.append(f"[{role.upper()}]: {content}") - - return "\n---\n".join(parts) + return self._serialize_for_summary(messages[start:end]) def _build_micro_summary_prompt( self, @@ -5349,11 +5307,17 @@ This compaction should PRIORITISE preserving all information related to the focu self._micro_compact_rolling_summary = old_summary return False self._micro_compact_rolling_summary = fresh_summary - # Rewrite the newest marker's content in place so the transcript and - # the in-memory summary stay in step (resume rehydrates from it). + # Rewrite the newest MICRO marker's content in place so the transcript + # and the in-memory summary stay in step (resume rehydrates from it). + # Scoped to micro-tagged markers: rewriting a batch-compaction marker + # would overwrite history the rolling summary does not contain. for idx in range(len(messages) - 1, -1, -1): entry = messages[idx] - if isinstance(entry, dict) and entry.get(COMPRESSED_SUMMARY_METADATA_KEY): + if ( + isinstance(entry, dict) + and entry.get(COMPRESSED_SUMMARY_METADATA_KEY) + and entry.get(MICRO_COMPACT_MARKER_KEY) + ): entry["content"] = self._render_micro_marker_content(fresh_summary) # Content changed after a possible flush — clear the persisted # stamp so the DB sync/flush rewrites the row. @@ -5700,6 +5664,10 @@ This compaction should PRIORITISE preserving all information related to the focu "role": "assistant", "content": self._render_micro_marker_content(summary_text), COMPRESSED_SUMMARY_METADATA_KEY: True, + # Micro-created marker: eligible for supersede/defrag rewrites. + # Batch markers never carry this key and are never touched — + # their content is not contained in the rolling summary. + MICRO_COMPACT_MARKER_KEY: True, # Honest provenance (#64650): this marker absorbs only # assistant/tool content — user turns are never micro-compacted, # so they remain in the transcript and _transcript_has_real_user_turn @@ -5715,22 +5683,39 @@ This compaction should PRIORITISE preserving all information related to the focu # its own prefix/heading/end-marker scaffolding — so the transcript # grows with every turn instead of shrinking, which defeats the point. # Keep only the newest marker. - # Only drop earlier markers when this one demonstrably contains them: - # the rolling summary must have been non-empty going into this pass. - # A pass that started from nothing (a resume that could not rehydrate) - # produces a marker covering one exchange, and dropping the previous - # marker would throw away the entire compacted history. + # Two containment gates before dropping an earlier marker: + # 1. supersede (the rolling summary was non-empty going into this + # pass) — a pass that started from nothing (a resume that could + # not rehydrate) covers one exchange, and dropping the previous + # marker would throw away the entire compacted history. + # 2. MICRO_COMPACT_MARKER_KEY on the candidate — only markers whose + # text is provably inside the rolling summary (created by our own + # splice, or rehydrated into the summary by + # _resolve_compact_cursor) carry it. A batch-compaction marker + # that landed after our last pass holds MORE history than the + # stale rolling summary; dropping it would destroy that history. if supersede: marker_idxs = [ i for i, m in enumerate(result) - if isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY) + if isinstance(m, dict) + and m.get(COMPRESSED_SUMMARY_METADATA_KEY) + and m.get(MICRO_COMPACT_MARKER_KEY) ] if len(marker_idxs) > 1: superseded = set(marker_idxs[:-1]) result = [m for i, m in enumerate(result) if i not in superseded] result = self._merge_adjacent_user_turns(result) - _strip_persistence_markers(result) + # NOTE: deliberately NO _strip_persistence_markers here. The batch + # path strips because compress() copies head/tail into a rotated + # child session (#57491); micro-compaction archives in place under + # the SAME session id, and the surviving dicts' _db_persisted stamps + # are accurate. Stripping them meant an archive_and_compact failure + # left every previously-persisted message unstamped, and the next + # append-only flush re-inserted them as duplicate active rows on top + # of the still-active originals. _sync_micro_compact_to_db re-stamps + # everything after a SUCCESSFUL archive; on failure the old stamps + # keep the flush idempotent (only the new marker row is appended). return result @staticmethod @@ -6468,6 +6453,19 @@ This compaction should PRIORITISE preserving all information related to the focu _strip_persistence_markers(compressed) self._last_compression_made_progress = True + # Batch compaction invalidates micro-compaction state: the batch + # marker now holds MORE history than the in-memory rolling summary + # (it summarized everything in the window, including exchanges micro + # never absorbed). Keeping the stale summary would let the next micro + # pass supersede-drop or defrag-rewrite content it does not contain. + # Reset instead; the next micro pass rehydrates from the batch marker + # via _resolve_compact_cursor, which re-tags it as micro-eligible + # only after absorbing its content into the rolling summary. + self._micro_compact_rolling_summary = "" + self._micro_compact_cursor = 0 + self._micro_compact_consecutive_failures = 0 + self._micro_compact_last_failure_cursor = -1 + return compressed diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 5078eed9d3..86f3f50992 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -368,6 +368,13 @@ def finalize_turn( and getattr(_compressor, '_micro_compact_enabled', False) is True and callable(getattr(_compressor, '_micro_compact', None)) and final_response + # Persistence-isolated agents (background review fork) + # must not micro-compact: the pass burns a real aux-LLM + # call on a throwaway replay transcript, and if the + # compressor ever holds a session_db binding it would + # archive_and_compact the CANONICAL session rows — the + # exact write class _persist_disabled exists to stop. + and not getattr(agent, "_persist_disabled", False) ): _before = len(messages) _compacted = _compressor._micro_compact(messages) diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py index 543360be4a..c3528d10f9 100644 --- a/tests/agent/test_micro_compaction.py +++ b/tests/agent/test_micro_compaction.py @@ -631,3 +631,124 @@ class TestMicroCompaction: marker = _summary_markers(result)[0] assert marker[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False + + def test_supersede_never_drops_a_batch_compaction_marker(self): + """A batch marker holds history the rolling summary does NOT contain. + + Sequence: micro absorbs some exchanges (rolling summary = those k + exchanges only), then batch compaction fires and its marker + summarizes MORE (exchanges 1..m). The next micro pass's supersede + must not treat the batch marker as redundant — dropping it destroys + everything batch summarized beyond exchange k. Only micro-tagged + markers (whose text is provably inside the rolling summary) may be + superseded. + """ + cc = _compressor(summary="MICRO SUMMARY (exchanges 1..k only)") + msgs = _conversation(exchanges=8) + msgs = cc._micro_compact(msgs) + assert cc._micro_compact_rolling_summary + + # Simulate a batch-compaction marker replacing the middle (batch + # markers carry the shared metadata key but NOT the micro tag). + batch_marker = { + "role": "user", + "content": "[batch summary] CRITICAL HISTORY: exchanges 1..m", + COMPRESSED_SUMMARY_METADATA_KEY: True, + } + micro_idx = next( + i for i, m in enumerate(msgs) + if m.get(COMPRESSED_SUMMARY_METADATA_KEY) + ) + msgs = msgs[:micro_idx] + [batch_marker] + msgs[micro_idx + 3:] + + out = cc._micro_compact(msgs) + + assert any( + "CRITICAL HISTORY" in str(m.get("content")) for m in out + ), "batch-compaction summary destroyed by micro supersede" + + def test_defrag_never_rewrites_a_batch_compaction_marker(self): + """Defrag rewrites only micro-tagged markers, never batch markers.""" + cc = _compressor(summary="DEFRAGGED") + msgs = [{"role": "system", "content": "sys"}] + msgs.append({ + "role": "user", + "content": "[batch summary] CRITICAL HISTORY: exchanges 1..m", + COMPRESSED_SUMMARY_METADATA_KEY: True, + }) + for i in range(6): + msgs.append({"role": "user", "content": f"q{i}"}) + msgs.append({"role": "assistant", "content": f"a{i} " + "z" * 400}) + + cc._micro_compact_rolling_summary = "x" * 40_000 # force defrag + result = cc._micro_compact(list(msgs)) + + batch = [m for m in result if "CRITICAL HISTORY" in str(m.get("content"))] + assert batch, "batch marker content overwritten by defrag" + + def test_batch_compress_resets_micro_state(self): + """compress() success path invalidates the stale rolling summary. + + Without the reset, the in-memory micro summary (exchanges 1..k) + outlives a batch compaction whose marker covers 1..m — the next + micro pass would then treat its stale summary as cumulative. + """ + cc = _compressor() + msgs = _conversation(exchanges=8) + msgs = cc._micro_compact(msgs) + assert cc._micro_compact_rolling_summary + assert cc._micro_compact_cursor > 0 + + cc.compress(msgs, force=True) + + assert cc._micro_compact_rolling_summary == "" + assert cc._micro_compact_cursor == 0 + + def test_persist_disabled_agent_never_micro_compacts(self): + """finalize_turn must skip micro-compaction on isolated fork agents. + + The background-review fork sets _persist_disabled=True; running a + pass there burns an aux-LLM call on a throwaway replay transcript + and, if the compressor ever holds a DB binding, would + archive_and_compact the CANONICAL session rows. + """ + import inspect + + from agent import turn_finalizer + + src = inspect.getsource(turn_finalizer.finalize_turn) + micro_block = src.split("Post-turn micro-compaction", 1)[1] + # Scope to the micro block only: stop at the persist call that follows. + micro_block = micro_block.split("agent._persist_session", 1)[0] + assert "_persist_disabled" in micro_block, ( + "micro-compaction gate must check agent._persist_disabled" + ) + + def test_splice_preserves_db_persisted_stamps(self): + """Surviving messages keep their _db_persisted stamps through a splice. + + Micro-compaction archives in place under the SAME session id, so the + stamps on untouched messages stay accurate. Stripping them (as the + batch path does for its child-session rotation) meant an + archive_and_compact failure left every previously-persisted message + unstamped and the next append-only flush re-inserted them all as + duplicate active rows. + """ + from agent.context_compressor import _DB_PERSISTED_MARKER + + cc = _compressor() + messages = _conversation(exchanges=8) + for m in messages: + m[_DB_PERSISTED_MARKER] = True + + # No DB bound -> _sync_micro_compact_to_db no-ops (the failure shape). + result = cc._micro_compact(messages) + + unstamped = [ + m for m in result + if not m.get(_DB_PERSISTED_MARKER) + and not m.get(COMPRESSED_SUMMARY_METADATA_KEY) + ] + assert not unstamped, ( + "splice must not strip _db_persisted from surviving messages" + ) From b24c915168397e526fab2e810fe303e4bbfb05ed Mon Sep 17 00:00:00 2001 From: KCAYAAI Date: Thu, 23 Jul 2026 18:28:53 +0000 Subject: [PATCH 50/55] fix(slack): trust adapter routing after stripping self mention --- plugins/platforms/slack/adapter.py | 15 +++++++++----- tests/gateway/test_slack.py | 32 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index a34c995cc8..62a5fdcd5f 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -3936,11 +3936,16 @@ class SlackAdapter(BasePlatformAdapter): return "" return ( f"You are connected to this Slack workspace as the bot " - f'"@{name}". In messages, each line is prefixed with the sender\'s ' - f"name, and mentions are shown as @DisplayName. Only treat a " - f'message as directed at you when it mentions "@{name}" ' - f"specifically; a mention of any other participant is not a " - f"mention of you, even if their name is similar." + f'"@{name}". The Slack adapter already applied authorization, mention, ' + f"and channel-routing rules before delivering this message. Treat every " + f"delivered turn as intentionally routed to you, including a message " + f"accepted from a free-response channel or active thread. Your own routing " + f'mention "@{name}" may have been removed from the model-visible text. ' + f"Do not ask for another mention, reject the message, or stay silent solely " + f'because "@{name}" is absent. In messages, each line is prefixed with the ' + f"sender's name, and visible mentions are shown as @DisplayName; a mention " + f"of any other participant is not a mention of you, even if their name is " + f"similar." ) async def _resolve_user_is_bot( diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index fca22ee1c5..15a0faf5eb 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -1864,6 +1864,38 @@ class TestMessageRouting: assert msg_event.text == "what's the weather?" assert "<@U_BOT>" not in msg_event.text + @pytest.mark.asyncio + async def test_accepted_mention_prompt_trusts_adapter_routing(self, adapter): + """Cleaned text must not make the model revalidate an accepted mention.""" + adapter.config.extra.update({"require_mention": True, "strict_mention": True}) + adapter._bot_display_name = "TestBot" + adapter._team_bot_names = {"T123": "WorkspaceBot"} + event = { + "text": "<@U_BOT> Hi", + "user": "U_USER", + "channel": "C123", + "channel_type": "channel", + "team": "T123", + "ts": "1234567890.000001", + } + + await adapter._handle_slack_message(event) + + adapter.handle_message.assert_awaited_once() + msg_event = adapter.handle_message.await_args.args[0] + prompt = msg_event.channel_prompt + assert msg_event.text == "Hi" + assert "@WorkspaceBot" in prompt + assert "already applied" in prompt + assert "may have been removed" in prompt + assert "Do not ask for another mention" in prompt + assert "free-response channel or active thread" in prompt + assert "not a mention of you" in prompt + assert "Only treat a message as directed" not in prompt + + @pytest.mark.asyncio + + @pytest.mark.asyncio async def test_allow_bots_mentions_ignores_bot_user_without_current_mention( From 1789e06ed8b1c68b6ebfb2fcd472534aedcff999 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:45:40 +0530 Subject: [PATCH 51/55] =?UTF-8?q?fix:=20trim=20identity=20prompt=20?= =?UTF-8?q?=E2=80=94=20remove=20jargon,=20tighten=20directive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #70238. Remove 'free-response channel' and 'authorization' jargon from the model-facing prompt. Collapse the triple-negative 'do not ask / do not reject / do not stay silent' into a single directive. ~70 tokens vs ~175 in the contributor's version, same semantics. --- plugins/platforms/slack/adapter.py | 18 ++++++++---------- tests/gateway/test_slack.py | 6 +++--- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 62a5fdcd5f..736598dba8 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -3936,16 +3936,14 @@ class SlackAdapter(BasePlatformAdapter): return "" return ( f"You are connected to this Slack workspace as the bot " - f'"@{name}". The Slack adapter already applied authorization, mention, ' - f"and channel-routing rules before delivering this message. Treat every " - f"delivered turn as intentionally routed to you, including a message " - f"accepted from a free-response channel or active thread. Your own routing " - f'mention "@{name}" may have been removed from the model-visible text. ' - f"Do not ask for another mention, reject the message, or stay silent solely " - f'because "@{name}" is absent. In messages, each line is prefixed with the ' - f"sender's name, and visible mentions are shown as @DisplayName; a mention " - f"of any other participant is not a mention of you, even if their name is " - f"similar." + f'"@{name}". The adapter already applied mention and channel ' + f"routing; treat every delivered turn as intentionally routed to " + f'you. Your routing mention "@{name}" may have been stripped from ' + f'the visible text — do not reject or ignore a message solely ' + f'because "@{name}" is absent. In messages, each line is prefixed ' + f"with the sender's name, and visible mentions are shown as " + f"@DisplayName; a mention of any other participant is not a " + f"mention of you, even if their name is similar." ) async def _resolve_user_is_bot( diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 15a0faf5eb..d9acaefaf6 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -1887,9 +1887,9 @@ class TestMessageRouting: assert msg_event.text == "Hi" assert "@WorkspaceBot" in prompt assert "already applied" in prompt - assert "may have been removed" in prompt - assert "Do not ask for another mention" in prompt - assert "free-response channel or active thread" in prompt + assert "may have been stripped" in prompt + assert "do not reject or ignore" in prompt + assert "intentionally routed" in prompt assert "not a mention of you" in prompt assert "Only treat a message as directed" not in prompt From c31c27e03a0f61eccb003c714aa8c59e809d44bd Mon Sep 17 00:00:00 2001 From: Carbon Date: Thu, 30 Jul 2026 04:06:11 +0300 Subject: [PATCH 52/55] fix(desktop): preserve voice stop across speech setup --- .../use-voice-conversation-rearm.test.tsx | 258 ++++++++++++++++++ .../composer/hooks/use-voice-conversation.ts | 41 ++- 2 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-voice-conversation-rearm.test.tsx diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation-rearm.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation-rearm.test.tsx new file mode 100644 index 0000000000..e993abc573 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation-rearm.test.tsx @@ -0,0 +1,258 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { $voicePlayback } from '@/store/voice-playback' + +import { useVoiceConversation } from './use-voice-conversation' + +const mocks = vi.hoisted(() => { + let deferStreamStart = false + let onSilence: null | (() => void) = null + let resolveStreamStart: null | (() => void) = null + let resolveSpeech: null | ((outcome: 'done' | 'fallback') => void) = null + let streamAvailable = true + + const stopVoicePlayback = vi.fn(() => { + const current = $voicePlayback.get() + $voicePlayback.set({ ...current, sequence: current.sequence + 1, status: 'idle' }) + }) + + const playSpeechText = vi.fn(() => { + stopVoicePlayback() + + return Promise.resolve(true) + }) + + const handle = { + cancel: vi.fn(), + start: vi.fn(async (options: { onSilence: () => void }) => { + onSilence = options.onSilence + }), + stop: vi.fn(async () => ({ + audio: new Blob(['voice'], { type: 'audio/webm' }), + heardSpeech: true + })) + } + + return { + continueStreamStart() { + resolveStreamStart?.() + resolveStreamStart = null + }, + deferStreamStart() { + deferStreamStart = true + }, + finishSpeech(outcome: 'done' | 'fallback') { + resolveSpeech?.(outcome) + }, + handle, + playSpeechText, + resetSpeechMocks() { + deferStreamStart = false + resolveStreamStart = null + resolveSpeech = null + streamAvailable = true + }, + startSpeechStream: vi.fn(async () => { + if (deferStreamStart) { + await new Promise(resolve => { + resolveStreamStart = resolve + }) + } + + if (!streamAvailable) { + return null + } + + const current = $voicePlayback.get() + $voicePlayback.set({ ...current, sequence: current.sequence + 1, status: 'preparing' }) + + return { + append: vi.fn(), + done: new Promise<'done' | 'fallback'>(resolve => { + resolveSpeech = resolve + }), + finish: vi.fn() + } + }), + stopVoicePlayback, + triggerSilence() { + onSilence?.() + }, + useFallbackSpeech() { + streamAvailable = false + } + } +}) + +vi.mock('./use-mic-recorder', () => ({ + useMicRecorder: () => ({ handle: mocks.handle, level: 0 }) +})) + +vi.mock('@/lib/voice-barge-in', () => ({ + monitorSpeechDuringPlayback: () => vi.fn() +})) + +vi.mock('@/lib/voice-playback', () => ({ + markVoicePlaybackInterrupted: vi.fn(), + playSpeechText: mocks.playSpeechText, + startSpeechStream: mocks.startSpeechStream, + stopVoicePlayback: mocks.stopVoicePlayback +})) + +vi.mock('@/lib/thinking-sound', () => ({ + startThinkingSound: vi.fn(), + stopThinkingSound: vi.fn() +})) + +vi.mock('@/store/notifications', () => ({ + notify: vi.fn(), + notifyError: vi.fn() +})) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + notifications: { + voice: { + configureSpeechToText: '', + couldNotStartSession: '', + microphoneFailed: '', + playbackFailed: '', + transcriptionFailed: '', + unavailable: '' + } + } + } + }) +})) + +function renderRearmConversation(responseId: string, responseText: string) { + let response: null | { id: string; pending: boolean; text: string } = null + + return renderHook( + ({ enabled }) => + useVoiceConversation({ + busy: false, + consumePendingResponse: vi.fn(), + enabled, + onSubmit: async () => { + response = { id: responseId, pending: false, text: responseText } + }, + onTranscribeAudio: async () => 'Hello', + pendingResponse: () => response + }), + { initialProps: { enabled: false } } + ) +} + +async function beginReply(hook: ReturnType) { + hook.rerender({ enabled: true }) + await waitFor(() => expect(mocks.handle.start).toHaveBeenCalledTimes(1)) + + await act(async () => { + mocks.triggerSilence() + }) +} + +describe('useVoiceConversation playback rearm', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + mocks.resetSpeechMocks() + $voicePlayback.set({ + audioElement: null, + messageId: null, + sequence: 0, + source: null, + status: 'idle' + }) + }) + + it('re-arms the microphone after normal streaming playback completes', async () => { + $voicePlayback.set({ + audioElement: null, + messageId: null, + sequence: 7, + source: null, + status: 'idle' + }) + const hook = renderRearmConversation('reply-1', 'Hello back') + + await beginReply(hook) + await waitFor(() => expect(mocks.startSpeechStream).toHaveBeenCalled()) + expect($voicePlayback.get().sequence).toBeGreaterThan(7) + + await act(async () => { + mocks.finishSpeech('done') + }) + + await waitFor(() => expect(mocks.handle.start).toHaveBeenCalledTimes(2)) + expect(hook.result.current.status).toBe('listening') + }) + + it('honors Stop while streaming playback is still preparing', async () => { + mocks.deferStreamStart() + const hook = renderRearmConversation('reply-preparing', 'Do not play this') + + await beginReply(hook) + await waitFor(() => expect(mocks.startSpeechStream).toHaveBeenCalled()) + + mocks.stopVoicePlayback() + await act(async () => { + mocks.continueStreamStart() + }) + + await waitFor(() => expect(hook.result.current.status).toBe('idle')) + expect(mocks.stopVoicePlayback).toHaveBeenCalledTimes(2) + expect(mocks.handle.start).toHaveBeenCalledTimes(1) + }) + + it('does not start fallback playback after Stop during stream discovery', async () => { + mocks.deferStreamStart() + mocks.useFallbackSpeech() + const hook = renderRearmConversation('reply-no-stream', 'Do not fall back') + + await beginReply(hook) + await waitFor(() => expect(mocks.startSpeechStream).toHaveBeenCalled()) + + mocks.stopVoicePlayback() + await act(async () => { + mocks.continueStreamStart() + }) + + await waitFor(() => expect(hook.result.current.status).toBe('idle')) + expect(mocks.playSpeechText).not.toHaveBeenCalled() + expect(mocks.handle.start).toHaveBeenCalledTimes(1) + }) + + it('does not re-arm after an external Stop during streaming playback', async () => { + const hook = renderRearmConversation('reply-stopped', 'Playing now') + + await beginReply(hook) + await waitFor(() => expect(mocks.startSpeechStream).toHaveBeenCalled()) + + mocks.stopVoicePlayback() + await act(async () => { + mocks.finishSpeech('done') + }) + + await waitFor(() => expect(hook.result.current.status).toBe('idle')) + expect(mocks.handle.start).toHaveBeenCalledTimes(1) + }) + + it('re-arms the microphone after normal fallback playback completes', async () => { + mocks.useFallbackSpeech() + const hook = renderRearmConversation('reply-fallback', 'Fallback reply') + + await beginReply(hook) + + await waitFor(() => + expect(mocks.playSpeechText).toHaveBeenCalledWith('Fallback reply', { + source: 'voice-conversation' + }) + ) + await waitFor(() => expect(mocks.handle.start).toHaveBeenCalledTimes(2)) + expect(hook.result.current.status).toBe('listening') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 94fb912ff1..5f4be4b637 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -262,7 +262,7 @@ export function useVoiceConversation({ }, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed]) const settleAfterSpeech = useCallback( - (barged: boolean) => { + (barged: boolean, stoppedDuringSetup = false) => { if (barged || !awaitingSpokenResponseRef.current) { awaitingSpokenResponseRef.current = false consumePendingResponse() @@ -286,7 +286,9 @@ export function useVoiceConversation({ // voice-playback sequence has advanced past what we captured at speech // start — don't auto-start the next sentence, the user chose to stop. const stoppedByUser = - speechStartSequenceRef.current > 0 && $voicePlayback.get().sequence > speechStartSequenceRef.current + stoppedDuringSetup || + (speechStartSequenceRef.current > 0 && + $voicePlayback.get().sequence > speechStartSequenceRef.current) speechStartSequenceRef.current = 0 @@ -458,9 +460,13 @@ export function useVoiceConversation({ // this is a safety net for read-aloud-style entries into the loop. ensureBargeMonitor() + const playback = playSpeechText(response.text, { source: 'voice-conversation' }) + // playSpeechText performs its normal cleanup synchronously before + // returning. Capture the sequence after that internal increment so + // only a later, external stop suppresses the next listen cycle. speechStartSequenceRef.current = $voicePlayback.get().sequence - void playSpeechText(response.text, { source: 'voice-conversation' }) + void playback .catch(error => notifyError(error, voiceCopy.playbackFailed)) .finally(() => { if (responseIdRef.current === responseId) { @@ -482,9 +488,10 @@ export function useVoiceConversation({ */ const openLiveSpeech = useCallback( (responseId: string) => { + const sequenceBeforeStart = $voicePlayback.get().sequence + responseIdRef.current = responseId spokenSourceLengthRef.current = 0 - speechStartSequenceRef.current = $voicePlayback.get().sequence setStatus('speaking') // VAD barge-in: the user talking over the reply cuts playback, drops @@ -506,6 +513,16 @@ export function useVoiceConversation({ } if (!session) { + // Stream discovery can also fail after an explicit Stop landed + // during its async URL lookup. In that case, do not turn the stopped + // live attempt into fresh fallback playback. + if ($voicePlayback.get().sequence > sequenceBeforeStart) { + awaitingSpokenResponseRef.current = false + settleAfterSpeech(false, true) + + return + } + // No streaming backend/provider: speak the whole reply once it lands. speechSessionRef.current = null awaitFallbackSpeech(responseId) @@ -513,8 +530,24 @@ export function useVoiceConversation({ return } + // startSpeechStream calls stopVoicePlayback once after its async URL + // lookup. A second sequence bump means the user pressed Stop while + // setup was still pending. Do not absorb that explicit stop into the + // post-start baseline or allow the new session to play. + const sequenceAfterStart = $voicePlayback.get().sequence + const stoppedDuringStart = sequenceAfterStart > sequenceBeforeStart + 1 + + speechStartSequenceRef.current = sequenceAfterStart speechSessionRef.current = session + if (stoppedDuringStart) { + stopVoicePlayback() + awaitingSpokenResponseRef.current = false + settleAfterSpeech(false, true) + + return + } + // Timer-driven feed: reply text flows into the session at delta rate // regardless of React render cadence. const feedTimer = window.setInterval(() => feedSpeechSession(responseId), 150) From 490f7048dddb3858532464cf950be0e3b9cb68ee Mon Sep 17 00:00:00 2001 From: xxxigm Date: Thu, 30 Jul 2026 17:18:06 +0700 Subject: [PATCH 53/55] feat(dashboard): add resume loading overlay helpers Extract overlay visibility helpers so the chat resume wait notice can be tested without mounting ChatPage. --- web/src/lib/pty-resume-loading.test.ts | 69 ++++++++++++++++++++++++++ web/src/lib/pty-resume-loading.ts | 47 ++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 web/src/lib/pty-resume-loading.test.ts create mode 100644 web/src/lib/pty-resume-loading.ts diff --git a/web/src/lib/pty-resume-loading.test.ts b/web/src/lib/pty-resume-loading.test.ts new file mode 100644 index 0000000000..09a2c16a19 --- /dev/null +++ b/web/src/lib/pty-resume-loading.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { + PTY_RESUME_LOADING_MAX_MS, + shouldFinishResumeHydrationOnChunk, + shouldShowResumeLoadingOverlay, +} from "./pty-resume-loading"; + +describe("shouldFinishResumeHydrationOnChunk", () => { + it("finishes on the first non-empty chunk", () => { + expect(shouldFinishResumeHydrationOnChunk("")).toBe(false); + expect(shouldFinishResumeHydrationOnChunk("hello")).toBe(true); + }); + + it("keeps a positive hard-cap timeout for wedged resumes", () => { + expect(PTY_RESUME_LOADING_MAX_MS).toBeGreaterThan(0); + }); +}); + +describe("shouldShowResumeLoadingOverlay", () => { + it("shows while a resume target is connecting or open and still hydrating", () => { + expect( + shouldShowResumeLoadingOverlay({ + hasResumeTarget: true, + ptyState: "connecting", + hydrating: true, + }), + ).toBe(true); + expect( + shouldShowResumeLoadingOverlay({ + hasResumeTarget: true, + ptyState: "open", + hydrating: true, + }), + ).toBe(true); + }); + + it("hides when there is no resume target", () => { + expect( + shouldShowResumeLoadingOverlay({ + hasResumeTarget: false, + ptyState: "connecting", + hydrating: true, + }), + ).toBe(false); + }); + + it("hides once hydration finishes", () => { + expect( + shouldShowResumeLoadingOverlay({ + hasResumeTarget: true, + ptyState: "open", + hydrating: false, + }), + ).toBe(false); + }); + + it("defers to reconnect / closed / ended overlays", () => { + for (const ptyState of ["reconnecting", "closed", "ended"] as const) { + expect( + shouldShowResumeLoadingOverlay({ + hasResumeTarget: true, + ptyState, + hydrating: true, + }), + ).toBe(false); + } + }); +}); diff --git a/web/src/lib/pty-resume-loading.ts b/web/src/lib/pty-resume-loading.ts new file mode 100644 index 0000000000..9a150664d5 --- /dev/null +++ b/web/src/lib/pty-resume-loading.ts @@ -0,0 +1,47 @@ +import type { PtyConnectionState } from "@/lib/pty-reconnect"; + +/** + * Hard cap so a wedged resume (never gets PTY payload) cannot leave the + * wait notice up forever. + */ +export const PTY_RESUME_LOADING_MAX_MS = 30000; + +export const PTY_RESUME_LOADING_MESSAGE = + "Please wait while the conversation loads…"; + +export interface ResumeLoadingOverlayInput { + hasResumeTarget: boolean; + ptyState: PtyConnectionState; + hydrating: boolean; +} + +/** + * Show a wait notice only while a resumed chat is still blank. Once the + * first real PTY payload arrives the terminal has something to show, so + * the notice hides and history can stream in underneath. + * + * Reconnect / ended / closed states keep their own overlays and must not + * stack this one on top. + */ +export function shouldShowResumeLoadingOverlay({ + hasResumeTarget, + ptyState, + hydrating, +}: ResumeLoadingOverlayInput): boolean { + if (!hasResumeTarget || !hydrating) { + return false; + } + if ( + ptyState === "reconnecting" || + ptyState === "closed" || + ptyState === "ended" + ) { + return false; + } + return ptyState === "connecting" || ptyState === "open"; +} + +/** First non-empty PTY chunk means the blank window is over. */ +export function shouldFinishResumeHydrationOnChunk(chunkText: string): boolean { + return chunkText.length > 0; +} From bb189bf6fea542a69e5942e983488432acf95949 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Thu, 30 Jul 2026 17:18:06 +0700 Subject: [PATCH 54/55] feat(dashboard): show wait notice while resumed chat history loads Cover the blank TUI + blinking-cursor window on session resume, then hide the notice as soon as the first real PTY payload arrives so history can stream in visibly. --- web/src/pages/ChatPage.tsx | 61 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 081cccc0c4..fd18e6133a 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -47,6 +47,12 @@ import { shouldBlockPtyInput, shouldReconnectPtyOnPageResume, } from "@/lib/pty-reconnect"; +import { + PTY_RESUME_LOADING_MAX_MS, + PTY_RESUME_LOADING_MESSAGE, + shouldFinishResumeHydrationOnChunk, + shouldShowResumeLoadingOverlay, +} from "@/lib/pty-resume-loading"; import { MOBILE_REPLACEMENT_WINDOW_MS, normalizePtyMobileInput, @@ -204,6 +210,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const [ptyState, setPtyState] = useState("connecting"); const ptyStateRef = useRef("connecting"); + // True until the first real PTY payload arrives for a resumed session. + // Covers the blank terminal + blinking-cursor window so users don't think + // chat is broken; clears as soon as there is something to show. + const [resumeHydrating, setResumeHydrating] = useState(false); const [lastCloseCode, setLastCloseCode] = useState(null); // NS-504: when the agent process exits cleanly (the user typed `/exit`, or // started a new session that ended the current PTY child), the PTY socket @@ -892,12 +902,42 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { let onDataDisposable: { dispose(): void } | null = null; let onResizeDisposable: { dispose(): void } | null = null; let eraseSuppressionTimer: ReturnType | null = null; + let resumeMaxTimer: ReturnType | null = null; const clearEraseSuppressionTimer = () => { if (eraseSuppressionTimer) { clearTimeout(eraseSuppressionTimer); eraseSuppressionTimer = null; } }; + const clearResumeLoadingTimers = () => { + if (resumeMaxTimer) { + clearTimeout(resumeMaxTimer); + resumeMaxTimer = null; + } + }; + const finishResumeHydration = () => { + clearResumeLoadingTimers(); + if (!unmounting) { + setResumeHydrating(false); + } + }; + const noteResumePtyChunk = (chunkText: string) => { + if (!resumeParam || unmounting) { + return; + } + if (shouldFinishResumeHydrationOnChunk(chunkText)) { + finishResumeHydration(); + } + }; + if (resumeParam) { + setResumeHydrating(true); + resumeMaxTimer = setTimeout( + finishResumeHydration, + PTY_RESUME_LOADING_MAX_MS, + ); + } else { + setResumeHydrating(false); + } const forceFresh = forceFreshPtyRef.current; forceFreshPtyRef.current = false; // A connect attempt is now in flight — set synchronously (before the async @@ -1019,6 +1059,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { stream: true, }); term.write(resumeParam ? sanitizer.next(text) : text); + noteResumePtyChunk(text); }; ws.onclose = (ev) => { @@ -1180,6 +1221,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { imageUploadDisposed = true; syncMetricsRef.current = null; clearEraseSuppressionTimer(); + clearResumeLoadingTimers(); + setResumeHydrating(false); onDataDisposable?.dispose(); onResizeDisposable?.dispose(); mobileInputCleanup?.(); @@ -1354,6 +1397,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const visibleBanner = banner ?? reconnectBanner; const showReconnectOverlay = ptyState === "reconnecting" || (ptyState === "closed" && !banner); + const showResumeLoadingOverlay = shouldShowResumeLoadingOverlay({ + hasResumeTarget: Boolean(resumeParam), + ptyState, + hydrating: resumeHydrating, + }); const mobileModelToolsPortal = isActive && narrow && @@ -1488,6 +1536,19 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
)} + {showResumeLoadingOverlay && ( +
+
+ {PTY_RESUME_LOADING_MESSAGE} +
+
+ )} + {/* NS-504: the agent process exited (e.g. `/exit` or a new session). Offer an in-place restart so the user never has to refresh the whole page to get a working chat back. */} From 539e9b5c1b7a6fce1ea80a4f8ce5a9178472a3bf Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Fri, 31 Jul 2026 09:28:55 -0400 Subject: [PATCH 55/55] fix(dashboard): gate resume hydration on sanitized PTY payload The resume wait notice cleared on the first nonempty raw PTY frame, but the terminal is written sanitizer.next(text). The sanitizer collapses an erase-only, all-newline, or partial-CSI resume frame to "", so a control- only first frame hid the notice while xterm was still blank. Gate hydration completion on the rendered payload actually written to the terminal, and cover the control-only-first-frame case with a regression test over the real sanitizer. Co-authored-by: teknium1 --- web/src/lib/pty-resume-loading.test.ts | 35 ++++++++++++++++++++++++++ web/src/pages/ChatPage.tsx | 9 +++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/web/src/lib/pty-resume-loading.test.ts b/web/src/lib/pty-resume-loading.test.ts index 09a2c16a19..72f6e5f779 100644 --- a/web/src/lib/pty-resume-loading.test.ts +++ b/web/src/lib/pty-resume-loading.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { PtyResumeSanitizer } from "./pty-resume-sanitizer"; import { PTY_RESUME_LOADING_MAX_MS, shouldFinishResumeHydrationOnChunk, @@ -17,6 +18,40 @@ describe("shouldFinishResumeHydrationOnChunk", () => { }); }); +describe("resume hydration gate over the real sanitizer", () => { + // Regression: the gate must key off the payload actually written to xterm, + // not the raw frame. The sanitizer collapses an erase-only / all-newline / + // partial-CSI resume frame to "", so a nonempty raw first frame would + // otherwise clear the wait notice while the terminal is still blank. + const ESC = String.fromCharCode(27); + const CRLF = String.fromCharCode(13, 10); + const VISIBLE = `Hello world${CRLF}`; + + const controlOnlyFirstFrames: Array<[string, string]> = [ + ["erase-only", `${ESC}[2K`], + ["all-newline", `${CRLF}${CRLF}`], + ["partial-CSI", `${ESC}[`], + ]; + + it.each(controlOnlyFirstFrames)( + "keeps hydrating on a %s first frame, then finishes on visible replay", + (_label, firstFrame) => { + const sanitizer = new PtyResumeSanitizer(); + + // Raw first frame is nonempty, but nothing is written to xterm... + expect(firstFrame.length).toBeGreaterThan(0); + const firstRendered = sanitizer.next(firstFrame); + expect(firstRendered).toBe(""); + expect(shouldFinishResumeHydrationOnChunk(firstRendered)).toBe(false); + + // ...so the notice only clears once real replay output arrives. + const secondRendered = sanitizer.next(VISIBLE); + expect(secondRendered.length).toBeGreaterThan(0); + expect(shouldFinishResumeHydrationOnChunk(secondRendered)).toBe(true); + }, + ); +}); + describe("shouldShowResumeLoadingOverlay", () => { it("shows while a resume target is connecting or open and still hydrating", () => { expect( diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index fd18e6133a..529f1d69f9 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -1058,8 +1058,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { : decoder.decode(new Uint8Array(ev.data as ArrayBuffer), { stream: true, }); - term.write(resumeParam ? sanitizer.next(text) : text); - noteResumePtyChunk(text); + // Gate hydration on the payload actually written to xterm. The + // sanitizer can turn a nonempty erase-only / all-newline / partial-CSI + // resume frame into "" (pty-resume-sanitizer.ts); keying off raw `text` + // would hide the wait notice while the terminal is still blank. + const rendered = resumeParam ? sanitizer.next(text) : text; + term.write(rendered); + noteResumePtyChunk(rendered); }; ws.onclose = (ev) => {