From 6e676c768c64b69fdc1f9a390b64737061db20b1 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Sun, 19 Jul 2026 05:55:30 -0700 Subject: [PATCH 001/205] fix(desktop): profile-scope all cron REST calls Salvaged from #49948 by @helix4u: every desktop cron API call (list/get/runs/create/update/pause/resume/trigger/delete) now carries profileScoped(), so global-remote mode routes the request to the profile the UI is acting for instead of silently hitting the primary backend's default profile. --- apps/desktop/src/hermes.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index f86843cd3b54..c91c015da5bf 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -958,6 +958,7 @@ export function testMessagingPlatform(platformId: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/cron/jobs', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) @@ -965,12 +966,14 @@ export function getCronJobs(): Promise { export function getCronJob(jobId: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}` }) } export async function getCronJobRuns(jobId: string, limit = 20): Promise { const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}` }) @@ -979,6 +982,7 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/cron/jobs', method: 'POST', body @@ -987,6 +991,7 @@ export function createCronJob(body: CronJobCreatePayload): Promise { export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, method: 'PUT', body: { updates } @@ -995,6 +1000,7 @@ export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`, method: 'POST' }) @@ -1002,6 +1008,7 @@ export function pauseCronJob(jobId: string): Promise { export function resumeCronJob(jobId: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`, method: 'POST' }) @@ -1009,6 +1016,7 @@ export function resumeCronJob(jobId: string): Promise { export function triggerCronJob(jobId: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`, method: 'POST' }) @@ -1016,6 +1024,7 @@ export function triggerCronJob(jobId: string): Promise { export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ + ...profileScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, method: 'DELETE' }) From 786df3ca6cf3fa063391491caa63d7ffad17e286 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 19 Jul 2026 05:55:30 -0700 Subject: [PATCH 002/205] fix(cron): resolve provider with the job's effective model; default dashboard cron creates to the backend's own profile Two follow-ups to the per-job model pin surface (#67472 / #49948 review): - cron/scheduler.py: pass target_model= to resolve_runtime_provider() on the primary path, so providers with model-specific api_mode routing derive the mode from the model the job actually runs (per-job pin > env > config default) instead of the stale persisted default. The auth-fallback path already did this for its fb_model. - hermes_cli/web_server.py: POST /api/cron/jobs (and its sync worker) no longer hardcodes profile="default" when the request carries no profile param. A pool backend scoped to a named profile now resolves its own profile via get_active_profile_name(), so pre-profileScoped desktop clients can't write a named profile's job into ~/.hermes. Unscoped / custom HERMES_HOME keeps the legacy default fallback. Tests: target_model capture test on run_job; two profile-default tests on the create endpoint. --- cron/scheduler.py | 5 ++ hermes_cli/web_server.py | 26 ++++++++-- tests/cron/test_cron_provider_pin.py | 39 +++++++++++++++ .../test_web_server_cron_profiles.py | 50 +++++++++++++++++++ 4 files changed, 117 insertions(+), 3 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 26b7eb517125..c5957070021e 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -3178,6 +3178,11 @@ def run_job( # example DeepSeek) for cron jobs that do not pin provider/model. runtime_kwargs = { "requested": job.get("provider"), + # Derive provider-specific api_mode from the model this job + # will actually run (per-job pin > env > config default), not + # the stale persisted default — mirrors the fallback path + # below, which already passes its fb_model. + "target_model": model, } if job.get("base_url"): runtime_kwargs["explicit_base_url"] = job.get("base_url") diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 1d4abc7f896e..4ddb16487fda 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -11096,11 +11096,31 @@ def _cron_profile_dicts() -> List[Dict[str, Any]]: return _fallback_profile_dicts(profiles_mod) +def _cron_default_profile() -> str: + """Profile to target when a cron request carries no explicit ``profile``. + + A desktop pool backend runs one process per profile (HERMES_HOME already + scoped), but these cron endpoints deliberately route storage through the + profiles tree via ``_cron_profile_home`` — so a hardcoded ``"default"`` + fallback would write a non-default profile's job into ``~/.hermes``. + Resolve the process's own profile instead. ``custom`` (an unrecognized + HERMES_HOME outside the profiles tree) has no profile-dir equivalent, so + it keeps the legacy ``default`` fallback. + """ + try: + from hermes_cli.profiles import get_active_profile_name + + name = get_active_profile_name() + except Exception: + return "default" + return "default" if name in ("default", "custom") else name + + def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]: """Resolve a profile query value to (profile_name, HERMES_HOME).""" from hermes_cli import profiles as profiles_mod - raw = (profile or "default").strip() or "default" + raw = (profile or _cron_default_profile()).strip() or "default" try: canon = profiles_mod.normalize_profile_name(raw) profiles_mod.validate_profile_name(canon) @@ -11256,7 +11276,7 @@ async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit: return await _run_cron_dashboard_io(_list_cron_job_runs_sync, job_id, profile, limit) -def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"): +def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None): try: profile_name, profile_home = _cron_profile_home(profile) script = _normalize_dashboard_cron_script(body.script, profile_home) @@ -11295,7 +11315,7 @@ def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"): @app.post("/api/cron/jobs") -async def create_cron_job(body: CronJobCreate, profile: str = "default"): +async def create_cron_job(body: CronJobCreate, profile: Optional[str] = None): return await _run_cron_dashboard_io(_create_cron_job_sync, body, profile) diff --git a/tests/cron/test_cron_provider_pin.py b/tests/cron/test_cron_provider_pin.py index 23fa89ddde17..23a42ce99b14 100644 --- a/tests/cron/test_cron_provider_pin.py +++ b/tests/cron/test_cron_provider_pin.py @@ -334,3 +334,42 @@ class TestModelDriftGuard: ) assert agent_constructed is True assert success is True + + +class TestRuntimeResolutionTargetModel: + """run_job must resolve the primary provider against the model the job + will actually run (per-job pin > env > config default), so providers with + model-specific api_mode routing (e.g. OpenCode Zen/Go) pick the mode for + the pinned model instead of the stale persisted default.""" + + def test_primary_resolution_passes_effective_model(self, tmp_path): + job = _base_job(model="my-pinned-model", provider="openrouter") + captured = {} + + def _capture(**kwargs): + captured.update(kwargs) + return { + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + } + + fake_db = MagicMock() + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + side_effect=_capture, + ), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + run_job(job) + + assert captured.get("target_model") == "my-pinned-model" + assert captured.get("requested") == "openrouter" diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py index abcdbb681fa4..2c5d8bfc71ec 100644 --- a/tests/hermes_cli/test_web_server_cron_profiles.py +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -735,3 +735,53 @@ async def test_cron_profile_validation_errors(isolated_profiles): with pytest.raises(HTTPException) as missing: await web_server.list_cron_jobs(profile="missing_profile") assert missing.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_cron_job_without_profile_uses_backend_own_profile( + isolated_profiles, monkeypatch +): + """A pool backend scoped to a named profile must not default creates to + ``~/.hermes`` when the request carries no explicit ``profile`` (the + Desktop app's pre-profileScoped clients sent none).""" + from hermes_cli import web_server + + monkeypatch.setenv( + "HERMES_HOME", str(isolated_profiles["worker_alpha"]) + ) + + job = await web_server.create_cron_job( + web_server.CronJobCreate( + prompt="runs in my own profile", + schedule="every 1h", + name="own-profile-job", + ), + profile=None, + ) + + assert job["profile"] == "worker_alpha" + assert (isolated_profiles["worker_alpha"] / "cron" / "jobs.json").exists() + assert not (isolated_profiles["default"] / "cron" / "jobs.json").exists() + + +@pytest.mark.asyncio +async def test_create_cron_job_without_profile_defaults_when_unscoped( + isolated_profiles, monkeypatch +): + """HERMES_HOME at the default home (or unrecognized) keeps the legacy + ``default`` fallback.""" + from hermes_cli import web_server + + monkeypatch.setenv("HERMES_HOME", str(isolated_profiles["default"])) + + job = await web_server.create_cron_job( + web_server.CronJobCreate( + prompt="runs in default", + schedule="every 1h", + name="default-job", + ), + profile=None, + ) + + assert job["profile"] == "default" + assert (isolated_profiles["default"] / "cron" / "jobs.json").exists() From 65b73eb1e90e05c0931e3eac68f179662bc7ca63 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 19 Jul 2026 06:03:05 -0700 Subject: [PATCH 003/205] test(cron): accept target_model kwarg in codex-path resolver stub run_job now passes target_model to resolve_runtime_provider; the codex 401-refresh test stubbed it with a requested-only lambda. Widen to **kwargs like every other cron resolver stub. --- tests/cron/test_codex_execution_paths.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cron/test_codex_execution_paths.py b/tests/cron/test_codex_execution_paths.py index 5c3e5cf060b9..8513ce695a14 100644 --- a/tests/cron/test_codex_execution_paths.py +++ b/tests/cron/test_codex_execution_paths.py @@ -98,7 +98,7 @@ def test_cron_run_job_codex_path_handles_internal_401_refresh(monkeypatch): monkeypatch.setattr(run_agent, "AIAgent", _Codex401ThenSuccessAgent) monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", - lambda requested=None: { + lambda requested=None, **kwargs: { "provider": "openai-codex", "api_mode": "codex_responses", "base_url": "https://chatgpt.com/backend-api/codex", From 0385e155444d36203a6ef431f635223d78490d34 Mon Sep 17 00:00:00 2001 From: isfttr Date: Sun, 19 Jul 2026 09:59:15 -0700 Subject: [PATCH 004/205] =?UTF-8?q?test(desktop):=20contract=20test=20?= =?UTF-8?q?=E2=80=94=20every=20cron=20helper=20is=20profile-scoped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvaged from #59888 by @isfttr: the profileScoped() fix itself landed via #67493 (salvaged from the earlier #49948), but this PR contributed a contract test locking all 9 cron helpers to the active gateway profile — omitted when none is set (single-profile users unaffected), attached when one is active. Keeps the multi-profile/remote cron routing from silently regressing. --- apps/desktop/src/hermes-cron-scope.test.ts | 59 +++++++++++++++++++ .../emails/lucas.fernandes.df@gmail.com | 1 + 2 files changed, 60 insertions(+) create mode 100644 apps/desktop/src/hermes-cron-scope.test.ts create mode 100644 contributors/emails/lucas.fernandes.df@gmail.com diff --git a/apps/desktop/src/hermes-cron-scope.test.ts b/apps/desktop/src/hermes-cron-scope.test.ts new file mode 100644 index 000000000000..a9d13256f751 --- /dev/null +++ b/apps/desktop/src/hermes-cron-scope.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + createCronJob, + deleteCronJob, + getCronJob, + getCronJobRuns, + getCronJobs, + pauseCronJob, + resumeCronJob, + setApiRequestProfile, + triggerCronJob, + updateCronJob +} from './hermes' + +// Contract: every cron helper must carry the active gateway profile, so a +// multi-profile / remote user's cron list, runs, and mutations hit the backend +// they're actually on — not the primary/default. Without it, selecting a remote +// profile still showed the local primary's jobs (the "remote cron jobs don't +// show up" bug), the counterpart to the backend-action-helper fix in +// hermes-profile-scope.test.ts. +describe('cron helpers are profile-scoped', () => { + const api = vi.fn(async (_req: { path: string; profile?: string }) => ({}) as never) + + beforeEach(() => { + ;(window as { hermesDesktop?: unknown }).hermesDesktop = { api } + api.mockClear() + }) + + afterEach(() => { + setApiRequestProfile(null) + delete (window as { hermesDesktop?: unknown }).hermesDesktop + }) + + const lastProfile = () => api.mock.calls.at(-1)?.[0].profile + + it('omits profile when none is active (single-profile users unaffected)', () => { + void getCronJobs() + expect(lastProfile()).toBeUndefined() + }) + + it('forwards the active profile to every cron helper', () => { + setApiRequestProfile('coder') + + void getCronJobs() + void getCronJob('job-1') + void getCronJobRuns('job-1') + void createCronJob({ name: 'nightly', prompt: 'run', schedule: '0 3 * * *' } as never) + void updateCronJob('job-1', { enabled: false } as never) + void pauseCronJob('job-1') + void resumeCronJob('job-1') + void triggerCronJob('job-1') + void deleteCronJob('job-1') + + for (const call of api.mock.calls) { + expect(call[0].profile).toBe('coder') + } + }) +}) diff --git a/contributors/emails/lucas.fernandes.df@gmail.com b/contributors/emails/lucas.fernandes.df@gmail.com new file mode 100644 index 000000000000..516ed2c38c69 --- /dev/null +++ b/contributors/emails/lucas.fernandes.df@gmail.com @@ -0,0 +1 @@ +isfttr From 299e409f15aa5615a8a64be488580be92cda351e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:29:14 -0700 Subject: [PATCH 005/205] =?UTF-8?q?feat(delegation):=20live-viewable=20sub?= =?UTF-8?q?agent=20transcripts=20=E2=80=94=20tail=20your=20subagents=20whi?= =?UTF-8?q?le=20they=20work=20(#67479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(delegation): live-viewable subagent transcripts for delegate_task Each child now streams an append-only, human-readable log to /cache/delegation/live//task-.log while it runs, and the dispatch return includes the paths so the caller can tail them immediately instead of waiting blind for the consolidated summary. - New tools/delegation_live_log.py: LiveTranscriptWriter (per-event append + flush, one-line rendering with truncation, never raises into the agent loop), wrap_progress_callback (tees the child's existing tool_progress_callback events into the log, preserves the _flush contract), dispatch-time creation with pre-headered files so tail -f attaches immediately, manifest.json (goals/task count/per-task status), and 7-day retention pruning on new dispatches. - delegate_task: wraps each child's progress callback with the writer; sync results and background dispatch responses gain live_transcripts (+ hint field on dispatch); per-task result entries carry live_transcript; transcripts finalized with exit-reason markers. - async_delegation: dispatch_async_delegation_batch accepts an optional delegation_id so the live/ dir name matches the returned handle; the completion event carries live_transcripts. - process_registry: consolidated batch-completion block references each task's live transcript path. - Tool schema description documents the live_transcripts return surface; docs gain a 'Live Transcripts' section with a tail -f example. Placement under cache/delegation means the logs are mounted read-only into remote terminal backends for free. Side-channel only: zero changes to message content, so prompt caching is unaffected. Transcript-OUT only — no overlap with the subagent control surfaces of PR #66046. * fix(delegation): label the kickoff transcript line as user — it is the child's one user message --- tests/tools/test_delegation_live_log.py | 483 ++++++++++++++++++ tools/async_delegation.py | 7 +- tools/delegate_tool.py | 69 ++- tools/delegation_live_log.py | 385 ++++++++++++++ tools/process_registry.py | 5 + .../docs/user-guide/features/delegation.md | 16 + 6 files changed, 963 insertions(+), 2 deletions(-) create mode 100644 tests/tools/test_delegation_live_log.py create mode 100644 tools/delegation_live_log.py diff --git a/tests/tools/test_delegation_live_log.py b/tests/tools/test_delegation_live_log.py new file mode 100644 index 000000000000..e73370cb84b5 --- /dev/null +++ b/tests/tools/test_delegation_live_log.py @@ -0,0 +1,483 @@ +"""Tests for tools/delegation_live_log.py — live subagent transcripts. + +Covers: +- writer event rendering + truncation + append/flush semantics +- failure-swallowing when the target dir is unwritable +- the tool_progress_callback observe() demux (assistant/tool events in order) +- dispatch-time creation: paths pre-created with a header, manifest written +- retention pruning of stale live dirs +- delegate_task return-shape: live_transcripts in sync + background dispatch +""" + +import json +import os +import threading +import time +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from tools import delegation_live_log as dll +from tools.delegation_live_log import ( + LiveTranscriptWriter, + create_live_transcripts, + live_transcript_root, + prune_stale_live_dirs, + update_manifest_statuses, + wrap_progress_callback, +) + + +# --------------------------------------------------------------------------- +# Writer unit tests +# --------------------------------------------------------------------------- + + +def test_writer_precreates_file_with_header(): + w = LiveTranscriptWriter("deleg_test1", 0, "do the thing", context="some ctx") + assert w.path is not None and w.path.exists() + text = w.path.read_text(encoding="utf-8") + assert "Hermes subagent live transcript" in text + assert "delegation: deleg_test1" in text + assert "goal: do the thing" in text + assert "kickoff" in text + assert "some ctx" in text + # Lives under the hermes cache/delegation/live root, named task-.log + assert w.path.name == "task-0.log" + assert w.path.parent.name == "deleg_test1" + assert w.path.parent.parent == live_transcript_root() + + +def test_writer_event_lines_append_in_order_and_flush_immediately(): + w = LiveTranscriptWriter("deleg_order", 1, "goal") + w.assistant_text("I'll inspect the repo first.") + w.tool_start("terminal", "ls -la /tmp") + w.tool_result("terminal", result="file1\nfile2", duration=1.234, is_error=False) + w.thinking("hmm, next step") + # No close() needed: every event is flushed on write. + lines = w.path.read_text(encoding="utf-8").splitlines() + body = [ln for ln in lines if "|" in ln and not ln.startswith("=")] + joined = "\n".join(body) + assert "assistant" in joined and "I'll inspect the repo first." in joined + assert "-> terminal(ls -la /tmp)" in joined + assert "terminal ok 1.2s: file1 file2" in joined + assert "hmm, next step" in joined + # Ordering: assistant before tool before result before think + idx = {k: joined.index(k) for k in ("I'll inspect", "-> terminal", "terminal ok", "hmm,")} + assert idx["I'll inspect"] < idx["-> terminal"] < idx["terminal ok"] < idx["hmm,"] + + +def test_writer_truncates_long_text_with_elision_note(): + w = LiveTranscriptWriter("deleg_trunc", 0, "g") + w.assistant_text("x" * 5000) + w.tool_result("web_search", result="y" * 5000) + text = w.path.read_text(encoding="utf-8") + assert "…(+" in text # elision marker present + # No line carries the full 5000 chars + assert all(len(ln) < 1200 for ln in text.splitlines()) + + +def test_writer_collapses_newlines_to_single_line_events(): + w = LiveTranscriptWriter("deleg_nl", 0, "g") + before = len(w.path.read_text(encoding="utf-8").splitlines()) + w.assistant_text("line1\nline2\n\nline3") + after = w.path.read_text(encoding="utf-8").splitlines() + assert len(after) == before + 1 + assert "line1 line2 line3" in after[-1] + + +def test_writer_swallows_failures_when_dir_unwritable(tmp_path): + # Point the writer at a root that is actually a FILE — mkdir will fail. + bogus_root = tmp_path / "not-a-dir" + bogus_root.write_text("occupied") + w = LiveTranscriptWriter("deleg_fail", 0, "g", root=bogus_root) + assert w.path is None + # All writes must be silent no-ops. + w.assistant_text("hello") + w.tool_start("terminal", "ls") + w.marker("done") + w.observe("tool.completed", "terminal", result="x") + w.finalize({"status": "completed"}) + + +def test_writer_disables_itself_after_write_failure(): + w = LiveTranscriptWriter("deleg_disable", 0, "g") + # Delete the parent dir out from under it and make writing impossible by + # replacing the path with a directory. + p = w.path + p.unlink() + p.mkdir() + w.assistant_text("should not raise") + assert w._ok is False + w.assistant_text("still silent") # no raise on subsequent calls + + +def test_stream_deltas_buffer_and_flush_as_one_line(): + w = LiveTranscriptWriter("deleg_stream", 0, "g") + w.add_stream_delta("Hello ") + w.add_stream_delta("world, ") + w.add_stream_delta("streaming.") + # Not yet flushed + assert "Hello world" not in w.path.read_text(encoding="utf-8") + w.flush_stream() + text = w.path.read_text(encoding="utf-8") + assert "Hello world, streaming." in text + # tool_start also flushes pending stream text first + w.add_stream_delta("more text") + w.tool_start("read_file", "foo.py") + text = w.path.read_text(encoding="utf-8") + assert text.index("more text") < text.index("-> read_file") + + +# --------------------------------------------------------------------------- +# observe() demux — the tool_progress_callback seam +# --------------------------------------------------------------------------- + + +def test_observe_maps_child_callback_events_to_lines(): + w = LiveTranscriptWriter("deleg_observe", 0, "g") + w.observe("subagent.start", preview="kick off the goal") + w.observe("_thinking", "first line of thinking") + w.observe("reasoning.available", "_thinking", "deep reasoning text", None) + w.observe("tool.started", "terminal", "ls /tmp", {"command": "ls /tmp"}) + w.observe("tool.completed", "terminal", None, None, + duration=0.5, is_error=False, result="ok output") + w.observe("subagent.text", preview="final reply ") + w.observe("subagent.text", preview="streamed in parts") + w.observe("subagent.complete", preview="short", status="completed", + duration_seconds=3.2, summary="did the thing") + text = w.path.read_text(encoding="utf-8") + assert "kick off the goal" in text + assert "first line of thinking" in text + assert "deep reasoning text" in text + assert "-> terminal(ls /tmp)" in text + assert "terminal ok 0.5s: ok output" in text + assert "final reply streamed in parts" in text + assert "status=completed" in text + assert "did the thing" in text + + +def test_observe_marks_tool_errors(): + w = LiveTranscriptWriter("deleg_err", 0, "g") + w.observe("tool.completed", "web_search", None, None, + is_error=True, result="Error: boom") + assert "web_search ERROR" in w.path.read_text(encoding="utf-8") + + +def test_finalize_records_budget_exhaustion_and_errors(): + w = LiveTranscriptWriter("deleg_final", 0, "g") + w.finalize({"status": "failed", "exit_reason": "max_iterations", + "error": "Subagent did not produce a response."}) + text = w.path.read_text(encoding="utf-8") + assert "end status=failed" in text + assert "exit_reason=max_iterations" in text + assert "iteration budget exhausted" in text + assert "did not produce a response" in text + + +def test_wrap_progress_callback_tees_and_preserves_inner(): + w = LiveTranscriptWriter("deleg_wrap", 0, "g") + seen = [] + + def inner(event_type, tool_name=None, preview=None, args=None, **kw): + seen.append((event_type, tool_name)) + + inner_flushed = [] + inner._flush = lambda: inner_flushed.append(True) + + cb = wrap_progress_callback(inner, w) + cb("tool.started", "terminal", "echo hi", None) + cb("_thinking", "pondering") + assert seen == [("tool.started", "terminal"), ("_thinking", "pondering")] + text = w.path.read_text(encoding="utf-8") + assert "-> terminal(echo hi)" in text and "pondering" in text + # _flush contract preserved + cb._flush() + assert inner_flushed == [True] + + +def test_wrap_progress_callback_with_no_inner_still_records(): + w = LiveTranscriptWriter("deleg_noinner", 0, "g") + cb = wrap_progress_callback(None, w) + cb("tool.started", "read_file", "a.py", None) + cb._flush() # must not raise + assert "-> read_file(a.py)" in w.path.read_text(encoding="utf-8") + + +def test_wrap_progress_callback_writer_failure_does_not_block_inner(): + w = LiveTranscriptWriter("deleg_wfail", 0, "g") + w.observe = MagicMock(side_effect=RuntimeError("disk on fire")) + seen = [] + cb = wrap_progress_callback(lambda *a, **k: seen.append(a), w) + cb("tool.started", "terminal", "x", None) # must not raise + assert len(seen) == 1 + + +# --------------------------------------------------------------------------- +# Dispatch-time creation + manifest + retention +# --------------------------------------------------------------------------- + + +def test_create_live_transcripts_precreates_paths_and_manifest(): + tasks = [{"goal": "task A"}, {"goal": "task B", "context": "ctx B"}] + deleg_id, writers, paths = create_live_transcripts(tasks, context="shared ctx") + assert deleg_id and deleg_id.startswith("deleg_") + assert len(writers) == 2 and all(w is not None for w in writers) + assert len(paths) == 2 + for i, p in enumerate(paths): + assert os.path.isabs(p) + assert p.endswith(f"task-{i}.log") + assert Path(p).exists() # tail -f works immediately + manifest = json.loads( + (live_transcript_root() / deleg_id / "manifest.json").read_text() + ) + assert manifest["task_count"] == 2 + assert manifest["tasks"][0]["goal"] == "task A" + assert manifest["tasks"][0]["status"] == "running" + assert manifest["tasks"][1]["log"] == paths[1] + # Per-task context beats shared context in the kickoff line. + assert "ctx B" in Path(paths[1]).read_text(encoding="utf-8") + + +def test_update_manifest_statuses(): + tasks = [{"goal": "a"}, {"goal": "b"}] + deleg_id, _writers, _paths = create_live_transcripts(tasks) + update_manifest_statuses(deleg_id, [ + {"task_index": 0, "status": "completed", "exit_reason": "completed"}, + {"task_index": 1, "status": "error"}, + ]) + manifest = json.loads( + (live_transcript_root() / deleg_id / "manifest.json").read_text() + ) + assert manifest["tasks"][0]["status"] == "completed" + assert manifest["tasks"][1]["status"] == "error" + assert "completed" in manifest + + +def test_update_manifest_statuses_none_id_is_noop(): + update_manifest_statuses(None, [{"task_index": 0, "status": "completed"}]) + + +def test_prune_stale_live_dirs(): + root = live_transcript_root() + old_dir = root / "deleg_old00001" + new_dir = root / "deleg_new00001" + old_dir.mkdir(parents=True) + new_dir.mkdir(parents=True) + (old_dir / "task-0.log").write_text("old") + (new_dir / "task-0.log").write_text("new") + stale = time.time() - 8 * 86400 + os.utime(old_dir, (stale, stale)) + removed = prune_stale_live_dirs(max_age_days=7) + assert removed == 1 + assert not old_dir.exists() + assert new_dir.exists() + + +def test_create_live_transcripts_survives_root_failure(monkeypatch): + monkeypatch.setattr( + dll, "live_transcript_root", + lambda: (_ for _ in ()).throw(RuntimeError("no home")), + ) + deleg_id, writers, paths = create_live_transcripts([{"goal": "g"}]) + assert deleg_id is None + assert writers == [None] + assert paths == [] + + +# --------------------------------------------------------------------------- +# delegate_task return-shape integration +# --------------------------------------------------------------------------- + + +def _make_parent(): + parent = MagicMock() + parent._delegate_depth = 0 + parent.session_id = "sess-live" + parent._interrupt_requested = False + parent._active_children = [] + parent._active_children_lock = None + return parent + + +_CREDS = { + "model": "m", "provider": None, "base_url": None, "api_key": None, + "api_mode": None, "command": None, "args": None, +} + + +def _fake_run(task_index, goal, child=None, parent_agent=None, **kw): + return { + "task_index": task_index, "status": "completed", + "summary": f"done: {goal}", "api_calls": 1, + "duration_seconds": 0.1, "model": "m", "exit_reason": "completed", + } + + +def test_delegate_task_sync_result_includes_live_transcripts(monkeypatch): + import tools.delegate_tool as dt + + parent = _make_parent() + fake_child = MagicMock() + fake_child._delegate_role = "leaf" + fake_child.tool_progress_callback = None + monkeypatch.setattr(dt, "_build_child_agent", lambda **kw: fake_child) + monkeypatch.setattr(dt, "_run_single_child", _fake_run) + monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: _CREDS) + + out = json.loads(dt.delegate_task(goal="sync goal", parent_agent=parent)) + assert "live_transcripts" in out + assert len(out["live_transcripts"]) == 1 + p = Path(out["live_transcripts"][0]) + assert p.exists() + assert "sync goal" in p.read_text(encoding="utf-8") + # Per-task entries carry their own path + a terminal marker was written. + assert out["results"][0]["live_transcript"] == str(p) + assert "end status=completed" in p.read_text(encoding="utf-8") + + +def test_delegate_task_background_dispatch_includes_live_transcripts(monkeypatch): + import tools.delegate_tool as dt + from tools import async_delegation as ad + from tools.process_registry import process_registry + + parent = _make_parent() + fake_child = MagicMock() + fake_child._delegate_role = "leaf" + fake_child._subagent_id = "s1" + fake_child.tool_progress_callback = None + + gate = threading.Event() + + def slow_child(task_index, goal, child=None, parent_agent=None, **kw): + gate.wait(timeout=60) + return _fake_run(task_index, goal) + + monkeypatch.setattr(dt, "_build_child_agent", lambda **kw: fake_child) + monkeypatch.setattr(dt, "_run_single_child", slow_child) + monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: _CREDS) + + out = json.loads(dt.delegate_task( + goal="bg goal", background=True, parent_agent=parent, + )) + try: + assert out["status"] == "dispatched" + assert "live_transcripts" in out + assert len(out["live_transcripts"]) == 1 + live = Path(out["live_transcripts"][0]) + # Pre-created at dispatch time — tail -f attaches immediately, + # while the child is still running behind the gate. + assert live.exists() + assert "bg goal" in live.read_text(encoding="utf-8") + assert "live_transcripts_hint" in out + # The dir name matches the returned delegation handle. + assert live.parent.name == out["delegation_id"] + finally: + gate.set() + # Drain the completion so it can't leak into other tests. + deadline = time.time() + 30 + evt = None + while time.time() < deadline: + try: + evt = process_registry.completion_queue.get(timeout=0.5) + break + except Exception: + continue + ad._reset_for_tests() + + assert evt is not None + # The completion event carries the same paths for the consolidated block. + assert evt.get("live_transcripts") == out["live_transcripts"] + assert evt["results"][0]["live_transcript"] == out["live_transcripts"][0] + + +def test_batch_dispatch_creates_one_log_per_task(monkeypatch): + import tools.delegate_tool as dt + + parent = _make_parent() + + def make_child(**kw): + c = MagicMock() + c._delegate_role = "leaf" + c.tool_progress_callback = None + return c + + monkeypatch.setattr(dt, "_build_child_agent", make_child) + monkeypatch.setattr(dt, "_run_single_child", _fake_run) + monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: _CREDS) + + out = json.loads(dt.delegate_task( + tasks=[{"goal": "alpha"}, {"goal": "beta"}], parent_agent=parent, + )) + assert len(out["live_transcripts"]) == 2 + names = [Path(p).name for p in out["live_transcripts"]] + assert names == ["task-0.log", "task-1.log"] + # Both under the same delegation dir + parents = {Path(p).parent for p in out["live_transcripts"]} + assert len(parents) == 1 + for p, goal in zip(out["live_transcripts"], ("alpha", "beta")): + assert goal in Path(p).read_text(encoding="utf-8") + + +def test_child_progress_events_land_in_live_log(monkeypatch): + """Events fired through the child's (wrapped) tool_progress_callback land + in the transcript file in order — the seam the real agent loop drives.""" + import tools.delegate_tool as dt + + parent = _make_parent() + built = [] + + def make_child(**kw): + c = MagicMock() + c._delegate_role = "leaf" + c.tool_progress_callback = None + built.append(c) + return c + + def run_child(task_index, goal, child=None, parent_agent=None, **kw): + # Simulate what agent/tool_executor.py + conversation_loop.py emit. + cb = child.tool_progress_callback + cb("_thinking", "planning the work") + cb("tool.started", "terminal", "echo hi", {"command": "echo hi"}) + cb("tool.completed", "terminal", None, None, + duration=0.2, is_error=False, result="hi") + return _fake_run(task_index, goal) + + monkeypatch.setattr(dt, "_build_child_agent", make_child) + monkeypatch.setattr(dt, "_run_single_child", run_child) + monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: _CREDS) + + out = json.loads(dt.delegate_task(goal="observable goal", parent_agent=parent)) + text = Path(out["live_transcripts"][0]).read_text(encoding="utf-8") + assert "planning the work" in text + assert "-> terminal(echo hi)" in text + assert "terminal ok 0.2s: hi" in text + assert text.index("planning") < text.index("-> terminal") < text.index("terminal ok") + + +def test_delegate_task_proceeds_when_transcripts_unavailable(monkeypatch): + """Live-log failure must never break delegation itself.""" + import tools.delegate_tool as dt + from tools import delegation_live_log as _dll + + parent = _make_parent() + fake_child = MagicMock() + fake_child._delegate_role = "leaf" + fake_child.tool_progress_callback = None + monkeypatch.setattr(dt, "_build_child_agent", lambda **kw: fake_child) + monkeypatch.setattr(dt, "_run_single_child", _fake_run) + monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: _CREDS) + monkeypatch.setattr( + _dll, "live_transcript_root", + lambda: (_ for _ in ()).throw(RuntimeError("nope")), + ) + + out = json.loads(dt.delegate_task(goal="resilient", parent_agent=parent)) + assert out["results"][0]["status"] == "completed" + assert "live_transcripts" not in out + + +if __name__ == "__main__": + import sys + sys.exit(pytest.main([__file__, "-v"])) diff --git a/tools/async_delegation.py b/tools/async_delegation.py index c743decf644e..6811d6867fdb 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -655,6 +655,7 @@ def dispatch_async_delegation_batch( origin_ui_session_id: str = "", interrupt_fn: Optional[Callable[[], None]] = None, max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN, + delegation_id: Optional[str] = None, ) -> Dict[str, Any]: """Dispatch a WHOLE fan-out batch as ONE background unit. @@ -676,7 +677,7 @@ def dispatch_async_delegation_batch( ``{"status": "rejected", "error": ...}`` when the async pool is at capacity. """ - delegation_id = _new_delegation_id() + delegation_id = delegation_id or _new_delegation_id() dispatched_at = time.time() n = len(goals) # A combined goal label for status listings / the completion header. @@ -805,6 +806,10 @@ def _finalize_batch( # The full per-task results list — the formatter renders a # consolidated multi-task block from this. "results": combined.get("results") or [], + # Per-task live transcript log paths (cache/delegation/live/...). + # They persist after completion and double as the full-fidelity + # operational record of each child's run. + "live_transcripts": combined.get("live_transcripts"), "error": combined.get("error"), "total_duration_seconds": combined.get("total_duration_seconds"), "dispatched_at": dispatched_at, diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 12f94a180a8e..6a0e4c0dcd6a 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2555,6 +2555,21 @@ def delegate_task( # Track goal labels for progress display (truncated for readability) task_labels = [t["goal"][:40] for t in task_list] + # Live transcripts: one pre-headered append-only log per task under + # cache/delegation/live//task-.log so the caller can + # tail each child's operations while it runs (side-channel only — zero + # effect on message content or prompt caching). Best-effort: on failure + # live_paths is empty and delegation proceeds exactly as before. + from tools.delegation_live_log import ( + create_live_transcripts, + update_manifest_statuses, + wrap_progress_callback, + ) + + live_deleg_id, live_writers, live_paths = create_live_transcripts( + task_list, context + ) + # Save parent tool names BEFORE any child construction mutates the global. # _build_child_agent() calls AIAgent() which calls get_tool_definitions(), # which overwrites model_tools._last_resolved_tool_names with child's toolset. @@ -2594,6 +2609,17 @@ def delegate_task( ) # Override with correct parent tool names (before child construction mutated global) child._delegate_saved_tool_names = _parent_tool_names + # Tee the child's progress events into its live transcript log. + # wrap_progress_callback preserves the inner callback contract + # (including the _flush attribute) and never lets writer failures + # reach the agent loop. When no parent display exists the inner + # callback is None and the wrapper still records events. + _writer = live_writers[i] if i < len(live_writers) else None + if _writer is not None: + child.tool_progress_callback = wrap_progress_callback( + getattr(child, "tool_progress_callback", None), _writer + ) + child._live_transcript_path = str(_writer.path) children.append((i, t, child)) finally: # Authoritative restore: reset global to parent's tool names after all children built @@ -2839,10 +2865,33 @@ def delegate_task( total_duration = round(time.monotonic() - overall_start, 2) - return { + # Close out the live transcripts: terminal marker per task + manifest + # status update. The files are retained (retention pruning happens on + # future dispatches) — they double as the full-fidelity operational + # record alongside the summary spill files. + for entry in results: + _idx = entry.get("task_index", -1) + _w = ( + live_writers[_idx] + if isinstance(_idx, int) and 0 <= _idx < len(live_writers) + else None + ) + if _w is not None: + try: + _w.finalize(entry) + except Exception: + logger.debug("Live transcript finalize failed", exc_info=True) + if _idx < len(live_paths): + entry["live_transcript"] = live_paths[_idx] + update_manifest_statuses(live_deleg_id, results) + + combined: Dict[str, Any] = { "results": results, "total_duration_seconds": total_duration, } + if live_paths: + combined["live_transcripts"] = list(live_paths) + return combined # ----- Background dispatch: run the WHOLE batch as one async unit ----- # When background is true, the entire fan-out runs on the daemon executor @@ -2964,6 +3013,9 @@ def delegate_task( runner=_batch_runner, interrupt_fn=_batch_interrupt, max_async_children=_get_max_async_children(), + # Reuse the live-transcript directory's id (when created) so the + # returned delegation_id matches cache/delegation/live//. + delegation_id=live_deleg_id, ) if dispatch.get("status") == "dispatched": @@ -2988,6 +3040,14 @@ def delegate_task( "goals": _goals, "note": note, } + if live_paths: + payload["live_transcripts"] = list(live_paths) + payload["live_transcripts_hint"] = ( + "Each subagent streams a human-readable transcript of its " + "operations to the file listed above (append-only, one per " + "task). Read or `tail -f` these paths at any time to watch " + "a child work while it runs." + ) return json.dumps(payload, ensure_ascii=False) # Pool at capacity / schedule failure — children are still attached @@ -3330,6 +3390,13 @@ def _build_top_level_description() -> str: "batch returns one handle, runs N subagents concurrently, and delivers " "one consolidated result after ALL of them finish. Do NOT wait or poll; " "just continue with other work after dispatching.\n\n" + "LIVE TRANSCRIPTS: the dispatch response includes 'live_transcripts' — " + "one append-only human-readable log file per task (under " + "cache/delegation/live//). Each child streams its " + "assistant text, tool calls, and tool results there while it runs. " + "Read (or `tail -f` in a terminal) those paths any time you or the " + "user want to see what a subagent is actually doing instead of " + "waiting for the final summary.\n\n" "WHEN TO USE delegate_task:\n" "- Reasoning-heavy subtasks (debugging, code review, research synthesis)\n" "- Tasks that would flood your context with intermediate data\n" diff --git a/tools/delegation_live_log.py b/tools/delegation_live_log.py new file mode 100644 index 000000000000..da157f623c89 --- /dev/null +++ b/tools/delegation_live_log.py @@ -0,0 +1,385 @@ +"""Live, tail-able transcripts for delegated subagents. + +Every ``delegate_task`` dispatch creates one append-only, human-readable log +per child under:: + + /cache/delegation/live//task-.log + +The files are pre-created with a header at dispatch time (so ``tail -f`` +attaches immediately) and then stream one line per child event: assistant +text, thinking, tool calls, tool results, and lifecycle markers. The paths +are returned from ``delegate_task`` so the parent agent (or the user) can +watch a child work instead of waiting blind for the consolidated summary. + +Placement under ``cache/delegation`` is deliberate: that directory is +mounted read-only into remote terminal backends (Docker/Modal/SSH) via +``credential_files._CACHE_DIRS``, so the logs are readable from any backend. + +Design constraints: + +* **Never raise into the agent loop.** Every write is wrapped; the first + failure disables the writer and degrades to a debug log. +* **Survive child crashes.** Files are opened in append mode per write — + no long-lived handle to lose, every event is flushed when written. +* **Side-channel only.** Nothing here touches message content, so prompt + caching is unaffected. +* **No config knobs.** Retention is a module constant (7 days), pruned + opportunistically on each new dispatch. +""" + +from __future__ import annotations + +import json +import logging +import shutil +import threading +import time +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# Live transcript directories older than this are pruned on new dispatches. +LIVE_RETENTION_DAYS = 7 + +# Per-line truncation budgets (chars). The .log is a compact operational +# view, not the full-fidelity record — the child's SessionDB transcript and +# the summary spill files carry complete text. +_ASSISTANT_MAX = 600 +_THINKING_MAX = 300 +_ARGS_MAX = 220 +_RESULT_MAX = 400 +_KICKOFF_MAX = 500 + +# Stream deltas are buffered and flushed as one assistant line when another +# event type arrives (or on completion). Cap the buffer so a huge streamed +# reply can't hold memory hostage. +_STREAM_BUFFER_FLUSH_CHARS = 4000 + + +def live_transcript_root() -> Path: + """Root directory for live transcripts (profile-safe, never ~/.hermes).""" + from hermes_constants import get_hermes_dir + + return get_hermes_dir("cache/delegation", "delegation_cache") / "live" + + +def new_live_delegation_id() -> str: + """Same shape as async_delegation's ids so the dir name matches the handle.""" + return f"deleg_{uuid.uuid4().hex[:8]}" + + +def _one_line(text: Any, limit: int) -> str: + """Collapse to a single line and truncate with an elided-chars note.""" + s = str(text or "") + s = " ".join(s.split()) # collapse newlines/runs of whitespace + if len(s) > limit: + omitted = len(s) - limit + s = s[:limit] + f" …(+{omitted} chars)" + return s + + +class LiveTranscriptWriter: + """Append-only human-readable event log for ONE subagent task. + + All methods are best-effort: the first write failure flips ``_ok`` off + and subsequent calls become no-ops (debug-logged). Never raises. + """ + + def __init__(self, delegation_id: str, task_index: int, goal: str, + context: Optional[str] = None, root: Optional[Path] = None): + self.delegation_id = delegation_id + self.task_index = task_index + self._ok = True + self._lock = threading.Lock() + self._stream_buf: List[str] = [] + self._stream_len = 0 + try: + base = (root if root is not None else live_transcript_root()) + d = base / delegation_id + d.mkdir(parents=True, exist_ok=True) + self.path: Optional[Path] = d / f"task-{task_index}.log" + header = [ + "=== Hermes subagent live transcript ===", + f"delegation: {delegation_id} task: {task_index}", + f"goal: {_one_line(goal, _KICKOFF_MAX)}", + f"started: {time.strftime('%Y-%m-%d %H:%M:%S')}", + "(append-only; streams while the subagent runs — tail -f me)", + "=" * 40, + ] + self.path.write_text("\n".join(header) + "\n", encoding="utf-8") + self.event("user", "kickoff: " + _one_line(goal, _KICKOFF_MAX) + + (f" | context: {_one_line(context, _KICKOFF_MAX)}" if context else "")) + except Exception as exc: + logger.debug("Live transcript init failed (%s task %s): %s", + delegation_id, task_index, exc) + self._ok = False + self.path = None + + # ── low-level ──────────────────────────────────────────────────────── + def event(self, role: str, text: str) -> None: + """Append one ``HH:MM:SS role ⟩ text`` line. Flushed per event.""" + if not self._ok or self.path is None: + return + line = f"{time.strftime('%H:%M:%S')} {role:<9}| {text}\n" + try: + with self._lock: + # Append mode per write: no held handle, survives child crash, + # and the close() acts as the flush. + with open(self.path, "a", encoding="utf-8") as fh: + fh.write(line) + except Exception as exc: + self._ok = False + logger.debug("Live transcript write failed (%s): %s", self.path, exc) + + # ── typed helpers ──────────────────────────────────────────────────── + def assistant_text(self, text: str) -> None: + t = _one_line(text, _ASSISTANT_MAX) + if t: + self.event("assistant", t) + + def thinking(self, text: str) -> None: + t = _one_line(text, _THINKING_MAX) + if t: + self.event("think", t) + + def tool_start(self, name: str, args_preview: Any = None) -> None: + self.flush_stream() + args = _one_line(args_preview, _ARGS_MAX) + self.event("tool", f"-> {name or '?'}({args})") + + def tool_result(self, name: str, result: Any = None, + duration: Any = None, is_error: bool = False) -> None: + status = "ERROR" if is_error else "ok" + dur = "" + try: + if duration is not None: + dur = f" {float(duration):.1f}s" + except (TypeError, ValueError): + pass + self.event("result", f"{name or '?'} {status}{dur}: " + f"{_one_line(result, _RESULT_MAX)}") + + def marker(self, text: str) -> None: + """Lifecycle marker: start / final / error / interrupt / budget.""" + self.flush_stream() + self.event("final", _one_line(text, _ASSISTANT_MAX)) + + # ── streamed reply buffering ───────────────────────────────────────── + def add_stream_delta(self, delta: str) -> None: + """Buffer streamed assistant reply text; flushed as one line.""" + if not delta or not self._ok: + return + self._stream_buf.append(delta) + self._stream_len += len(delta) + if self._stream_len >= _STREAM_BUFFER_FLUSH_CHARS: + self.flush_stream() + + def flush_stream(self) -> None: + if not self._stream_buf: + return + text = "".join(self._stream_buf) + self._stream_buf = [] + self._stream_len = 0 + self.assistant_text(text) + + # ── event demux (the tool_progress_callback surface) ───────────────── + def observe(self, event_type: Any, tool_name: Any = None, + preview: Any = None, args: Any = None, **kwargs: Any) -> None: + """Map a child tool_progress_callback event onto transcript lines. + + Mirrors the shapes emitted by agent/tool_executor.py, + agent/conversation_loop.py, and tools/delegate_tool._run_single_child. + Unknown events are ignored. Never raises (event() swallows I/O). + """ + et = str(event_type or "") + if et == "tool.started": + self.tool_start(str(tool_name or ""), preview if preview else args) + elif et == "tool.completed": + self.tool_result( + str(tool_name or ""), + result=kwargs.get("result"), + duration=kwargs.get("duration"), + is_error=bool(kwargs.get("is_error")), + ) + elif et == "_thinking": + # Fired as cb("_thinking", ) — the text rides in the + # tool_name positional slot (see conversation_loop.py). + self.thinking(str(tool_name or preview or "")) + elif et == "reasoning.available": + # cb("reasoning.available", "_thinking", , None) + self.thinking(str(preview or "")) + elif et == "subagent.text": + self.add_stream_delta(str(preview or "")) + elif et == "subagent.start": + self.event("start", _one_line(preview, _KICKOFF_MAX)) + elif et == "subagent.complete": + self.flush_stream() + status = kwargs.get("status", "?") + dur = kwargs.get("duration_seconds") + parts = [f"status={status}"] + if dur is not None: + parts.append(f"duration={dur}s") + summary = kwargs.get("summary") or preview + if summary: + parts.append(f"summary: {_one_line(summary, _RESULT_MAX)}") + self.marker(" ".join(parts)) + + def finalize(self, entry: Dict[str, Any]) -> None: + """Terminal marker from the aggregated result entry. + + Adds exit-reason detail the subagent.complete event doesn't carry + (budget exhaustion via exit_reason=max_iterations, errors, etc.). + """ + parts = [f"end status={entry.get('status', '?')}"] + exit_reason = entry.get("exit_reason") + if exit_reason: + parts.append(f"exit_reason={exit_reason}") + if exit_reason == "max_iterations": + parts.append("(iteration budget exhausted)") + if entry.get("error"): + parts.append(f"error: {_one_line(entry['error'], _RESULT_MAX)}") + self.marker(" ".join(parts)) + + +def wrap_progress_callback(inner_cb, writer: LiveTranscriptWriter): + """Wrap a child's tool_progress_callback so events also land in the log. + + ``inner_cb`` may be None (no parent display) — the wrapper still records. + Writer failures never propagate; inner callback behavior is unchanged + (its own exceptions are handled by callers exactly as before). + Preserves the ``_flush`` attribute contract used by _run_single_child. + """ + + def _cb(event_type, tool_name=None, preview=None, args=None, **kwargs): + try: + writer.observe(event_type, tool_name, preview, args, **kwargs) + except Exception as exc: # noqa: BLE001 — must never hit the agent loop + logger.debug("Live transcript observe failed: %s", exc) + if inner_cb is not None: + inner_cb(event_type, tool_name, preview, args, **kwargs) + + def _flush(): + try: + writer.flush_stream() + except Exception: + pass + inner_flush = getattr(inner_cb, "_flush", None) + if callable(inner_flush): + inner_flush() + + _cb._flush = _flush + return _cb + + +# ── dispatch-time helpers ──────────────────────────────────────────────── + +def create_live_transcripts( + task_list: List[Dict[str, Any]], + context: Optional[str] = None, + delegation_id: Optional[str] = None, +) -> tuple[Optional[str], List[Optional[LiveTranscriptWriter]], List[str]]: + """Create one pre-headered writer per task + a manifest.json. + + Returns ``(delegation_id, writers, paths)``. On any top-level failure + returns ``(None, [None]*n, [])`` so delegation proceeds untouched. + Also opportunistically prunes stale live dirs (retention). + """ + n = len(task_list) + try: + prune_stale_live_dirs() + except Exception: + pass + try: + deleg_id = delegation_id or new_live_delegation_id() + writers: List[Optional[LiveTranscriptWriter]] = [] + paths: List[str] = [] + for i, t in enumerate(task_list): + w = LiveTranscriptWriter( + deleg_id, i, str(t.get("goal", "")), + context=t.get("context") or context, + ) + writers.append(w if w.path is not None else None) + if w.path is not None: + paths.append(str(w.path)) + if not paths: + return None, [None] * n, [] + _write_manifest(deleg_id, task_list, paths) + return deleg_id, writers, paths + except Exception as exc: + logger.debug("Live transcript creation failed: %s", exc) + return None, [None] * n, [] + + +def _manifest_path(delegation_id: str) -> Path: + return live_transcript_root() / delegation_id / "manifest.json" + + +def _write_manifest(delegation_id: str, task_list: List[Dict[str, Any]], + paths: List[str]) -> None: + try: + manifest = { + "delegation_id": delegation_id, + "started": time.strftime("%Y-%m-%d %H:%M:%S"), + "task_count": len(task_list), + "tasks": [ + { + "index": i, + "goal": str(t.get("goal", ""))[:500], + "log": paths[i] if i < len(paths) else None, + "status": "running", + } + for i, t in enumerate(task_list) + ], + } + _manifest_path(delegation_id).write_text( + json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8" + ) + except Exception as exc: + logger.debug("Live transcript manifest write failed: %s", exc) + + +def update_manifest_statuses(delegation_id: Optional[str], + results: List[Dict[str, Any]]) -> None: + """Best-effort per-task status update once the batch has aggregated.""" + if not delegation_id: + return + try: + mp = _manifest_path(delegation_id) + manifest = json.loads(mp.read_text(encoding="utf-8")) + by_index = {r.get("task_index"): r for r in results if isinstance(r, dict)} + for task in manifest.get("tasks", []): + r = by_index.get(task.get("index")) + if r is not None: + task["status"] = r.get("status", task.get("status")) + if r.get("exit_reason"): + task["exit_reason"] = r["exit_reason"] + manifest["completed"] = time.strftime("%Y-%m-%d %H:%M:%S") + mp.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), + encoding="utf-8") + except Exception as exc: + logger.debug("Live transcript manifest update failed: %s", exc) + + +def prune_stale_live_dirs(max_age_days: int = LIVE_RETENTION_DAYS) -> int: + """Remove live/ dirs older than the retention window. + + Returns how many were removed. Fully best-effort. + """ + removed = 0 + try: + root = live_transcript_root() + if not root.is_dir(): + return 0 + cutoff = time.time() - max_age_days * 86400 + for child in root.iterdir(): + try: + if child.is_dir() and child.stat().st_mtime < cutoff: + shutil.rmtree(child, ignore_errors=True) + removed += 1 + except OSError: + continue + except Exception as exc: + logger.debug("Live transcript pruning failed: %s", exc) + return removed diff --git a/tools/process_registry.py b/tools/process_registry.py index 0c7616db0630..97cf89b3bda8 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -2119,6 +2119,11 @@ def _format_async_delegation(evt: dict) -> str: + (f": {r_error}" if r_error else "") + ")" ) + r_live = r.get("live_transcript") + if r_live: + lines.append( + f"Full live transcript (complete tool/assistant trace): {r_live}" + ) return "\n".join(lines) age = "" diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index e27887461351..36dddd819d4c 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -206,6 +206,22 @@ The TUI ships a `/agents` overlay (alias `/tasks`) that turns recursive `delegat The classic CLI just prints `/agents` as a text summary; the TUI is where the overlay shines. See [TUI — Slash commands](/user-guide/tui#slash-commands). +## Live Transcripts + +Every `delegate_task` dispatch also creates one **append-only, human-readable log per task** so you (or the parent agent) can watch a subagent work in real time instead of waiting for the consolidated summary: + +``` +/cache/delegation/live//task-.log +``` + +The dispatch response includes the paths as `live_transcripts`, and the files are pre-created at dispatch time, so this works immediately: + +```bash +tail -f ~/.hermes/cache/delegation/live/deleg_ab12cd34/task-0.log +``` + +Each line is timestamped and shows the child's assistant text, thinking snippets, tool calls (`-> tool_name({args})`), tool results, and a final status marker. A `manifest.json` in the same directory describes the batch (goals, task count, per-task status). The logs persist after completion — they double as the full-fidelity operational record alongside the summary — and directories older than 7 days are pruned automatically on new dispatches. Because they live under `cache/delegation`, they are also readable from remote terminal backends (Docker/Modal/SSH). + ## Depth Limit and Nested Orchestration By default, delegation is **flat**: a parent (depth 0) spawns children (depth 1), and those children cannot delegate further. This prevents runaway recursive delegation. From 5f2bfb66317f5d55691cd4ff8a8805133257276a Mon Sep 17 00:00:00 2001 From: digitalbase Date: Sun, 19 Jul 2026 10:40:30 -0700 Subject: [PATCH 006/205] fix(desktop): scope the cron jobs list to the active profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvaged from #42654 by @digitalbase (earliest report of the leak, June 9): the desktop sidebar and cron overlay showed EVERY profile's jobs because GET /api/cron/jobs defaults to profile=all and the desktop never sent the param — profileScoped() (landed in #67493) routes the backend process but adds no endpoint filter on local pools. - hermes.ts: getCronJobs(profile?) appends ?profile= when given; omitting the arg keeps the legacy unfiltered path. profileScoped() still rides along for process routing. - use-session-list-actions.ts: sidebar cron refresh passes the sidebar's profile scope (concrete profile → own jobs; ALL_PROFILES → 'all'). - app/cron/index.tsx: the cron overlay's refresh uses the same scope so the overlay and sidebar (shared $cronJobs atom) always agree. - Tests: list ?profile= contract in hermes-cron-scope.test.ts; sidebar scoping in use-session-list-actions.test.tsx. Reworked onto current main per the sweeper review: threaded through the existing profileScoped()/list-param seams instead of the original PR's pre-refactor call sites (DesktopController has since delegated to use-session-list-actions). --- apps/desktop/src/app/cron/index.tsx | 10 +++++++-- .../hooks/use-session-list-actions.test.tsx | 21 +++++++++++++++++++ .../session/hooks/use-session-list-actions.ts | 9 +++++--- apps/desktop/src/hermes-cron-scope.test.ts | 15 +++++++++++++ apps/desktop/src/hermes.ts | 11 ++++++++-- contributors/emails/gijs@digitalbase.eu | 1 + 6 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 contributors/emails/gijs@digitalbase.eu diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index eb9e4257cf2e..e0c421e41057 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -43,6 +43,7 @@ import { requestModelOptions } from '@/lib/model-options' import { asText } from '@/lib/text' import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron' import { notify, notifyError } from '@/store/notifications' +import { $profileScope, ALL_PROFILES } from '@/store/profile' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { @@ -293,15 +294,20 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt const [pendingDelete, setPendingDelete] = useState(null) const [deleting, setDeleting] = useState(false) + // Jobs live per-profile on disk and the list endpoint aggregates 'all' by + // default — scope the fetch to the sidebar's profile scope so this overlay + // and the sidebar (which share the $cronJobs atom) agree on what's shown. + const profileScope = useStore($profileScope) + const refresh = useCallback(async () => { try { - setCronJobs(await getCronJobs()) + setCronJobs(await getCronJobs(profileScope === ALL_PROFILES ? 'all' : profileScope)) } catch (err) { notifyError(err, c.failedLoad) } finally { setLoading(false) } - }, [c]) + }, [c, profileScope]) useRefreshHotkey(refresh) diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx index 0c85d59d0b78..0147d45e4a8b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -204,4 +204,25 @@ describe('refreshSessions batches slices into one request', () => { }) ) }) + + it('scopes the cron-jobs fetch to the active profile (all → unified view)', async () => { + const { getCronJobs } = await import('@/hermes') + listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} })) + + const scoped = renderHook(() => useSessionListActions({ profileScope: 'work' })) + + await act(async () => { + await scoped.result.current.refreshCronJobs() + }) + + expect(getCronJobs).toHaveBeenLastCalledWith('work') + + const unified = renderHook(() => useSessionListActions({ profileScope: '__all__' })) + + await act(async () => { + await unified.result.current.refreshCronJobs() + }) + + expect(getCronJobs).toHaveBeenLastCalledWith('all') + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index 0b5e0e219161..38850cad3a77 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -123,16 +123,19 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created // synchronously (agent tool call or the cron UI), so refreshing here right // after an agent turn surfaces a new job immediately; the interval poll keeps - // next-run/state fresh as the scheduler advances them. + // next-run/state fresh as the scheduler advances them. Jobs live per-profile + // on disk and the list endpoint aggregates 'all' by default, so scope the + // fetch to the sidebar's profile scope — a concrete profile sees only its + // own jobs; ALL_PROFILES keeps the unified view. const refreshCronJobs = useCallback(async () => { try { - const jobs = await getCronJobs() + const jobs = await getCronJobs(profileScope === ALL_PROFILES ? 'all' : profileScope) setCronJobs(jobs) } catch { // Non-fatal: the cron section just keeps its last-known jobs. } - }, []) + }, [profileScope]) const refreshSessions = useCallback(async () => { const requestId = refreshSessionsRequestRef.current + 1 diff --git a/apps/desktop/src/hermes-cron-scope.test.ts b/apps/desktop/src/hermes-cron-scope.test.ts index a9d13256f751..ac94bc6698fd 100644 --- a/apps/desktop/src/hermes-cron-scope.test.ts +++ b/apps/desktop/src/hermes-cron-scope.test.ts @@ -56,4 +56,19 @@ describe('cron helpers are profile-scoped', () => { expect(call[0].profile).toBe('coder') } }) + + it('list accepts an explicit ?profile= for endpoint-level filtering', () => { + // profileScoped() routes the backend process; the list endpoint ALSO + // aggregates 'all' by default, so callers pass an explicit profile to + // filter what the endpoint returns (sidebar / cron overlay scoping). + void getCronJobs('worker_alpha') + expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs?profile=worker_alpha') + + void getCronJobs('all') + expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs?profile=all') + + // Omitting the arg keeps the legacy unfiltered path. + void getCronJobs() + expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs') + }) }) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index c91c015da5bf..637f9566b66c 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -956,10 +956,17 @@ export function testMessagingPlatform(platformId: string): Promise { +// Cron jobs are stored per-profile (/cron/jobs.json), and the +// backend's list endpoint defaults to 'all'. Pass a concrete profile key to +// list just that profile's jobs, or 'all' for the unified cross-profile view. +// Omitting the arg keeps the legacy 'all' default for non-profile callers. +// profileScoped() still rides along for backend-process routing. +export function getCronJobs(profile?: string): Promise { + const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' + return window.hermesDesktop.api({ ...profileScoped(), - path: '/api/cron/jobs', + path: `/api/cron/jobs${suffix}`, timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } diff --git a/contributors/emails/gijs@digitalbase.eu b/contributors/emails/gijs@digitalbase.eu new file mode 100644 index 000000000000..ae8639955259 --- /dev/null +++ b/contributors/emails/gijs@digitalbase.eu @@ -0,0 +1 @@ +digitalbase From 60811ced376a66f9ed17277d5ff168cbb5461642 Mon Sep 17 00:00:00 2001 From: liqiping Date: Sun, 19 Jul 2026 00:52:23 +0000 Subject: [PATCH 007/205] feat(agent): adaptive thinking for Kimi-family Anthropic endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kimi's Anthropic-compatible endpoints (api.moonshot.cn/anthropic, api.kimi.com/coding) implement the adaptive thinking contract — they accept thinking.type=adaptive + output_config.effort (all of low, medium, high, xhigh, max verified live) and return thinking blocks, and the replay-validation 400s that originally motivated dropping the parameter (#13848) no longer occur. _supports_adaptive_thinking() now returns True for Kimi-family models, so they get thinking={type: adaptive, display: summarized} + output_config.effort via ADAPTIVE_EFFORT_MAP instead of nothing, and the blanket drop of the thinking parameter for Kimi-family endpoints is removed. MiniMax and other non-adaptive third parties keep the manual budget_tokens path; Claude behavior is unchanged. --- agent/anthropic_adapter.py | 28 +-- contributors/emails/liqiping@msh.team | 1 + tests/agent/test_anthropic_adapter.py | 22 ++- .../test_kimi_coding_anthropic_thinking.py | 159 +++++++++--------- 4 files changed, 112 insertions(+), 98 deletions(-) create mode 100644 contributors/emails/liqiping@msh.team diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 947d8ed4697d..4e20ae412eef 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -247,7 +247,13 @@ def _supports_adaptive_thinking(model: str) -> bool: only returns False for the explicit legacy list of older Claude families that require manual budget-based thinking. Non-Claude Anthropic-Messages models (minimax, qwen3, …) return False so they keep the manual path. + + Kimi / Moonshot models are the exception: their Anthropic-compatible + endpoints implement the adaptive contract (``thinking.type="adaptive"`` + + ``output_config.effort``, including ``xhigh`` and ``display``). """ + if _model_name_is_kimi_family(model): + return True if not _is_claude_model(model): return False m = model.lower() @@ -2624,25 +2630,19 @@ def build_anthropic_kwargs( # MiniMax Anthropic-compat endpoints support thinking (manual mode only, # not adaptive). Haiku does NOT support extended thinking — skip entirely. # - # Kimi's /coding endpoint speaks the Anthropic Messages protocol but has - # its own thinking semantics: when ``thinking.enabled`` is sent, Kimi - # validates the message history and requires every prior assistant - # tool-call message to carry OpenAI-style ``reasoning_content``. The - # Anthropic path never populates that field, and - # ``convert_messages_to_anthropic`` strips all Anthropic thinking blocks - # on third-party endpoints — so the request fails with HTTP 400 - # "thinking is enabled but reasoning_content is missing in assistant - # tool call message at index N". Kimi's reasoning is driven server-side - # on the /coding route, so skip Anthropic's thinking parameter entirely - # for that host. (Kimi on chat_completions enables thinking via - # extra_body in the ChatCompletionsTransport — see #13503.) + # Kimi / Moonshot models also use adaptive thinking: their + # Anthropic-compatible endpoints (api.moonshot.cn/anthropic, + # api.kimi.com/coding) accept ``thinking.type="adaptive"`` + + # ``output_config.effort``, and the replay-validation 400s that + # originally motivated dropping the parameter (#13848) no longer + # occur. (Kimi on chat_completions enables thinking via extra_body + # in the ChatCompletionsTransport — see #13503.) # # On 4.7+ the `thinking.display` field defaults to "omitted", which # silently hides reasoning text that Hermes surfaces in its CLI. We # request "summarized" so the reasoning blocks stay populated — matching # 4.6 behavior and preserving the activity-feed UX during long tool runs. - _is_kimi_coding = _is_kimi_family_endpoint(base_url, model) - if reasoning_config and isinstance(reasoning_config, dict) and not _is_kimi_coding: + if reasoning_config and isinstance(reasoning_config, dict): if reasoning_config.get("enabled") is not False and "haiku" not in model.lower(): effort = str(reasoning_config.get("effort", "medium")).lower() budget = THINKING_BUDGET.get(effort, 8000) diff --git a/contributors/emails/liqiping@msh.team b/contributors/emails/liqiping@msh.team new file mode 100644 index 000000000000..9fb31ce647df --- /dev/null +++ b/contributors/emails/liqiping@msh.team @@ -0,0 +1 @@ +chouqin diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 1519c41c81ae..7fb250294cc2 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -1603,18 +1603,34 @@ class TestBuildAnthropicKwargs: assert _forbids_sampling_params(m) is False, m def test_non_claude_anthropic_models_use_manual_path(self): - """Non-Claude Anthropic-Messages models (minimax, qwen3, kimi) must not - be misclassified as adaptive by the default-to-modern rule.""" + """Non-Claude Anthropic-Messages models (minimax, qwen3, glm) must not + be misclassified as adaptive by the default-to-modern rule. Kimi is + the deliberate exception — see test_kimi_family_uses_adaptive_path.""" from agent.anthropic_adapter import ( _supports_adaptive_thinking, _supports_xhigh_effort, _forbids_sampling_params, ) - for m in ("minimax-m2", "qwen3-max", "moonshotai/kimi-k2.5", "glm-4.6"): + for m in ("minimax-m2", "qwen3-max", "glm-4.6"): assert _supports_adaptive_thinking(m) is False, m assert _supports_xhigh_effort(m) is False, m assert _forbids_sampling_params(m) is False, m + def test_kimi_family_uses_adaptive_path(self): + """Kimi / Moonshot models use adaptive thinking: their + Anthropic-compatible endpoints accept thinking.type="adaptive" + + output_config.effort including xhigh. Sampling params stay untouched + (the 4.7+ sampling ban is a Claude-only contract).""" + from agent.anthropic_adapter import ( + _supports_adaptive_thinking, + _supports_xhigh_effort, + _forbids_sampling_params, + ) + for m in ("moonshotai/kimi-k2.5", "kimi-0714-preview", "k2-thinking"): + assert _supports_adaptive_thinking(m) is True, m + assert _supports_xhigh_effort(m) is True, m + assert _forbids_sampling_params(m) is False, m + def test_fast_mode_omitted_for_unsupported_model(self): """fast_mode=True on Opus 4.7 must NOT inject speed=fast (API 400s).""" kwargs = build_anthropic_kwargs( diff --git a/tests/agent/test_kimi_coding_anthropic_thinking.py b/tests/agent/test_kimi_coding_anthropic_thinking.py index 89872cc2f006..a58e584c715f 100644 --- a/tests/agent/test_kimi_coding_anthropic_thinking.py +++ b/tests/agent/test_kimi_coding_anthropic_thinking.py @@ -1,22 +1,20 @@ -"""Regression guard: don't send Anthropic ``thinking`` to Kimi's /coding endpoint. +"""Kimi / Moonshot thinking behavior on the Anthropic-Messages wire. -Kimi's ``api.kimi.com/coding`` endpoint speaks the Anthropic Messages protocol -but has its own thinking semantics. When ``thinking.enabled`` is present in -the request, Kimi validates the message history and requires every prior -assistant tool-call message to carry OpenAI-style ``reasoning_content``. +Contract (changed from the original #13848 mitigation): -The Anthropic path never populates that field, and -``convert_messages_to_anthropic`` strips Anthropic thinking blocks on -third-party endpoints — so after one turn with tool calls the next request -fails with HTTP 400:: +- Kimi-family endpoints receive ``thinking`` in **adaptive** form + (``thinking.type="adaptive"`` + ``output_config.effort``) — never manual + ``budget_tokens``. Their Anthropic-compatible endpoints + (``api.moonshot.cn/anthropic``, ``api.kimi.com/coding``) accept the + field set, and the replay-validation 400s that originally motivated + dropping the parameter (#13848) no longer occur. - thinking is enabled but reasoning_content is missing in assistant - tool call message at index N +- ``convert_messages_to_anthropic`` still preserves unsigned + reasoning_content-derived thinking blocks on replay for this family, so + multi-turn tool-call history round-trips. -Kimi on the chat_completions route handles ``thinking`` via ``extra_body`` in -``ChatCompletionsTransport`` (#13503). On the Anthropic route the right -thing to do is drop the parameter entirely and let Kimi drive reasoning -server-side. +Kimi on the chat_completions route handles ``thinking`` via ``extra_body`` +in ``ChatCompletionsTransport`` (#13503). """ from __future__ import annotations @@ -24,36 +22,10 @@ from __future__ import annotations import pytest -class TestKimiCodingSkipsAnthropicThinking: - """build_anthropic_kwargs must not inject ``thinking`` for Kimi /coding.""" +class TestKimiCodingAnthropicThinking: + """Kimi-family thinking on the Anthropic wire (incl. /coding).""" - @pytest.mark.parametrize( - "base_url", - [ - "https://api.kimi.com/coding", - "https://api.kimi.com/coding/v1", - "https://api.kimi.com/coding/anthropic", - "https://api.kimi.com/coding/", - ], - ) - def test_kimi_coding_endpoint_omits_thinking(self, base_url: str) -> None: - from agent.anthropic_adapter import build_anthropic_kwargs - - kwargs = build_anthropic_kwargs( - model="kimi-k2.5", - messages=[{"role": "user", "content": "hello"}], - tools=None, - max_tokens=4096, - reasoning_config={"enabled": True, "effort": "medium"}, - base_url=base_url, - ) - assert "thinking" not in kwargs, ( - "Anthropic thinking must not be sent to Kimi /coding — " - "endpoint requires reasoning_content on history we don't preserve." - ) - assert "output_config" not in kwargs - - def test_kimi_coding_with_explicit_disabled_also_omits(self) -> None: + def test_kimi_coding_with_explicit_disabled_omits_thinking(self) -> None: from agent.anthropic_adapter import build_anthropic_kwargs kwargs = build_anthropic_kwargs( @@ -94,48 +66,29 @@ class TestKimiCodingSkipsAnthropicThinking: ) assert "thinking" in kwargs - def test_kimi_root_endpoint_via_anthropic_transport_omits_thinking(self) -> None: - """Plain ``api.kimi.com`` hit via the Anthropic transport also omits thinking. - Auto-detection routes ``api.kimi.com/v1`` to ``chat_completions`` by - default, but users can explicitly configure - ``api_mode: anthropic_messages`` against any Kimi host. The upstream - validation (reasoning_content required on replayed tool-call - messages) is the same regardless of URL path, so the thinking - suppression must apply to every Kimi host, not just ``/coding``. - See #17057. - """ - from agent.anthropic_adapter import build_anthropic_kwargs +class TestKimiFamilyGetsAdaptiveThinking: + """Kimi-family endpoints get adaptive thinking + output_config.effort.""" - kwargs = build_anthropic_kwargs( - model="kimi-k2.5", - messages=[{"role": "user", "content": "hello"}], - tools=None, - max_tokens=4096, - reasoning_config={"enabled": True, "effort": "medium"}, - base_url="https://api.kimi.com/v1", - ) - assert "thinking" not in kwargs - - # ── #17057: custom / proxied Kimi-compatible endpoints ────────── @pytest.mark.parametrize( "base_url,model", [ - # Custom host with Kimi-family model — the reporter's case - ("http://my-kimi-proxy.internal", "kimi-2.6"), - ("https://llm.example.com/anthropic", "kimi-k2.5"), - ("https://llm.example.com/anthropic", "moonshot-v1-8k"), - ("https://llm.example.com/anthropic", "kimi_thinking"), - ("https://llm.example.com/anthropic", "moonshotai/kimi-k2.5"), - # Official Moonshot host (previously uncovered) + # Official Kimi / Moonshot hosts (all URL shapes) + ("https://api.kimi.com/coding", "kimi-k2.5"), + ("https://api.kimi.com/coding/v1", "kimi-k2.5"), + ("https://api.kimi.com/coding/anthropic", "kimi-k2.5"), + ("https://api.kimi.com/v1", "kimi-k2.5"), ("https://api.moonshot.ai/anthropic", "moonshot-v1-32k"), ("https://api.moonshot.cn/anthropic", "moonshot-v1-32k"), + ("https://api.moonshot.cn/anthropic/v1", "kimi-0714-preview"), + # Custom / proxied hosts with a Kimi-family model (#17057) + ("http://my-kimi-proxy.internal", "kimi-2.6"), + ("https://llm.example.com/anthropic", "moonshotai/kimi-k2.5"), ], ) - def test_kimi_family_custom_endpoint_omits_thinking( + def test_kimi_family_endpoint_gets_adaptive_thinking( self, base_url: str, model: str ) -> None: - """Custom / proxied Kimi endpoints must also strip Anthropic thinking.""" from agent.anthropic_adapter import build_anthropic_kwargs kwargs = build_anthropic_kwargs( @@ -143,21 +96,65 @@ class TestKimiCodingSkipsAnthropicThinking: messages=[{"role": "user", "content": "hello"}], tools=None, max_tokens=4096, - reasoning_config={"enabled": True, "effort": "medium"}, + reasoning_config={"enabled": True, "effort": "high"}, base_url=base_url, ) - assert "thinking" not in kwargs, ( - f"Kimi-family endpoint ({base_url}, {model}) must not receive " - f"Anthropic thinking — upstream validates reasoning_content on " - f"replayed tool-call history we don't preserve." + assert kwargs.get("thinking", {}).get("type") == "adaptive", ( + f"Kimi-family endpoint ({base_url}, {model}) must receive " + f"adaptive thinking, got {kwargs.get('thinking')!r}" ) + assert "budget_tokens" not in kwargs["thinking"] + assert kwargs["output_config"] == {"effort": "high"} + # Adaptive mode must not force temperature or inflate max_tokens + # (those are manual-budget-mode side effects). + assert "temperature" not in kwargs + assert kwargs["max_tokens"] == 4096 + + @pytest.mark.parametrize( + "hermes_effort,wire_effort", + [ + ("minimal", "low"), + ("low", "low"), + ("medium", "medium"), + ("high", "high"), + ("xhigh", "xhigh"), + ("max", "max"), + ("ultra", "max"), + ], + ) + def test_kimi_effort_mapping(self, hermes_effort: str, wire_effort: str) -> None: + from agent.anthropic_adapter import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="kimi-0714-preview", + messages=[{"role": "user", "content": "hello"}], + tools=None, + max_tokens=4096, + reasoning_config={"enabled": True, "effort": hermes_effort}, + base_url="https://api.moonshot.cn/anthropic/v1", + ) + assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"} + assert kwargs["output_config"] == {"effort": wire_effort} + + def test_kimi_thinking_disabled_omits_parameter(self) -> None: + from agent.anthropic_adapter import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="kimi-0714-preview", + messages=[{"role": "user", "content": "hello"}], + tools=None, + max_tokens=4096, + reasoning_config={"enabled": False}, + base_url="https://api.moonshot.cn/anthropic/v1", + ) + assert "thinking" not in kwargs assert "output_config" not in kwargs def test_custom_endpoint_non_kimi_model_keeps_thinking(self) -> None: """Custom endpoint with a non-Kimi model must keep thinking intact. Guards against over-broad model-family matching — only model names - starting with a Kimi/Moonshot prefix should trigger suppression. + starting with a Kimi/Moonshot prefix should route to adaptive. """ from agent.anthropic_adapter import build_anthropic_kwargs From e361c5e20402375c74a65ca52810c6a380461226 Mon Sep 17 00:00:00 2001 From: mark Date: Sun, 19 Jul 2026 10:36:40 -0700 Subject: [PATCH 008/205] fix(desktop): support spaced Windows Git paths in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simple-git's custom-binary validation rejects paths containing spaces, so the default Windows Git install (C:\Program Files\Git\cmd\git.exe) made every Review pane git call throw and the pane silently showed 'No diffs'. The binary is resolved inside the Electron main process from known install locations or PATH — never renderer/user input — so for spaced paths we opt into simple-git's supported unsafe.allowUnsafeCustomBinary escape hatch rather than falling back to PATH (often absent in GUI-launched apps). Simplified from PR #64713 by @unsupportedpastels; supersedes the 8.3 short-path approaches in #55337/#60156. Fixes #54888 --- apps/desktop/electron/git-review-ops.test.ts | 26 +++++++++++++++++++- apps/desktop/electron/git-review-ops.ts | 16 +++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron/git-review-ops.test.ts b/apps/desktop/electron/git-review-ops.test.ts index a48c067ab1ab..3d102c082de1 100644 --- a/apps/desktop/electron/git-review-ops.test.ts +++ b/apps/desktop/electron/git-review-ops.test.ts @@ -6,7 +6,7 @@ import path from 'node:path' import { afterEach, test } from 'vitest' -import { repoStatus, resolveRenamePath } from './git-review-ops' +import { gitFor, repoStatus, resolveRenamePath } from './git-review-ops' const tempDirs: string[] = [] @@ -34,6 +34,30 @@ test('resolveRenamePath: plain path is unchanged', () => { assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts') }) +test('gitFor accepts an internally resolved git binary path containing spaces', () => { + assert.doesNotThrow(() => gitFor(process.cwd(), 'C:\\Program Files\\Git\\cmd\\git.exe')) +}) + +test('gitFor runs git through a spaced binary path', async () => { + if (process.platform !== 'win32') { + return + } + + const gitBin = path.join(process.env.ProgramFiles || String.raw`C:\Program Files`, 'Git', 'cmd', 'git.exe') + + if (!fs.existsSync(gitBin)) { + return + } + + const repo = makeRepo() + + fs.writeFileSync(path.join(repo, 'changed.txt'), 'review me\n') + + const status = await gitFor(repo, gitBin).status() + + assert.equal(status.not_added.includes('changed.txt'), true) +}) + test('resolveRenamePath: simple rename resolves to the new path', () => { assert.equal(resolveRenamePath('old.ts => new.ts'), 'new.ts') }) diff --git a/apps/desktop/electron/git-review-ops.ts b/apps/desktop/electron/git-review-ops.ts index 472e246ccf01..5fcb41fd0a18 100644 --- a/apps/desktop/electron/git-review-ops.ts +++ b/apps/desktop/electron/git-review-ops.ts @@ -43,7 +43,20 @@ function runGh(args, cwd, ghBin): Promise<{ ok: boolean; stdout: string }> { } function gitFor(cwd, gitBin) { - return simpleGit({ baseDir: cwd, binary: gitBin || 'git', maxConcurrentProcesses: 4, trimmed: false }) + // `gitBin` is resolved inside the Electron main process from known install + // locations or PATH — never renderer/user input. simple-git's custom-binary + // validation rejects paths containing spaces (the default Windows install is + // `C:\Program Files\Git\cmd\git.exe`), which silently broke the Review pane. + // For spaced paths, opt into simple-git's trusted-binary escape hatch instead + // of falling back to PATH (often absent in GUI-launched apps, and PATH lookup + // could resolve a repo-local git.exe). + return simpleGit({ + baseDir: cwd, + binary: gitBin || 'git', + maxConcurrentProcesses: 4, + trimmed: false, + ...(gitBin && /\s/.test(gitBin) ? { unsafe: { allowUnsafeCustomBinary: true } } : {}) + }) } // simple-git reports renames as `old => new` (and `dir/{old => new}/f`); resolve @@ -680,6 +693,7 @@ async function repoStatus(repoPath, gitBin) { export { branchBase, fileDiffVsHead, + gitFor, repoStatus, resolveRenamePath, reviewCommit, From 3345b3cdfdd193443748afb1e87d46be0b7baa9a Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sun, 19 Jul 2026 16:37:35 -0400 Subject: [PATCH 009/205] bench(desktop): make --spawn work + capture a real baseline (#67670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve the vite CLI via vite/package.json `bin` (Vite 8's exports block importing vite/bin/vite.js directly — --spawn failed with ERR_PACKAGE_PATH_NOT_EXPORTED). - Add a post-launch settle so cold-start contention (vite dep pre-bundling, first backend-connect attempts) doesn't contaminate the first scenario. - Drop the raw autolink from the default stream chunk (resolvable URLs trigger link-embed DNS lookups unrelated to render cost). - Replace seed baseline with real numbers from a darwin-arm64 --spawn run. keystroke + transcript are clean; stream is a clean single-run capture (the isolated backend may not connect, and its reconnect churn inflates frame pacing — re-capture on a connected instance for tighter tolerances). --- apps/desktop/scripts/perf/baseline.json | 36 +++++++++---------- apps/desktop/scripts/perf/lib/launch.mjs | 26 ++++++++++++-- .../desktop/scripts/perf/scenarios/stream.mjs | 4 ++- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/apps/desktop/scripts/perf/baseline.json b/apps/desktop/scripts/perf/baseline.json index 0999aa0f7fe7..b0cb9ef00303 100644 --- a/apps/desktop/scripts/perf/baseline.json +++ b/apps/desktop/scripts/perf/baseline.json @@ -1,37 +1,37 @@ { "_meta": { - "note": "SEED baseline only. Values are approximations from apps/desktop/scripts/profile-typing-lag.md (May 2026, 34MB session, PRE incremental-lex). Run `npm run perf -- --update-baseline` on YOUR reference device to capture real numbers, then commit. Tolerances are intentionally loose until then.", - "platform": null, + "note": "Captured on darwin-arm64 via `npm run perf -- --spawn`. keystroke + transcript are clean medians. stream = a clean single-run capture; under --spawn the isolated backend may not connect, and its reconnect churn inflates frame-pacing, so re-capture stream on a backend-CONNECTED instance (perf:serve against a live profile, or attach) for tighter tolerances. Re-baseline with `npm run perf -- --update-baseline`.", + "platform": "darwin-arm64", "node": null, - "updated": null + "updated": "2026-07-19" }, "scenarios": { "stream": { - "tolerance": { "tolFrac": 0.5, "tolAbs": 2 }, + "tolerance": { "tolFrac": 0.6, "tolAbs": 5 }, "metrics": { "longtasks_n": 2, - "longtask_max_ms": 127, - "frame_p95_ms": 25.6, - "frame_p99_ms": 31.4, - "slow_frames_33": 6, - "intermut_p95_ms": 45 + "longtask_max_ms": 97, + "frame_p95_ms": 18.4, + "frame_p99_ms": 28.5, + "slow_frames_33": 4, + "intermut_p95_ms": 37.1 } }, "keystroke": { - "tolerance": { "tolFrac": 0.5, "tolAbs": 3 }, + "tolerance": { "tolFrac": 0.6, "tolAbs": 4 }, "metrics": { - "keystroke_p50_ms": 8, - "keystroke_p95_ms": 17, - "keystroke_p99_ms": 28, - "keystroke_slow_16": 6 + "keystroke_p50_ms": 2.7, + "keystroke_p95_ms": 9.9, + "keystroke_p99_ms": 17.7, + "keystroke_slow_16": 2 } }, "transcript": { - "tolerance": { "tolFrac": 0.5, "tolAbs": 20 }, + "tolerance": { "tolFrac": 0.75, "tolAbs": 40 }, "metrics": { - "transcript_mount_ms": 450, - "transcript_longtask_ms": 350, - "transcript_longtask_max_ms": 160 + "transcript_mount_ms": 287.3, + "transcript_longtask_ms": 559, + "transcript_longtask_max_ms": 295 } } } diff --git a/apps/desktop/scripts/perf/lib/launch.mjs b/apps/desktop/scripts/perf/lib/launch.mjs index fbafff95931a..64490fcbe7e9 100644 --- a/apps/desktop/scripts/perf/lib/launch.mjs +++ b/apps/desktop/scripts/perf/lib/launch.mjs @@ -12,7 +12,7 @@ // spent regardless of the isolated backend. import { spawn } from 'node:child_process' -import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs' +import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { createRequire } from 'node:module' import { homedir, tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' @@ -69,6 +69,20 @@ function seedConfigFrom(sourceHome, targetHome) { } } +// Resolve the vite CLI entry via its package.json `bin` (Vite 8's `exports` +// blocks importing `vite/bin/vite.js` directly). +function resolveViteBin() { + const pkgPath = require.resolve('vite/package.json') + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.vite + + if (!rel) { + throw new Error('could not resolve the vite CLI from vite/package.json') + } + + return join(dirname(pkgPath), rel) +} + function runNode(scriptRelPath, args = []) { return new Promise((resolveRun, reject) => { const child = spawn(process.execPath, [join(DESKTOP_DIR, scriptRelPath), ...args], { @@ -99,7 +113,8 @@ export async function startIsolatedInstance({ hermesHome, userDataDir, seedConfig = true, - bootFakeStepMs = 120 + bootFakeStepMs = 120, + settleMs = 2500 } = {}) { const children = [] const tempDirs = [] @@ -141,7 +156,7 @@ export async function startIsolatedInstance({ try { // 1. Renderer: reuse an already-running dev server, else start one. if (!(await reachable(devUrl))) { - const viteBin = require.resolve('vite/bin/vite.js') + const viteBin = resolveViteBin() const vite = spawn(process.execPath, [viteBin, '--host', '127.0.0.1', '--port', String(devPort)], { cwd: DESKTOP_DIR, stdio: ['ignore', 'inherit', 'inherit'] @@ -194,6 +209,11 @@ export async function startIsolatedInstance({ { timeoutMs: 120000, label: 'isolated renderer + __PERF_DRIVE__' } ) + // Let cold-start contention (vite dep pre-bundling, first backend-connect + // attempts, initial paint) drain before scenarios measure, so numbers + // reflect steady state rather than launch noise. + await sleep(settleMs) + return { cdp, devUrl, diff --git a/apps/desktop/scripts/perf/scenarios/stream.mjs b/apps/desktop/scripts/perf/scenarios/stream.mjs index 0f42339132c1..a1c567934e9a 100644 --- a/apps/desktop/scripts/perf/scenarios/stream.mjs +++ b/apps/desktop/scripts/perf/scenarios/stream.mjs @@ -113,7 +113,9 @@ export default { const tokens = Number(opts.tokens ?? 400) const intervalMs = Number(opts.intervalMs ?? 16) const flushMinMs = Number(opts.flushMinMs ?? 33) - const chunk = opts.chunk ?? '**word** in _italic_ with `code`, a [link](https://x.dev) and prose. ' + // No raw autolink in the default chunk — a resolvable URL triggers link + // embeds / DNS lookups that add noise unrelated to render cost. + const chunk = opts.chunk ?? '**word** in _italic_ with `code` and a bit of ordinary prose. ' const real = Boolean(opts.real) await cdp.send('Runtime.enable') From 1b17015f7a8d0c0d68b1f08aa389538e7fd172e3 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sun, 19 Jul 2026 16:37:53 -0400 Subject: [PATCH 010/205] refactor(desktop): tidy session-color pass (#67671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sessionColorFor: drop the no-op `?? undefined` (the map read is already string | undefined). - sessionProjectColor: fix a now-stale doc line — a rootless (no cwd AND no git_repo_root) row returns null, not any cwd-less row (repo-root-only rows resolve since the grouped-but-grey fix). - ProjectMenu.applyAppearance: await instead of a .then block; flatten the auto-branch's nested ternary. --- .../chat/sidebar/projects/project-menu.tsx | 20 +++++++++---------- .../chat/sidebar/projects/workspace-groups.ts | 2 +- apps/desktop/src/store/session-color.ts | 2 +- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx index 5b77fe342a48..19d86a11cd62 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx @@ -112,13 +112,11 @@ export function ProjectMenu({ // Appearance writes route through the adopt-aware helper: an auto project is // materialized on its first change (its id then changes), so close the picker - // when that happens to avoid a second write double-creating from a stale node. - const applyAppearance = (patch: { color?: null | string; icon?: null | string }) => { - void setProjectAppearance(project, patch).then(adopted => { - if (adopted) { - setAppearanceOpen(false) - } - }) + // on adopt to stop a second write double-creating from a now-stale node. + const applyAppearance = async (patch: { color?: null | string; icon?: null | string }) => { + if (await setProjectAppearance(project, patch)) { + setAppearanceOpen(false) + } } // Set color / pick an icon — shown for explicit projects and for auto ones @@ -168,12 +166,12 @@ export function ProjectMenu({ // Inherited (auto) repos can still be themed — the change adopts the // repo as a real project. Rename / add-folder / set-active stay out // until then (they need the materialized record). - project.path ? ( + project.path && ( <> {appearanceItem} - ) : null + ) ) : ( <> openProjectRename(target)}> @@ -224,7 +222,7 @@ export function ProjectMenu({ applyAppearance({ color })} + onChange={color => void applyAppearance({ color })} swatches={PROFILE_SWATCHES} value={project.color ?? null} /> @@ -239,7 +237,7 @@ export function ProjectMenu({ project.icon === name && 'bg-(--ui-control-active-background) text-foreground' )} key={name} - onClick={() => applyAppearance({ icon: project.icon === name ? null : name })} + onClick={() => void applyAppearance({ icon: project.icon === name ? null : name })} style={project.icon === name && project.color ? { color: project.color } : undefined} type="button" > diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index 7931030feb11..fc57cb1cd28c 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -409,7 +409,7 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro * unless the user set one, so a session only tints when it belongs to a colored * project (inheritance is opt-in by coloring the project). Reuses * {@link liveSessionProjectId} so the color follows the SAME membership the - * sidebar groups by; returns null for cwd-less / kanban / out-of-tree rows and + * sidebar groups by; returns null for rootless / kanban / out-of-tree rows and * for sessions under an uncolored (or auto) project. */ export function sessionProjectColor(session: SessionInfo, projects: ProjectInfo[]): null | string { diff --git a/apps/desktop/src/store/session-color.ts b/apps/desktop/src/store/session-color.ts index 6d9c473958df..79426ae2d938 100644 --- a/apps/desktop/src/store/session-color.ts +++ b/apps/desktop/src/store/session-color.ts @@ -32,5 +32,5 @@ export const $sessionColorById = computed([$sessions, $projects], (sessions, pro // The color for a single session object (the tabs already hold the SessionInfo // they render, so they resolve through the same map the sidebar reads). export function sessionColorFor(session: null | SessionInfo | undefined): string | undefined { - return session ? ($sessionColorById.get()[session.id] ?? undefined) : undefined + return session ? $sessionColorById.get()[session.id] : undefined } From 0d2ad3993eb91c486854bc71e2721b747ab1d0f4 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sun, 19 Jul 2026 17:05:16 -0400 Subject: [PATCH 011/205] feat(desktop): per-session color override (#66565 layer 2) (#67681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a color picker to the session menu (an Appearance submenu of reusable ColorSwatches, in both the dropdown and right-click flavors). The pick is a per-session override that wins over the inherited project color; clearing falls back to it. Storage is desktop-local like pins ($sessionColorOverrides persistentAtom), keyed by the DURABLE lineage id so a color survives auto-compression's id rotation. Precedence folds into the existing $sessionColorById resolver, so sidebar rows AND pane tabs pick it up with no changes to either — the payoff of the shared store. To take this to the TUI later, promote this one atom to a backend SessionInfo.color field; the resolver and picker stay put. --- .../app/chat/sidebar/session-actions-menu.tsx | 70 +++++++++++++++++-- apps/desktop/src/store/session-color.test.ts | 40 ++++++++++- apps/desktop/src/store/session-color.ts | 57 +++++++++++---- 3 files changed, 147 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index d60f10016fc4..889ebd4e0d6a 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -10,11 +10,15 @@ import { } from '@/components/pane-shell/tree/store' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { ColorSwatches } from '@/components/ui/color-swatches' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger } from '@/components/ui/context-menu' import { CopyButton } from '@/components/ui/copy-button' @@ -31,16 +35,28 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { renameSession } from '@/hermes' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' +import { PROFILE_SWATCHES } from '@/lib/profile-color' import { exportSession } from '@/lib/session-export' import { activeGateway } from '@/store/gateway' import { notify, notifyError } from '@/store/notifications' -import { $activeSessionId, $selectedStoredSessionId, setSessions } from '@/store/session' +import { + $activeSessionId, + $selectedStoredSessionId, + $sessions, + sessionMatchesStoredId, + sessionPinId, + setSessions +} from '@/store/session' +import { $sessionColorOverrides, setSessionColorOverride } from '@/store/session-color' import { $sessionTiles, openSessionTile } from '@/store/session-states' import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows' @@ -116,14 +132,30 @@ interface SessionActions { type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem -/** A menu flavour (dropdown / context) — item + separator components. */ +/** A menu flavour (dropdown / context) — item + separator + submenu components. */ interface MenuKit { Item: MenuItem Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator + Sub: typeof DropdownMenuSub | typeof ContextMenuSub + SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger + SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent } -const DROPDOWN_KIT: MenuKit = { Item: DropdownMenuItem, Separator: DropdownMenuSeparator } -const CONTEXT_KIT: MenuKit = { Item: ContextMenuItem, Separator: ContextMenuSeparator } +const DROPDOWN_KIT: MenuKit = { + Item: DropdownMenuItem, + Separator: DropdownMenuSeparator, + Sub: DropdownMenuSub, + SubContent: DropdownMenuSubContent, + SubTrigger: DropdownMenuSubTrigger +} + +const CONTEXT_KIT: MenuKit = { + Item: ContextMenuItem, + Separator: ContextMenuSeparator, + Sub: ContextMenuSub, + SubContent: ContextMenuSubContent, + SubTrigger: ContextMenuSubTrigger +} interface ItemSpec { className?: string @@ -134,6 +166,27 @@ interface ItemSpec { variant?: 'destructive' } +// The color picker inside the session menu's Appearance submenu. Its own +// component so only an OPEN submenu subscribes to the stores (not every row's +// menu). Reads/writes the override keyed by the DURABLE id so a color survives +// compression; clearing falls back to the inherited project color. +function SessionColorSwatches({ sessionId }: { sessionId: string }) { + const { t } = useI18n() + const overrides = useStore($sessionColorOverrides) + const session = useStore($sessions).find(s => sessionMatchesStoredId(s, sessionId)) + const durableId = session ? sessionPinId(session) : sessionId + + return ( + setSessionColorOverride(durableId, color)} + swatches={PROFILE_SWATCHES} + value={overrides[durableId] ?? null} + /> + ) +} + function useSessionActions({ sessionId, title, @@ -326,6 +379,15 @@ function useSessionActions({ {openItems.map(item => renderMenuItem(kit.Item, item))} {openItems.length > 0 && } {identityItems.map(item => renderMenuItem(kit.Item, item))} + + + + {t.sidebar.projects.menuAppearance} + + + + + { $sessions.set([]) $projects.set([]) + $sessionColorOverrides.set({}) }) describe('$sessionColorById', () => { @@ -86,6 +87,43 @@ describe('$sessionColorById', () => { }) }) +describe('$sessionColorOverrides', () => { + it('an override wins over the inherited project color', () => { + const a = makeSession('/www/app', { git_repo_root: '/www/app' }) + + $projects.set([makeProject('p_app', ['/www/app'], '#4a9eff')]) + $sessions.set([a]) + setSessionColorOverride(a.id, '#ff0000') + + expect($sessionColorById.get()[a.id]).toBe('#ff0000') + }) + + it('clearing an override falls back to the project color', () => { + const a = makeSession('/www/app', { git_repo_root: '/www/app' }) + + $projects.set([makeProject('p_app', ['/www/app'], '#4a9eff')]) + $sessions.set([a]) + + setSessionColorOverride(a.id, '#ff0000') + expect($sessionColorById.get()[a.id]).toBe('#ff0000') + + setSessionColorOverride(a.id, null) + expect($sessionColorById.get()[a.id]).toBe('#4a9eff') + }) + + it('keys on the durable lineage id so a color survives compression', () => { + // The live id rotates on auto-compression; the override is stored against the + // lineage root, so the continuation tip still resolves to the same color. + const root = makeSession('/x', { id: 'root' }) + const tip = makeSession('/x', { id: 'tip', _lineage_root_id: 'root' }) + + setSessionColorOverride('root', '#abcdef') + + $sessions.set([tip]) + expect($sessionColorById.get().tip).toBe('#abcdef') + }) +}) + describe('sessionColorFor', () => { it('reads a single session through the same shared map', () => { const a = makeSession('/www/app', { git_repo_root: '/www/app' }) diff --git a/apps/desktop/src/store/session-color.ts b/apps/desktop/src/store/session-color.ts index 79426ae2d938..08f5f079448c 100644 --- a/apps/desktop/src/store/session-color.ts +++ b/apps/desktop/src/store/session-color.ts @@ -1,33 +1,60 @@ import { computed } from 'nanostores' import { sessionProjectColor } from '@/app/chat/sidebar/projects/workspace-groups' +import { Codecs, persistentAtom } from '@/lib/persisted' import { $projects } from '@/store/projects' -import { $sessions } from '@/store/session' +import { $sessions, sessionPinId } from '@/store/session' import type { SessionInfo } from '@/types/hermes' +// Per-session color OVERRIDES — a user-picked color that wins over the inherited +// project color (#66565 layer 2). Desktop-local like pins, keyed by the DURABLE +// lineage id so a color survives auto-compression's session-id rotation. To take +// this to the TUI later, promote this one atom to a backend SessionInfo.color +// field — the resolver below and the picker UI stay exactly as they are. +export const $sessionColorOverrides = persistentAtom>( + 'hermes.desktop.sessionColors', + {}, + Codecs.stringRecord +) + +// Set a session's override (null clears it → falls back to the project color). +export function setSessionColorOverride(durableId: string, color: null | string): void { + const prev = $sessionColorOverrides.get() + + if (color) { + $sessionColorOverrides.set({ ...prev, [durableId]: color }) + } else if (durableId in prev) { + const next = { ...prev } + delete next[durableId] + $sessionColorOverrides.set(next) + } +} + // The resolved color for every session, keyed by live session id — the ONE // source of truth both the sidebar rows and the pane tabs read, so the two -// surfaces can never drift. Recomputed only when the session list or the -// projects change (both cold atoms; the working/streaming pulse lives in +// surfaces can never drift. Recomputed only when the session list, projects, or +// overrides change (all cold atoms; the working/streaming pulse lives in // $sessionStates, so a busy flip never rebuilds this), and every consumer reads // it as an O(1) lookup rather than re-deriving membership per render. // -// Precedence lives in one place: today a session inherits its project's color; -// when per-session overrides / agent-set colors land (#66565 layers 2-3), fold -// them in ABOVE the project fallback here and every surface updates for free. -export const $sessionColorById = computed([$sessions, $projects], (sessions, projects) => { - const map: Record = {} +// Precedence in one place: an explicit per-session override wins over the +// inherited project color. Agent-set color (#66565 layer 3) slots in here too. +export const $sessionColorById = computed( + [$sessions, $projects, $sessionColorOverrides], + (sessions, projects, overrides) => { + const map: Record = {} - for (const session of sessions) { - const color = sessionProjectColor(session, projects) + for (const session of sessions) { + const color = overrides[sessionPinId(session)] ?? sessionProjectColor(session, projects) - if (color) { - map[session.id] = color + if (color) { + map[session.id] = color + } } - } - return map -}) + return map + } +) // The color for a single session object (the tabs already hold the SessionInfo // they render, so they resolve through the same map the sidebar reads). From dd418284db1804d33cd3d6d51c17bbfb1ad8f685 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sun, 19 Jul 2026 17:30:40 -0400 Subject: [PATCH 012/205] bench(desktop): trustworthy --spawn stream numbers + real baseline (#67694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chased the "stream frame p95 = 60ms with ZERO longtasks" mystery to its actual cause: the default stream chunk had no paragraph breaks, so it grew into one giant ~22KB block that re-rendered fully every flush — defeating the block memoization real streaming relies on. Plain text = 21ms; realistic chunk with `\n\n` breaks (blocks settle, only the tail re-renders) = 23ms. Fixed the default chunk to model real LLM output; a break-less `--chunk` remains available as a single-block worst-case stress. Also hardened the isolated instance so measurements reflect real cost: - Wait for the gateway socket to actually connect before measuring (a booting/ absent backend's reconnect backoff churns the main thread). Exposed via a new __PERF_DRIVE__.connected() probe reading $gateway.connectionState. - Focus emulation + anti-throttle/occlusion flags so a backgrounded perf window isn't frame-throttled (no OS focus stealing). - Generation-guarded the rAF frame recorder so repeated runs don't leave overlapping recorders polluting frame intervals. Baseline re-captured as the median of 5 --spawn runs (darwin-arm64); all three CI scenarios now green and stable. Absolute values are dev-build (noted in _meta) — regression guards, not shipped numbers. --- apps/desktop/scripts/perf/baseline.json | 45 +++++++----- apps/desktop/scripts/perf/lib/launch.mjs | 72 +++++++++++++++++-- .../desktop/scripts/perf/scenarios/stream.mjs | 19 +++-- apps/desktop/src/app/chat/perf-probe.tsx | 14 ++++ 4 files changed, 123 insertions(+), 27 deletions(-) diff --git a/apps/desktop/scripts/perf/baseline.json b/apps/desktop/scripts/perf/baseline.json index b0cb9ef00303..bbe15b56288d 100644 --- a/apps/desktop/scripts/perf/baseline.json +++ b/apps/desktop/scripts/perf/baseline.json @@ -1,37 +1,46 @@ { "_meta": { - "note": "Captured on darwin-arm64 via `npm run perf -- --spawn`. keystroke + transcript are clean medians. stream = a clean single-run capture; under --spawn the isolated backend may not connect, and its reconnect churn inflates frame-pacing, so re-capture stream on a backend-CONNECTED instance (perf:serve against a live profile, or attach) for tighter tolerances. Re-baseline with `npm run perf -- --update-baseline`.", + "note": "Median of 5 runs, darwin-arm64, `npm run perf -- --spawn` (dev renderer, gateway waited-for-connected, realistic block-settling stream chunk). Absolute values are dev-build (React dev mode is ~3x prod) so treat them as regression guards, not shipped numbers; re-baseline per device with `npm run perf -- --update-baseline`. Tolerances are loose to absorb cross-machine variance.", "platform": "darwin-arm64", - "node": null, - "updated": "2026-07-19" + "node": "v24.11.0", + "updated": "2026-07-19T21:24:19.047Z" }, "scenarios": { "stream": { - "tolerance": { "tolFrac": 0.6, "tolAbs": 5 }, + "tolerance": { + "tolFrac": 0.6, + "tolAbs": 5 + }, "metrics": { - "longtasks_n": 2, - "longtask_max_ms": 97, - "frame_p95_ms": 18.4, - "frame_p99_ms": 28.5, - "slow_frames_33": 4, - "intermut_p95_ms": 37.1 + "longtasks_n": 1, + "longtask_max_ms": 145, + "frame_p95_ms": 23.5, + "frame_p99_ms": 26.9, + "slow_frames_33": 1, + "intermut_p95_ms": 48.2 } }, "keystroke": { - "tolerance": { "tolFrac": 0.6, "tolAbs": 4 }, + "tolerance": { + "tolFrac": 0.6, + "tolAbs": 4 + }, "metrics": { - "keystroke_p50_ms": 2.7, - "keystroke_p95_ms": 9.9, - "keystroke_p99_ms": 17.7, + "keystroke_p50_ms": 2, + "keystroke_p95_ms": 8.2, + "keystroke_p99_ms": 17.4, "keystroke_slow_16": 2 } }, "transcript": { - "tolerance": { "tolFrac": 0.75, "tolAbs": 40 }, + "tolerance": { + "tolFrac": 0.75, + "tolAbs": 40 + }, "metrics": { - "transcript_mount_ms": 287.3, - "transcript_longtask_ms": 559, - "transcript_longtask_max_ms": 295 + "transcript_mount_ms": 280.1, + "transcript_longtask_ms": 431, + "transcript_longtask_max_ms": 221 } } } diff --git a/apps/desktop/scripts/perf/lib/launch.mjs b/apps/desktop/scripts/perf/lib/launch.mjs index 64490fcbe7e9..bc56626c0604 100644 --- a/apps/desktop/scripts/perf/lib/launch.mjs +++ b/apps/desktop/scripts/perf/lib/launch.mjs @@ -83,6 +83,28 @@ function resolveViteBin() { return join(dirname(pkgPath), rel) } +// Poll the perf driver's `connected()` until the gateway socket is open. +// Returns false if the probe predates this helper or the timeout elapses. +async function waitForConnected(cdp, timeoutMs) { + const hasProbe = await cdp.eval('typeof window.__PERF_DRIVE__.connected === "function"') + + if (!hasProbe) { + return false + } + + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + if (await cdp.eval('window.__PERF_DRIVE__.connected()')) { + return true + } + + await sleep(500) + } + + return false +} + function runNode(scriptRelPath, args = []) { return new Promise((resolveRun, reject) => { const child = spawn(process.execPath, [join(DESKTOP_DIR, scriptRelPath), ...args], { @@ -114,7 +136,8 @@ export async function startIsolatedInstance({ userDataDir, seedConfig = true, bootFakeStepMs = 120, - settleMs = 2500 + settleMs = 2500, + connectTimeoutMs = 90000 } = {}) { const children = [] const tempDirs = [] @@ -173,7 +196,21 @@ export async function startIsolatedInstance({ const electronBin = require('electron') const electron = spawn( electronBin, - ['.', `--user-data-dir=${userData}`, `--remote-debugging-port=${port}`], + [ + '.', + `--user-data-dir=${userData}`, + `--remote-debugging-port=${port}`, + // The perf window usually opens behind the user's other windows, and + // Chromium throttles frame production for backgrounded/occluded windows + // (~17fps), which shows up as choppy frames with ZERO longtasks and + // wrecks the stream frame-pacing metric. Disable every throttle path so + // measurements reflect real render cost regardless of window state + // (CalculateNativeWinOcclusion is the macOS/Windows occlusion detector). + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + '--disable-features=CalculateNativeWinOcclusion' + ], { cwd: DESKTOP_DIR, stdio: ['ignore', 'inherit', 'inherit'], @@ -209,12 +246,37 @@ export async function startIsolatedInstance({ { timeoutMs: 120000, label: 'isolated renderer + __PERF_DRIVE__' } ) - // Let cold-start contention (vite dep pre-bundling, first backend-connect - // attempts, initial paint) drain before scenarios measure, so numbers - // reflect steady state rather than launch noise. + // Electron throttles rAF/timers for a window that isn't foregrounded + // (per-window backgroundThrottling, which the Chromium CLI flags above don't + // override). Focus emulation makes the renderer behave as if focused so + // frame-pacing measurements are real even though the perf window sits behind + // the user's other windows — WITHOUT actually stealing OS focus. + try { + // Behave as if focused so frame-pacing isn't throttled while the perf + // window sits behind the user's IDE/terminal — WITHOUT stealing OS focus. + await cdp.send('Emulation.setFocusEmulationEnabled', { enabled: true }) + } catch { + // Older CDP / not supported — fall back to the anti-throttle flags above. + } + + // Wait for the gateway socket to actually open. A booting/absent backend + // retries on a 1–15s backoff, and that churn contaminates frame-pacing + // (the `stream` scenario). Best-effort: proceed after the timeout so the + // backend-independent scenarios (keystroke, transcript) still run. + const connected = await waitForConnected(cdp, connectTimeoutMs) + + if (!connected) { + console.warn( + `[perf] gateway did not connect within ${connectTimeoutMs}ms — ` + + 'stream/frame numbers may be inflated by reconnect churn.' + ) + } + + // Let residual cold-start work (vite dep pre-bundling, initial paint) drain. await sleep(settleMs) return { + connected, cdp, devUrl, port, diff --git a/apps/desktop/scripts/perf/scenarios/stream.mjs b/apps/desktop/scripts/perf/scenarios/stream.mjs index a1c567934e9a..66ae3ca58d53 100644 --- a/apps/desktop/scripts/perf/scenarios/stream.mjs +++ b/apps/desktop/scripts/perf/scenarios/stream.mjs @@ -10,10 +10,16 @@ import { frameHistogram, percentile } from '../lib/stats.mjs' const RECORDERS = ` (() => { + // Generation guard: a prior run's rAF loop re-reads window.__FT__ each frame, + // so simply reassigning it would leave the old loop running and pushing into + // the new array (overlapping recorders inflate frame intervals on run 2+). + // Bumping the generation makes every stale loop exit on its next tick. + window.__FT_GEN__ = (window.__FT_GEN__ || 0) + 1 + const ftGen = window.__FT_GEN__ window.__FT__ = { times: [], stop: false } let last = performance.now() const tick = () => { - if (window.__FT__.stop) return + if (window.__FT_GEN__ !== ftGen || window.__FT__.stop) return const now = performance.now() window.__FT__.times.push(now - last) last = now @@ -113,9 +119,14 @@ export default { const tokens = Number(opts.tokens ?? 400) const intervalMs = Number(opts.intervalMs ?? 16) const flushMinMs = Number(opts.flushMinMs ?? 33) - // No raw autolink in the default chunk — a resolvable URL triggers link - // embeds / DNS lookups that add noise unrelated to render cost. - const chunk = opts.chunk ?? '**word** in _italic_ with `code` and a bit of ordinary prose. ' + // Realistic default: a short markdown paragraph ending in a blank line, so + // blocks SETTLE as they stream — exactly how real LLM output behaves, and + // what block-memoization is designed for (only the growing tail re-renders). + // A chunk with NO paragraph break (e.g. `--chunk 'word '`) instead grows one + // ever-larger block that re-renders fully every flush — a useful worst-case + // stress, but not the typical number. No raw autolink (avoids DNS/link-embed + // noise unrelated to render cost). + const chunk = opts.chunk ?? 'A streamed sentence with **bold**, `code`, and ordinary prose like a normal reply.\n\n' const real = Boolean(opts.real) await cdp.send('Runtime.enable') diff --git a/apps/desktop/src/app/chat/perf-probe.tsx b/apps/desktop/src/app/chat/perf-probe.tsx index ad881450eca2..987d89fb428d 100644 --- a/apps/desktop/src/app/chat/perf-probe.tsx +++ b/apps/desktop/src/app/chat/perf-probe.tsx @@ -1,5 +1,6 @@ import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react' +import { $gateway } from '@/store/gateway' import { $messages, setBusy, setMessages } from '@/store/session' type Sample = { @@ -31,6 +32,12 @@ declare global { * proxy). Used by the `transcript` perf scenario. `reset()` restores. */ loadTranscript: (turns?: number) => Promise + /** + * Whether the active gateway socket is open. The perf harness waits on + * this before measuring so background reconnect churn (a booting/absent + * backend) doesn't contaminate frame-pacing numbers. + */ + connected: () => boolean reset: () => void snapshotMsgs: () => number } @@ -152,6 +159,13 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) { window.__PERF_DRIVE__ = { snapshotMsgs: () => $messages.get().length, + connected: () => { + try { + return $gateway.get()?.connectionState === 'open' + } catch { + return false + } + }, loadTranscript: (turns = 200) => { if (!baseline) { baseline = $messages.get() From b1fb3c528582d1282c7393f72ebb816c86bdc13c Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sun, 19 Jul 2026 18:52:39 -0400 Subject: [PATCH 013/205] =?UTF-8?q?bench(desktop):=20measure=20the=20full?= =?UTF-8?q?=20picture=20=E2=80=94=20prod=20build,=20cold-start,=20first-to?= =?UTF-8?q?ken=20(#67697)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop drip-feeding scenarios: extend the harness to cover the latencies that actually dominate perceived speed, and measure them on a REAL production build. - --prod: build a production renderer with the probe included (VITE_PERF_PROBE=1, off in normal builds) and launch it from dist/. Measures minified React, so numbers are representative shipped figures instead of ~3x-inflated dev ones. - cold-start scenario (tier "cold"): launch → CDP → driver → first paint, via a fresh isolated spawn per run. Captures spawn_to_cdp_ms, spawn_to_driver_ms, fcp_ms. - first-token scenario (backend tier): Enter → first assistant token painted — the TTFT latency an agent app is uniquely judged on. - run.mjs gained --prod (build once), cold-start fresh-spawn loop, and gates ci+cold tiers against the baseline. Baseline re-captured on a PRODUCTION build (median of 5), darwin-arm64 — all green. Representative numbers: cold-start spawn→interactive ~1.6s, FCP ~0.5s stream frame p95 22ms, 1 longtask keystroke p50 2ms, p95 8.7ms transcript mount 145ms, 82ms longtask (400-msg open) The prod build also settled the open question from the dev numbers: the transcript-mount "lead" (221ms longtask in dev) is only ~72-82ms in prod — not actionable. Measurement did its job. --- apps/desktop/scripts/perf/README.md | 22 +- apps/desktop/scripts/perf/baseline.json | 35 ++-- apps/desktop/scripts/perf/lib/launch.mjs | 195 +++++++++++------- apps/desktop/scripts/perf/run.mjs | 120 +++++++---- .../scripts/perf/scenarios/cold-start.mjs | 18 ++ .../scripts/perf/scenarios/first-token.mjs | 84 ++++++++ apps/desktop/scripts/perf/scenarios/index.mjs | 4 + apps/desktop/src/main.tsx | 6 +- 8 files changed, 358 insertions(+), 126 deletions(-) create mode 100644 apps/desktop/scripts/perf/scenarios/cold-start.mjs create mode 100644 apps/desktop/scripts/perf/scenarios/first-token.mjs diff --git a/apps/desktop/scripts/perf/README.md b/apps/desktop/scripts/perf/README.md index 56673c467348..e8cfc5752312 100644 --- a/apps/desktop/scripts/perf/README.md +++ b/apps/desktop/scripts/perf/README.md @@ -19,10 +19,21 @@ npm run perf # attaches, runs the CI suite, gates on baseline # One scenario, with a CPU profile: npm run perf -- stream --cpuprofile --tokens 800 +# Representative PRODUCTION numbers (minified React, not the ~3x-slower dev build): +npm run perf -- cold-start stream keystroke transcript --spawn --prod + # Re-capture the baseline on your reference device, then commit baseline.json: -npm run perf -- --update-baseline +npm run perf -- cold-start stream keystroke transcript --spawn --prod --update-baseline ``` +## Dev vs prod + +By default the harness measures the **dev** renderer (fast to spin up, good for +relative regression checks). Pass `--prod` (with `--spawn`) to build a +production renderer *with the probe included* (`VITE_PERF_PROBE=1`) and measure +minified React — the representative shipped numbers. The committed baseline is +captured with `--prod`. + ## Why isolation matters The measurement this harness exists to run was historically blocked: a running @@ -40,13 +51,16 @@ directly via `window.__PERF_DRIVE__`, so no LLM credits are spent. | `stream --real` | backend | same, from a real LLM stream | measure-real-stream, profile-real-stream | | `keystroke` | ci | composer keystroke → paint latency | measure-latency, profile-typing, leak-typing | | `transcript` | ci | large-transcript mount + paint cost | (new) | +| `cold-start` | cold | launch → CDP → driver → first paint (fresh spawn/run) | (new) | +| `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) | | `submit` | backend | Enter → cleared → user msg painted, scroll jump | measure-submit, measure-jump | | `session-switch` | backend | route → first-paint → settle | profile-session-switch | | `profile-switch` | backend | rail click → sidebar settled | measure-profile-switch | -`ci` scenarios need no backend/credits and are gated against `baseline.json`. -`backend` scenarios need a live backend (and `--spawn` or a real session) and -are report-only. +`ci` + `cold` scenarios need no backend/credits and are gated against +`baseline.json` (`cold-start` requires `--spawn` since it measures a fresh +launch, and must be run in its own invocation). `backend` scenarios need a live +backend (and `--spawn` or a real session/credits) and are report-only. CPU profiling is a cross-cutting `--cpuprofile` flag on any scenario (it wraps the run in `Profiler.start/stop` and prints a top-self-time table), replacing diff --git a/apps/desktop/scripts/perf/baseline.json b/apps/desktop/scripts/perf/baseline.json index bbe15b56288d..1de3fea43b90 100644 --- a/apps/desktop/scripts/perf/baseline.json +++ b/apps/desktop/scripts/perf/baseline.json @@ -1,9 +1,9 @@ { "_meta": { - "note": "Median of 5 runs, darwin-arm64, `npm run perf -- --spawn` (dev renderer, gateway waited-for-connected, realistic block-settling stream chunk). Absolute values are dev-build (React dev mode is ~3x prod) so treat them as regression guards, not shipped numbers; re-baseline per device with `npm run perf -- --update-baseline`. Tolerances are loose to absorb cross-machine variance.", + "note": "Median of 5 runs, darwin-arm64, `npm run perf -- cold-start stream keystroke transcript --spawn --prod` — a PRODUCTION renderer (minified React), so these are representative shipped numbers, not dev-inflated. Re-baseline per device with the same command + `--update-baseline`. Tolerances are loose to absorb cross-machine variance; cold-start especially varies with disk/OS state.", "platform": "darwin-arm64", "node": "v24.11.0", - "updated": "2026-07-19T21:24:19.047Z" + "updated": "2026-07-19T21:38:19.701Z" }, "scenarios": { "stream": { @@ -13,11 +13,11 @@ }, "metrics": { "longtasks_n": 1, - "longtask_max_ms": 145, - "frame_p95_ms": 23.5, - "frame_p99_ms": 26.9, + "longtask_max_ms": 67, + "frame_p95_ms": 22, + "frame_p99_ms": 23.7, "slow_frames_33": 1, - "intermut_p95_ms": 48.2 + "intermut_p95_ms": 36.1 } }, "keystroke": { @@ -26,9 +26,9 @@ "tolAbs": 4 }, "metrics": { - "keystroke_p50_ms": 2, - "keystroke_p95_ms": 8.2, - "keystroke_p99_ms": 17.4, + "keystroke_p50_ms": 2.1, + "keystroke_p95_ms": 8.7, + "keystroke_p99_ms": 16.9, "keystroke_slow_16": 2 } }, @@ -38,9 +38,20 @@ "tolAbs": 40 }, "metrics": { - "transcript_mount_ms": 280.1, - "transcript_longtask_ms": 431, - "transcript_longtask_max_ms": 221 + "transcript_mount_ms": 145, + "transcript_longtask_ms": 82, + "transcript_longtask_max_ms": 82 + } + }, + "cold-start": { + "tolerance": { + "tolFrac": 0.6, + "tolAbs": 150 + }, + "metrics": { + "spawn_to_cdp_ms": 326, + "spawn_to_driver_ms": 1647, + "fcp_ms": 500 } } } diff --git a/apps/desktop/scripts/perf/lib/launch.mjs b/apps/desktop/scripts/perf/lib/launch.mjs index bc56626c0604..c95da3f28545 100644 --- a/apps/desktop/scripts/perf/lib/launch.mjs +++ b/apps/desktop/scripts/perf/lib/launch.mjs @@ -105,17 +105,32 @@ async function waitForConnected(cdp, timeoutMs) { return false } -function runNode(scriptRelPath, args = []) { +function runProcess(command, args, { env } = {}) { return new Promise((resolveRun, reject) => { - const child = spawn(process.execPath, [join(DESKTOP_DIR, scriptRelPath), ...args], { + const child = spawn(command, args, { cwd: DESKTOP_DIR, - stdio: 'inherit' + stdio: 'inherit', + env: env ? { ...process.env, ...env } : process.env }) child.on('error', reject) - child.on('exit', code => (code === 0 ? resolveRun() : reject(new Error(`${scriptRelPath} exited ${code}`)))) + child.on('exit', code => (code === 0 ? resolveRun() : reject(new Error(`${command} ${args[0]} exited ${code}`)))) }) } +function runNode(scriptRelPath, args = []) { + return runProcess(process.execPath, [join(DESKTOP_DIR, scriptRelPath), ...args]) +} + +// Build a production renderer WITH the perf probe included (VITE_PERF_PROBE=1), +// plus the prod electron-main bundle, so the harness can measure a real, +// minified React build instead of the ~3x-slower dev build. Slow (a full vite +// build); do it once, then run/attach many times. +export async function buildProdRenderer() { + const viteBin = resolveViteBin() + await runProcess(process.execPath, [viteBin, 'build'], { env: { VITE_PERF_PROBE: '1' } }) + await runNode('scripts/bundle-electron-main.mjs') +} + /** Attach to a renderer already listening on `port` (launched via perf:serve or with --remote-debugging-port). */ export async function attach({ port = 9222, match } = {}) { const cdp = await CDP.connect({ port, match }) @@ -129,9 +144,29 @@ export async function attach({ port = 9222, match } = {}) { * and return `{ cdp, teardown, devUrl, port }`. `teardown` kills both children * and removes any temp dirs it created. */ +// Chromium switches that stop frame-production throttling for a window that +// isn't foregrounded (the perf window usually sits behind the IDE/terminal). +const ANTI_THROTTLE_FLAGS = [ + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + '--disable-features=CalculateNativeWinOcclusion' +] + +/** + * Spawn an isolated instance and connect the perf driver. Two render modes: + * · dev (default): vite dev server + dev electron-main bundle. + * · prod (`prod: true`): a production build (call buildProdRenderer first); + * electron loads dist/index.html — representative, minified React. + * `coldStart: true` skips the gateway-connect wait and settle (for launch-time + * measurement) and returns `timings` (spawn→CDP, spawn→driver) plus renderer + * boot marks (FCP, time-to-composer). + */ export async function startIsolatedInstance({ port = 9222, devPort = 5174, + prod = false, + coldStart = false, hermesHome, userDataDir, seedConfig = true, @@ -151,9 +186,8 @@ export async function startIsolatedInstance({ const home = hermesHome ?? mkTemp('hermes-perf-home-') const userData = userDataDir ?? mkTemp('hermes-perf-ud-') - const devUrl = `http://127.0.0.1:${devPort}` + const devUrl = prod ? null : `http://127.0.0.1:${devPort}` - // Only seed a temp home we created — never scribble into a user-provided one. if (seedConfig && !hermesHome) { seedConfigFrom(join(homedir(), '.hermes'), home) } @@ -177,61 +211,56 @@ export async function startIsolatedInstance({ } try { - // 1. Renderer: reuse an already-running dev server, else start one. - if (!(await reachable(devUrl))) { - const viteBin = resolveViteBin() - const vite = spawn(process.execPath, [viteBin, '--host', '127.0.0.1', '--port', String(devPort)], { - cwd: DESKTOP_DIR, - stdio: ['ignore', 'inherit', 'inherit'] - }) - children.push(vite) - await waitFor(() => reachable(devUrl), { timeoutMs: 60000, label: `vite dev server on :${devPort}` }) + if (prod) { + // Renderer + main are expected pre-built (buildProdRenderer). Cheap to + // re-bundle main so an isolated run always matches current source. + await runNode('scripts/bundle-electron-main.mjs') + } else { + if (!(await reachable(devUrl))) { + const viteBin = resolveViteBin() + const vite = spawn(process.execPath, [viteBin, '--host', '127.0.0.1', '--port', String(devPort)], { + cwd: DESKTOP_DIR, + stdio: ['ignore', 'inherit', 'inherit'] + }) + children.push(vite) + await waitFor(() => reachable(devUrl), { timeoutMs: 60000, label: `vite dev server on :${devPort}` }) + } + + await runNode('scripts/bundle-electron-main.mjs', ['--dev']) } - // 2. Electron main bundle (dev variant) — same step the dev script runs. - await runNode('scripts/bundle-electron-main.mjs', ['--dev']) - - // 3. Isolated Electron. --user-data-dir gives it its own single-instance - // lock scope; HERMES_HOME gives it its own backend + sessions. + // Isolated Electron: own --user-data-dir (single-instance lock scope) + own + // HERMES_HOME (backend + sessions). No DEV_SERVER env in prod → dist load. const electronBin = require('electron') + const env = { + ...process.env, + HERMES_HOME: home, + HERMES_DESKTOP_BOOT_FAKE: '1', + HERMES_DESKTOP_BOOT_FAKE_STEP_MS: String(bootFakeStepMs), + XCURSOR_SIZE: '24' + } + + if (devUrl) { + env.HERMES_DESKTOP_DEV_SERVER = devUrl + } + + const spawnAt = Date.now() const electron = spawn( electronBin, - [ - '.', - `--user-data-dir=${userData}`, - `--remote-debugging-port=${port}`, - // The perf window usually opens behind the user's other windows, and - // Chromium throttles frame production for backgrounded/occluded windows - // (~17fps), which shows up as choppy frames with ZERO longtasks and - // wrecks the stream frame-pacing metric. Disable every throttle path so - // measurements reflect real render cost regardless of window state - // (CalculateNativeWinOcclusion is the macOS/Windows occlusion detector). - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--disable-backgrounding-occluded-windows', - '--disable-features=CalculateNativeWinOcclusion' - ], - { - cwd: DESKTOP_DIR, - stdio: ['ignore', 'inherit', 'inherit'], - env: { - ...process.env, - HERMES_HOME: home, - HERMES_DESKTOP_DEV_SERVER: devUrl, - HERMES_DESKTOP_BOOT_FAKE: '1', - HERMES_DESKTOP_BOOT_FAKE_STEP_MS: String(bootFakeStepMs), - XCURSOR_SIZE: '24' - } - } + ['.', `--user-data-dir=${userData}`, `--remote-debugging-port=${port}`, ...ANTI_THROTTLE_FLAGS], + { cwd: DESKTOP_DIR, stdio: ['ignore', 'inherit', 'inherit'], env } ) children.push(electron) - // 4. Wait for the renderer + the perf driver to be live. + // Wait for the renderer + perf driver. In prod the target URL is file://, + // so don't match on the dev port. let cdp = null + let cdpAt = 0 await waitFor( async () => { try { - cdp = await CDP.connect({ port, match: String(devPort), timeoutMs: 2000 }) + cdp = await CDP.connect({ port, match: devUrl ? String(devPort) : undefined, timeoutMs: 2000 }) + cdpAt = cdpAt || Date.now() return await cdp.eval('!!(window.__PERF_DRIVE__ && window.__PERF_DRIVE__.stream)') } catch { @@ -245,41 +274,46 @@ export async function startIsolatedInstance({ }, { timeoutMs: 120000, label: 'isolated renderer + __PERF_DRIVE__' } ) + const driverAt = Date.now() - // Electron throttles rAF/timers for a window that isn't foregrounded - // (per-window backgroundThrottling, which the Chromium CLI flags above don't - // override). Focus emulation makes the renderer behave as if focused so - // frame-pacing measurements are real even though the perf window sits behind - // the user's other windows — WITHOUT actually stealing OS focus. try { - // Behave as if focused so frame-pacing isn't throttled while the perf - // window sits behind the user's IDE/terminal — WITHOUT stealing OS focus. await cdp.send('Emulation.setFocusEmulationEnabled', { enabled: true }) } catch { - // Older CDP / not supported — fall back to the anti-throttle flags above. + // Older CDP / not supported — fall back to the anti-throttle flags. } - // Wait for the gateway socket to actually open. A booting/absent backend - // retries on a 1–15s backoff, and that churn contaminates frame-pacing - // (the `stream` scenario). Best-effort: proceed after the timeout so the - // backend-independent scenarios (keystroke, transcript) still run. - const connected = await waitForConnected(cdp, connectTimeoutMs) - - if (!connected) { - console.warn( - `[perf] gateway did not connect within ${connectTimeoutMs}ms — ` + - 'stream/frame numbers may be inflated by reconnect churn.' - ) + // Renderer-side boot marks (relative to its own navigation start). + const bootMarks = await readBootMarks(cdp) + const timings = { + spawn_to_cdp_ms: cdpAt ? cdpAt - spawnAt : null, + spawn_to_driver_ms: driverAt - spawnAt, + ...bootMarks } - // Let residual cold-start work (vite dep pre-bundling, initial paint) drain. - await sleep(settleMs) + let connected = true + + if (!coldStart) { + // Steady-state scenarios: wait for the gateway to connect (reconnect churn + // contaminates frame pacing) and let residual cold-start work drain. + connected = await waitForConnected(cdp, connectTimeoutMs) + + if (!connected) { + console.warn( + `[perf] gateway did not connect within ${connectTimeoutMs}ms — ` + + 'stream/frame numbers may be inflated by reconnect churn.' + ) + } + + await sleep(settleMs) + } return { connected, cdp, devUrl, port, + prod, + timings, teardown: () => { cdp?.close() teardown() @@ -291,4 +325,25 @@ export async function startIsolatedInstance({ } } +// Read First Contentful Paint + time-to-composer from the renderer, relative to +// its navigation start (the process-spawn deltas live in `timings`). +async function readBootMarks(cdp) { + try { + return await cdp.eval(`(() => { + const paints = performance.getEntriesByType('paint') + const fcp = paints.find(p => p.name === 'first-contentful-paint') + const composer = document.querySelector('[data-slot="composer-rich-input"]') + return { + fcp_ms: fcp ? Math.round(fcp.startTime) : null, + // performance.now() at read time ≈ time since nav start; only meaningful + // right after boot (cold-start reads it immediately). + nav_to_read_ms: Math.round(performance.now()), + composer_present: !!composer + } + })()`) + } catch { + return { fcp_ms: null, nav_to_read_ms: null, composer_present: false } + } +} + export { DESKTOP_DIR } diff --git a/apps/desktop/scripts/perf/run.mjs b/apps/desktop/scripts/perf/run.mjs index 570e1b9e096f..2bf0c0649a2f 100644 --- a/apps/desktop/scripts/perf/run.mjs +++ b/apps/desktop/scripts/perf/run.mjs @@ -29,7 +29,7 @@ import { fileURLToPath } from 'node:url' import { withCpuProfile } from './lib/cdp.mjs' import { compareScenario, loadBaseline, updateBaseline } from './lib/baseline.mjs' -import { attach, startIsolatedInstance } from './lib/launch.mjs' +import { attach, buildProdRenderer, startIsolatedInstance } from './lib/launch.mjs' import { cpuProfileTopSelf, median } from './lib/stats.mjs' import { CI_SCENARIOS, SCENARIOS } from './scenarios/index.mjs' @@ -111,52 +111,94 @@ async function main() { const runs = Number(flags.runs ?? 1) const port = Number(flags.port ?? 9222) const devPort = Number(flags['dev-port'] ?? 5174) + const prod = 'prod' in flags const cpuProfile = 'cpuprofile' in flags const cpuProfileDir = typeof flags.cpuprofile === 'string' ? flags.cpuprofile : HERE - const connection = flags.spawn - ? await startIsolatedInstance({ port, devPort }) - : await attach({ port, match: String(devPort) }) + const coldNames = names.filter(n => SCENARIOS[n].tier === 'cold') + const liveNames = names.filter(n => SCENARIOS[n].tier !== 'cold') - const { cdp, teardown } = connection + // ci + cold metrics are stable enough to gate against the baseline; backend + // scenarios vary too much with the live environment, so they're report-only. + const GATED = new Set(['ci', 'cold']) + const baseline = loadBaseline(BASELINE_PATH) const results = [] let regressed = false - try { - const baseline = loadBaseline(BASELINE_PATH) + const record = (name, tier, metrics, detail) => { + const comparison = GATED.has(tier) ? compareScenario(name, metrics, baseline) : null + regressed = regressed || Boolean(comparison?.regressed) + results.push({ name, tier, metrics, detail }) + printMetrics(name, metrics, comparison) + } - for (const name of names) { - const scenario = SCENARIOS[name] - const perRun = [] - let detail = null - - for (let i = 0; i < runs; i++) { - if (cpuProfile && i === 0) { - const { result, profile } = await withCpuProfile(cdp, () => scenario.run(cdp, flags)) - const out = join(cpuProfileDir, `${name}-${Date.now()}.cpuprofile`) - writeFileSync(out, JSON.stringify(profile)) - console.log(`\n[cpuprofile] wrote ${out}`) - console.log('[cpuprofile] top self-time (ms):') - for (const r of cpuProfileTopSelf(profile, 15)) { - console.log(` ${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(38)} ${r.url}:${r.line}`) - } - perRun.push(result.metrics) - detail = result.detail - } else { - const result = await scenario.run(cdp, flags) - perRun.push(result.metrics) - detail = result.detail - } - } - - const metrics = medianMetrics(perRun) - const comparison = scenario.tier === 'ci' ? compareScenario(name, metrics, baseline) : null - regressed = regressed || Boolean(comparison?.regressed) - results.push({ name, tier: scenario.tier, metrics, detail }) - printMetrics(name, metrics, comparison) + if (prod) { + if (!flags.spawn) { + console.error('--prod requires --spawn (it builds and launches an isolated production renderer)') + process.exit(2) + } + + console.log('[perf] building production renderer with the probe (VITE_PERF_PROBE=1)…') + await buildProdRenderer() + } + + // Cold start measures the launch itself → a fresh spawn per run. + if (coldNames.length) { + if (!flags.spawn) { + console.error('cold-start requires --spawn (it measures a fresh launch)') + process.exit(2) + } + + const perRun = [] + + for (let i = 0; i < runs; i++) { + const inst = await startIsolatedInstance({ port, devPort, prod, coldStart: true }) + const t = inst.timings + perRun.push({ spawn_to_cdp_ms: t.spawn_to_cdp_ms, spawn_to_driver_ms: t.spawn_to_driver_ms, fcp_ms: t.fcp_ms }) + inst.teardown() + } + + record('cold-start', 'cold', medianMetrics(perRun), { runs }) + } + + // Steady-state scenarios share one persistent connection. + if (liveNames.length) { + const connection = flags.spawn + ? await startIsolatedInstance({ port, devPort, prod }) + : await attach({ port, match: prod ? undefined : String(devPort) }) + + const { cdp, teardown } = connection + + try { + for (const name of liveNames) { + const scenario = SCENARIOS[name] + const perRun = [] + let detail = null + + for (let i = 0; i < runs; i++) { + if (cpuProfile && i === 0) { + const { result, profile } = await withCpuProfile(cdp, () => scenario.run(cdp, flags)) + const out = join(cpuProfileDir, `${name}-${Date.now()}.cpuprofile`) + writeFileSync(out, JSON.stringify(profile)) + console.log(`\n[cpuprofile] wrote ${out}`) + console.log('[cpuprofile] top self-time (ms):') + for (const r of cpuProfileTopSelf(profile, 15)) { + console.log(` ${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(38)} ${r.url}:${r.line}`) + } + perRun.push(result.metrics) + detail = result.detail + } else { + const result = await scenario.run(cdp, flags) + perRun.push(result.metrics) + detail = result.detail + } + } + + record(name, scenario.tier, medianMetrics(perRun), detail) + } + } finally { + teardown() } - } finally { - teardown() } if (flags.json) { @@ -165,7 +207,7 @@ async function main() { } if (flags['update-baseline']) { - updateBaseline(BASELINE_PATH, results.filter(r => r.tier === 'ci')) + updateBaseline(BASELINE_PATH, results.filter(r => GATED.has(r.tier))) console.log(`\nupdated ${BASELINE_PATH}`) return } diff --git a/apps/desktop/scripts/perf/scenarios/cold-start.mjs b/apps/desktop/scripts/perf/scenarios/cold-start.mjs new file mode 100644 index 000000000000..0d67833c7b1a --- /dev/null +++ b/apps/desktop/scripts/perf/scenarios/cold-start.mjs @@ -0,0 +1,18 @@ +// Cold start — launch → renderer → interactive. Unlike the other scenarios this +// measures the LAUNCH itself, so it can't run against an already-up instance: +// the runner spawns a fresh isolated instance per run (requires --spawn) and +// reads the timings/boot-marks the launcher captures. Registered here so it's a +// known name with a baseline entry; the actual measurement lives in run.mjs. +// +// Metrics (lower is better): +// spawn_to_cdp_ms process spawn → CDP page target reachable (electron/V8 up) +// spawn_to_driver_ms process spawn → renderer mounted + perf driver present +// fcp_ms renderer nav start → first contentful paint +export default { + name: 'cold-start', + tier: 'cold', + description: 'Launch → first paint → interactive (fresh spawn per run).', + run() { + throw new Error('cold-start is measured by the runner via fresh spawns; use `--spawn`.') + } +} diff --git a/apps/desktop/scripts/perf/scenarios/first-token.mjs b/apps/desktop/scripts/perf/scenarios/first-token.mjs new file mode 100644 index 000000000000..55477c824be8 --- /dev/null +++ b/apps/desktop/scripts/perf/scenarios/first-token.mjs @@ -0,0 +1,84 @@ +// Time-to-first-token — Enter → first assistant token painted. The latency an +// agent app is uniquely judged on, spanning the desktop submit path AND the +// backend/agent-loop first-token time. Backend tier: fires a REAL prompt, needs +// a live backend (and credits). Report-only. +// +// node scripts/perf/run.mjs first-token --spawn --prompt "hi" + +import { SELECTORS, sleep, typeIntoComposer } from '../lib/cdp.mjs' +import { summarize } from '../lib/stats.mjs' + +export default { + name: 'first-token', + tier: 'backend', + description: 'Enter → first assistant token painted (real backend).', + async run(cdp, opts = {}) { + const rounds = Number(opts.rounds ?? 3) + const prompt = opts.prompt ?? 'reply with a single short sentence' + const timeoutMs = Number(opts.timeoutMs ?? 60000) + + await cdp.send('Runtime.enable') + + const firstTokens = [] + + for (let i = 0; i < rounds; i++) { + const baseText = await cdp.eval(`(() => { + const a = document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}) + return a.length ? a[a.length - 1].textContent.length : 0 + })()`) + const baseCount = await cdp.eval(`document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}).length`) + + await typeIntoComposer(cdp, `${prompt} (${i})`, { cps: 60 }) + const submitAt = Date.now() + await cdp.eval(`(() => { + const el = document.querySelector(${JSON.stringify(SELECTORS.composer)}) + el && el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true })) + })()`) + + const deadline = Date.now() + timeoutMs + let firstTokenMs = null + + while (Date.now() < deadline) { + await sleep(25) + const grown = await cdp.eval(`(() => { + const a = document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}) + if (a.length > ${baseCount}) return true + return a.length ? a[a.length - 1].textContent.length > ${baseText} : false + })()`) + + if (grown) { + firstTokenMs = Date.now() - submitAt + break + } + } + + if (firstTokenMs !== null) { + firstTokens.push(firstTokenMs) + } + + // Let the turn finish before the next round. + const turnDeadline = Date.now() + timeoutMs + while (Date.now() < turnDeadline) { + await sleep(250) + const busy = await cdp.eval(`!!document.querySelector('[data-status="running"], [data-busy="true"]')`) + + if (!busy) { + break + } + } + + await sleep(500) + } + + if (!firstTokens.length) { + throw new Error('no first token observed — is a backend with credits connected?') + } + + const s = summarize(firstTokens) + + return { + metrics: { first_token_p50_ms: s.p50, first_token_p95_ms: s.p95 }, + detail: { rounds, samples: firstTokens, summary: s } + } + } +} diff --git a/apps/desktop/scripts/perf/scenarios/index.mjs b/apps/desktop/scripts/perf/scenarios/index.mjs index 02771b2f0d98..d9aeece1effd 100644 --- a/apps/desktop/scripts/perf/scenarios/index.mjs +++ b/apps/desktop/scripts/perf/scenarios/index.mjs @@ -1,6 +1,8 @@ // Scenario registry. Add a scenario module here and it's automatically // available to the runner, the default suite (tier 'ci'), and the baseline gate. +import coldStart from './cold-start.mjs' +import firstToken from './first-token.mjs' import keystroke from './keystroke.mjs' import profileSwitch from './profile-switch.mjs' import sessionSwitch from './session-switch.mjs' @@ -12,6 +14,8 @@ export const SCENARIOS = { [stream.name]: stream, [keystroke.name]: keystroke, [transcript.name]: transcript, + [coldStart.name]: coldStart, + [firstToken.name]: firstToken, [submit.name]: submit, [sessionSwitch.name]: sessionSwitch, [profileSwitch.name]: profileSwitch diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 343816b8e024..b1dd657655ba 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -17,7 +17,11 @@ import { ThemeProvider } from './themes/context' installClipboardShim() -if (import.meta.env.MODE !== 'production') { +// The perf probe ships in dev, and in a production build ONLY when explicitly +// opted in (VITE_PERF_PROBE=1) — this lets the perf harness measure a real, +// minified production renderer for representative absolute numbers. Normal +// `npm run build` leaves the flag unset, so the probe never reaches users. +if (import.meta.env.MODE !== 'production' || import.meta.env.VITE_PERF_PROBE === '1') { import('./app/chat/perf-probe') } From 07ba9e9266c857d41d4f5b1787b9aa8d0fac4f3f Mon Sep 17 00:00:00 2001 From: Wesley Simplicio Date: Sun, 19 Jul 2026 20:05:44 -0300 Subject: [PATCH 014/205] fix(dashboard): don't let a provider-name query hide the selected provider's models (#65374) (#65413) Co-authored-by: Simplicio, Wesley (ext) --- web/src/components/ModelPickerDialog.tsx | 20 +++++++++++-- web/src/lib/model-picker-filter.test.ts | 38 ++++++++++++++++++++++++ web/src/lib/model-picker-filter.ts | 23 ++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/model-picker-filter.test.ts create mode 100644 web/src/lib/model-picker-filter.ts diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index b05e389bdb40..e73c959e6f8e 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -11,6 +11,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { cn, themedBody } from "@/lib/utils"; import { fuzzyRank } from "@/lib/fuzzy"; +import { queryMatchesProviderOnly } from "@/lib/model-picker-filter"; /** * Two-stage model picker modal. @@ -226,15 +227,30 @@ export function ModelPickerDialog(props: Props) { [providers, trimmedQuery], ); + // A query that matched the SELECTED provider by name/slug (not its models) + // located that provider — it shouldn't also hide that provider's models + // just because their ids don't share a substring with the provider name + // (e.g. typing "aws" to find "AWS Build" then finding zero of its Claude + // model ids contain "aws"). Fall back to an unfiltered model list in that + // case; a query that also matches a model id keeps filtering normally. + const queryMatchesSelectedProviderOnly = useMemo( + () => queryMatchesProviderOnly(selectedProvider, models, trimmedQuery), + [trimmedQuery, selectedProvider, models], + ); + // Fuzzy-ranked models carrying the matched character positions so the model // list can highlight why each entry matched. const filteredModels = useMemo( () => - fuzzyRank(models, trimmedQuery, (m) => m).map((r) => ({ + fuzzyRank( + models, + queryMatchesSelectedProviderOnly ? "" : trimmedQuery, + (m) => m, + ).map((r) => ({ model: r.item, positions: r.positions, })), - [models, trimmedQuery], + [models, trimmedQuery, queryMatchesSelectedProviderOnly], ); const canConfirm = !!selectedProvider && !!selectedModel && !applying; diff --git a/web/src/lib/model-picker-filter.test.ts b/web/src/lib/model-picker-filter.test.ts new file mode 100644 index 000000000000..4163d0782487 --- /dev/null +++ b/web/src/lib/model-picker-filter.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { queryMatchesProviderOnly } from "./model-picker-filter"; + +describe("queryMatchesProviderOnly", () => { + it("returns true when the query finds the provider but no model id (issue #65374)", () => { + // Reproduces the exact case from the issue: typing "aws" locates the + // "AWS Build" provider, but none of its Claude model ids contain "aws". + const provider = { name: "AWS Build", slug: "aws-build" }; + const models = ["claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5"]; + + expect(queryMatchesProviderOnly(provider, models, "aws")).toBe(true); + }); + + it("returns false when the query also matches a model id — keeps normal filtering", () => { + const provider = { name: "AWS Build", slug: "aws-build" }; + const models = ["claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5"]; + + expect(queryMatchesProviderOnly(provider, models, "sonnet")).toBe(false); + }); + + it("returns false when the query does not match the provider at all", () => { + const provider = { name: "AWS Build", slug: "aws-build" }; + const models = ["claude-sonnet-4.5"]; + + expect(queryMatchesProviderOnly(provider, models, "openrouter")).toBe(false); + }); + + it("returns false for an empty query", () => { + const provider = { name: "AWS Build", slug: "aws-build" }; + const models = ["claude-sonnet-4.5"]; + + expect(queryMatchesProviderOnly(provider, models, "")).toBe(false); + }); + + it("returns false when there is no selected provider", () => { + expect(queryMatchesProviderOnly(null, ["claude-sonnet-4.5"], "aws")).toBe(false); + }); +}); diff --git a/web/src/lib/model-picker-filter.ts b/web/src/lib/model-picker-filter.ts new file mode 100644 index 000000000000..6d173f8e694f --- /dev/null +++ b/web/src/lib/model-picker-filter.ts @@ -0,0 +1,23 @@ +import { fuzzyScoreMulti } from "@/lib/fuzzy"; + +/** + * True when `trimmedQuery` located the selected provider by name/slug but + * matches none of its models by id — the case where a single search box + * filtering both the provider and model columns would otherwise leave the + * model pane empty even though the user just successfully found the + * provider they were looking for. + */ +export function queryMatchesProviderOnly( + selectedProvider: { name: string; slug: string } | null, + models: readonly string[], + trimmedQuery: string, +): boolean { + if (!trimmedQuery || !selectedProvider) return false; + + const matchesProvider = + fuzzyScoreMulti(`${selectedProvider.name} ${selectedProvider.slug}`, trimmedQuery) != + null; + const matchesAnyModel = models.some((m) => fuzzyScoreMulti(m, trimmedQuery) != null); + + return matchesProvider && !matchesAnyModel; +} From 1cf2c763efb0f60a22edfa5b45c4f550c29e0466 Mon Sep 17 00:00:00 2001 From: HexLab <8422520+HexLab98@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:09:12 +0700 Subject: [PATCH 015/205] fix(dashboard): opaque MoA presets modal (stop page bleed-through) (#67410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): make MoA presets modal opaque and readable Card defaults to bg-background-base/80 glass, so the Mixture of Agents dialog let the Models page bleed through — especially on Cyberpunk/mobile. Portal an opaque dialog shell above the z-2 dashboard column, and ignore Escape while the nested model picker is open. * test(web): lock dashboard modal shell to opaque panel classes Guard the MoA/dialog shell contract so glass Card defaults cannot quietly return to modal panels, and Escape stays picker-aware. --- web/src/lib/dashboard-modal-shell.test.ts | 23 +++++++++ web/src/lib/dashboard-modal-shell.ts | 29 +++++++++++ web/src/pages/ModelsPage.tsx | 63 +++++++++++++++++++---- 3 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 web/src/lib/dashboard-modal-shell.test.ts create mode 100644 web/src/lib/dashboard-modal-shell.ts diff --git a/web/src/lib/dashboard-modal-shell.test.ts b/web/src/lib/dashboard-modal-shell.test.ts new file mode 100644 index 000000000000..2f358ebd21d1 --- /dev/null +++ b/web/src/lib/dashboard-modal-shell.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + DASHBOARD_MODAL_BACKDROP, + DASHBOARD_MODAL_PANEL, + shouldCloseOuterModalOnEscape, +} from "./dashboard-modal-shell"; + +describe("dashboard modal shell", () => { + it("uses an opaque panel (bg-card), not the glass Card default", () => { + expect(DASHBOARD_MODAL_PANEL).toMatch(/\bbg-card\b/); + expect(DASHBOARD_MODAL_PANEL).not.toMatch(/bg-background-base\/\d+/); + }); + + it("keeps the backdrop above page chrome (z-[100])", () => { + expect(DASHBOARD_MODAL_BACKDROP).toMatch(/z-\[100\]/); + expect(DASHBOARD_MODAL_BACKDROP).toMatch(/\bbg-background\/85\b/); + }); + + it("does not close the outer modal on Escape while a nested picker is open", () => { + expect(shouldCloseOuterModalOnEscape(true)).toBe(false); + expect(shouldCloseOuterModalOnEscape(false)).toBe(true); + }); +}); diff --git a/web/src/lib/dashboard-modal-shell.ts b/web/src/lib/dashboard-modal-shell.ts new file mode 100644 index 000000000000..64cabbd84cae --- /dev/null +++ b/web/src/lib/dashboard-modal-shell.ts @@ -0,0 +1,29 @@ +/** + * Shared dashboard dialog shell classes. + * + * Page `` defaults to `bg-background-base/80` (glass). That looks fine + * on the page canvas, but as a modal panel it lets the Models page bleed + * through and kills readability — especially on Cyberpunk / mobile. + * + * Modal panels must use opaque `bg-card`; backdrops use the same z-index + * band as Auxiliary / Confirm dialogs so they sit above page chrome. + * + * Callers must `createPortal(..., document.body)` — `z-[100]` alone cannot + * escape the dashboard column's `relative z-2` stacking context (see + * ModelPickerDialog / ToolsetConfigDrawer). + */ +export const DASHBOARD_MODAL_BACKDROP = + "fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"; + +export const DASHBOARD_MODAL_PANEL = + "relative w-full border border-border bg-card shadow-2xl"; + +/** + * Outer modals that host a nested picker (e.g. MoA → ModelPickerDialog) + * must ignore Escape while the picker is open; the picker owns that key. + */ +export function shouldCloseOuterModalOnEscape( + nestedPickerOpen: boolean, +): boolean { + return !nestedPickerOpen; +} diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index 504c8ddb54ee..e4fb338bc970 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { createPortal } from "react-dom"; import { Brain, ChevronDown, @@ -22,6 +23,11 @@ import type { ModelsAnalyticsResponse, } from "@/lib/api"; import { timeAgo, cn, themedBody } from "@/lib/utils"; +import { + DASHBOARD_MODAL_BACKDROP, + DASHBOARD_MODAL_PANEL, + shouldCloseOuterModalOnEscape, +} from "@/lib/dashboard-modal-shell"; import { formatTokenCount } from "@/lib/format"; import { Button } from "@nous-research/ui/ui/components/button"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; @@ -711,6 +717,17 @@ function MoaModelsModal({ const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // Nested ModelPickerDialog owns Escape while open — don't dismiss MoA too. + const closeMoaUnlessPickerOpen = useCallback(() => { + if (!shouldCloseOuterModalOnEscape(picker !== null)) return; + onClose(); + }, [picker, onClose]); + + const modalRef = useModalBehavior({ + open: true, + onClose: closeMoaUnlessPickerOpen, + }); + const presetNames = Object.keys(draft.presets || {}); const preset = draft.presets[selected] || draft.presets[presetNames[0]]; const slotLabel = (slot: MoaModelSlot) => `${slot.provider || "(provider)"} · ${slot.model || "(model)"}`; @@ -778,13 +795,36 @@ function MoaModelsModal({ if (!preset) return null; - return ( -
- - - Configure Mixture of Agents presets - - + // Portal to document.body: the main dashboard column is `relative z-2`, + // which traps fixed descendants below the sidebar (same as ModelPickerDialog). + return createPortal( +
{ + if (e.target === e.currentTarget) closeMoaUnlessPickerOpen(); + }} + role="dialog" + aria-modal="true" + aria-labelledby="moa-modal-title" + > + {/* Opaque panel — do not use here; Card defaults to bg-background-base/80. */} +
+
+

+ Configure Mixture of Agents presets +

+
+

Presets appear as models under the Mixture of Agents provider. References produce perspectives; the aggregator is the acting model that answers and calls tools.

@@ -835,10 +875,10 @@ function MoaModelsModal({ {error &&
{error}
}
- +
- - +
+
{picker && ( setPicker(null)} /> )} -
+
, + document.body, ); } From b6ae910d8c1b2e5841ff45a3e85031c93d754b64 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sun, 19 Jul 2026 19:13:04 -0400 Subject: [PATCH 016/205] bench(desktop): trustworthy cold-start measurement (code-splitting is not the lever) (#67720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bench(desktop): measure the full picture — prod build, cold-start, first-token Stop drip-feeding scenarios: extend the harness to cover the latencies that actually dominate perceived speed, and measure them on a REAL production build. - --prod: build a production renderer with the probe included (VITE_PERF_PROBE=1, off in normal builds) and launch it from dist/. Measures minified React, so numbers are representative shipped figures instead of ~3x-inflated dev ones. - cold-start scenario (tier "cold"): launch → CDP → driver → first paint, via a fresh isolated spawn per run. Captures spawn_to_cdp_ms, spawn_to_driver_ms, fcp_ms. - first-token scenario (backend tier): Enter → first assistant token painted — the TTFT latency an agent app is uniquely judged on. - run.mjs gained --prod (build once), cold-start fresh-spawn loop, and gates ci+cold tiers against the baseline. Baseline re-captured on a PRODUCTION build (median of 5), darwin-arm64 — all green. Representative numbers: cold-start spawn→interactive ~1.6s, FCP ~0.5s stream frame p95 22ms, 1 longtask keystroke p50 2ms, p95 8.7ms transcript mount 145ms, 82ms longtask (400-msg open) The prod build also settled the open question from the dev numbers: the transcript-mount "lead" (221ms longtask in dev) is only ~72-82ms in prod — not actionable. Measurement did its job. * bench(desktop): trustworthy cold-start measurement (code-splitting is NOT the lever) Investigated code-splitting the ~22MB renderer bundle to cut cold start. It is the wrong fix on both counts: 1. Intentional design: vite.config disables codeSplitting because Shiki emits thousands of dynamic chunks and electron-builder OOMs scanning them — a packaging/installer constraint, not an oversight. 2. The data says it wouldn't help. Fixing the cold-start measurement to be trustworthy and reading the boot composition (prod build): spawn → interactive ~1.5s renderer nav → DOMInteractive ~0.8s, → DOMContentLoaded ~1.06s so the whole 22MB bundle EVAL is only ~0.27s (DCL − DOMInteractive) of the ~1.5s. The dominant costs are Electron/window startup and React app mount — neither touched by splitting. The measurement fixes (the real content of this PR — no app change, since the optimization was rejected): - Drop HERMES_DESKTOP_BOOT_FAKE from spawned instances — it injected artificial per-phase boot-overlay sleeps that inflated cold-start (and slowed every run). - Unique debug/dev port per cold-start run — a just-killed instance can hold :9222 briefly, so reusing it made CDP attach to the DYING instance and report garbage (spawn_to_cdp of ~4ms). Stepping the port per run fixes the race. - Richer boot marks (dom_interactive, dom_content_loaded, main-script size) so cold-start composition is visible, not just a single number. - Forward all numeric boot marks from the cold-start loop. - Re-baseline cold-start with the clean numbers. A real cold-start win would target Electron startup / app-mount (e.g. V8 code cache, deferred non-critical mount) — a future pass, now that it's measurable. --- apps/desktop/scripts/perf/baseline.json | 12 ++++++----- apps/desktop/scripts/perf/lib/launch.mjs | 26 ++++++++++++++++-------- apps/desktop/scripts/perf/run.mjs | 10 ++++++--- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/apps/desktop/scripts/perf/baseline.json b/apps/desktop/scripts/perf/baseline.json index 1de3fea43b90..e24e85fb9d59 100644 --- a/apps/desktop/scripts/perf/baseline.json +++ b/apps/desktop/scripts/perf/baseline.json @@ -1,9 +1,9 @@ { "_meta": { - "note": "Median of 5 runs, darwin-arm64, `npm run perf -- cold-start stream keystroke transcript --spawn --prod` — a PRODUCTION renderer (minified React), so these are representative shipped numbers, not dev-inflated. Re-baseline per device with the same command + `--update-baseline`. Tolerances are loose to absorb cross-machine variance; cold-start especially varies with disk/OS state.", + "note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot — no fake-boot). Representative shipped numbers, not dev-inflated. cold-start uses a fresh unique-port spawn per run and its marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances are loose for cross-machine/disk variance.", "platform": "darwin-arm64", "node": "v24.11.0", - "updated": "2026-07-19T21:38:19.701Z" + "updated": "2026-07-19T22:59:21.605Z" }, "scenarios": { "stream": { @@ -49,9 +49,11 @@ "tolAbs": 150 }, "metrics": { - "spawn_to_cdp_ms": 326, - "spawn_to_driver_ms": 1647, - "fcp_ms": 500 + "spawn_to_cdp_ms": 1098, + "spawn_to_driver_ms": 1482, + "dom_interactive_ms": 794, + "dom_content_loaded_ms": 1057, + "nav_to_read_ms": 1209 } } } diff --git a/apps/desktop/scripts/perf/lib/launch.mjs b/apps/desktop/scripts/perf/lib/launch.mjs index c95da3f28545..46a9cba7e1e9 100644 --- a/apps/desktop/scripts/perf/lib/launch.mjs +++ b/apps/desktop/scripts/perf/lib/launch.mjs @@ -170,7 +170,6 @@ export async function startIsolatedInstance({ hermesHome, userDataDir, seedConfig = true, - bootFakeStepMs = 120, settleMs = 2500, connectTimeoutMs = 90000 } = {}) { @@ -232,11 +231,13 @@ export async function startIsolatedInstance({ // Isolated Electron: own --user-data-dir (single-instance lock scope) + own // HERMES_HOME (backend + sessions). No DEV_SERVER env in prod → dist load. const electronBin = require('electron') + // NB: do NOT set HERMES_DESKTOP_BOOT_FAKE here — it injects artificial + // per-phase sleeps into the boot overlay, which inflates cold-start timing + // (and adds pointless startup latency to the steady-state runs). We want the + // real boot sequence. const env = { ...process.env, HERMES_HOME: home, - HERMES_DESKTOP_BOOT_FAKE: '1', - HERMES_DESKTOP_BOOT_FAKE_STEP_MS: String(bootFakeStepMs), XCURSOR_SIZE: '24' } @@ -332,17 +333,26 @@ async function readBootMarks(cdp) { return await cdp.eval(`(() => { const paints = performance.getEntriesByType('paint') const fcp = paints.find(p => p.name === 'first-contentful-paint') + const nav = performance.getEntriesByType('navigation')[0] const composer = document.querySelector('[data-slot="composer-rich-input"]') + // Largest script resource ≈ the (intentionally single) renderer bundle. + // responseEnd → the script's own decode; the eval cost shows up as the gap + // between the bundle's responseEnd and domInteractive. + const scripts = performance.getEntriesByType('resource').filter(r => r.initiatorType === 'script') + const mainScript = scripts.sort((a, b) => (b.encodedBodySize || 0) - (a.encodedBodySize || 0))[0] + const round = n => (typeof n === 'number' ? Math.round(n) : null) return { - fcp_ms: fcp ? Math.round(fcp.startTime) : null, - // performance.now() at read time ≈ time since nav start; only meaningful - // right after boot (cold-start reads it immediately). - nav_to_read_ms: Math.round(performance.now()), + fcp_ms: fcp ? round(fcp.startTime) : null, + dom_interactive_ms: nav ? round(nav.domInteractive) : null, + dom_content_loaded_ms: nav ? round(nav.domContentLoadedEventEnd) : null, + main_script_kb: mainScript ? round((mainScript.encodedBodySize || 0) / 1024) : null, + main_script_response_end_ms: mainScript ? round(mainScript.responseEnd) : null, + nav_to_read_ms: round(performance.now()), composer_present: !!composer } })()`) } catch { - return { fcp_ms: null, nav_to_read_ms: null, composer_present: false } + return { fcp_ms: null, dom_interactive_ms: null, composer_present: false } } } diff --git a/apps/desktop/scripts/perf/run.mjs b/apps/desktop/scripts/perf/run.mjs index 2bf0c0649a2f..16db25f05090 100644 --- a/apps/desktop/scripts/perf/run.mjs +++ b/apps/desktop/scripts/perf/run.mjs @@ -152,9 +152,13 @@ async function main() { const perRun = [] for (let i = 0; i < runs; i++) { - const inst = await startIsolatedInstance({ port, devPort, prod, coldStart: true }) - const t = inst.timings - perRun.push({ spawn_to_cdp_ms: t.spawn_to_cdp_ms, spawn_to_driver_ms: t.spawn_to_driver_ms, fcp_ms: t.fcp_ms }) + // Unique debug + dev port per run: a just-killed instance can keep :9222 + // held for a beat, and reusing it makes the next CDP.connect attach to the + // dying instance (garbage timings). Stepping the port sidesteps the race. + const inst = await startIsolatedInstance({ port: port + i, devPort: devPort + i, prod, coldStart: true }) + // Forward every numeric boot mark; only the baseline keys are gated, the + // rest (dom_interactive, main_script_kb, …) are reported for composition. + perRun.push(Object.fromEntries(Object.entries(inst.timings).filter(([, v]) => typeof v === 'number'))) inst.teardown() } From 8142331616da7d005f66455aaec8aa7919ae14f3 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sun, 19 Jul 2026 19:21:45 -0400 Subject: [PATCH 017/205] bench(desktop): measure representative (warm-cache) cold start (#67733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiling the boot answered "is there a real cold-start win?": no wasteful hotspot — the renderer does only ~tens of ms of work at mount, no heavy library (shiki/mermaid/katex/d3/motion) initializes at startup; the rest is Electron runtime + waiting, near the Electron floor. It also exposed that the cold-start number was pessimistic: a fresh --user-data-dir per run means a COLD V8 code cache and worst-case bundle recompile every launch. Real users reuse their profile. Measured delta: fresh (cold cache): spawn→interactive ~1.48s reused (warm cache): ~1.0s So representative launch is ~1.0s; only first-launch-after-install pays ~+400ms. - coldStartSamples() reuses one profile (run 0 warms the cache, discarded; runs 1..N are warm samples), stepping ports + pausing so the single-instance lock releases. `--cold-fresh` measures the first-launch worst case. - Re-baselined cold-start with the representative warm numbers. Net: nothing high-ROI left to optimize. The only lever is shipping a pre-warmed V8 code cache to make first launch match warm (~400ms, once per update) — real packaging complexity for a marginal win, deliberately not pursued. --- apps/desktop/scripts/perf/baseline.json | 14 +++--- apps/desktop/scripts/perf/lib/launch.mjs | 59 ++++++++++++++++++++++++ apps/desktop/scripts/perf/run.mjs | 19 ++------ 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/apps/desktop/scripts/perf/baseline.json b/apps/desktop/scripts/perf/baseline.json index e24e85fb9d59..392993f618c6 100644 --- a/apps/desktop/scripts/perf/baseline.json +++ b/apps/desktop/scripts/perf/baseline.json @@ -1,9 +1,9 @@ { "_meta": { - "note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot — no fake-boot). Representative shipped numbers, not dev-inflated. cold-start uses a fresh unique-port spawn per run and its marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances are loose for cross-machine/disk variance.", + "note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot — no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.", "platform": "darwin-arm64", "node": "v24.11.0", - "updated": "2026-07-19T22:59:21.605Z" + "updated": "2026-07-19T23:16:01.227Z" }, "scenarios": { "stream": { @@ -49,11 +49,11 @@ "tolAbs": 150 }, "metrics": { - "spawn_to_cdp_ms": 1098, - "spawn_to_driver_ms": 1482, - "dom_interactive_ms": 794, - "dom_content_loaded_ms": 1057, - "nav_to_read_ms": 1209 + "spawn_to_cdp_ms": 606, + "spawn_to_driver_ms": 984, + "dom_interactive_ms": 324, + "dom_content_loaded_ms": 574, + "nav_to_read_ms": 721 } } } diff --git a/apps/desktop/scripts/perf/lib/launch.mjs b/apps/desktop/scripts/perf/lib/launch.mjs index 46a9cba7e1e9..08410bbf2869 100644 --- a/apps/desktop/scripts/perf/lib/launch.mjs +++ b/apps/desktop/scripts/perf/lib/launch.mjs @@ -326,6 +326,65 @@ export async function startIsolatedInstance({ } } +// Representative cold-start sampling. A fresh --user-data-dir means a COLD V8 +// code cache and worst-case bundle recompile every run (~+400ms measured); real +// users reuse their profile, so a warm cache is the representative case. We reuse +// ONE profile across runs: run 0 warms the cache (discarded), runs 1..N are the +// warm samples. Each run steps the port so a just-killed instance can't be +// re-attached, and we pause between runs so the single-instance lock releases. +export async function coldStartSamples({ runs = 3, port = 9222, devPort = 5174, prod = false, warm = true } = {}) { + const pickNumeric = timings => Object.fromEntries(Object.entries(timings).filter(([, v]) => typeof v === 'number')) + const samples = [] + + if (warm) { + // Shared profile across runs: run 0 warms the V8 code cache (discarded), + // runs 1..N are the representative warm samples. + const home = mkdtempSync(join(tmpdir(), 'hermes-perf-cold-home-')) + const userDataDir = mkdtempSync(join(tmpdir(), 'hermes-perf-cold-ud-')) + seedConfigFrom(join(homedir(), '.hermes'), home) + + try { + for (let i = 0; i <= runs; i++) { + const inst = await startIsolatedInstance({ + port: port + i, + devPort: devPort + i, + prod, + coldStart: true, + hermesHome: home, + userDataDir, + seedConfig: false + }) + + if (i > 0) { + samples.push(pickNumeric(inst.timings)) + } + + inst.teardown() + await sleep(2500) // let the single-instance lock release before reuse + } + } finally { + for (const dir of [home, userDataDir]) { + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + // best-effort + } + } + } + } else { + // Worst case: a fresh profile per run → cold code cache every launch + // (first-launch-after-install). startIsolatedInstance makes+removes its dirs. + for (let i = 0; i < runs; i++) { + const inst = await startIsolatedInstance({ port: port + i, devPort: devPort + i, prod, coldStart: true }) + samples.push(pickNumeric(inst.timings)) + inst.teardown() + await sleep(2500) + } + } + + return samples +} + // Read First Contentful Paint + time-to-composer from the renderer, relative to // its navigation start (the process-spawn deltas live in `timings`). async function readBootMarks(cdp) { diff --git a/apps/desktop/scripts/perf/run.mjs b/apps/desktop/scripts/perf/run.mjs index 16db25f05090..6d4dc757c249 100644 --- a/apps/desktop/scripts/perf/run.mjs +++ b/apps/desktop/scripts/perf/run.mjs @@ -29,7 +29,7 @@ import { fileURLToPath } from 'node:url' import { withCpuProfile } from './lib/cdp.mjs' import { compareScenario, loadBaseline, updateBaseline } from './lib/baseline.mjs' -import { attach, buildProdRenderer, startIsolatedInstance } from './lib/launch.mjs' +import { attach, buildProdRenderer, coldStartSamples, startIsolatedInstance } from './lib/launch.mjs' import { cpuProfileTopSelf, median } from './lib/stats.mjs' import { CI_SCENARIOS, SCENARIOS } from './scenarios/index.mjs' @@ -149,20 +149,11 @@ async function main() { process.exit(2) } - const perRun = [] + // Representative WARM-cache samples (see coldStartSamples). Pass --cold-fresh + // to instead measure the worst-case first-launch (cold code cache). + const perRun = await coldStartSamples({ runs, port, devPort, prod, warm: !('cold-fresh' in flags) }) - for (let i = 0; i < runs; i++) { - // Unique debug + dev port per run: a just-killed instance can keep :9222 - // held for a beat, and reusing it makes the next CDP.connect attach to the - // dying instance (garbage timings). Stepping the port sidesteps the race. - const inst = await startIsolatedInstance({ port: port + i, devPort: devPort + i, prod, coldStart: true }) - // Forward every numeric boot mark; only the baseline keys are gated, the - // rest (dom_interactive, main_script_kb, …) are reported for composition. - perRun.push(Object.fromEntries(Object.entries(inst.timings).filter(([, v]) => typeof v === 'number'))) - inst.teardown() - } - - record('cold-start', 'cold', medianMetrics(perRun), { runs }) + record('cold-start', 'cold', medianMetrics(perRun), { runs, warm: !('cold-fresh' in flags) }) } // Steady-state scenarios share one persistent connection. From 5f154e881c21164d6411f7c1fde8cebe31880412 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Jul 2026 18:29:22 -0500 Subject: [PATCH 018/205] perf(desktop): stop per-token sidebar + tool-row re-renders during streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real render-cost wins found by inspection (no behavior change): 1. Sidebar re-rendered on every stream token. $sessionStates is republished on every message delta (tens/sec during a turn), and the derived ID computeds ($workingSessionIds, $attentionSessionIds, $backgroundRunningSessionIds) allocated a fresh array each time. nanostores notifies on !==, so the whole ChatSidebar + every mounted row re-rendered per token even when the working/ attention/background set was unchanged. Return the previous array reference when the contents match → nanostores skips the notify unless the set actually changes. Turns streaming from O(visible rows)/token into O(0) for the sidebar. 2. Tool rows normalized the FULL uncapped detail every render. `looksRedundant` (lowercase + whitespace-collapse over the entire read_file/terminal payload) ran twice in the ToolEntry render body, so every completed tool re-normalized its whole output on every stream tick of the running message. Memoize on the view fields so it recomputes only when the tool's content changes. Both are correctness-preserving (stable refs + memoization). The CI stream scenario drives $messages directly, not the publishSessionState path, so it won't reflect #1 — verified by inspection. --- .../components/assistant-ui/tool/fallback.tsx | 12 +++-- apps/desktop/src/store/composer-status.ts | 16 ++++++- apps/desktop/src/store/session-states.ts | 47 +++++++++++++++---- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 96db51f49550..5b14de5fead5 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -348,15 +348,17 @@ function ToolEntry({ part }: ToolEntryProps) { return { body: rest.join('\n\n').trim(), summary } }, [view.detail, view.status, view.subtitle]) - const detailMatchesSubtitle = looksRedundant(view.subtitle, view.detail) + // `looksRedundant` normalizes the FULL (uncapped) detail payload — a + // read_file / terminal result can be huge. Memoize on the view fields so it + // recomputes only when the tool's content changes, not on every parent + // re-render (tool rows re-render on every stream tick of the running message). + const detailMatchesSubtitle = useMemo(() => looksRedundant(view.subtitle, view.detail), [view.subtitle, view.detail]) + const detailMatchesTitle = useMemo(() => looksRedundant(view.title, view.detail), [view.title, view.detail]) const showDetail = !view.inlineDiff && ((view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) || - (view.status !== 'error' && - Boolean(view.detail) && - !looksRedundant(view.title, view.detail) && - !detailMatchesSubtitle)) + (view.status !== 'error' && Boolean(view.detail) && !detailMatchesTitle && !detailMatchesSubtitle)) const renderDetailAsCode = view.status !== 'error' && diff --git a/apps/desktop/src/store/composer-status.ts b/apps/desktop/src/store/composer-status.ts index b87cf389a1bb..dafc934d5fee 100644 --- a/apps/desktop/src/store/composer-status.ts +++ b/apps/desktop/src/store/composer-status.ts @@ -44,6 +44,12 @@ export const $backgroundStatusBySession = atom { const ids = new Set() @@ -59,7 +65,15 @@ export const $backgroundRunningSessionIds = computed([$backgroundStatusBySession } } - return [...ids] + const next = [...ids] + + if (next.length === backgroundRunningCache.length && next.every((id, i) => id === backgroundRunningCache[i])) { + return backgroundRunningCache + } + + backgroundRunningCache = next + + return next }) // Rows the user X-ed away. The registry keeps finished processes around for a diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 869433436bbd..e8bb58ff5ca7 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -203,17 +203,44 @@ export function clearAllSessionStates() { // are pure projections of it, not independently maintained atoms. This keeps the // data flow one-directional: gateway event → cache → $sessionStates → computed // views, eliminating the "projection atom out of sync with cache" bug class. -export const $workingSessionIds = computed($sessionStates, states => - Object.values(states) - .filter(s => s.busy && s.storedSessionId) - .map(s => s.storedSessionId!) -) +// +// CRITICAL for streaming perf: `$sessionStates` is republished on EVERY message +// delta (tens of times/sec during a turn), but the *membership* of these ID sets +// only changes on busy/needsInput edges. `computed` notifies on `!==`, so +// returning a fresh array each time would re-render the whole sidebar (and every +// row) per token. Return the PREVIOUS array reference when the contents match so +// nanostores skips the notify unless the set actually changed. +function stableIds(previous: string[], next: string[]): string[] { + if (previous.length === next.length && previous.every((id, i) => id === next[i])) { + return previous + } -export const $attentionSessionIds = computed($sessionStates, states => - Object.values(states) - .filter(s => s.needsInput && s.storedSessionId) - .map(s => s.storedSessionId!) -) + return next +} + +let workingIdsCache: string[] = [] +export const $workingSessionIds = computed($sessionStates, states => { + workingIdsCache = stableIds( + workingIdsCache, + Object.values(states) + .filter(s => s.busy && s.storedSessionId) + .map(s => s.storedSessionId!) + ) + + return workingIdsCache +}) + +let attentionIdsCache: string[] = [] +export const $attentionSessionIds = computed($sessionStates, states => { + attentionIdsCache = stableIds( + attentionIdsCache, + Object.values(states) + .filter(s => s.needsInput && s.storedSessionId) + .map(s => s.storedSessionId!) + ) + + return attentionIdsCache +}) // --------------------------------------------------------------------------- // Session tiles. From bc6839aa37b7ee63600fe5d3c614796d330eaae7 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Sun, 19 Jul 2026 19:29:36 -0400 Subject: [PATCH 019/205] fix(desktop): stop hard-failing pack on non-git checkouts + fix ZIP-path autocrlf (supersedes #67643) (#67730) * fix(desktop): allow write-build-stamp from non-git checkouts Stop hard-failing npm pack when neither GITHUB_SHA nor git HEAD is available (ZIP installs / broken .git). Emit an explicit fallback stamp instead so local Windows desktop builds can finish (#50823). * fix(desktop): treat fallback stamps as unpinned; harden Windows install Keep all-zero fallback commits out of -Commit/--commit pins and fetch install.ps1 by branch instead. After bootstrap, pin the marker to the checkout HEAD so isBootstrapComplete accepts it. On Windows, force ZIP checkout, seed GITHUB_SHA (ASCII-only install.ps1), and avoid the pack stamp failure. * fix(install): pin core.autocrlf=false before ZIP-path checkout (#50823 review) The ZIP-fallback path added in #67643 runs `git checkout -f FETCH_HEAD` before core.autocrlf gets pinned (which only happened later, on the shared clone-path config). On Git for Windows -- where core.autocrlf defaults to true -- that renormalizes the repo's LF text files to CRLF in the working tree during checkout, leaving the freshly-created managed checkout dirty versus HEAD and aborting the next `hermes update`. That is the exact "dirty tree the user never touched" failure the surrounding code already guards against (install.ps1:1461-1469, 1750-1753). Move the `config core.autocrlf false` pin to run immediately after `git init`, before the fetch/checkout. The later idempotent pin on the shared clone path is retained so git-clone installs are unaffected. Addresses teknium1's review on #67643 and supersedes it, preserving the original author's two commits. Co-authored-by: HexLab98 <8422520+HexLab98@users.noreply.github.com> * chore(contributors): map austinpickett commit email for attribution The check-attribution CI gate flagged austinpickett@users.noreply.github.com as an unmapped commit-author email (introduced by the autocrlf fix commit on this PR). Add the per-email mapping file as the gate instructs (the legacy AUTHOR_MAP in scripts/release.py is frozen). --------- Co-authored-by: HexLab98 Co-authored-by: austinpickett Co-authored-by: HexLab98 <8422520+HexLab98@users.noreply.github.com> --- .../desktop/electron/bootstrap-runner.test.ts | 81 ++++++++ apps/desktop/electron/bootstrap-runner.ts | 185 ++++++++++++++++-- apps/desktop/scripts/write-build-stamp.mjs | 80 ++++++-- .../scripts/write-build-stamp.test.mjs | 86 ++++++++ .../austinpickett@users.noreply.github.com | 2 + scripts/install.ps1 | 80 +++++++- 6 files changed, 478 insertions(+), 36 deletions(-) create mode 100644 apps/desktop/scripts/write-build-stamp.test.mjs create mode 100644 contributors/emails/austinpickett@users.noreply.github.com diff --git a/apps/desktop/electron/bootstrap-runner.test.ts b/apps/desktop/electron/bootstrap-runner.test.ts index d23704cb30d7..9fe76fa69f5b 100644 --- a/apps/desktop/electron/bootstrap-runner.test.ts +++ b/apps/desktop/electron/bootstrap-runner.test.ts @@ -10,12 +10,16 @@ import { buildPosixPinArgs, cachedScriptPath, hasExistingGitCheckout, + installRefForStamp, installedAgentInstallScript, + isPinnedCommit, resolveInstallScript, + resolveMarkerPinnedCommit, runBootstrap } from './bootstrap-runner' const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh' +const ZERO_COMMIT = '0000000000000000000000000000000000000000' function mkTmpHome() { return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-bootstrap-test-')) @@ -106,6 +110,83 @@ test('existing-checkout bootstrap args keep branch but skip the packaged commit ) }) +test('fallback install stamps use an unpinned branch ref', () => { + const stamp = { commit: ZERO_COMMIT, branch: 'main' } + + assert.equal(isPinnedCommit(ZERO_COMMIT), false) + assert.deepEqual(installRefForStamp(stamp), { + ref: 'main', + cacheKey: 'fallback-main', + pinned: false + }) + // Must NOT pass -Commit / --commit for the all-zero placeholder. + assert.deepEqual(buildPinArgs(stamp), ['-Branch', 'main']) + assert.deepEqual( + buildPosixPinArgs({ + installStamp: stamp, + activeRoot: '/tmp/hermes', + hermesHome: '/tmp/home' + }), + ['--dir', '/tmp/hermes', '--hermes-home', '/tmp/home', '--branch', 'main'] + ) +}) + +test('resolveMarkerPinnedCommit prefers real HEAD over fallback stamp zeros', () => { + const realHead = 'c'.repeat(40) + assert.equal( + resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/checkout', { + resolveHead: () => realHead + }), + realHead + ) + assert.equal( + resolveMarkerPinnedCommit({ commit: 'd'.repeat(40), branch: 'main' }, '/tmp/checkout', { + resolveHead: () => realHead + }), + 'd'.repeat(40), + 'packaged real pin wins over checkout HEAD' + ) + assert.equal( + resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/missing', { + resolveHead: () => null + }), + null + ) +}) + +test('resolveInstallScript downloads fallback stamps by branch instead of zero commit', async () => { + const home = mkTmpHome() + + try { + const logs = [] + const refs = [] + const result = await resolveInstallScript({ + installStamp: { commit: ZERO_COMMIT, branch: 'main' }, + sourceRepoRoot: null, + hermesHome: home, + emit: ev => logs.push(ev), + _download: async (ref, destPath) => { + refs.push(ref) + fs.mkdirSync(path.dirname(destPath), { recursive: true }) + fs.writeFileSync(destPath, '#!/bin/sh\necho fallback branch\n') + + return destPath + } + }) + + assert.deepEqual(refs, ['main']) + assert.equal(result.source, 'download') + assert.equal(result.commit, null) + assert.equal(result.path, cachedScriptPath(home, 'fallback-main')) + assert.ok( + logs.some(ev => /fallback, unpinned/.test(ev.line || '')), + 'emits an unpinned fallback log line' + ) + } finally { + fs.rmSync(home, { recursive: true, force: true }) + } +}) + test('resolveInstallScript prefers a cached script without touching the network', async () => { const home = mkTmpHome() diff --git a/apps/desktop/electron/bootstrap-runner.ts b/apps/desktop/electron/bootstrap-runner.ts index 47f76774b427..2f28424c7e4d 100644 --- a/apps/desktop/electron/bootstrap-runner.ts +++ b/apps/desktop/electron/bootstrap-runner.ts @@ -32,7 +32,7 @@ * no UI consumes them yet) */ -import { spawn } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import fs from 'node:fs' import fsp from 'node:fs/promises' import https from 'node:https' @@ -43,6 +43,117 @@ import { hiddenWindowsChildOptions } from './windows-child-options' const IS_WINDOWS = process.platform === 'win32' const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i +const FALLBACK_COMMIT_RE = /^0{7,40}$/ +const FALLBACK_BRANCH = 'main' + +function isPinnedCommit(commit) { + return typeof commit === 'string' && STAMP_COMMIT_RE.test(commit) && !FALLBACK_COMMIT_RE.test(commit) +} + +type ExecGitFn = (args: string[], cwd: string) => string +type ResolveHeadFn = (activeRoot: string | null | undefined) => string | null + +/** + * Read HEAD from a managed checkout. Used after bootstrap so fallback + * (all-zero) install stamps still produce a marker that + * isBootstrapComplete() accepts (pinnedCommit length >= 7). + */ +function resolveCheckoutHead( + activeRoot: string | null | undefined, + opts: { execGit?: ExecGitFn } = {} +): string | null { + if (!activeRoot) { + return null + } + + const run: ExecGitFn = + opts.execGit || + ((args, cwd) => + execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 15_000, + ...hiddenWindowsChildOptions() + }).trim()) + + try { + const sha = run(['-c', 'windows.appendAtomically=false', 'rev-parse', 'HEAD'], activeRoot) + + return isPinnedCommit(sha) ? sha : null + } catch { + return null + } +} + +/** Prefer a real pin already written by install.ps1's bootstrap-marker stage. */ +function readExistingPinnedCommit(activeRoot: string | null | undefined): string | null { + if (!activeRoot) { + return null + } + + try { + const raw = fs.readFileSync(path.join(activeRoot, '.hermes-bootstrap-complete'), 'utf8') + const parsed = JSON.parse(raw) + + return parsed && isPinnedCommit(parsed.pinnedCommit) ? parsed.pinnedCommit : null + } catch { + return null + } +} + +/** + * Pick the commit to store on the bootstrap-complete marker. + * Packaged fallback stamps must NOT win (all-zero is not a real pin); after a + * successful install the checkout's HEAD (or install.ps1's marker) does. + */ +function resolveMarkerPinnedCommit( + installStamp: { commit?: string; branch?: string | null } | null | undefined, + activeRoot: string | null | undefined, + opts: { resolveHead?: ResolveHeadFn } = {} +): string | null { + const resolveHead = opts.resolveHead || resolveCheckoutHead + + if (installStamp && isPinnedCommit(installStamp.commit)) { + return installStamp.commit + } + + const head = resolveHead(activeRoot) + + if (head) { + return head + } + + return readExistingPinnedCommit(activeRoot) +} + +/** + * Map an install stamp to the GitHub ref used to fetch install.ps1/sh. + * Real CI/git stamps pin an immutable SHA. Non-git fallback stamps carry an + * all-zero placeholder -- treat those as an unpinned branch ref so bootstrap + * never asks GitHub for commit 0000000... (#50823). + */ +function installRefForStamp(installStamp) { + if (installStamp && isPinnedCommit(installStamp.commit)) { + return { + ref: installStamp.commit, + cacheKey: installStamp.commit, + pinned: true + } + } + + if (installStamp && typeof installStamp.commit === 'string' && FALLBACK_COMMIT_RE.test(installStamp.commit)) { + const ref = installStamp.branch || FALLBACK_BRANCH + + return { + ref, + cacheKey: `fallback-${String(ref).replace(/[^0-9A-Za-z._-]/g, '_')}`, + pinned: false + } + } + + return null +} // Stages flagged needs_user_input=true in the manifest are skipped by the // runner (passed -NonInteractive to install.ps1, which the install script @@ -119,12 +230,13 @@ function cachedScriptPath(hermesHome, commit) { return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`) } -function downloadInstallScript(commit, destPath) { - // Fetch from GitHub raw at the pinned commit. The raw URL with a SHA - // is immutable (unlike a branch ref), so we don't need integrity - // verification beyond "did the file we wrote pass a syntax probe." +function downloadInstallScript(ref, destPath) { + // Fetch from GitHub raw at the install ref. Normal production builds pass a + // pinned SHA (immutable). Non-git fallback builds pass an unpinned branch + // ref so local builds can still bootstrap without pretending the all-zero + // placeholder is a real GitHub commit. const scriptName = installScriptName() - const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}` + const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${ref}/scripts/${scriptName}` return new Promise((resolve, reject) => { fs.mkdirSync(path.dirname(destPath), { recursive: true }) @@ -223,38 +335,45 @@ async function resolveInstallScript({ return { path: localScript, source: 'local', kind: installScriptKind() } } - // 2. Packaged path: download from GitHub at the pinned commit (1B's stamp). - if (!installStamp || !installStamp.commit || !STAMP_COMMIT_RE.test(installStamp.commit)) { + // 2. Packaged path: download from GitHub at the install stamp's ref. + // Non-git fallback builds carry an all-zero commit; treat that as an + // unpinned branch ref instead of trying to fetch a non-existent SHA. + const installRef = installRefForStamp(installStamp) + + if (!installRef) { throw new Error( `Cannot resolve ${installScriptName()}: no SOURCE_REPO_ROOT and no install stamp. ` + 'This packaged build was produced without a valid build-time stamp.' ) } - const cached = cachedScriptPath(hermesHome, installStamp.commit) + const cached = cachedScriptPath(hermesHome, installRef.cacheKey) + const resolvedCommit = installRef.pinned ? installRef.ref : null try { await fsp.access(cached, fs.constants.R_OK) emit({ type: 'log', - line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}` + line: `[bootstrap] using cached ${installScriptName()} for ${installRef.ref.slice(0, 12)}` }) - return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() } + return { path: cached, source: 'cache', commit: resolvedCommit, kind: installScriptKind() } } catch { // not cached; download } emit({ type: 'log', - line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub` + line: + `[bootstrap] fetching ${installScriptName()} for ${installRef.ref.slice(0, 12)} from GitHub` + + (installRef.pinned ? '' : ' (fallback, unpinned)') }) try { - await _download(installStamp.commit, cached) + await _download(installRef.ref, cached) emit({ type: 'log', line: `[bootstrap] saved to ${cached}` }) - return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() } + return { path: cached, source: 'download', commit: resolvedCommit, kind: installScriptKind() } } catch (err) { // The pinned commit may not be fetchable from GitHub -- most commonly a // locally-built desktop app stamped to an unpushed HEAD (see @@ -275,10 +394,10 @@ async function resolveInstallScript({ fs.mkdirSync(path.dirname(cached), { recursive: true }) fs.copyFileSync(installed, cached) - return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() } + return { path: cached, source: 'installed-agent', commit: resolvedCommit, kind: installScriptKind() } } catch { // Cache copy failed (read-only FS, etc.) -- use the source path directly. - return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() } + return { path: installed, source: 'installed-agent', commit: resolvedCommit, kind: installScriptKind() } } } @@ -544,11 +663,12 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome // Build the installer branch/pin args from the install stamp. The commit pin // is fresh-install only: once a managed checkout already exists, bootstrap is // a repair/update path and must not let an old packaged app detach the checkout -// back to the commit baked into that app. +// back to the commit baked into that app. All-zero fallback stamps are never +// passed as -Commit/--commit — only the branch is used (#50823 / #50864 review). function buildPinArgs(installStamp, { pinCommit = true } = {}) { const args = [] - if (pinCommit && installStamp && installStamp.commit) { + if (pinCommit && installStamp && isPinnedCommit(installStamp.commit)) { args.push('-Commit', installStamp.commit) } @@ -566,7 +686,7 @@ function buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit = t args.push('--branch', installStamp.branch) } - if (pinCommit && installStamp && installStamp.commit) { + if (pinCommit && installStamp && isPinnedCommit(installStamp.commit)) { args.push('--commit', installStamp.commit) } @@ -860,9 +980,28 @@ async function runBootstrap(opts) { } } - // 4. Write the bootstrap-complete marker. + // 4. Write the bootstrap-complete marker. Fallback (all-zero) stamps are + // not real pins -- resolve HEAD from the checkout we just installed so + // isBootstrapComplete() (pinnedCommit.length >= 7) accepts the marker + // instead of re-running bootstrap on every launch (#50823 review). + const pinnedCommit = resolveMarkerPinnedCommit(installStamp, activeRoot) + + if (!pinnedCommit) { + emit({ + type: 'log', + line: + '[bootstrap] WARNING: could not resolve a real pinnedCommit for the ' + + 'bootstrap-complete marker; subsequent launches may re-run bootstrap' + }) + } else if (installStamp && !isPinnedCommit(installStamp.commit)) { + emit({ + type: 'log', + line: `[bootstrap] fallback stamp resolved marker pin to ${pinnedCommit.slice(0, 12)} from checkout` + }) + } + const markerPayload = { - pinnedCommit: installStamp ? installStamp.commit : null, + pinnedCommit, pinnedBranch: installStamp ? installStamp.branch : null } @@ -888,10 +1027,14 @@ export { buildPosixPinArgs, cachedScriptPath, hasExistingGitCheckout, + installRefForStamp, installedAgentInstallScript, + isPinnedCommit, // Exposed for testability parseStageResult, + resolveCheckoutHead, resolveInstallScript, resolveLocalInstallScript, + resolveMarkerPinnedCommit, runBootstrap } diff --git a/apps/desktop/scripts/write-build-stamp.mjs b/apps/desktop/scripts/write-build-stamp.mjs index 005db35d1772..076d5a893e23 100644 --- a/apps/desktop/scripts/write-build-stamp.mjs +++ b/apps/desktop/scripts/write-build-stamp.mjs @@ -11,25 +11,33 @@ * "branch": "", * "builtAt": "", * "dirty": true|false, - * "source": "ci" | "local" + * "source": "ci" | "local" | "fallback" * } * * Source preference order: * 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with * shallow clones, detached HEADs, etc. in CI. * 2. Local `git rev-parse` against the parent repo (../..). + * 3. Fallback stamp for local/personal builds from non-git source trees + * (ZIP extract, interrupted clone with no HEAD, etc.). * - * Dev / out-of-repo builds without git produce an explicit error rather than - * silently writing an unstamped manifest -- the packaged app refuses to - * bootstrap without a stamp. + * Dev / out-of-repo builds without git produce an explicit fallback stamp + * rather than aborting the whole build. Bootstrap treats the all-zero + * commit as unpinned and follows the branch instead of fetching a fake SHA. */ import { mkdirSync, writeFileSync } from "fs" import { resolve, join, relative } from "path" import { execSync } from "child_process" +import { isMain } from "./utils.mjs" + const STAMP_SCHEMA_VERSION = 1 +/** All-zero placeholder used when no real commit can be resolved. */ +export const FALLBACK_COMMIT = "0000000000000000000000000000000000000000" +export const FALLBACK_BRANCH = "main" + const DESKTOP_ROOT = resolve(import.meta.dirname, "..") const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..") const OUT_DIR = join(DESKTOP_ROOT, "build") @@ -43,10 +51,10 @@ function tryExec(cmd, opts) { } } -function fromCI() { - const sha = process.env.GITHUB_SHA +export function fromCI(env = process.env) { + const sha = env.GITHUB_SHA if (!sha) return null - const branch = process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || null + const branch = env.GITHUB_REF_NAME || env.GITHUB_HEAD_REF || null return { commit: sha, branch: branch, @@ -55,17 +63,17 @@ function fromCI() { } } -function fromLocalGit() { - const sha = tryExec("git rev-parse HEAD", { cwd: REPO_ROOT }) +export function fromLocalGit(repoRoot = REPO_ROOT, execFn = tryExec) { + const sha = execFn("git rev-parse HEAD", { cwd: repoRoot }) if (!sha) return null - const branch = tryExec("git rev-parse --abbrev-ref HEAD", { cwd: REPO_ROOT }) + const branch = execFn("git rev-parse --abbrev-ref HEAD", { cwd: repoRoot }) // `git status --porcelain -uno` is empty iff tracked files match HEAD. // We exclude untracked files (-uno) intentionally: a developer who's // checked out an installer scratch dir alongside the repo shouldn't // poison every local build with a [DIRTY] stamp. We DO care about // tracked-but-modified files because those mean the .exe content // differs from the commit being pinned. - const status = tryExec("git status --porcelain -uno", { cwd: REPO_ROOT }) + const status = execFn("git status --porcelain -uno", { cwd: repoRoot }) const dirty = status !== null && status.length > 0 return { commit: sha, @@ -75,9 +83,41 @@ function fromLocalGit() { } } +export function fromFallback(branch = FALLBACK_BRANCH) { + // Non-git builds (ZIP download, bootstrap installer without a resolvable + // HEAD) cannot determine a real commit. Use a placeholder so local / + // personal builds can still complete. The desktop bootstrap treats the + // all-zero commit as "unknown" and falls back to an unpinned branch + // bootstrap instead of trying to fetch a non-existent GitHub commit. + return { + commit: FALLBACK_COMMIT, + branch: branch || FALLBACK_BRANCH, + dirty: false, + source: "fallback" + } +} + +/** + * Resolve the install stamp without writing it. Pure enough for unit tests: + * inject env / execFn / repoRoot to simulate CI, local git, or no-git trees. + */ +export function resolveStamp({ + env = process.env, + repoRoot = REPO_ROOT, + execFn = tryExec, + fallbackBranch = FALLBACK_BRANCH +} = {}) { + return fromCI(env) || fromLocalGit(repoRoot, execFn) || fromFallback(fallbackBranch) +} + +export function isFallbackCommit(commit) { + return typeof commit === "string" && /^0{7,40}$/.test(commit) +} + function main() { - const stamp = fromCI() || fromLocalGit() + const stamp = resolveStamp() if (!stamp || !stamp.commit) { + // Should not happen — fromFallback() always provides a commit. console.error( "[write-build-stamp] ERROR: could not determine git commit.\n" + " - $GITHUB_SHA not set\n" + @@ -90,6 +130,15 @@ function main() { process.exit(1) } + if (isFallbackCommit(stamp.commit)) { + console.warn( + "[write-build-stamp] WARNING: no git commit found (non-git checkout?).\n" + + " Using placeholder commit — the packaged app will fall back to the\n" + + " default branch for first-launch bootstrap. For production builds,\n" + + " run from a git checkout or set $GITHUB_SHA." + ) + } + if (stamp.dirty) { console.warn( "[write-build-stamp] WARNING: working tree is dirty.\n" + @@ -117,8 +166,11 @@ function main() { " -> " + stamp.commit.slice(0, 12) + (stamp.branch ? " (" + stamp.branch + ")" : "") + - (stamp.dirty ? " [DIRTY]" : "") + (stamp.dirty ? " [DIRTY]" : "") + + (stamp.source === "fallback" ? " [FALLBACK]" : "") ) } -main() +if (isMain(import.meta.url)) { + main() +} diff --git a/apps/desktop/scripts/write-build-stamp.test.mjs b/apps/desktop/scripts/write-build-stamp.test.mjs new file mode 100644 index 000000000000..53c88e23704c --- /dev/null +++ b/apps/desktop/scripts/write-build-stamp.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { test } from 'vitest' + +import { + FALLBACK_BRANCH, + FALLBACK_COMMIT, + fromCI, + fromFallback, + fromLocalGit, + isFallbackCommit, + resolveStamp +} from './write-build-stamp.mjs' + +test('fromCI reads GITHUB_SHA / GITHUB_REF_NAME', () => { + assert.deepEqual( + fromCI({ GITHUB_SHA: 'a'.repeat(40), GITHUB_REF_NAME: 'release' }), + { commit: 'a'.repeat(40), branch: 'release', dirty: false, source: 'ci' } + ) + assert.equal(fromCI({}), null) +}) + +test('fromLocalGit returns null when git rev-parse fails', () => { + const stamp = fromLocalGit('/tmp/not-a-repo', () => null) + assert.equal(stamp, null) +}) + +test('fromLocalGit reads HEAD + branch + dirty status', () => { + const calls = [] + const execFn = (cmd) => { + calls.push(cmd) + if (cmd === 'git rev-parse HEAD') return 'b'.repeat(40) + if (cmd === 'git rev-parse --abbrev-ref HEAD') return 'main' + if (cmd === 'git status --porcelain -uno') return ' M apps/desktop/package.json' + return null + } + assert.deepEqual(fromLocalGit('/repo', execFn), { + commit: 'b'.repeat(40), + branch: 'main', + dirty: true, + source: 'local' + }) + assert.ok(calls.includes('git rev-parse HEAD')) +}) + +test('fromFallback uses the all-zero placeholder commit', () => { + assert.deepEqual(fromFallback(), { + commit: FALLBACK_COMMIT, + branch: FALLBACK_BRANCH, + dirty: false, + source: 'fallback' + }) + assert.equal(isFallbackCommit(FALLBACK_COMMIT), true) + assert.equal(isFallbackCommit('a'.repeat(40)), false) +}) + +test('resolveStamp prefers CI over local git over fallback', () => { + const ci = resolveStamp({ + env: { GITHUB_SHA: 'c'.repeat(40), GITHUB_REF_NAME: 'main' }, + execFn: () => 'should-not-run' + }) + assert.equal(ci.source, 'ci') + assert.equal(ci.commit, 'c'.repeat(40)) + + const local = resolveStamp({ + env: {}, + execFn: (cmd) => { + if (cmd === 'git rev-parse HEAD') return 'd'.repeat(40) + if (cmd === 'git rev-parse --abbrev-ref HEAD') return 'main' + if (cmd === 'git status --porcelain -uno') return '' + return null + } + }) + assert.equal(local.source, 'local') + assert.equal(local.commit, 'd'.repeat(40)) + assert.equal(local.dirty, false) +}) + +test('resolveStamp falls back when neither CI nor git is available', () => { + const stamp = resolveStamp({ env: {}, execFn: () => null }) + assert.deepEqual(stamp, { + commit: FALLBACK_COMMIT, + branch: FALLBACK_BRANCH, + dirty: false, + source: 'fallback' + }) +}) diff --git a/contributors/emails/austinpickett@users.noreply.github.com b/contributors/emails/austinpickett@users.noreply.github.com new file mode 100644 index 000000000000..d30e8a7fe8a1 --- /dev/null +++ b/contributors/emails/austinpickett@users.noreply.github.com @@ -0,0 +1,2 @@ +austinpickett +# PR #67730 salvage of #67643 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 746b5090faaf..0a98ad6e457b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1686,11 +1686,54 @@ function Install-Repository { Move-Item $extractedDir.FullName $InstallDir -Force Write-Success "Downloaded and extracted" - # Initialize git repo so updates work later + # Initialize git repo so updates work later. A bare + # `git init` leaves NO HEAD -- desktop's write-build-stamp + # then hard-fails with "could not determine git commit" + # (#50823 / #61657). Fetch the requested ref and force-check + # it out (-f) so untracked ZIP files cannot block checkout. Push-Location $InstallDir git -c windows.appendAtomically=false init 2>$null git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null + # Pin autocrlf=false BEFORE the checkout below. Git for Windows + # defaults to core.autocrlf=true, which would renormalize the + # repo's LF text files to CRLF in the working tree during + # `checkout -f FETCH_HEAD` -- leaving this freshly-created + # managed checkout dirty vs HEAD and aborting the next + # `hermes update` (see the notes at the shared clone-path + # config below and install.ps1:1461-1469). The later pin on + # the shared path is idempotent and still covers git clones. + git -c windows.appendAtomically=false config core.autocrlf false 2>$null git remote add origin $RepoUrlHttps 2>$null + $fetchRef = if ($Commit) { $Commit } elseif ($Tag) { "refs/tags/$Tag" } else { $Branch } + Write-Info "Fetching $fetchRef so the ZIP checkout has a resolvable HEAD..." + $prevZipEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + git -c windows.appendAtomically=false fetch --depth 1 origin $fetchRef 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + if ($Commit -or $Tag) { + git -c windows.appendAtomically=false checkout -f --detach FETCH_HEAD 2>&1 | Out-Null + } else { + git -c windows.appendAtomically=false checkout -f -B $Branch FETCH_HEAD 2>&1 | Out-Null + } + if ($LASTEXITCODE -eq 0) { + Write-Success "ZIP checkout pinned to $fetchRef" + } else { + # Checkout blocked, but FETCH_HEAD still has a SHA we can stamp with. + $fetchSha = & git -c windows.appendAtomically=false rev-parse FETCH_HEAD 2>$null + if ($LASTEXITCODE -eq 0 -and $fetchSha) { + if (-not $env:GITHUB_SHA) { $env:GITHUB_SHA = ("$fetchSha").Trim() } + Write-Warn "ZIP checkout failed; seeded GITHUB_SHA from FETCH_HEAD for desktop stamp" + } else { + Write-Warn "ZIP extract succeeded but git checkout failed -- desktop build may need `$env:GITHUB_SHA" + } + } + } else { + Write-Warn "ZIP extract succeeded but git fetch of $fetchRef failed -- desktop build may need `$env:GITHUB_SHA" + } + } finally { + $ErrorActionPreference = $prevZipEAP + } Pop-Location Write-Success "Git repo initialized for future updates" @@ -2888,6 +2931,41 @@ function Install-Desktop { # for some other tool, electron-builder would still try to sign. Write-Info "Building desktop app (this takes 1-3 minutes)..." $buildLog = "$env:TEMP\hermes-desktop-build-$(Get-Random).log" + # Seed GITHUB_SHA for write-build-stamp.mjs. The stamp prefers CI env vars + # over `git rev-parse`, so this covers: (1) node can't find git.exe on PATH + # even though this PowerShell session can, (2) ZIP/init trees that still + # lack a HEAD after a failed post-extract fetch. Without it the desktop + # pack dies with "could not determine git commit" (#50823). + if (-not $env:GITHUB_SHA) { + if ($Commit) { + $env:GITHUB_SHA = $Commit + } else { + Push-Location $InstallDir + try { + $global:LASTEXITCODE = 0 + $resolvedSha = & git -c windows.appendAtomically=false rev-parse HEAD 2>$null + if ($LASTEXITCODE -ne 0 -or -not $resolvedSha) { + # ZIP path may have FETCH_HEAD after a fetch even when HEAD is unset. + $global:LASTEXITCODE = 0 + $resolvedSha = & git -c windows.appendAtomically=false rev-parse FETCH_HEAD 2>$null + } + if ($LASTEXITCODE -eq 0 -and $resolvedSha) { + $env:GITHUB_SHA = ("$resolvedSha").Trim() + } + } catch { } finally { + Pop-Location + } + } + } + if (-not $env:GITHUB_REF_NAME) { + $env:GITHUB_REF_NAME = if ($Branch) { $Branch } else { "main" } + } + if ($env:GITHUB_SHA) { + $shaPreview = if ($env:GITHUB_SHA.Length -ge 12) { $env:GITHUB_SHA.Substring(0, 12) } else { $env:GITHUB_SHA } + Write-Info "Desktop build stamp: $shaPreview ($($env:GITHUB_REF_NAME))" + } else { + Write-Warn "Could not resolve a git commit for the desktop stamp -- write-build-stamp will use its non-git fallback" + } Push-Location $desktopDir $prevEAP = $ErrorActionPreference $prevCSCAuto = $env:CSC_IDENTITY_AUTO_DISCOVERY From 33d71d687f602b9bcec99ea7ceb6ef190e2c03f1 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Sun, 19 Jul 2026 19:31:34 -0400 Subject: [PATCH 020/205] fix(desktop): preserve new-chat selector choices (#67729) Salvaged and rebased from #66354 by @UnathiCodex onto current main. Fixes a fresh-chat race in Hermes Desktop where a model, reasoning-effort, or Fast selection made before the first Send could be replaced by an in-flight profile refresh, or read only after the profile handshake yielded. Send is now the linearization point: the visible selector state is snapshotted before awaiting profile readiness, and intent-generation guards make older config/model responses stand down after a picker/toggle action. Adds the contract-v4 session-create wire contract for explicit Fast=false. Conflict resolution vs the original branch (use-model-controls.ts / .test.tsx): combined main's catalog-aware keepManualPick() sticky-pick logic with the PR's profileRefreshEpoch + composerSelectionGeneration staleness guards so both a removed-from-catalog reseed and the in-flight-picker race are handled. Verified on current main: apps/desktop tsc --noEmit clean; 80 affected UI/store tests pass (use-model-controls, use-hermes-config, use-session-actions, model-edit-submenu, model-presets, updates). Co-authored-by: UnathiCodex --- apps/desktop/src/app/contrib/wiring.tsx | 8 +- .../session/hooks/use-hermes-config.test.ts | 90 ++++++++++++++- .../app/session/hooks/use-hermes-config.ts | 107 +++++++++++------- .../session/hooks/use-model-controls.test.tsx | 63 +++++++++++ .../app/session/hooks/use-model-controls.ts | 26 ++++- .../hooks/use-session-actions.test.tsx | 81 ++++++++++++- .../hooks/use-session-actions/index.ts | 23 ++-- .../src/app/shell/model-edit-submenu.test.tsx | 24 +++- .../src/app/shell/model-edit-submenu.tsx | 9 +- apps/desktop/src/store/model-presets.test.ts | 11 +- apps/desktop/src/store/model-presets.ts | 16 +-- apps/desktop/src/store/session.ts | 13 +++ apps/desktop/src/store/updates.test.ts | 6 +- apps/desktop/src/store/updates.ts | 3 +- tests/test_tui_gateway_server.py | 95 +++++++++++++++- tui_gateway/server.py | 38 +++++-- 16 files changed, 527 insertions(+), 86 deletions(-) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index a548e191bbb3..fe77f99e15cc 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -433,11 +433,13 @@ export function ContribWiring({ children }: { children: ReactNode }) { } lastGatewayProfileRef.current = activeGatewayProfile - // Force: the new profile has its own default, so reseed even if the - // composer already shows the previous profile's model. + // Force: the new profile has its own defaults, so reseed the selector even + // if the composer already shows values from the previous profile. Both + // refreshes carry an intent token so a picker click made in flight wins. void refreshCurrentModel(true) + void refreshHermesConfig(true) void refreshActiveProfile() - }, [activeGatewayProfile, refreshCurrentModel]) + }, [activeGatewayProfile, refreshCurrentModel, refreshHermesConfig]) // New session anchored to a workspace (sidebar "+" on a project/worktree). // Seeds cwd + branch from the clicked workspace; an explicit worktree path diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts index f4c6878b7f64..576fcad5ccd8 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts @@ -4,7 +4,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getHermesConfig } from '@/hermes' import { persistString } from '@/lib/storage' -import { $currentCwd, setCurrentCwd } from '@/store/session' +import { + $currentCwd, + $currentFastMode, + $currentReasoningEffort, + markComposerSelectionManual, + setCurrentCwd, + setCurrentFastMode, + setCurrentModelSource, + setCurrentReasoningEffort +} from '@/store/session' import { useHermesConfig } from './use-hermes-config' @@ -15,6 +24,16 @@ vi.mock('@/hermes', () => ({ const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' +function deferred() { + let resolve!: (value: T | PromiseLike) => void + + const promise = new Promise(done => { + resolve = done + }) + + return { promise, resolve } +} + const mockConfig = (config: Record) => vi.mocked(getHermesConfig).mockResolvedValue(config as Awaited>) @@ -22,6 +41,9 @@ describe('useHermesConfig refreshHermesConfig', () => { beforeEach(() => { // Reset atoms and localStorage between tests setCurrentCwd('') + setCurrentFastMode(false) + setCurrentModelSource('') + setCurrentReasoningEffort('') persistString(WORKSPACE_CWD_KEY, null) }) @@ -141,4 +163,70 @@ describe('useHermesConfig refreshHermesConfig', () => { expect(refreshProjectBranch).toHaveBeenCalledWith('/workspace/attached-project') }) + + it('does not let a stale forced config refresh overwrite newer draft selector intent', async () => { + const profileConfig = deferred>>() + vi.mocked(getHermesConfig).mockReturnValueOnce(profileConfig.promise) + + const { result } = renderHook(() => + useHermesConfig({ + activeSessionIdRef: { current: null }, + refreshProjectBranch: vi.fn().mockResolvedValue(undefined) + }) + ) + + let pendingRefresh!: Promise + act(() => { + pendingRefresh = result.current.refreshHermesConfig(true) + }) + expect(getHermesConfig).toHaveBeenCalled() + + // The user turns Fast off and chooses a different effort while the profile + // defaults are still loading. That newer picker intent owns the composer. + markComposerSelectionManual() + setCurrentReasoningEffort('high') + setCurrentFastMode(false) + profileConfig.resolve({ + agent: { reasoning_effort: 'low', service_tier: 'priority' } + } as Awaited>) + + await act(async () => { + await pendingRefresh + }) + + expect($currentReasoningEffort.get()).toBe('high') + expect($currentFastMode.get()).toBe(false) + }) + + it('does not let an older profile config overwrite a newer profile', async () => { + const profileB = deferred>>() + const profileC = deferred>>() + vi.mocked(getHermesConfig).mockReturnValueOnce(profileB.promise).mockReturnValueOnce(profileC.promise) + + const { result } = renderHook(() => + useHermesConfig({ + activeSessionIdRef: { current: null }, + refreshProjectBranch: vi.fn().mockResolvedValue(undefined) + }) + ) + + let refreshB!: Promise + let refreshC!: Promise + act(() => { + refreshB = result.current.refreshHermesConfig(true) + refreshC = result.current.refreshHermesConfig(true) + }) + + profileC.resolve({ agent: { reasoning_effort: 'low', service_tier: 'normal' } }) + await act(async () => { + await refreshC + }) + profileB.resolve({ agent: { reasoning_effort: 'high', service_tier: 'priority' } }) + await act(async () => { + await refreshB + }) + + expect($currentReasoningEffort.get()).toBe('low') + expect($currentFastMode.get()).toBe(false) + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 8c3cbac65a38..52681e20a858 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -1,10 +1,12 @@ -import { type MutableRefObject, useCallback, useState } from 'react' +import { type MutableRefObject, useCallback, useRef, useState } from 'react' import { getHermesConfig, getHermesConfigDefaults } from '@/hermes' import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime' import { normalize } from '@/lib/text' import { $currentCwd, + getComposerSelectionGeneration, + getCurrentModelSource, setAvailablePersonalities, setCurrentCwd, setCurrentFastMode, @@ -47,51 +49,74 @@ interface HermesConfigOptions { export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: HermesConfigOptions) { const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_SECONDS) const [sttEnabled, setSttEnabled] = useState(true) + const profileRefreshEpochRef = useRef(0) - const refreshHermesConfig = useCallback(async () => { - try { - const [config, defaults] = await Promise.all([getHermesConfig(), getHermesConfigDefaults().catch(() => ({}))]) - - const personality = normalizePersonalityValue( - typeof config.display?.personality === 'string' ? config.display.personality : '' - ) - - setIntroPersonality(personality) - // Active sessions keep their per-session value; standalone falls back to config. - setCurrentPersonality(prev => (activeSessionIdRef.current ? prev || personality : personality)) - setAvailablePersonalities([ - ...new Set([ - 'none', - ...BUILTIN_PERSONALITIES, - ...personalityNamesFromConfig(defaults), - ...personalityNamesFromConfig(config) - ]) - ]) - - const cwd = (config.terminal?.cwd ?? '').trim() - - if (cwd && cwd !== '.') { - // Configured terminal.cwd beats a stale remembered workspace cwd - // (#38855) — but never yank the workspace out from under an active - // session; those keep their own cwd until the user detaches. - setCurrentCwd(prev => (activeSessionIdRef.current ? prev : cwd)) - void refreshProjectBranch($currentCwd.get() || cwd) + const refreshHermesConfig = useCallback( + async (force = false) => { + if (force) { + profileRefreshEpochRef.current += 1 } - const reasoning = normalizeConfigEffort(config.agent?.reasoning_effort) - const tier = (config.agent?.service_tier ?? '').trim() + const profileRefreshEpoch = profileRefreshEpochRef.current + const selectionGeneration = getComposerSelectionGeneration() - setCurrentReasoningEffort(prev => (activeSessionIdRef.current ? prev : reasoning)) - setCurrentServiceTier(prev => (activeSessionIdRef.current ? prev : tier)) - setCurrentFastMode(prev => (activeSessionIdRef.current ? prev : FAST_TIERS.has(tier.toLowerCase()))) + try { + const [config, defaults] = await Promise.all([getHermesConfig(), getHermesConfigDefaults().catch(() => ({}))]) - setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds)) - setSttEnabled(config.stt?.enabled !== false) - applyAutoSpeakFromConfig(config) - } catch { - // Config is nice-to-have; chat still works without it. - } - }, [activeSessionIdRef, refreshProjectBranch]) + if (profileRefreshEpochRef.current !== profileRefreshEpoch) { + return + } + + const personality = normalizePersonalityValue( + typeof config.display?.personality === 'string' ? config.display.personality : '' + ) + + setIntroPersonality(personality) + // Active sessions keep their per-session value; standalone falls back to config. + setCurrentPersonality(prev => (activeSessionIdRef.current ? prev || personality : personality)) + setAvailablePersonalities([ + ...new Set([ + 'none', + ...BUILTIN_PERSONALITIES, + ...personalityNamesFromConfig(defaults), + ...personalityNamesFromConfig(config) + ]) + ]) + + const cwd = (config.terminal?.cwd ?? '').trim() + + if (cwd && cwd !== '.') { + // Configured terminal.cwd beats a stale remembered workspace cwd + // (#38855) — but never yank the workspace out from under an active + // session; those keep their own cwd until the user detaches. + setCurrentCwd(prev => (activeSessionIdRef.current ? prev : cwd)) + void refreshProjectBranch($currentCwd.get() || cwd) + } + + const reasoning = normalizeConfigEffort(config.agent?.reasoning_effort) + const tier = (config.agent?.service_tier ?? '').trim() + + const shouldSeedComposer = + !activeSessionIdRef.current && + getComposerSelectionGeneration() === selectionGeneration && + (force || getCurrentModelSource() !== 'manual') + + if (shouldSeedComposer) { + setCurrentReasoningEffort(reasoning) + setCurrentFastMode(FAST_TIERS.has(tier.toLowerCase())) + } + + setCurrentServiceTier(prev => (activeSessionIdRef.current ? prev : tier)) + + setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds)) + setSttEnabled(config.stt?.enabled !== false) + applyAutoSpeakFromConfig(config) + } catch { + // Config is nice-to-have; chat still works without it. + } + }, + [activeSessionIdRef, refreshProjectBranch] + ) return { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } } diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index 6e1f459d1c18..f5ab213d0450 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -18,6 +18,16 @@ import { useModelControls } from './use-model-controls' const setGlobalModel = vi.fn() const notifyError = vi.fn() +function deferred() { + let resolve!: (value: T | PromiseLike) => void + + const promise = new Promise(done => { + resolve = done + }) + + return { promise, resolve } +} + vi.mock('@/hermes', () => ({ getGlobalModelInfo: vi.fn(), setGlobalModel: (...args: Parameters) => setGlobalModel(...args) @@ -248,6 +258,59 @@ describe('useModelControls', () => { expect(getCurrentModelSource()).toBe('manual') }) + it('does not let a stale forced profile refresh overwrite a newer picker choice', async () => { + const profileDefault = deferred>>() + vi.mocked(getGlobalModelInfo).mockReturnValueOnce(profileDefault.promise) + + const { result } = renderHook(() => + useModelControls({ + queryClient: new QueryClient(), + requestGateway: vi.fn() + }) + ) + + const pendingRefresh = result.current.refreshCurrentModel(true) + expect(getGlobalModelInfo).toHaveBeenCalled() + + await expect( + result.current.selectModel({ + model: 'claude-sonnet-4.6', + provider: 'anthropic' + }) + ).resolves.toBe(true) + + profileDefault.resolve({ model: 'gpt-5.5', provider: 'openai-codex' }) + await pendingRefresh + + expect($currentModel.get()).toBe('claude-sonnet-4.6') + expect($currentProvider.get()).toBe('anthropic') + expect(getCurrentModelSource()).toBe('manual') + }) + + it('does not let an older profile refresh overwrite a newer profile', async () => { + const profileB = deferred>>() + const profileC = deferred>>() + vi.mocked(getGlobalModelInfo).mockReturnValueOnce(profileB.promise).mockReturnValueOnce(profileC.promise) + + const { result } = renderHook(() => + useModelControls({ + queryClient: new QueryClient(), + requestGateway: vi.fn() + }) + ) + + const refreshB = result.current.refreshCurrentModel(true) + const refreshC = result.current.refreshCurrentModel(true) + + profileC.resolve({ model: 'profile-c-model', provider: 'profile-c-provider' }) + await refreshC + profileB.resolve({ model: 'profile-b-model', provider: 'profile-b-provider' }) + await refreshB + + expect($currentModel.get()).toBe('profile-c-model') + expect($currentProvider.get()).toBe('profile-c-provider') + }) + it('refreshes legacy/default-derived composer state from the profile default', async () => { setCurrentModel('openai/gpt-5.5') setCurrentProvider('nous') diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index edc2747e5996..c7f4d245cb71 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -1,5 +1,5 @@ import { type QueryClient } from '@tanstack/react-query' -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { getGlobalModelInfo } from '@/hermes' import { useI18n } from '@/i18n' @@ -9,7 +9,9 @@ import { $activeSessionId, $currentModel, $currentProvider, + getComposerSelectionGeneration, getCurrentModelSource, + markComposerSelectionManual, setCurrentModel, setCurrentModelSource, setCurrentProvider @@ -29,6 +31,7 @@ interface ModelControlsOptions { export function useModelControls({ queryClient, requestGateway }: ModelControlsOptions) { const { t } = useI18n() const copy = t.desktop + const profileRefreshEpochRef = useRef(0) // All callbacks here read reactive session state from the store (.get()) // rather than capturing it as a prop. The actions bag in wiring.tsx mutates @@ -55,6 +58,14 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO // draft / session events. A live session owns the footer, so skip entirely. const refreshCurrentModel = useCallback( async (force = false) => { + // A forced profile swap opens a new intent epoch; an older in-flight + // response for a previous profile must stand down when it resolves. + if (force) { + profileRefreshEpochRef.current += 1 + } + + const profileRefreshEpoch = profileRefreshEpochRef.current + try { if ($activeSessionId.get()) { return @@ -79,9 +90,18 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO return } + // Snapshot the selection generation before awaiting so a picker click + // that lands while getGlobalModelInfo is in flight wins over this older + // default — value comparisons alone miss re-selecting the same row. + const selectionGeneration = getComposerSelectionGeneration() const result = await getGlobalModelInfo() - if ($activeSessionId.get() || keepManualPick()) { + if ( + profileRefreshEpochRef.current !== profileRefreshEpoch || + $activeSessionId.get() || + getComposerSelectionGeneration() !== selectionGeneration || + keepManualPick() + ) { return } @@ -122,7 +142,7 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO setCurrentModel(selection.model) setCurrentProvider(selection.provider) - setCurrentModelSource('manual') + markComposerSelectionManual() updateModelOptionsCache(selection.provider, selection.model, !liveSessionId) // No live session yet: the pick is pure UI state. session.create reads diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index f9288eb86860..d13717ad48e9 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -5,12 +5,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getSessionMessages, type SessionInfo } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' -import { $activeGatewayProfile, $newChatProfile } from '@/store/profile' +import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile' import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' import { $activeSessionId, $activeSessionStoredIdRotation, $currentCwd, + $currentFastMode, + $currentModel, + $currentProvider, + $currentReasoningEffort, $messages, $newChatWorkspaceTarget, $resumeFailedSessionId, @@ -18,6 +22,10 @@ import { setActiveSessionId, setActiveSessionStoredIdRotation, setCurrentCwd, + setCurrentFastMode, + setCurrentModel, + setCurrentProvider, + setCurrentReasoningEffort, setMessages, setNewChatWorkspaceTarget, setResumeFailedSessionId, @@ -39,7 +47,23 @@ vi.mock('@/hermes', async importOriginal => ({ setSessionArchived: vi.fn() })) +vi.mock('@/store/profile', async importOriginal => ({ + ...(await importOriginal>()), + ensureGatewayProfile: vi.fn().mockResolvedValue(undefined) +})) + const RUNTIME_SESSION_ID = 'rt-new-001' + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + + const promise = new Promise(done => { + resolve = done + }) + + return { promise, resolve } +} + type HarnessHandle = Pick< ReturnType, 'createBackendSessionForSend' | 'startFreshSessionDraft' @@ -304,6 +328,10 @@ describe('createBackendSessionForSend profile routing', () => { $projectScope.set(ALL_PROJECTS) $projectTree.set([]) $currentCwd.set('') + $currentFastMode.set(false) + $currentModel.set('') + $currentProvider.set('') + $currentReasoningEffort.set('') setNewChatWorkspaceTarget(undefined) vi.restoreAllMocks() }) @@ -353,6 +381,57 @@ describe('createBackendSessionForSend profile routing', () => { expect(params).toMatchObject({ cwd: '/remote/worktree' }) }) + it('freezes the visible selector state before profile readiness and sends fast: false explicitly', async () => { + const profileReady = deferred() + vi.mocked(ensureGatewayProfile).mockReturnValueOnce(profileReady.promise) + + setCurrentModel('anthropic/claude-sonnet-4.6') + setCurrentProvider('anthropic') + setCurrentReasoningEffort('high') + setCurrentFastMode(false) + + let createParams: Record | undefined + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.create') { + createParams = params + + return { session_id: RUNTIME_SESSION_ID, stored_session_id: null } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( (handle = next)} requestGateway={requestGateway} />) + await waitFor(() => expect(handle).not.toBeNull()) + + let createPromise!: Promise + act(() => { + createPromise = handle!.createBackendSessionForSend() + }) + await waitFor(() => expect(ensureGatewayProfile).toHaveBeenCalled()) + + // A background refresh or a second click can mutate the sticky atoms while + // the profile is waking. This send must still use what was visible at Enter. + setCurrentModel('openai/gpt-5.5') + setCurrentProvider('openai-codex') + setCurrentReasoningEffort('low') + setCurrentFastMode(true) + profileReady.resolve() + + await act(async () => { + await createPromise + }) + + expect(createParams).toMatchObject({ + fast: false, + model: 'anthropic/claude-sonnet-4.6', + provider: 'anthropic', + reasoning_effort: 'high' + }) + }) + it('falls back to the entered project cwd when the current cwd is blank', async () => { const params = await createWith(() => { $projectTree.set([ 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 9f48aeed5a5c..6934d00208c5 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 @@ -147,21 +147,30 @@ function reconcileAuthoritativeMessages( // profile to None). The sticky UI model/effort/fast ride as per-session overrides, // never the profile default (that lives in Settings → Model). async function desktopSessionCreateParams(cwd: string): Promise> { + // Treat Send as the linearization point for the visible selector state. The + // profile handshake below can yield long enough for background config/model + // refreshes to finish; reading atoms afterward would silently create the + // session with a different selection than the one the user submitted. + const selection = { + effort: $currentReasoningEffort.get().trim(), + fast: $currentFastMode.get(), + model: $currentModel.get().trim(), + provider: $currentProvider.get().trim() + } + const profile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get()) await ensureGatewayProfile(profile) - const model = $currentModel.get().trim() - const provider = $currentProvider.get().trim() - const effort = $currentReasoningEffort.get().trim() - return { cols: 96, source: 'desktop', ...(cwd && { cwd }), ...(profile ? { profile } : {}), - ...(model ? { model, ...(provider ? { provider } : {}) } : {}), - ...(effort ? { reasoning_effort: effort } : {}), - ...($currentFastMode.get() ? { fast: true } : {}) + ...(selection.model + ? { model: selection.model, ...(selection.provider ? { provider: selection.provider } : {}) } + : {}), + ...(selection.effort ? { reasoning_effort: selection.effort } : {}), + fast: selection.fast } } diff --git a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx index 4358b75f8c73..c46e38aa240f 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx @@ -8,7 +8,15 @@ import { DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' import { $modelPresets, getModelPreset } from '@/store/model-presets' -import { $activeSessionId } from '@/store/session' +import { + $activeSessionId, + $currentFastMode, + $currentReasoningEffort, + getCurrentModelSource, + setCurrentFastMode, + setCurrentModelSource, + setCurrentReasoningEffort +} from '@/store/session' import { type FastControl, ModelEditSubmenu } from './model-edit-submenu' @@ -22,6 +30,9 @@ beforeAll(() => { beforeEach(() => { $modelPresets.set({}) $activeSessionId.set(null) + setCurrentFastMode(false) + setCurrentModelSource('') + setCurrentReasoningEffort('') }) afterEach(() => { @@ -56,13 +67,16 @@ function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; req // preset-only — the gateway's config.set falls back to global config when no // session matches, so it must not be called. (Caught in the second review.) describe('ModelEditSubmenu no-session guard', () => { - it('param fast: records the preset but skips the gateway without a session', () => { + it('param fast: records explicit off in the draft but skips the gateway without a session', () => { const requestGateway = vi.fn().mockResolvedValue({}) - renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway }) + setCurrentFastMode(true) + renderSubmenu({ fastControl: { kind: 'param', on: true }, reasoning: false, requestGateway }) fireEvent.click(screen.getByRole('switch')) - expect(getModelPreset('p1', 'm1').fast).toBe(true) + expect(getModelPreset('p1', 'm1').fast).toBe(false) + expect($currentFastMode.get()).toBe(false) + expect(getCurrentModelSource()).toBe('manual') expect(requestGateway).not.toHaveBeenCalled() }) @@ -74,6 +88,8 @@ describe('ModelEditSubmenu no-session guard', () => { fireEvent.click(screen.getByRole('switch')) expect(getModelPreset('p1', 'm1').effort).toBe('none') + expect($currentReasoningEffort.get()).toBe('none') + expect(getCurrentModelSource()).toBe('manual') expect(requestGateway).not.toHaveBeenCalled() }) diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index dda409699f55..527c84cec45c 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -15,7 +15,12 @@ import { useI18n } from '@/i18n' import { normalize } from '@/lib/text' import { setModelPreset } from '@/store/model-presets' import { notifyError } from '@/store/notifications' -import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session' +import { + $activeSessionId, + markComposerSelectionManual, + setCurrentFastMode, + setCurrentReasoningEffort +} from '@/store/session' // Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned // by the Thinking toggle, not the radio. @@ -120,6 +125,7 @@ export function ModelEditSubmenu({ return } + markComposerSelectionManual() setCurrentReasoningEffort(next) // Preset-only without a session: `isActive` holds for the global/default @@ -161,6 +167,7 @@ export function ModelEditSubmenu({ return } + markComposerSelectionManual() setCurrentFastMode(enabled) // Preset-only without a session (see patchReasoning). diff --git a/apps/desktop/src/store/model-presets.test.ts b/apps/desktop/src/store/model-presets.test.ts index efe49ffa6e53..ef37cecc07d7 100644 --- a/apps/desktop/src/store/model-presets.test.ts +++ b/apps/desktop/src/store/model-presets.test.ts @@ -1,9 +1,14 @@ import { beforeEach, describe, expect, it } from 'vitest' import { $modelPresets, applyModelPreset, getModelPreset, modelPresetKey, setModelPreset } from './model-presets' +import { $currentFastMode, $currentReasoningEffort, setCurrentFastMode, setCurrentReasoningEffort } from './session' describe('model presets', () => { - beforeEach(() => $modelPresets.set({})) + beforeEach(() => { + $modelPresets.set({}) + setCurrentFastMode(false) + setCurrentReasoningEffort('') + }) it('round-trips a preset and merges patches without dropping prior fields', () => { setModelPreset('anthropic', 'claude-opus-4-8', { effort: 'high' }) @@ -35,7 +40,7 @@ describe('model presets', () => { expect(calls).toEqual([{ method: 'config.set', params: { key: 'reasoning', session_id: 's1', value: 'high' } }]) }) - it('no-ops without a session so selecting a model cannot mutate global config', async () => { + it('applies a fresh-draft preset locally without mutating gateway config', async () => { const calls: { method: string; params?: Record }[] = [] const request = async (method: string, params?: Record) => { @@ -46,6 +51,8 @@ describe('model presets', () => { await applyModelPreset({ effort: 'high', fast: true }, { failMessage: 'x', request, sessionId: null }) + expect($currentReasoningEffort.get()).toBe('high') + expect($currentFastMode.get()).toBe(true) expect(calls).toEqual([]) }) }) diff --git a/apps/desktop/src/store/model-presets.ts b/apps/desktop/src/store/model-presets.ts index 9a66a8b0d2ce..8771b38e0827 100644 --- a/apps/desktop/src/store/model-presets.ts +++ b/apps/desktop/src/store/model-presets.ts @@ -51,19 +51,15 @@ export function setModelPreset(provider: string, model: string, patch: ModelPres persistString(STORAGE_KEY, JSON.stringify(next)) } -/** Push a model's preset onto the active session (optimistic + gateway). +/** Apply a model's preset to the composer, then push it to a live session. * `undefined` skips that dimension; values are capability-gated upstream. - * No-ops without a session — the gateway's `config.set` reasoning/fast fall - * back to persistent (global/profile) config when none matches, so selecting - * a model must not reach it (else it rewrites `agent.*`, defaults included). */ + * Without a session the local draft still needs the preset, but must not call + * `config.set`: that falls back to persistent profile config when no session + * matches and would rewrite the user's defaults. */ export async function applyModelPreset( { effort, fast }: ModelPreset, ctx: { failMessage: string; request: RequestGateway; sessionId: null | string } ): Promise { - if (!ctx.sessionId) { - return - } - if (effort !== undefined) { setCurrentReasoningEffort(effort) } @@ -72,6 +68,10 @@ export async function applyModelPreset( setCurrentFastMode(fast) } + if (!ctx.sessionId) { + return + } + try { if (effort !== undefined) { await ctx.request('config.set', { key: 'reasoning', session_id: ctx.sessionId, value: effort }) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 07ce07c02a47..dbfd7dbc7995 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -381,6 +381,19 @@ export const setCurrentModelSource = (source: ComposerModelSource) => { $currentModelSource.set(source) } +// Monotonic intent token for async default refreshes. A profile/config request +// may start before the user opens the picker and finish after their click; the +// token lets that older response stand down even when the selected value is +// unchanged (value comparisons alone cannot detect re-selecting the same row). +let composerSelectionGeneration = 0 + +export const getComposerSelectionGeneration = (): number => composerSelectionGeneration + +export const markComposerSelectionManual = (): void => { + composerSelectionGeneration += 1 + setCurrentModelSource('manual') +} + export const setCurrentReasoningEffort = (next: Updater) => { updateAtom($currentReasoningEffort, next) persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null) diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index 7439a8a8345f..1a0dc7f07923 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -120,7 +120,7 @@ describe('reportBackendContract', () => { }) it('dismisses the toast when the backend meets the contract', () => { - reportBackendContract(3) + reportBackendContract(4) expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew') expect(notifySpy).not.toHaveBeenCalled() }) @@ -160,8 +160,8 @@ describe('reportBackendContract', () => { lastToast().onDismiss() notifySpy.mockClear() - reportBackendContract(3) // backend updated → satisfied, snooze cleared - reportBackendContract(2) // a later regression must warn immediately + reportBackendContract(4) // backend updated → satisfied, snooze cleared + reportBackendContract(3) // a later regression must warn immediately expect(notifySpy).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index a7b0bbc8b94e..6c7e1483cf93 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -92,7 +92,8 @@ function isUpdateToastSnoozed(): boolean { // value (or none — a pre-GUI checkout) means GUI<->backend skew. // v2: requires the file.attach RPC (remote-gateway non-image file upload). // v3: requires approvals.mode config RPCs and session.info reconciliation. -const REQUIRED_BACKEND_CONTRACT = 3 +// v4: requires explicit Fast-off session creation and session-scoped Fast edits. +const REQUIRED_BACKEND_CONTRACT = 4 const SKEW_TOAST_ID = 'backend-contract-skew' // The contract check runs on every session.resume (applyRuntimeInfo), so // without a snooze the warning re-popped on every thread the user opened, even diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 71d709f5dc6a..786587b07905 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1757,6 +1757,18 @@ def test_stored_session_runtime_overrides_skips_bare_billing_provider(): assert ov["model_override"]["provider"] == "custom:myendpoint" +def test_stored_session_runtime_overrides_restores_explicit_normal_tier(): + overrides = server._stored_session_runtime_overrides( + { + "model": "gpt-5.4", + "model_config": {"service_tier": "normal"}, + } + ) + + assert "service_tier_override" in overrides + assert overrides["service_tier_override"] == "" + + def test_persist_live_session_runtime_preserves_resume_metadata(monkeypatch): updates = {} @@ -1796,6 +1808,37 @@ def test_persist_live_session_runtime_preserves_resume_metadata(monkeypatch): ) +def test_persist_live_session_runtime_preserves_explicit_normal_tier(): + updates = {} + + class FakeDB: + def get_session(self, _session_id): + return {"model_config": '{"service_tier":"priority"}'} + + def update_session_meta(self, _session_id, model_config_json, model=None): + updates["config"] = json.loads(model_config_json) + + agent = types.SimpleNamespace( + model="gpt-5.4", + provider="openai-codex", + base_url=None, + api_mode=None, + reasoning_config=None, + service_tier="", + _session_db=FakeDB(), + ) + + server._persist_live_session_runtime( + { + "agent": agent, + "session_key": "stored-session", + "create_service_tier_override": "", + } + ) + + assert updates["config"]["service_tier"] == "normal" + + def test_status_callback_emits_kind_and_text(): with patch("tui_gateway.server._emit") as emit: cb = server._agent_cbs("sid")["status_callback"] @@ -10267,8 +10310,16 @@ def test_session_create_records_ui_model_as_session_override(monkeypatch): assert resp["result"]["info"]["model"] == "claude-sonnet-4.6" assert resp["result"]["info"]["provider"] == "anthropic" + # Explicit false is not the same as omission: it must suppress a Fast + # profile default for this session's first request. + normal = server._methods["session.create"]( + "r2", {"cols": 80, "fast": False} + ) + normal_sess = server._sessions[normal["result"]["session_id"]] + assert normal_sess["create_service_tier_override"] == "" + # No knobs → no overrides; the session builds from the profile default. - plain = server._methods["session.create"]("r2", {"cols": 80}) + plain = server._methods["session.create"]("r3", {"cols": 80}) plain_sess = server._sessions[plain["result"]["session_id"]] assert plain_sess["model_override"] is None assert plain_sess["create_reasoning_override"] is None @@ -10277,7 +10328,10 @@ def test_session_create_records_ui_model_as_session_override(monkeypatch): server._sessions.clear() -def test_start_agent_build_passes_session_model_override(monkeypatch): +@pytest.mark.parametrize("service_tier_override", ["priority", ""]) +def test_start_agent_build_passes_session_model_override( + monkeypatch, service_tier_override +): """A model staged on the session (e.g. by session.create from the desktop composer) must reach _make_agent so the first build runs on it directly — no global config, no build-then-switch. @@ -10317,7 +10371,7 @@ def test_start_agent_build_passes_session_model_override(monkeypatch): "profile_home": None, "model_override": override, "create_reasoning_override": reasoning, - "create_service_tier_override": "priority", + "create_service_tier_override": service_tier_override, } server._sessions[sid] = session try: @@ -10325,7 +10379,7 @@ def test_start_agent_build_passes_session_model_override(monkeypatch): assert session["agent_ready"].wait(timeout=3), "agent build did not finish" assert captured.get("model_override") == override assert captured.get("reasoning_config_override") == reasoning - assert captured.get("service_tier_override") == "priority" + assert captured.get("service_tier_override") == service_tier_override assert session["agent"].model == "claude-sonnet-4.6" finally: server._sessions.clear() @@ -10334,6 +10388,39 @@ def test_start_agent_build_passes_session_model_override(monkeypatch): # ── billing/subscription state + error serialization ───────────────── +def test_reset_session_agent_preserves_explicit_normal_fast(monkeypatch): + captured = {} + new_agent = types.SimpleNamespace(model="openai/gpt-5.4", service_tier="") + session = _session( + agent=types.SimpleNamespace( + model="openai/gpt-5.4", + reasoning_config=None, + service_tier="", + ), + model_override={"model": "openai/gpt-5.4"}, + create_service_tier_override="", + ) + + def make_agent(*_args, **kwargs): + captured.update(kwargs) + return new_agent + + monkeypatch.setattr(server, "_set_session_context", lambda _key: []) + monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None) + monkeypatch.setattr(server, "_make_agent", make_agent) + monkeypatch.setattr(server, "_config_model_target", lambda: ("", "")) + monkeypatch.setattr(server, "_load_show_reasoning", lambda: True) + monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all") + monkeypatch.setattr(server, "_session_info", lambda *_args: {}) + monkeypatch.setattr(server, "_emit", lambda *_args: None) + monkeypatch.setattr(server, "_restart_slash_worker", lambda *_args: None) + + server._reset_session_agent("sid", session) + + assert captured["service_tier_override"] == "" + assert session["agent"] is new_agent + + @pytest.mark.parametrize( "card,expected", [ diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5f04efaabd90..801f855910ca 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1984,8 +1984,13 @@ def _ensure_session_db_row(session: dict) -> None: ) if (reasoning := session.get("create_reasoning_override")) is not None: model_config["reasoning_config"] = reasoning - if tier := session.get("create_service_tier_override"): - model_config["service_tier"] = tier + create_service_tier_override = session.get("create_service_tier_override") + if create_service_tier_override is not None: + # Empty string is the in-memory sentinel for an explicit normal tier: + # it bypasses _make_agent's profile fallback without sending a bogus + # service_tier value to the provider. Persist a durable marker so resume + # can distinguish that choice from an omitted/inherited tier. + model_config["service_tier"] = create_service_tier_override or "normal" # Branch lineage: stamp the same ``_branched_from`` marker the TUI /branch # uses so list_sessions_rich keeps the branch listed and the desktop sidebar # can nest it under its parent. @@ -2604,7 +2609,11 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict: overrides["provider_override"] = provider if isinstance(reasoning_config, dict): overrides["reasoning_config_override"] = reasoning_config - if service_tier: + if service_tier.lower() == "normal": + # None means "inherit the profile" at _make_agent. Empty string is a + # real override that means "do not request a priority service tier". + overrides["service_tier_override"] = "" + elif service_tier: overrides["service_tier_override"] = service_tier return overrides @@ -2692,6 +2701,12 @@ def _persist_live_session_runtime(session: dict | None) -> None: if isinstance(parsed, dict): existing_config = parsed model_config = _runtime_model_config(agent, existing_config) + create_service_tier_override = session.get("create_service_tier_override") + if create_service_tier_override is not None: + # _runtime_model_config sees agent.service_tier=None for explicit + # normal and would otherwise erase the distinction on every live + # metadata persist. + model_config["service_tier"] = create_service_tier_override or "normal" model = str(getattr(agent, "model", "") or "").strip() if hasattr(db, "update_session_meta"): db.update_session_meta(session_key, json.dumps(model_config), model or None) @@ -3686,7 +3701,8 @@ def _current_profile_name() -> str: # cryptically downstream. Bump whenever the desktop's backend contract changes. # v2: adds the file.attach RPC (remote-gateway non-image file upload). # v3: adds approvals.mode config RPCs and session.info reconciliation. -DESKTOP_BACKEND_CONTRACT = 3 +# v4: session.create fast=false is an explicit per-session normal-tier override. +DESKTOP_BACKEND_CONTRACT = 4 def _session_usage_snapshot(session: dict | None) -> dict: @@ -4758,6 +4774,9 @@ def _reset_session_agent(sid: str, session: dict) -> dict: old_reasoning = session.get("create_reasoning_override") if isinstance(old_reasoning, dict): reset_kw["reasoning_config_override"] = old_reasoning + create_service_tier_override = session.get("create_service_tier_override") + if create_service_tier_override is not None: + reset_kw["service_tier_override"] = create_service_tier_override new_agent = _make_agent( sid, session["session_key"], @@ -5759,9 +5778,14 @@ def _(rid, params: dict) -> dict: create_reasoning_override = parse_reasoning_effort(effort) except Exception: create_reasoning_override = None - # Only pin "fast" when explicitly requested; leaving it None lets the build - # fall back to the profile default service tier rather than forcing normal. - create_service_tier_override = "priority" if params.get("fast") else None + # Presence is part of the contract: omitted means inherit the profile, + # true pins priority, and false pins normal. Empty string is the internal + # explicit-normal sentinel because _make_agent uses None for inheritance. + create_service_tier_override = None + if "fast" in params: + create_service_tier_override = ( + "priority" if is_truthy_value(params.get("fast")) else "" + ) ready = threading.Event() now = time.time() From 9b428ddd08415e3016cb39171541cb464731327a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:32:20 -0700 Subject: [PATCH 021/205] feat(x_search): default model grok-4.20-reasoning -> grok-4.5 (#67719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grok-4.5 is xAI's newest release (their versioning is non-monotonic: 4.5 > 4.20) and is the model xAI's own docs use for the server-side x_search tool. Users who explicitly pinned x_search.model keep their choice; everyone else picks up the new default via the config deep-merge — no _config_version bump needed. - tools/x_search_tool.py: DEFAULT_X_SEARCH_MODEL - hermes_cli/config.py: DEFAULT_CONFIG x_search.model + comment - agent/reasoning_timeouts.py: 300s stale-timeout floor entry for grok-4.5 (grok-4.20-reasoning entry kept for pinned users) - docs: x-search.md en + zh-Hans (config sample + troubleshooting) - tests: default-model assertion + timeout-floor positive case --- agent/reasoning_timeouts.py | 1 + hermes_cli/config.py | 6 +++--- tests/agent/test_reasoning_stale_timeout_floor.py | 1 + tests/tools/test_x_search_tool.py | 2 +- tools/x_search_tool.py | 2 +- website/docs/user-guide/features/x-search.md | 6 +++--- .../current/user-guide/features/x-search.md | 6 +++--- 7 files changed, 13 insertions(+), 11 deletions(-) diff --git a/agent/reasoning_timeouts.py b/agent/reasoning_timeouts.py index 768ce0494def..bebf7825fde8 100644 --- a/agent/reasoning_timeouts.py +++ b/agent/reasoning_timeouts.py @@ -111,6 +111,7 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = ( # non-reasoning pairs. ("grok-4-fast-reasoning", 300), ("grok-4.20-reasoning", 300), + ("grok-4.5", 300), ("grok-4-fast-non-reasoning", 180), ) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3cd431260c0e..66a4024809b9 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3299,10 +3299,10 @@ DEFAULT_CONFIG = { # OAuth or XAI_API_KEY) AND the x_search toolset is enabled in # `hermes tools`. These settings tune the backing Responses API call. "x_search": { - # xAI model used for the Responses call. grok-4.20-reasoning is - # the recommended default; any Grok model with x_search tool + # xAI model used for the Responses call. grok-4.5 is the + # recommended default; any Grok model with x_search tool # access works. - "model": "grok-4.20-reasoning", + "model": "grok-4.5", # Request timeout in seconds (minimum 30). x_search can take # 60-120s for complex queries — the default is generous. "timeout_seconds": 180, diff --git a/tests/agent/test_reasoning_stale_timeout_floor.py b/tests/agent/test_reasoning_stale_timeout_floor.py index d17ed4d01352..ec05cb54dd60 100644 --- a/tests/agent/test_reasoning_stale_timeout_floor.py +++ b/tests/agent/test_reasoning_stale_timeout_floor.py @@ -77,6 +77,7 @@ import pytest # xAI Grok reasoning variants — explicit, not bare `grok`. ("x-ai/grok-4-fast-reasoning", 300.0), ("x-ai/grok-4.20-reasoning", 300.0), + ("x-ai/grok-4.5", 300.0), ("x-ai/grok-4-fast-non-reasoning", 180.0), ]) def test_reasoning_stale_timeout_floor_positive_cases(model, expected): diff --git a/tests/tools/test_x_search_tool.py b/tests/tools/test_x_search_tool.py index f0138e9f83d7..8aeb07ef3d50 100644 --- a/tests/tools/test_x_search_tool.py +++ b/tests/tools/test_x_search_tool.py @@ -70,7 +70,7 @@ def test_x_search_posts_responses_request(monkeypatch): tool_def = captured["json"]["tools"][0] assert captured["url"] == "https://api.x.ai/v1/responses" assert captured["headers"]["User-Agent"] == f"Hermes-Agent/{__version__}" - assert captured["json"]["model"] == "grok-4.20-reasoning" + assert captured["json"]["model"] == "grok-4.5" assert captured["json"]["store"] is False assert tool_def["type"] == "x_search" assert tool_def["allowed_x_handles"] == ["xai", "grok"] diff --git a/tools/x_search_tool.py b/tools/x_search_tool.py index 39ecf2daf33b..8ed35ff2ee1e 100644 --- a/tools/x_search_tool.py +++ b/tools/x_search_tool.py @@ -56,7 +56,7 @@ from tools.xai_http import hermes_xai_user_agent, resolve_xai_http_credentials logger = logging.getLogger(__name__) DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1" -DEFAULT_X_SEARCH_MODEL = "grok-4.20-reasoning" +DEFAULT_X_SEARCH_MODEL = "grok-4.5" DEFAULT_X_SEARCH_TIMEOUT_SECONDS = 180 DEFAULT_X_SEARCH_RETRIES = 2 MAX_HANDLES = 10 diff --git a/website/docs/user-guide/features/x-search.md b/website/docs/user-guide/features/x-search.md index 2e2004cabe02..c1512823c387 100644 --- a/website/docs/user-guide/features/x-search.md +++ b/website/docs/user-guide/features/x-search.md @@ -50,9 +50,9 @@ Either choice satisfies the gating. You can pick whichever credentials you alrea # ~/.hermes/config.yaml x_search: # xAI model used for the Responses call. - # grok-4.20-reasoning is the recommended default; any Grok model + # grok-4.5 is the recommended default; any Grok model # with x_search tool access works. - model: grok-4.20-reasoning + model: grok-4.5 # Request timeout in seconds. x_search can take 60–120s for # complex queries — the default is generous. Minimum: 30. @@ -118,7 +118,7 @@ The tool surfaces this when both auth paths fail. Either set `XAI_API_KEY` in `~ ### "`x_search` is not enabled for this model" -The configured `x_search.model` doesn't have access to the server-side `x_search` tool. Switch to `grok-4.20-reasoning` (the default) or another Grok model that supports it. Check the [xAI documentation](https://docs.x.ai/) for the current list. +The configured `x_search.model` doesn't have access to the server-side `x_search` tool. Switch to `grok-4.5` (the default) or another Grok model that supports it. Check the [xAI documentation](https://docs.x.ai/) for the current list. ### Tool doesn't appear in the schema diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/x-search.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/x-search.md index 50e26c39742b..65de81dc71de 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/x-search.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/x-search.md @@ -46,9 +46,9 @@ hermes tools # ~/.hermes/config.yaml x_search: # 用于 Responses 调用的 xAI 模型。 - # grok-4.20-reasoning 是推荐的默认值;任何支持 + # grok-4.5 是推荐的默认值;任何支持 # x_search 工具访问权限的 Grok 模型均可使用。 - model: grok-4.20-reasoning + model: grok-4.5 # 请求超时时间(秒)。复杂查询的 x_search 可能需要 60–120 秒, # 默认值较为宽松。最小值:30。 @@ -114,7 +114,7 @@ agent 将: ### "`x_search` is not enabled for this model" -配置的 `x_search.model` 没有访问服务端 `x_search` 工具的权限。请切换至 `grok-4.20-reasoning`(默认值)或其他支持该工具的 Grok 模型。当前支持列表请查阅 [xAI 文档](https://docs.x.ai/)。 +配置的 `x_search.model` 没有访问服务端 `x_search` 工具的权限。请切换至 `grok-4.5`(默认值)或其他支持该工具的 Grok 模型。当前支持列表请查阅 [xAI 文档](https://docs.x.ai/)。 ### 工具未出现在 schema 中 From b30108143e5f6feb605e40d1f2624ea8b58bc4a0 Mon Sep 17 00:00:00 2001 From: alelpoan <155192176+alelpoan@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:33:03 +0300 Subject: [PATCH 022/205] fix(docs): fix broken image and video in TUI docs (#43501) * fix(docs): fix video tag self-closing in tui.md * fix(docs): fix image and video paths, fix self-closing video tag --- website/docs/user-guide/tui.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 91a56b164b7a..f6dcfdf81895 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -134,9 +134,9 @@ Open it with any of these: - `/sessions new` to create a fresh live session immediately. - Click the `N live sessions` count in the status line. -Hermes TUI Session Orchestrator with one live session and a +new row +Hermes TUI Session Orchestrator with one live session and a +new row -