diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts index ba1107e9e0645..57bfa92bfa260 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts @@ -79,6 +79,7 @@ export function useSessionTileDelegate({ requestGateway('session.resume', { session_id: storedSessionId, cols: 96, + omit_messages: true, ...(profile ? { profile } : {}) }) ]) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index a7de88bf4de3b..dad686f3b67c7 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1402,7 +1402,7 @@ describe('usePromptActions redirectPrompt', () => { expect(await handle!.redirectPrompt('reconnect nudge')).toBe(true) expect(calls.map(c => c.method)).toEqual(['session.redirect', 'session.resume', 'session.redirect']) expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID, text: 'reconnect nudge' }) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', omit_messages: true }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'reconnect nudge' }) expect(handle!.activeSessionIdRef.current).toBe(RECOVERED_SESSION_ID) }) @@ -1832,7 +1832,7 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(ok).toBe(true) // First submit (stale id) → session.resume (stored id) → retry submit (fresh id). expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', omit_messages: true }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' }) }) @@ -1876,7 +1876,12 @@ describe('usePromptActions sleep/wake session recovery', () => { ) expect(await handle!.submitText('message after wake')).toBe(true) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' }) + expect(calls[1]?.params).toEqual({ + session_id: STORED_SESSION_ID, + source: 'desktop', + omit_messages: true, + profile: 'work' + }) setSessions(() => []) }) @@ -1923,7 +1928,12 @@ describe('usePromptActions sleep/wake session recovery', () => { ) expect(await handle!.submitText('message after wake')).toBe(true) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' }) + expect(calls[1]?.params).toEqual({ + session_id: STORED_SESSION_ID, + source: 'desktop', + omit_messages: true, + profile: 'work' + }) vi.mocked(getSession).mockReset() setSessions(() => []) @@ -1976,7 +1986,11 @@ describe('usePromptActions sleep/wake session recovery', () => { session_id: 'rt-background-stale', text: 'queued background message after wake' }) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) + expect(calls[1]?.params).toEqual({ + session_id: STORED_SESSION_ID, + source: 'desktop', + omit_messages: true + }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'queued background message after wake' @@ -2023,7 +2037,11 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt']) expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID }) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) + expect(calls[1]?.params).toEqual({ + session_id: STORED_SESSION_ID, + source: 'desktop', + omit_messages: true + }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID }) }) @@ -2155,7 +2173,11 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(ok).toBe(true) expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) + expect(calls[1]?.params).toEqual({ + session_id: STORED_SESSION_ID, + source: 'desktop', + omit_messages: true + }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message during starved loop' @@ -2198,7 +2220,11 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(ok).toBe(true) expect(createBackendSessionForSend).not.toHaveBeenCalled() expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit']) - expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) + expect(calls[0]?.params).toEqual({ + session_id: STORED_SESSION_ID, + source: 'desktop', + omit_messages: true + }) expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID }) }) @@ -2508,7 +2534,8 @@ describe('usePromptActions submit session-context isolation (#54527)', () => { expect(calls.some(c => c.method === 'prompt.submit')).toBe(false) expect(calls.find(c => c.method === 'session.resume')?.params).toEqual({ session_id: STORED_SESSION_A, - source: 'desktop' + source: 'desktop', + omit_messages: true }) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 1e314fe714a8a..3c033c35d8277 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -607,6 +607,7 @@ export function usePromptActions({ const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current, source: 'desktop', + omit_messages: true, ...(resumeProfile ? { profile: resumeProfile } : {}) }) @@ -709,6 +710,7 @@ export function usePromptActions({ const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current, source: 'desktop', + omit_messages: true, ...(resumeProfile ? { profile: resumeProfile } : {}) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 398a72f3be4a2..1bef21651fcd2 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -392,6 +392,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: targetStoredSessionId, source: 'desktop', + omit_messages: true, ...(resumeProfile ? { profile: resumeProfile } : {}) }) @@ -539,6 +540,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: recoverStoredSessionId, source: 'desktop', + omit_messages: true, ...(resumeProfile ? { profile: resumeProfile } : {}) }) 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 7648c01a4037f..c102a2564216c 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 @@ -889,7 +889,7 @@ describe('resumeSession failure recovery', () => { expect(resumeParams).not.toHaveProperty('lazy') expect(resumeParams).not.toHaveProperty('eager_build') - expect(resumeParams).toMatchObject({ source: 'desktop' }) + expect(resumeParams).toMatchObject({ source: 'desktop', omit_messages: true }) }) it('arms the failure latch when resume succeeds with an empty transcript for a non-empty stored session', async () => { @@ -1238,6 +1238,10 @@ describe('resumeSession warm-cache mapping integrity', () => { expect(methods).toContain('session.activate') expect(methods).not.toContain('session.resume') expect(getSessionMessages).toHaveBeenCalledWith('stored-A', undefined) + expect(requestGateway).toHaveBeenCalledWith( + 'session.activate', + expect.objectContaining({ omit_messages: true, session_id: 'rt-A' }) + ) expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A') }) 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 a4ac8ec4ad72b..8c912aa843f60 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 @@ -680,7 +680,8 @@ export function useSessionActions({ try { activated = await requestGateway('session.activate', { session_id: cachedRuntimeId, - cols: 96 + cols: 96, + omit_messages: true }) } catch (error) { // Compatibility for older backends. Modern backends require @@ -842,12 +843,14 @@ export function useSessionActions({ session_id: storedSessionId, cols: 96, source: 'desktop', + // REST is the transcript authority for Desktop. Avoid duplicating a + // potentially huge compression lineage in the WebSocket response. // Watch windows attach lazily (live mirror). Every other cold resume // gets the gateway's default deferred build: the RPC returns the // transcript immediately instead of blocking the switch on _make_agent // (MCP discovery / prompt build), and the agent pre-warms in the // background while the prefetch above paints the transcript. - ...(watchWindow ? { lazy: true } : {}), + ...(watchWindow ? { lazy: true } : { omit_messages: true }), ...(sessionProfile ? { profile: sessionProfile } : {}) }) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 4651a92b5dae8..becaa76117556 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -456,6 +456,7 @@ export interface SessionResumeResponse { info?: SessionRuntimeInfo message_count: number messages: SessionMessage[] + messages_omitted?: boolean resumed: string running?: boolean session_id: string diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 82f2e70ee472e..c1a40cb7606a4 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -12055,6 +12055,81 @@ 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) +_MAX_CRON_OUTPUT_CHARS = 200_000 + + +def _cron_output_dir_for_job(home: Path, job_id: str) -> Path: + text = str(job_id or "").strip() + if not text or text in {".", ".."} or "/" in text or "\\" in text: + raise HTTPException(status_code=400, detail="Invalid cron job id") + + base = (home / "cron" / "output").resolve() + target = (base / text).resolve() + if target != base and base not in target.parents: + raise HTTPException(status_code=400, detail="Invalid cron output path") + return target + + +def _list_cron_job_outputs_sync( + job_id: str, + profile: Optional[str] = None, + limit: int = 5, +): + """Recent markdown outputs for a cron job, newest first.""" + selected = profile or _find_cron_job_profile(job_id) + if not selected: + raise HTTPException(status_code=404, detail="Job not found") + + job = _call_cron_for_profile(selected, "get_job", job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + canonical = str(job.get("id") or job_id) + + try: + limit_n = max(1, min(int(limit), 25)) + except (TypeError, ValueError): + limit_n = 5 + + _profile_name, home = _cron_profile_home(selected) + output_dir = _cron_output_dir_for_job(home, canonical) + if not output_dir.exists(): + return {"outputs": [], "limit": limit_n} + + files = sorted( + [p for p in output_dir.glob("*.md") if p.is_file()], + key=lambda p: (p.stat().st_mtime, p.name), + reverse=True, + )[:limit_n] + + outputs: List[Dict[str, Any]] = [] + for path in files: + stat_result = path.stat() + text = path.read_text(encoding="utf-8", errors="replace") + truncated = len(text) > _MAX_CRON_OUTPUT_CHARS + if truncated: + text = text[:_MAX_CRON_OUTPUT_CHARS] + outputs.append( + { + "id": path.stem, + "filename": path.name, + "created_at": datetime.fromtimestamp( + stat_result.st_mtime, + tz=timezone.utc, + ).isoformat(), + "size": stat_result.st_size, + "content": text, + "truncated": truncated, + "profile": selected, + } + ) + return {"outputs": outputs, "limit": limit_n} + + +@app.get("/api/cron/jobs/{job_id}/outputs") +async def list_cron_job_outputs(job_id: str, profile: Optional[str] = None, limit: int = 5): + return await _run_cron_dashboard_io(_list_cron_job_outputs_sync, job_id, profile, limit) + + def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None): try: profile_name, profile_home = _cron_profile_home(profile) diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py index 2c5d8bfc71ec0..78c54506a4143 100644 --- a/tests/hermes_cli/test_web_server_cron_profiles.py +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -2,6 +2,7 @@ from concurrent.futures import ThreadPoolExecutor import json +import os from queue import Empty, SimpleQueue import threading @@ -249,6 +250,54 @@ async def test_list_cron_jobs_specific_profile_filters_results(isolated_profiles assert jobs[0]["profile"] == "worker_alpha" +@pytest.mark.asyncio +async def test_list_cron_job_outputs_reads_named_profile_newest_first( + isolated_profiles, +): + from hermes_cli import web_server + + job = web_server._call_cron_for_profile( + "worker_alpha", + "create_job", + prompt="write a saved report", + schedule="every 1h", + name="saved-output-job", + ) + output_dir = isolated_profiles["worker_alpha"] / "cron" / "output" / job["id"] + output_dir.mkdir(parents=True) + older = output_dir / "older.md" + newer = output_dir / "newer.md" + older.write_text("old report", encoding="utf-8") + newer.write_text("new report", encoding="utf-8") + os.utime(older, (100, 100)) + os.utime(newer, (200, 200)) + + result = await web_server.list_cron_job_outputs(job["id"], limit=1) + + assert result["limit"] == 1 + assert result["outputs"] == [ + { + "id": "newer", + "filename": "newer.md", + "created_at": "1970-01-01T00:03:20+00:00", + "size": len("new report"), + "content": "new report", + "truncated": False, + "profile": "worker_alpha", + } + ] + + +@pytest.mark.parametrize("job_id", ["", ".", "..", "../escape", "nested/job", r"nested\\job"]) +def test_cron_output_dir_rejects_unsafe_job_ids(tmp_path, job_id): + from hermes_cli import web_server + + with pytest.raises(HTTPException) as exc: + web_server._cron_output_dir_for_job(tmp_path, job_id) + + assert exc.value.status_code == 400 + + @pytest.mark.asyncio async def test_create_cron_job_normalizes_representative_core_fields( isolated_profiles, tmp_path diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index aa8275e5e9ab0..399799361ffa9 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1565,8 +1565,10 @@ def test_history_to_messages_keeps_real_user_bracket_text(): ] -def test_session_resume_uses_parent_lineage_for_display(monkeypatch): +@pytest.mark.parametrize("omit_messages", [False, True]) +def test_session_resume_uses_parent_lineage_for_display(monkeypatch, omit_messages): captured = {} + target = "tip-omit" if omit_messages else "tip-full" class FakeDB: def get_session(self, target): @@ -1616,15 +1618,25 @@ def get_messages_as_conversation(self, target, include_ancestors=False, repair_a # _neuter_agent_prewarm_timer fixture; this test only asserts the # returned display history. + params = {"session_id": target} + if omit_messages: + params["omit_messages"] = True resp = server.handle_request( - {"id": "1", "method": "session.resume", "params": {"session_id": "tip"}} + {"id": "1", "method": "session.resume", "params": params} ) - assert resp["result"]["messages"] == [ + expected = [] if omit_messages else [ {"role": "user", "text": "root prompt"}, {"role": "assistant", "text": "root answer"}, ] - assert captured["history_calls"] == [("tip", False), ("tip", True)] + assert resp["result"]["messages"] == expected + assert resp["result"]["message_count"] == (1 if omit_messages else 2) + assert resp["result"]["messages_omitted"] is omit_messages + expected_calls = [(target, False)] if omit_messages else [ + (target, False), + (target, True), + ] + assert captured["history_calls"] == expected_calls def test_live_visible_history_prefers_db_display_with_candidate(): @@ -9249,6 +9261,33 @@ def test_session_activate_switches_live_session_without_closing_siblings(monkeyp server._sessions.pop("sid-b", None) +def test_session_activate_can_omit_duplicate_desktop_transcript(monkeypatch): + monkeypatch.setattr(server, "_session_info", lambda agent: {"model": agent.model}) + server._sessions["sid-large"] = _session( + agent=types.SimpleNamespace(model="model-large"), + history=[ + {"role": "user", "content": "large prompt"}, + {"role": "assistant", "content": "large answer"}, + ], + session_key="key-large", + ) + try: + resp = server.handle_request( + { + "id": "1", + "method": "session.activate", + "params": {"session_id": "sid-large", "omit_messages": True}, + } + ) + + assert resp["result"]["messages"] == [] + assert resp["result"]["message_count"] == 2 + assert resp["result"]["messages_omitted"] is True + assert resp["result"]["session_key"] == "key-large" + finally: + server._sessions.pop("sid-large", None) + + # ── session.most_recent ────────────────────────────────────────────── diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6cc0c8c22d74a..1360263c78cfb 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -6071,7 +6071,6 @@ def _(rid, params: dict) -> dict: # and each turn re-bind HERMES_HOME. None/own profile → launch (unchanged). profile = (params.get("profile") or "").strip() or None profile_home = _profile_home(profile) - # The desktop composer owns its model/effort/fast as plain UI state and ships # it on every session.create. Honor each as a PER-SESSION override (built into # the agent below) — never a global config write, so picking a model/effort @@ -6443,6 +6442,10 @@ def _(rid, params: dict) -> dict: # local profile's state.db. None/own profile → the launch profile (unchanged). profile = (params.get("profile") or "").strip() or None profile_home = _profile_home(profile) + # Desktop hydrates persisted transcripts through the authenticated REST + # route in parallel. Suppress the duplicate WebSocket transcript only when + # the caller explicitly requests it; other clients keep upstream behavior. + omit_messages = is_truthy_value(params.get("omit_messages", False)) # In a profile scope, the agent OWNS a long-lived db handle bound to that # profile (do NOT auto-close it here). Otherwise reuse the shared launch db. @@ -6507,6 +6510,7 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: cols=cols, touch=True, transport=current_transport() or _stdio_transport, + omit_messages=omit_messages, ) payload["resumed"] = target # A lazy watch session never owns a run loop, so its payload's running @@ -6581,14 +6585,15 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: except Exception: logger.debug("child-watch display projection read failed", exc_info=True) display_history = history - messages = _history_to_messages(display_history) + messages = [] if omit_messages else _history_to_messages(display_history) return _ok( rid, { "session_id": sid, "resumed": target, - "message_count": len(messages), + "message_count": len(display_history) if omit_messages else len(messages), "messages": messages, + "messages_omitted": omit_messages, "info": _lazy_resume_info(cwd), "inflight": None, "running": child_running, @@ -6629,7 +6634,13 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: # (raw_history → sanitize_replay_history → the resumed session's # working conversation) and the display copy stays verbatim — # inspection/export must show what is actually stored. - raw_history, display_history = db.get_resume_conversations(target) + if omit_messages: + raw_history = db.get_messages_as_conversation( + target, repair_alternation=True + ) + display_history = [] + else: + raw_history, display_history = db.get_resume_conversations(target) except Exception as e: if lease is not None: lease.release() @@ -6637,7 +6648,7 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: # Display keeps the full transcript; the model-fed history drops a # dangling/interrupted tool-call tail so a session killed mid-loop does # not replay the unanswered call forever (#29086). - prefix = db.get_ancestor_display_prefix(target) + prefix = [] if omit_messages else db.get_ancestor_display_prefix(target) history = sanitize_replay_history(raw_history) # Restore the model/provider/reasoning/tier this chat last used so the # deferred build (and the info below) match the eager path — without them @@ -6664,14 +6675,15 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: _schedule_agent_build(sid) _schedule_session_cap_enforcement() # trim detached idle sessions over the cap - messages = _history_to_messages(display_history) + messages = [] if omit_messages else _history_to_messages(display_history) return _ok( rid, { "session_id": sid, "resumed": target, - "message_count": len(messages), + "message_count": len(raw_history) if omit_messages else len(messages), "messages": messages, + "messages_omitted": omit_messages, "info": _lazy_resume_info( cwd, model=model_override.get("model") or "", @@ -6705,7 +6717,13 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: # One lineage SELECT feeds both projections (see the interactive resume # above): the model-fed copy is alternation-repaired for LIVE REPLAY, the # display copy stays verbatim. - raw_history, display_history = db.get_resume_conversations(target) + if omit_messages: + raw_history = db.get_messages_as_conversation( + target, repair_alternation=True + ) + display_history = [] + else: + raw_history, display_history = db.get_resume_conversations(target) # The display transcript keeps every row so the user still sees their # full history. The model-fed history is sanitized: a session whose # last turn died mid-tool-loop persists a dangling assistant(tool_calls) @@ -6713,9 +6731,11 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: # re-issue the unanswered call forever — the permanent-"thinking" stuck # session in #29086. The messaging gateway already strips this; this is # the WebUI/TUI resume path picking up the same cleanup. - display_history_prefix = db.get_ancestor_display_prefix(target) + display_history_prefix = ( + [] if omit_messages else db.get_ancestor_display_prefix(target) + ) history = sanitize_replay_history(raw_history) - messages = _history_to_messages(display_history) + messages = [] if omit_messages else _history_to_messages(display_history) tokens = _set_session_context(target) try: # Pass the profile's db so the agent persists turns to the right @@ -6762,6 +6782,7 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: cols=cols, touch=True, transport=current_transport() or _stdio_transport, + omit_messages=omit_messages, ) payload["resumed"] = target return _ok(rid, payload) @@ -6807,8 +6828,9 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: { "session_id": sid, "resumed": target, - "message_count": len(messages), + "message_count": len(raw_history) if omit_messages else len(messages), "messages": messages, + "messages_omitted": omit_messages, "info": _session_info(agent, session), "inflight": None, "running": False, @@ -7026,6 +7048,7 @@ def _live_session_payload( cols: int | None = None, touch: bool = False, transport: Transport | None = None, + omit_messages: bool = False, ) -> dict: with session["history_lock"]: if cols is not None: @@ -7043,11 +7066,16 @@ def _live_session_payload( # Prefer the persisted display lineage (candidate-inclusive) so this payload # matches the eager session.resume + REST transcript; the DB has its own # lock, so read it outside the session history lock. - history = _live_visible_history(session, _get_db(), in_memory_history) + history = ( + in_memory_history + if omit_messages + else _live_visible_history(session, _get_db(), in_memory_history) + ) payload = { "info": _fallback_session_info(session), "message_count": len(history), - "messages": _history_to_messages(history), + "messages": [] if omit_messages else _history_to_messages(history), + "messages_omitted": omit_messages, "running": running, "session_id": sid, "session_key": _session_lookup_key(session, fallback=sid), @@ -7119,6 +7147,7 @@ def _(rid, params: dict) -> dict: session, touch=True, transport=current_transport() or _stdio_transport, + omit_messages=is_truthy_value(params.get("omit_messages", False)), ), )