From a4bc1ca502be7290549736d8310c6cc90d992a78 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 23 Jul 2026 14:46:24 -0400 Subject: [PATCH] fix(timeline): persist typed display events (#69771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): hide persisted agent-only history scaffolding Filter verification-stop nudges and context-compaction handoffs at the stored-history mapper boundary. Preserve a real reply when a compaction handoff shares its stored message. * test(desktop): build persisted E2E sessions through the real agent Drive tui_gateway.entry over its stdio JSON-RPC transport against the mock provider, wait for real completion events, and persist normal session history through AIAgent and SessionDB. Migrate resume and hidden-history coverage, including real compression and live verify-on-stop scaffolding, then remove the unused direct SessionDB import scripts. * fix(desktop): use the provisioned Python for real-session E2Es Run the stdio gateway through uv's synced project environment outside the Nix dev shell, while retaining the fully provisioned Nix Python when the shell advertises HERMES_PYTHON_SRC_ROOT. * fix(nix): expose the provisioned Python environment to uv Mark the Nix-built Python environment active in the dev shell so the shared E2E session builder can always run through `uv run --active --no-sync`. * fix(timeline): persist typed display events * fix(timeline): strip display-only fields from provider payloads, preserve through rewrites, fix /resume display history Three review findings from PR #69771: 1. Provider payload leak: display_kind and display_metadata were forwarded to the provider API as unknown message fields. Strict OpenAI-compatible backends can reject the next request after a model switch or resumed typed event. Strip both from the per-request api_msg copy in conversation_loop alongside the existing api_content pop. 2. Rewrite/import data loss: _insert_message_rows preserved display_kind but silently dropped display_metadata. After replace_messages, archive_and_compact, or session import, async-delegation completion events lost their task counts and fell back to generic display text. Add display_metadata to the INSERT columns and bind tuple. 3. CLI /resume stale recap: startup --resume A set _resume_display_history from A's lineage. A subsequent in-session /resume B loaded B only into conversation_history via get_messages_as_conversation, leaving the stale A display projection. _display_resumed_history preferentially read the stale attribute, showing A's recap for B. Switch /resume to get_resume_conversations and update _resume_display_history alongside conversation_history. Tests: 890 Python (5 files), 35 desktop TS — all green. * feat(tui): render typed display events as ◈ markers in the Ink TUI The TUI was not handling display_kind at all — model switch markers and async delegation completions rendered as opaque user messages with the full [System: ...] text, and hidden compaction handoffs were visible. Wire display_kind through the full TUI chain: - _history_to_messages (tui_gateway/server.py) forwards display_kind and display_metadata to the gateway transcript payload. - GatewayTranscriptMessage (gatewayTypes.ts) gains both fields. - Msg.kind (types.ts) gains 'event' value. - toTranscriptMessages (domain/messages.ts) maps: - hidden → skip entirely - model_switch → event "model changed" - async_delegation_complete → event "N background agents finished" (or "background agent work finished" without metadata) - messageGroup (blockLayout.ts) routes event to its own group, with SELF_SPACED + PAINTS_TRAILING_GAP so it owns its margins. - messageLine.tsx renders event-kind as a dim ◈ marker with no gutter, matching the CLI's ◈ event rendering. - 4 new TUI tests for hidden/model_switch/async_delegation mapping. TUI typecheck: clean. TUI lint: 0 errors (2 pre-existing warnings). TUI tests: 9 passed (1 pre-existing failure on main, unrelated). --- agent/conversation_loop.py | 7 + .../e2e/hidden-history-messages.spec.ts | 147 ++++++++++++ apps/desktop/e2e/large-session-resume.spec.ts | 26 +- apps/desktop/e2e/mock-server.ts | 51 +++- apps/desktop/e2e/real-session-builder.ts | 226 ++++++++++++++++++ .../desktop/e2e/scripts/seed_large_session.py | 52 ---- apps/desktop/e2e/scripts/seed_session_db.py | 56 ----- apps/desktop/e2e/warm-resume-jitter.spec.ts | 88 ++----- apps/desktop/src/lib/chat-messages.test.ts | 33 +++ apps/desktop/src/lib/chat-messages.ts | 36 ++- apps/desktop/src/types/hermes.ts | 6 + hermes_cli/cli_agent_setup_mixin.py | 35 ++- hermes_cli/cli_commands_mixin.py | 17 +- hermes_state.py | 66 ++++- nix/devShell.nix | 5 + run_agent.py | 6 + tests/cli/test_cli_resume_command.py | 17 +- tests/cli/test_resume_display.py | 62 ++++- .../run_agent/test_session_meta_filtering.py | 48 ++++ tests/test_hermes_state.py | 48 ++++ .../test_model_switch_marker_role.py | 1 + tui_gateway/server.py | 98 +++++++- ui-tui/src/__tests__/messages.test.ts | 54 +++++ ui-tui/src/components/messageLine.tsx | 15 ++ ui-tui/src/domain/blockLayout.ts | 9 +- ui-tui/src/domain/messages.ts | 29 ++- ui-tui/src/gatewayTypes.ts | 2 + ui-tui/src/types.ts | 2 +- 28 files changed, 1007 insertions(+), 235 deletions(-) create mode 100644 apps/desktop/e2e/hidden-history-messages.spec.ts create mode 100644 apps/desktop/e2e/real-session-builder.ts delete mode 100644 apps/desktop/e2e/scripts/seed_large_session.py delete mode 100644 apps/desktop/e2e/scripts/seed_session_db.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index bf117b6dc93..bcaa80dce8c 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -989,6 +989,13 @@ def run_conversation( # outgoing copy. _api_content = api_msg.pop("api_content", None) + # Display-only timeline metadata. Never a provider field — strip + # from every outgoing copy so strict OpenAI-compatible backends + # don't reject the request after a model switch or resumed typed + # event row enters the live history. + api_msg.pop("display_kind", None) + api_msg.pop("display_metadata", None) + # Inject ephemeral context into the current turn's user message. # Sources: memory manager prefetch + plugin pre_llm_call hooks # with target="user_message" (the default). Both are diff --git a/apps/desktop/e2e/hidden-history-messages.spec.ts b/apps/desktop/e2e/hidden-history-messages.spec.ts new file mode 100644 index 00000000000..23076f766e7 --- /dev/null +++ b/apps/desktop/e2e/hidden-history-messages.spec.ts @@ -0,0 +1,147 @@ +/** + * E2E regression: desktop resume must hide agent-only transcript rows. + * + * Compaction handoffs are active user rows because the model needs them for + * context continuity. They are not authored chat content, so the desktop + * transcript must never display them after a real compressor-generated resume. + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { expect, test } from './test' + +import { + type MockBackendFixture, + buildAppEnv, + createSandbox, + launchDesktop, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig, +} from './fixtures' +import { + MOCK_REPLY, + startMockServer, + VERIFICATION_STOP_TEXT, + VERIFICATION_STOP_TRIGGER, +} from './mock-server' +import { RealSessionBuilder } from './real-session-builder' + +const SESSION_TITLE = 'E2E Hidden History Messages' +const VISIBLE_USER_TEXT = 'E2E_VISIBLE_USER_HISTORY' +const VISIBLE_POST_COMPACTION_TEXT = 'E2E_VISIBLE_POST_COMPACTION_HISTORY' +const COMPACTION_TRIGGER_PADDING = ' force real context compression'.repeat(600) + +async function setupSeededMockBackend(): Promise { + const mock = await startMockServer() + const sandbox = createSandbox('hidden-history') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + fs.appendFileSync( + path.join(sandbox.hermesHome, 'config.yaml'), + '\ncompression:\n threshold_tokens: 1\n', + 'utf8', + ) + writeEnvFile(sandbox.hermesHome) + const builder = await RealSessionBuilder.start(sandbox.hermesHome) + try { + await builder.createSession({ + title: SESSION_TITLE, + turns: [ + `${VISIBLE_USER_TEXT}${COMPACTION_TRIGGER_PADDING}`, + VISIBLE_POST_COMPACTION_TEXT, + ], + }) + } finally { + await builder.close() + } + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + return { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } +} + +test('resume hides real context-compaction handoffs', async ({}, testInfo) => { + const fixture = await setupSeededMockBackend() + + try { + const { page } = fixture + await waitForAppReady(fixture, 120_000) + + const sessionRow = page + .locator('[data-slot="sidebar"] button') + .filter({ hasText: SESSION_TITLE }) + .first() + await sessionRow.click() + + const transcript = page.locator('[data-slot="aui_thread-viewport"]') + await expect(transcript).toContainText(VISIBLE_USER_TEXT) + await expect(transcript).toContainText(VISIBLE_POST_COMPACTION_TEXT) + await expect(transcript).toContainText(MOCK_REPLY) + await expect(transcript).not.toContainText('[CONTEXT COMPACTION — REFERENCE ONLY]') + await page.screenshot({ path: testInfo.outputPath('hidden-history-resume.png') }) + } finally { + await fixture.cleanup() + } +}) + +test('live verify-on-stop continuations stay out of the transcript', async ({}, testInfo) => { + const sandbox = createSandbox('live-verification-nudge') + const projectRoot = path.join(sandbox.root, 'project') + const changedFile = path.join(projectRoot, 'e2e-verification-target.py') + fs.mkdirSync(projectRoot) + fs.writeFileSync( + path.join(projectRoot, 'pyproject.toml'), + '[project]\nname = "e2e-verification-project"\nversion = "0.0.0"\n', + 'utf8', + ) + + const mock = await startMockServer({ verificationWritePath: changedFile }) + writeMockProviderConfig(sandbox.hermesHome, mock.url) + fs.appendFileSync(path.join(sandbox.hermesHome, 'config.yaml'), '\nagent:\n verify_on_stop: true\n', 'utf8') + writeEnvFile(sandbox.hermesHome) + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + const fixture: MockBackendFixture = { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } + + try { + await waitForAppReady(fixture, 120_000) + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type(VERIFICATION_STOP_TRIGGER) + await page.keyboard.press('Enter') + + const transcript = page.locator('[data-slot="aui_thread-viewport"]') + await expect(transcript).toContainText(VERIFICATION_STOP_TEXT, { timeout: 60_000 }) + await expect.poll( + () => mock.receivedPrompts.some(prompt => prompt.includes('[System: You edited code in this turn')), + { timeout: 30_000 }, + ).toBe(true) + expect(fs.existsSync(changedFile), 'The scripted write_file call should edit only the sandbox project').toBe(true) + await expect(transcript).not.toContainText('[System: You edited code in this turn') + await page.screenshot({ path: testInfo.outputPath('live-verification-nudge.png') }) + } finally { + await fixture.cleanup() + } +}) diff --git a/apps/desktop/e2e/large-session-resume.spec.ts b/apps/desktop/e2e/large-session-resume.spec.ts index 7445d823238..b39ab3f1a4d 100644 --- a/apps/desktop/e2e/large-session-resume.spec.ts +++ b/apps/desktop/e2e/large-session-resume.spec.ts @@ -1,4 +1,3 @@ -import { spawnSync } from 'node:child_process' import * as path from 'node:path' import { type TestInfo } from '@playwright/test' @@ -15,13 +14,16 @@ import { writeMockProviderConfig, } from './fixtures' import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server' +import { RealSessionBuilder } from './real-session-builder' const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') -const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') -const SEED_SCRIPT = path.resolve(import.meta.dirname, 'scripts', 'seed_large_session.py') const SESSION_TITLE = 'E2E large persisted session' const EXPECTED_TEXT = 'E2E persisted user message 52' const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume' +const HISTORY_TURNS = Array.from( + { length: 27 }, + (_, index) => `E2E persisted user message ${index * 2}: audit the compatibility matrix`, +) interface SeededFixture { app: ElectronApplication @@ -43,13 +45,11 @@ async function setupSeededDesktop(mockServer?: MockServerOptions): Promise { await waitForAppReady(fixture, 120_000) await openSeededSession(fixture.page) + const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY) await submitPrompt(fixture.page, BACKGROUND_PROMPT) await fixture.mock.waitForHeldStream() await openNewSession(fixture.page) @@ -229,7 +230,10 @@ test.describe('large session resume', () => { await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false }) expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1) - expect(await textNodeOccurrences(fixture.page, MOCK_REPLY), 'the completed assistant reply should appear once').toBe(1) + expect( + await textNodeOccurrences(fixture.page, MOCK_REPLY), + 'the completed assistant reply should add exactly one transcript row', + ).toBe(initialMockReplyCount + 1) }) } }) diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts index 93c0ce12a57..a198ff8989c 100644 --- a/apps/desktop/e2e/mock-server.ts +++ b/apps/desktop/e2e/mock-server.ts @@ -23,8 +23,10 @@ export const MOCK_REPLY = 'Hello from the mock inference server! The full boot c export interface MockServerOptions { /** Pause the matching stream after its first token for session-switch E2E coverage. */ holdFirstStreamForPrompt?: string - /** Pause the first completion whose request JSON contains this text. */ - holdFirstCompletionContaining?: string +/** Pause the first completion whose request JSON contains this text. */ +holdFirstCompletionContaining?: string +/** Absolute sandbox path written by the verify-on-stop scripted tool call. */ +verificationWritePath?: string } export interface MockServer { @@ -104,6 +106,9 @@ let _queueStopIndex = 0 /** Per-server counter for the correction/session-switch script. */ let _correctionSwitchIndex = 0 +/** Per-server counter for the verify-on-stop script. */ +let _verificationStopIndex = 0 + /** User messages received by the mock, for E2E assertions on real submits. */ const _receivedUserTexts: string[] = [] @@ -114,6 +119,7 @@ function resetScriptIndex(): void { _sidebarCrossIndex = 0 _queueStopIndex = 0 _correctionSwitchIndex = 0 + _verificationStopIndex = 0 _receivedUserTexts.length = 0 } @@ -214,6 +220,32 @@ const CORRECTION_SWITCH_SCRIPT: ScriptedTurn[] = [ export const CORRECTION_SWITCH_TRIGGER = 'E2E_CORRECTION_SWITCH_TRIGGER' +/** + * Drives a real code edit followed by two finish attempts. Hermes should add + * its synthetic verify-on-stop continuation after each finish attempt until + * the bounded verifier gives up. The mock's request capture proves the nudge + * reached the model; desktop must never render it as chat content. + */ +function verificationStopScript(writePath: string): ScriptedTurn[] { + return [ + { + text: 'I will make the requested code change.', + toolCalls: [{ + name: 'write_file', + args: { + path: writePath, + content: 'def changed_by_e2e():\n return "changed"\n', + }, + }], + }, + { text: 'The code edit is complete.' }, + { text: 'I cannot provide fresh verification evidence for that edit.' }, + ] +} + +export const VERIFICATION_STOP_TRIGGER = 'E2E_VERIFY_ON_STOP_TRIGGER' +export const VERIFICATION_STOP_TEXT = 'I cannot provide fresh verification evidence for that edit.' + /** * A marker that makes the mock emit a real blocking clarify tool call. Tests * use it to hold a turn open while exercising busy-composer interactions. @@ -340,6 +372,9 @@ export function startMockServer(options: MockServerOptions = {}): Promise typeof message?.content === 'string' && message.content.includes(VERIFICATION_STOP_TRIGGER), + ) const isCorrectionSwitchTrigger = messages.some( message => typeof message?.content === 'string' && message.content.includes(CORRECTION_SWITCH_TRIGGER), ) @@ -364,6 +399,18 @@ export function startMockServer(options: MockServerOptions = {}): Promise void; resolve: (value: unknown) => void }>() + private readonly events: JsonRpcFrame[] = [] + private readonly eventWaiters: Array<{ + predicate: (frame: JsonRpcFrame) => boolean + reject: (reason: Error) => void + resolve: (frame: JsonRpcFrame) => void + }> = [] + private readonly stderr: string[] = [] + private closed = false + + private constructor(hermesHome: string) { + this.child = spawn('uv', ['run', '--active', '--no-sync', 'python', '-m', 'tui_gateway.entry'], { + cwd: REPO_ROOT, + env: { + ...process.env, + HERMES_HOME: hermesHome, + PYTHONPATH: REPO_ROOT, + }, + stdio: 'pipe', + }) + + createInterface({ input: this.child.stdout }).on('line', line => this.handleLine(line)) + createInterface({ input: this.child.stderr }).on('line', line => { + this.stderr.push(line) + if (this.stderr.length > 80) this.stderr.shift() + }) + this.child.once('error', error => this.failAll(new Error(`real-session gateway failed to start: ${error.message}`))) + this.child.once('exit', (code, signal) => { + if (!this.closed) { + this.failAll(new Error(`real-session gateway exited unexpectedly (${signal ?? code ?? 'unknown'}):\n${this.stderr.join('\n')}`)) + } + }) + } + + static async start(hermesHome: string): Promise { + const builder = new RealSessionBuilder(hermesHome) + await builder.waitForEvent(frame => frame.params?.type === 'gateway.ready') + return builder + } + + async createSession(spec: RealSessionSpec): Promise { + if (spec.turns.length === 0) { + throw new Error('RealSessionBuilder requires at least one turn so the real agent creates a durable session row') + } + + const created = await this.request('session.create', { + cols: 120, + cwd: REPO_ROOT, + source: 'desktop', + title: spec.title, + }) + const runtimeId = requireString(created, 'session_id') + const sessionId = requireString(created, 'stored_session_id') + + for (const text of spec.turns) { + const completion = this.waitForEvent( + frame => frame.params?.type === 'message.complete' && frame.params.session_id === runtimeId, + ) + await this.request('prompt.submit', { session_id: runtimeId, text }) + const frame = await completion + const status = readString(frame.params?.payload, 'status') + if (status !== 'complete') { + throw new Error(`real session turn failed with status ${status ?? 'unknown'}: ${JSON.stringify(frame.params?.payload)}`) + } + } + + await this.request('session.close', { session_id: runtimeId }) + return { runtimeId, sessionId } + } + + async close(): Promise { + if (this.closed) return + this.closed = true + this.child.stdin.end() + await new Promise(resolve => { + const timeout = setTimeout(() => { + this.child.kill('SIGTERM') + resolve() + }, 5_000) + this.child.once('exit', () => { + clearTimeout(timeout) + resolve() + }) + }) + } + + private request(method: string, params: Record): Promise { + const id = ++this.nextRequestId + return this.withTimeout(new Promise((resolve, reject) => { + this.pending.set(id, { resolve: value => resolve(value as T), reject }) + this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, error => { + if (error) { + this.pending.delete(id) + reject(error) + } + }) + }), `request ${method}`) + } + + private waitForEvent(predicate: (frame: JsonRpcFrame) => boolean): Promise { + const index = this.events.findIndex(predicate) + if (index >= 0) { + return Promise.resolve(this.events.splice(index, 1)[0]) + } + return this.withTimeout(new Promise((resolve, reject) => { + this.eventWaiters.push({ predicate, resolve, reject }) + }), 'gateway event') + } + + private handleLine(line: string): void { + let frame: JsonRpcFrame + try { + frame = JSON.parse(line) as JsonRpcFrame + } catch { + return + } + + if (typeof frame.id === 'number') { + const pending = this.pending.get(frame.id) + if (!pending) return + this.pending.delete(frame.id) + if (frame.error) { + pending.reject(new Error(`JSON-RPC error ${frame.error.code ?? 'unknown'}: ${frame.error.message ?? 'unknown error'}`)) + } else { + pending.resolve(frame.result) + } + return + } + + if (frame.method !== 'event') return + const waiter = this.eventWaiters.find(candidate => candidate.predicate(frame)) + if (!waiter) { + this.events.push(frame) + return + } + this.eventWaiters.splice(this.eventWaiters.indexOf(waiter), 1) + waiter.resolve(frame) + } + + private withTimeout(promise: Promise, operation: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out after ${DEFAULT_TIMEOUT_MS / 1000}s waiting for ${operation}:\n${this.stderr.join('\n')}`)), DEFAULT_TIMEOUT_MS) + promise.then(value => { + clearTimeout(timer) + resolve(value) + }, error => { + clearTimeout(timer) + reject(error) + }) + }) + } + + private failAll(error: Error): void { + for (const pending of this.pending.values()) pending.reject(error) + this.pending.clear() + for (const waiter of this.eventWaiters) waiter.reject(error) + this.eventWaiters.length = 0 + } +} + +function readString(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object') return undefined + const candidate = (value as Record)[key] + return typeof candidate === 'string' ? candidate : undefined +} + +function requireString(value: unknown, key: string): string { + const candidate = readString(value, key) + if (!candidate) throw new Error(`Gateway response omitted required ${key}: ${JSON.stringify(value)}`) + return candidate +} diff --git a/apps/desktop/e2e/scripts/seed_large_session.py b/apps/desktop/e2e/scripts/seed_large_session.py deleted file mode 100644 index dc0ef750a69..00000000000 --- a/apps/desktop/e2e/scripts/seed_large_session.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -"""Seed a deterministic, tool-free large session into an isolated state.db.""" - -import sys -from pathlib import Path - -repo_root = Path(__file__).resolve().parents[3] -sys.path.insert(0, str(repo_root)) - -from hermes_state import SessionDB # noqa: E402 - -SESSION_ID = "e2e-large-session" -SESSION_TITLE = "E2E large persisted session" - - -def main() -> None: - if len(sys.argv) != 2: - raise SystemExit(f"usage: {sys.argv[0]} ") - - messages = [] - for index in range(53): - role = "user" if index % 2 == 0 else "assistant" - content = ( - f"E2E persisted user message {index}: audit the compatibility matrix" - if role == "user" - else f"E2E persisted assistant reply {index}: recorded the audit result" - ) - messages.append({"role": role, "content": content, "timestamp": 1_700_000_000 + index}) - - database = SessionDB(db_path=Path(sys.argv[1])) - result = database.import_sessions( - [ - { - "id": SESSION_ID, - "source": "desktop", - "model": "mock-model", - "started_at": 1_700_000_000, - "title": SESSION_TITLE, - "cwd": str(repo_root), - "system_prompt": "", - "messages": messages, - } - ] - ) - database.close() - - if not result.get("ok") or result.get("imported") != 1: - raise SystemExit(f"failed to seed large session: {result}") - - -if __name__ == "__main__": - main() diff --git a/apps/desktop/e2e/scripts/seed_session_db.py b/apps/desktop/e2e/scripts/seed_session_db.py deleted file mode 100644 index 47127799e83..00000000000 --- a/apps/desktop/e2e/scripts/seed_session_db.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -"""Seed a Hermes state.db with a session exported from a real conversation. - -Usage: seed_session_db.py - -Creates the database with the full SessionDB schema (if it doesn't exist) -and imports the session from the JSON fixture. Uses the real -SessionDB.import_sessions() so the data shape matches what the desktop -backend expects. -""" -import json -import sys -from pathlib import Path - -# Add the repo root to sys.path so we can import hermes_state. -# The script is invoked from apps/desktop/e2e/ — repo root is ../../.. -repo_root = Path(__file__).resolve().parents[3] -sys.path.insert(0, str(repo_root)) - -from hermes_state import SessionDB # noqa: E402 - - -def main(): - if len(sys.argv) != 3: - print(f"Usage: {sys.argv[0]} ", file=sys.stderr) - sys.exit(1) - - db_path = Path(sys.argv[1]) - fixture_path = Path(sys.argv[2]) - - db_path.parent.mkdir(parents=True, exist_ok=True) - - with open(fixture_path, "r", encoding="utf-8") as f: - session_data = json.load(f) - - db = SessionDB(db_path=db_path) - result = db.import_sessions([session_data]) - - if not result.get("ok"): - print(f"Import failed: {result}", file=sys.stderr) - sys.exit(1) - - imported = result.get("imported", 0) - skipped = result.get("skipped", 0) - errors = result.get("errors", []) - - if errors: - print(f"Import had errors: {errors}", file=sys.stderr) - sys.exit(1) - - print(f"Seeded {imported} session(s), skipped {skipped} → {db_path}") - db.close() - - -if __name__ == "__main__": - main() diff --git a/apps/desktop/e2e/warm-resume-jitter.spec.ts b/apps/desktop/e2e/warm-resume-jitter.spec.ts index f53241269f3..72e71087d69 100644 --- a/apps/desktop/e2e/warm-resume-jitter.spec.ts +++ b/apps/desktop/e2e/warm-resume-jitter.spec.ts @@ -28,11 +28,6 @@ * Prerequisite: `npm run build` must have been run so dist/ exists. */ -import { spawnSync } from 'node:child_process' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' - import { expect, test } from './test' import { @@ -45,12 +40,9 @@ import { launchDesktop, } from './fixtures' import { startMockServer } from './mock-server' +import { RealSessionBuilder } from './real-session-builder' -const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') -const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') -const SEED_SCRIPT = path.join(DESKTOP_ROOT, 'e2e', 'scripts', 'seed_session_db.py') const SESSION_TITLE = 'E2E Warm Resume Jitter Test' -const SESSION_ID = 'e2e-warm-resume-session' /** 32 messages (16 user/assistant pairs) — enough DOM churn for detection. */ const MESSAGE_COUNT = 32 /** Seeded PRNG so the generated content is deterministic across runs. */ @@ -82,54 +74,27 @@ function gibberish(rng: () => number): string { const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED)) /** - * Generate a session fixture with MESSAGE_COUNT messages (user/assistant - * pairs) of seeded gibberish — just role + content, enough for SessionDB - * to import and the transcript to render. Written to a temp file for the - * seed script. + * Generate the user turns for a real session. The mock provider produces the + * assistant side of each pair through the normal AIAgent persistence path. */ -function generateSessionFixture(fixturePath: string): void { +function generateSessionTurns(): string[] { const rng = mulberry32(RNG_SEED) - const messages: Array<{ role: string; content: string }> = [] + const turns: string[] = [] for (let i = 0; i < MESSAGE_COUNT / 2; i++) { - messages.push({ role: 'user', content: gibberish(rng) }) - messages.push({ role: 'assistant', content: gibberish(rng) }) + turns.push(gibberish(rng)) + gibberish(rng) } - const session = { - id: SESSION_ID, - source: 'cli', - model: 'mock-model', - system_prompt: '', - started_at: 1721692800.0, - message_count: MESSAGE_COUNT, - title: SESSION_TITLE, - cwd: '/tmp', - archived: 0, - rewind_count: 0, - compression_fallback_streak: 0, - messages, - } - - fs.writeFileSync(fixturePath, JSON.stringify(session), 'utf8') -} - -/** Resolve the python binary from the nix devshell (falls back to python3). */ -function findPython(): string { - const result = spawnSync('which', ['python'], { encoding: 'utf8' }) - if (result.status === 0 && result.stdout.trim()) { - return result.stdout.trim() - } - return 'python3' + return turns } /** - * Set up a mock-backend sandbox with a pre-seeded session in state.db. + * Set up a mock-backend sandbox with a real persisted session in state.db. * - * Unlike the shared `setupMockBackend()`, this variant seeds the DB - * BEFORE launching the app so the session appears in the sidebar on first - * load — exercising the real `resumeSession()` cold path without needing - * to send a message first. + * Unlike the shared `setupMockBackend()`, this variant creates the session + * through the real stdio gateway before launching desktop so the session is + * visible in the sidebar on first load. */ async function setupSeededMockBackend(): Promise { // 1. Start mock server @@ -140,28 +105,13 @@ async function setupSeededMockBackend(): Promise { writeMockProviderConfig(sandbox.hermesHome, mock.url) writeEnvFile(sandbox.hermesHome) - // 3. Pre-seed state.db: generate a fixture JSON to a temp file, then - // run the seed script to import it into state.db BEFORE launching. - const stateDbPath = path.join(sandbox.hermesHome, 'state.db') - const fixturePath = path.join(os.tmpdir(), `hermes-e2e-warm-resume-${Date.now()}.json`) - generateSessionFixture(fixturePath) - const python = findPython() - const seedResult = spawnSync( - python, - [SEED_SCRIPT, stateDbPath, fixturePath], - { - cwd: REPO_ROOT, - env: { ...process.env, PYTHONPATH: REPO_ROOT }, - encoding: 'utf8', - timeout: 30_000, - }, - ) - fs.unlinkSync(fixturePath) - - if (seedResult.status !== 0) { - throw new Error( - `Failed to seed state.db:\nstdout: ${seedResult.stdout}\nstderr: ${seedResult.stderr}`, - ) + // 3. Produce all 16 user/assistant pairs through the real TUI gateway, + // AIAgent, mock provider, and SessionDB persistence path before desktop starts. + const builder = await RealSessionBuilder.start(sandbox.hermesHome) + try { + await builder.createSession({ title: SESSION_TITLE, turns: generateSessionTurns() }) + } finally { + await builder.close() } // 4. Build env + launch diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index 1275aae44e2..f7ef7d540f0 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -158,6 +158,39 @@ describe('toChatMessages', () => { expect(chatMessageText(message)).toBe('@file:foo.ts\n\nlook') }) + + it('projects durable timeline kinds without inspecting their text', () => { + const messages = toChatMessages([ + { role: 'user', content: 'real user turn', timestamp: 1 }, + { role: 'assistant', content: 'real assistant reply', timestamp: 2 }, + { + role: 'user', + content: 'opaque compaction payload', + display_kind: 'hidden', + timestamp: 3, + }, + { + role: 'user', + content: 'opaque model context payload', + display_kind: 'model_switch', + timestamp: 4, + }, + { + role: 'user', + content: 'opaque delegation context payload', + display_kind: 'async_delegation_complete', + timestamp: 5, + }, + ]) + + expect(messages.map(message => message.role)).toEqual(['user', 'assistant', 'system', 'system']) + expect(messages.map(chatMessageText)).toEqual([ + 'real user turn', + 'real assistant reply', + 'model changed', + 'background agent work finished', + ]) + }) }) describe('renderMediaTags', () => { diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 30b94d59228..4b20a6208db 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -303,6 +303,27 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText } +function transcriptContent(displayKind: SessionMessage['display_kind'], content: string): string | null { + return displayKind === 'hidden' ? null : content +} + +function timelineDisplayContent(message: SessionMessage, content: string): string { + if (message.display_kind === 'model_switch') { + return 'model changed' + } + + if (message.display_kind === 'async_delegation_complete') { + const count = message.display_metadata && 'task_count' in message.display_metadata + ? message.display_metadata.task_count + : undefined + return count === undefined + ? 'background agent work finished' + : `${count} background agent${count === 1 ? '' : 's'} finished` + } + + return content +} + const STREAM_PART: Record<'reasoning' | 'text', (text: string) => ChatMessagePart> = { reasoning: reasoningPart, text: textPart @@ -884,7 +905,14 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] { } const content = message.content || message.text || message.context || message.name - const displayContent = displayContentForMessage(message.role, content) + const displayContent = transcriptContent( + message.display_kind, + timelineDisplayContent(message, displayContentForMessage(message.role, content)) + ) + const displayRole = + message.display_kind === 'model_switch' || message.display_kind === 'async_delegation_complete' + ? 'system' + : message.role const parts: ChatMessagePart[] = [] const reasoning = @@ -897,7 +925,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] { } if (displayContent) { - parts.push(message.role === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent)) + parts.push(displayRole === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent)) } if (message.role === 'assistant' && Array.isArray(message.tool_calls)) { @@ -951,8 +979,8 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] { } result.push({ - id: `${message.timestamp || Date.now()}-${index}-${message.role}`, - role: message.role, + id: `${message.timestamp || Date.now()}-${index}-${displayRole}`, + role: displayRole, parts, timestamp: message.timestamp }) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 4651a92b5da..a80d6ba8137 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -423,6 +423,10 @@ export interface SessionInfo { is_default_profile?: boolean } +export type TimelineDisplayMetadata = + | { model: string; provider?: string } + | { delegation_id: string; task_count: number; completed_count?: number; failed_count?: number; duration_seconds?: number } + export interface SessionMessage { codex_reasoning_items?: unknown content: unknown @@ -431,6 +435,8 @@ export interface SessionMessage { reasoning?: null | string reasoning_content?: null | string reasoning_details?: unknown + display_kind?: 'async_delegation_complete' | 'hidden' | 'model_switch' | string + display_metadata?: TimelineDisplayMetadata role: 'assistant' | 'system' | 'tool' | 'user' text?: unknown timestamp?: number diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 2714c2c0eda..e7ef885240b 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -503,13 +503,21 @@ class CLIAgentSetupMixin: if resolved_meta: session_meta = resolved_meta - restored = self._session_db.get_messages_as_conversation( - self.session_id, repair_alternation=True - ) + model_history, display_history = self._session_db.get_resume_conversations(self.session_id) + restored = model_history if restored: restored = [m for m in restored if m.get("role") != "session_meta"] self.conversation_history = restored - msg_count = len([m for m in restored if m.get("role") == "user"]) + self._resume_display_history = [ + m for m in display_history if m.get("role") != "session_meta" + ] + msg_count = len( + [ + m + for m in self._resume_display_history + if m.get("role") == "user" and not m.get("display_kind") + ] + ) title_part = "" if session_meta.get("title"): title_part = f' "{session_meta["title"]}"' @@ -552,7 +560,8 @@ class CLIAgentSetupMixin: """ from cli import CLI_CONFIG, _record_output_history_entry, _strip_reasoning_tags, _suspend_output_history from tools.ansi_strip import sanitize_display_text as _sanitize_display_text - if not self.conversation_history: + display_history = getattr(self, "_resume_display_history", self.conversation_history) + if not display_history: return # Check config: resume_display setting @@ -571,11 +580,21 @@ class CLIAgentSetupMixin: entries = [] # list of (role, display_text) _last_asst_idx = None # index of last assistant entry _last_asst_full = None # un-truncated display text for last assistant - for msg in self.conversation_history: + for msg in display_history: role = msg.get("role", "") + display_kind = msg.get("display_kind") content = msg.get("content") tool_calls = msg.get("tool_calls") or [] + if display_kind == "hidden": + continue + if display_kind == "model_switch": + entries.append(("event", "model changed")) + continue + if display_kind == "async_delegation_complete": + entries.append(("event", "background delegation completed")) + continue + if role == "system": continue if role == "tool": @@ -682,7 +701,9 @@ class CLIAgentSetupMixin: ) for i, (role, text) in enumerate(entries): - if role == "user": + if role == "event": + lines.append(f" ◈ {text}\n", style="dim italic") + elif role == "user": lines.append(" ● You: ", style=f"dim bold {_session_label_c}") # Show first line inline, indent rest msg_lines = text.splitlines() diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index f28c0760231..014391bfe04 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -780,11 +780,20 @@ class CLICommandsMixin: # becomes ``self.conversation_history`` for subsequent turns. Heal a # durable ``user;user`` violation once here instead of re-firing the # pre-request repair on every request for the rest of the session. - restored = self._session_db.get_messages_as_conversation( - target_id, repair_alternation=True + # + # Both projections come from one lineage SELECT: model_history is + # alternation-repaired for live replay; display_history is the full + # lineage verbatim, used by _display_resumed_history() so timeline + # events and ancestor rows render correctly (matching the startup + # --resume path in _preload_resumed_session). + model_history, display_history = self._session_db.get_resume_conversations( + target_id ) - restored = [m for m in (restored or []) if m.get("role") != "session_meta"] + restored = [m for m in (model_history or []) if m.get("role") != "session_meta"] self.conversation_history = restored + self._resume_display_history = [ + m for m in (display_history or []) if m.get("role") != "session_meta" + ] # Re-open the target session so it's not marked as ended try: @@ -824,7 +833,7 @@ class CLICommandsMixin: pass title_part = f" \"{session_meta['title']}\"" if session_meta.get("title") else "" - msg_count = len([m for m in self.conversation_history if m.get("role") == "user"]) + msg_count = len([m for m in self._resume_display_history if m.get("role") == "user" and not m.get("display_kind")]) if self.conversation_history: _cprint( f" ↻ Resumed session {target_id}{title_part}" diff --git a/hermes_state.py b/hermes_state.py index a424591d998..a4f57d32e74 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1068,7 +1068,9 @@ CREATE TABLE IF NOT EXISTS messages ( observed INTEGER DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, compacted INTEGER NOT NULL DEFAULT 0, - api_content TEXT + api_content TEXT, + display_kind TEXT, + display_metadata TEXT ); CREATE TABLE IF NOT EXISTS session_model_usage ( @@ -5644,6 +5646,8 @@ class SessionDB: effect_disposition: Optional[str] = None, timestamp: Any = None, api_content: Optional[str] = None, + display_kind: Optional[str] = None, + display_metadata: Optional[Dict[str, Any]] = None, ) -> int: """ Append a message to a session. Returns the message row ID. @@ -5665,6 +5669,9 @@ class SessionDB: from every outgoing payload anyway, so the scrubbed form IS the wire bytes). """ + # Display metadata is presentation-only and never changes the model + # context role/content replayed to providers. + display_metadata_json = json.dumps(display_metadata) if display_metadata else None # Serialize structured fields to JSON before entering the write txn reasoning_details_json = ( json.dumps(reasoning_details) @@ -5711,8 +5718,8 @@ class SessionDB: """INSERT INTO messages (session_id, role, content, tool_call_id, tool_calls, tool_name, effect_disposition, timestamp, token_count, finish_reason, reasoning, reasoning_content, reasoning_details, codex_reasoning_items, - codex_message_items, platform_message_id, observed, active, api_content) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + codex_message_items, platform_message_id, observed, active, api_content, display_kind, display_metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, role, @@ -5733,6 +5740,8 @@ class SessionDB: 1 if observed else 0, 1, _scrub_surrogates(api_content) if isinstance(api_content, str) else None, + _scrub_surrogates(display_kind) if isinstance(display_kind, str) else None, + display_metadata_json, ), ) msg_id = cursor.lastrowid @@ -5753,6 +5762,40 @@ class SessionDB: return self._execute_write(_do) + def set_latest_matching_message_display_kind( + self, session_id: str, *, role: str, content: str, display_kind: str, + display_metadata: Optional[Dict[str, Any]] = None, + ) -> bool: + """Stamp presentation metadata on this turn's freshly persisted row. + + The model still receives ``role`` and ``content`` unchanged. Gateway and + CLI synthetic inputs call this immediately after their serial turn has + flushed, preserving producer provenance without classifying by content + during transcript rendering. + """ + if not session_id or not content or not display_kind: + return False + + def _do(conn): + row = conn.execute( + "SELECT id FROM messages WHERE session_id = ? AND role = ? " + "AND content = ? AND active = 1 ORDER BY id DESC LIMIT 1", + (session_id, role, self._encode_content(content)), + ).fetchone() + if row is None: + return False + conn.execute( + "UPDATE messages SET display_kind = ?, display_metadata = ? WHERE id = ?", + ( + _scrub_surrogates(display_kind), + json.dumps(display_metadata) if display_metadata else None, + row[0], + ), + ) + return True + + return bool(self._execute_write(_do)) + def _insert_message_rows(self, conn, session_id: str, messages: List[Dict[str, Any]]) -> tuple[int, int]: """Insert *messages* as fresh active rows for *session_id*. @@ -5816,8 +5859,8 @@ class SessionDB: """INSERT INTO messages (session_id, role, content, tool_call_id, tool_calls, tool_name, effect_disposition, timestamp, token_count, finish_reason, reasoning, reasoning_content, reasoning_details, codex_reasoning_items, - codex_message_items, platform_message_id, observed, active, api_content) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + codex_message_items, platform_message_id, observed, active, api_content, display_kind, display_metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, role, @@ -5838,6 +5881,8 @@ class SessionDB: 1 if msg.get("observed") else 0, 1, _scrub_surrogates(api_content) if isinstance(api_content, str) else None, + _scrub_surrogates(msg.get("display_kind")) if isinstance(msg.get("display_kind"), str) else None, + json.dumps(msg["display_metadata"]) if msg.get("display_metadata") else None, ), ) inserted += 1 @@ -6367,7 +6412,7 @@ class SessionDB: "SELECT role, content, tool_call_id, tool_calls, tool_name, effect_disposition, " "finish_reason, reasoning, reasoning_content, reasoning_details, " "codex_reasoning_items, codex_message_items, platform_message_id, observed, timestamp, " - "api_content " + "api_content, display_kind, display_metadata " f"FROM messages WHERE session_id IN ({placeholders})" # Order by AUTOINCREMENT id (true insertion order), NOT timestamp: # append_message stamps rows with time.time(), which is not @@ -6395,7 +6440,7 @@ class SessionDB: "role, content, tool_call_id, tool_calls, tool_name, effect_disposition, " "finish_reason, reasoning, reasoning_content, reasoning_details, " "codex_reasoning_items, codex_message_items, platform_message_id, observed, timestamp, " - "api_content" + "api_content, display_kind, display_metadata" ) def _rows_to_conversation( @@ -6427,6 +6472,13 @@ class SessionDB: # re-introduce the divergence it exists to remove. if row["api_content"]: msg["api_content"] = row["api_content"] + if row["display_kind"]: + msg["display_kind"] = row["display_kind"] + if row["display_metadata"]: + try: + msg["display_metadata"] = json.loads(row["display_metadata"]) + except (TypeError, json.JSONDecodeError): + logger.warning("Ignoring invalid display metadata on message row") if row["timestamp"]: msg["timestamp"] = row["timestamp"] if row["tool_call_id"]: diff --git a/nix/devShell.nix b/nix/devShell.nix index 10791e3a146..2e4007f8544 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -57,6 +57,11 @@ # for the devshell to pick up the src export HERMES_PYTHON_SRC_ROOT=$(git rev-parse --show-toplevel) + + # Let `uv run --active --no-sync` reuse Nix's provisioned Python + # environment instead of creating an empty project .venv. + export VIRTUAL_ENV="$(dirname "$(dirname "$(readlink -f "$(command -v python)")")")" + echo "Hermes Agent dev shell in $HERMES_PYTHON_SRC_ROOT" echo "Ready. Run 'hermes' or 'sandbox hermes' to start." ''; diff --git a/run_agent.py b/run_agent.py index 98fc7d31065..75846d5bec8 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2085,6 +2085,12 @@ class AIAgent: codex_message_items=msg.get("codex_message_items") if role == "assistant" else None, timestamp=_row_timestamp, api_content=_row_api_content, + display_kind=( + "hidden" + if msg.get(COMPRESSED_SUMMARY_METADATA_KEY) + and not msg.get("_compressed_summary_has_user_turn") + else msg.get("display_kind") + ), ) msg[_DB_PERSISTED_MARKER] = True # The intrinsic markers are now the sole source of truth. Reset the diff --git a/tests/cli/test_cli_resume_command.py b/tests/cli/test_cli_resume_command.py index 33d665d0fe3..58409ef0895 100644 --- a/tests/cli/test_cli_resume_command.py +++ b/tests/cli/test_cli_resume_command.py @@ -80,7 +80,10 @@ class TestCliResumeCommand: {"id": "sess_001", "title": "Research"}, ]) cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"} - cli_obj._session_db.get_messages_as_conversation.return_value = [ + cli_obj._session_db.get_resume_conversations.return_value = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ], [ {"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}, ] @@ -120,7 +123,7 @@ class TestCliResumeCommand: """ cli_obj = _make_cli() cli_obj._session_db.get_session.return_value = {"id": "sess_alpha", "title": "Alpha"} - cli_obj._session_db.get_messages_as_conversation.return_value = [] + cli_obj._session_db.get_resume_conversations.return_value = ([], []) cli_obj._session_db.resolve_resume_session_id.return_value = "sess_alpha" for raw in ("", "[sess_alpha]", '"sess_alpha"', "'sess_alpha'"): @@ -170,7 +173,9 @@ class TestCliResumeRestoresCwd: def _resumable_cli(self, session_meta): cli_obj = _make_cli() cli_obj._session_db.get_session.return_value = session_meta - cli_obj._session_db.get_messages_as_conversation.return_value = [ + cli_obj._session_db.get_resume_conversations.return_value = [ + {"role": "user", "content": "hello"}, + ], [ {"role": "user", "content": "hello"}, ] cli_obj._session_db.resolve_resume_session_id.return_value = session_meta["id"] @@ -270,7 +275,9 @@ class TestPendingResumeNumberedSelection: # _list_recent_sessions, so it must return the same list. cli_obj._list_recent_sessions = MagicMock(return_value=sessions) cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"} - cli_obj._session_db.get_messages_as_conversation.return_value = [ + cli_obj._session_db.get_resume_conversations.return_value = [ + {"role": "user", "content": "hello"}, + ], [ {"role": "user", "content": "hello"}, ] cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001" @@ -411,7 +418,7 @@ class TestResumeFlushesBeforeEndSession: cli_obj.agent = agent cli_obj._session_db.get_session.return_value = {"id": "target", "title": "T"} - cli_obj._session_db.get_messages_as_conversation.return_value = [] + cli_obj._session_db.get_resume_conversations.return_value = ([], []) cli_obj._session_db.resolve_resume_session_id.return_value = "target" with ( diff --git a/tests/cli/test_resume_display.py b/tests/cli/test_resume_display.py index 4a314860291..6f36ace71ae 100644 --- a/tests/cli/test_resume_display.py +++ b/tests/cli/test_resume_display.py @@ -144,6 +144,21 @@ class TestDisplayResumedHistory: assert "You are a helpful assistant" not in output + def test_timeline_markers_render_as_events_not_user_input(self): + cli = _make_cli() + cli.conversation_history = [ + {"role": "user", "content": "opaque model context", "display_kind": "model_switch"}, + {"role": "user", "content": "opaque delegation context", "display_kind": "async_delegation_complete"}, + {"role": "user", "content": "opaque hidden context", "display_kind": "hidden"}, + ] + + output = self._capture_display(cli) + + assert "◈ model changed" in output + assert "◈ background delegation completed" in output + assert "You:" not in output + assert "opaque" not in output + def test_tool_messages_hidden(self): cli = _make_cli() cli.conversation_history = _tool_call_history() @@ -556,8 +571,7 @@ class TestPreloadResumedSession: def test_returns_false_when_session_has_no_messages(self): cli = _make_cli(resume="empty_session") mock_db = MagicMock() - mock_db.get_session.return_value = {"id": "empty_session", "title": None} - mock_db.get_messages_as_conversation.return_value = [] + mock_db.get_resume_conversations.return_value = ([], []) cli._session_db = mock_db buf = StringIO() @@ -573,7 +587,7 @@ class TestPreloadResumedSession: messages = _simple_history() mock_db = MagicMock() mock_db.get_session.return_value = {"id": "good_session", "title": "Test Session"} - mock_db.get_messages_as_conversation.return_value = messages + mock_db.get_resume_conversations.return_value = (messages, messages) cli._session_db = mock_db buf = StringIO() @@ -593,7 +607,7 @@ class TestPreloadResumedSession: messages = [{"role": "user", "content": "hi"}] mock_db = MagicMock() mock_db.get_session.return_value = {"id": "reopen_session", "title": None} - mock_db.get_messages_as_conversation.return_value = messages + mock_db.get_resume_conversations.return_value = (messages, messages) mock_conn = MagicMock() mock_db._conn = mock_conn cli._session_db = mock_db @@ -617,7 +631,7 @@ class TestPreloadResumedSession: ] mock_db = MagicMock() mock_db.get_session.return_value = {"id": "one_msg_session", "title": None} - mock_db.get_messages_as_conversation.return_value = messages + mock_db.get_resume_conversations.return_value = (messages, messages) mock_db._conn = MagicMock() cli._session_db = mock_db @@ -643,7 +657,7 @@ class TestHandleResumeCommandRecap: mock_db = MagicMock() mock_db.get_session.return_value = {"id": "target_session", "title": "Test Session"} - mock_db.get_messages_as_conversation.return_value = messages + mock_db.get_resume_conversations.return_value = (messages, messages) # resolve_resume_session_id passes the id through when no compression chain. mock_db.resolve_resume_session_id.return_value = "target_session" cli._session_db = mock_db @@ -656,6 +670,7 @@ class TestHandleResumeCommandRecap: assert cli.session_id == "target_session" assert cli.conversation_history == messages + assert cli._resume_display_history == messages mock_db.end_session.assert_called_once_with("current_session", "resumed_other") mock_db.reopen_session.assert_called_once_with("target_session") display_mock.assert_called_once_with() @@ -666,7 +681,7 @@ class TestHandleResumeCommandRecap: mock_db = MagicMock() mock_db.get_session.return_value = {"id": "target_session", "title": None} - mock_db.get_messages_as_conversation.return_value = [] + mock_db.get_resume_conversations.return_value = ([], []) mock_db.resolve_resume_session_id.return_value = "target_session" cli._session_db = mock_db @@ -678,6 +693,39 @@ class TestHandleResumeCommandRecap: display_mock.assert_not_called() + def test_resume_command_replaces_stale_display_history(self): + """In-session /resume B after startup --resume A must show B's recap, + not A's. The _resume_display_history attribute set by startup resume + must be replaced, not retained.""" + cli = _make_cli(resume="session_a") + cli.session_id = "session_a" + # Simulate startup --resume A having populated both projections. + messages_a = [{"role": "user", "content": "from session A"}] + cli.conversation_history = messages_a + cli._resume_display_history = messages_a + + messages_b = [{"role": "user", "content": "from session B"}] + mock_db = MagicMock() + mock_db.get_session.return_value = {"id": "session_b", "title": "Session B"} + mock_db.get_resume_conversations.return_value = (messages_b, messages_b) + mock_db.resolve_resume_session_id.return_value = "session_b" + cli._session_db = mock_db + + with ( + patch("hermes_cli.main._resolve_session_by_name_or_id", return_value="session_b"), + patch.object(cli, "_display_resumed_history") as display_mock, + ): + cli._handle_resume_command("/resume session_b") + + assert cli.session_id == "session_b" + assert cli.conversation_history == messages_b + # The stale A display history must have been replaced by B's. + assert cli._resume_display_history == messages_b + assert "from session A" not in [ + m.get("content", "") for m in cli._resume_display_history + ] + display_mock.assert_called_once_with() + # ── Integration: _init_agent skips when preloaded ──────────────────── diff --git a/tests/run_agent/test_session_meta_filtering.py b/tests/run_agent/test_session_meta_filtering.py index 23628b8848a..f2603543364 100644 --- a/tests/run_agent/test_session_meta_filtering.py +++ b/tests/run_agent/test_session_meta_filtering.py @@ -63,6 +63,54 @@ class TestSanitizeApiMessagesRoleFilter: assert [m["role"] for m in out] == ["user", "assistant"] +# --------------------------------------------------------------------------- +# Layer 1b — display-only timeline fields must not reach the provider +# --------------------------------------------------------------------------- + +class TestDisplayFieldsStrippedFromApiPayload: + """Display-only fields (display_kind, display_metadata) are persisted on + message rows for timeline rendering, but must never appear in the + provider-bound API payload — strict OpenAI-compatible backends reject + unknown fields.""" + + def test_sanitizer_does_not_remove_display_fields(self): + """sanitize_api_messages is NOT the chokepoint for display fields — + they are popped earlier in conversation_loop. But this test documents + that the sanitizer alone does NOT strip them, proving the pop in + conversation_loop is load-bearing.""" + msgs = [ + {"role": "user", "content": "hello", "display_kind": "model_switch"}, + {"role": "assistant", "content": "hi", "display_metadata": {"model": "m"}}, + ] + out = AIAgent._sanitize_api_messages(msgs) + # The sanitizer preserves them — the conversation_loop pop is the fix. + assert "display_kind" in out[0] + assert "display_metadata" in out[1] + + def test_conversation_loop_strips_display_fields(self): + """The per-request api_msg copy in conversation_loop strips + display_kind and display_metadata before the message reaches the + provider. This simulates that pop.""" + msg = { + "role": "user", + "content": "switch event", + "display_kind": "model_switch", + "display_metadata": {"model": "test"}, + "api_content": "sidecar", + } + # Reproduce the pop sequence from conversation_loop.py + api_msg = msg.copy() + api_msg.pop("api_content", None) + api_msg.pop("display_kind", None) + api_msg.pop("display_metadata", None) + assert "display_kind" not in api_msg + assert "display_metadata" not in api_msg + assert "api_content" not in api_msg + assert api_msg["content"] == "switch event" + # Original message dict is untouched. + assert msg.get("display_kind") == "model_switch" + + # --------------------------------------------------------------------------- # Layer 2 — CLI session-restore filters session_meta before loading # --------------------------------------------------------------------------- diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 9e87ff501f7..d66d072e0e4 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -7187,3 +7187,51 @@ class TestLoneSurrogatePersistence: assert db.set_session_title("s1", "title \ud835 bad") is True assert db.get_session("s1")["title"] == "title \ufffd bad" + +class TestDisplayMetadataPersistence: + """Round-trip display_kind/display_metadata through every write path.""" + + def test_append_message_round_trips_display_fields(self, db): + db.create_session("s1", source="cli") + meta = {"task_count": 2, "delegation_id": "del-1"} + db.append_message( + "s1", "user", "event text", + display_kind="async_delegation_complete", + display_metadata=meta, + ) + conv = db.get_messages_as_conversation("s1") + assert conv[0]["display_kind"] == "async_delegation_complete" + assert conv[0]["display_metadata"] == meta + + def test_replace_messages_preserves_display_metadata(self, db): + db.create_session("s1", source="cli") + meta = {"task_count": 3, "delegation_id": "del-2", "duration_seconds": 12.5} + db.append_message( + "s1", "user", "event", + display_kind="async_delegation_complete", + display_metadata=meta, + ) + # Reload via get_messages_as_conversation (which decodes display fields) + # then replace_messages (which re-inserts via _insert_message_rows). + conv = db.get_messages_as_conversation("s1") + db.replace_messages("s1", conv) + reloaded = db.get_messages_as_conversation("s1") + assert reloaded[0]["display_kind"] == "async_delegation_complete" + assert reloaded[0]["display_metadata"] == meta + + def test_archive_and_compact_preserves_display_metadata(self, db): + db.create_session("s1", source="cli") + meta = {"model": "test-model", "provider": "test-provider"} + db.append_message( + "s1", "user", "switch event", + display_kind="model_switch", + display_metadata=meta, + ) + db.append_message("s1", "assistant", "reply") + conv = db.get_messages_as_conversation("s1") + db.archive_and_compact("s1", conv) + reloaded = db.get_messages_as_conversation("s1") + switched = [m for m in reloaded if m.get("display_kind") == "model_switch"] + assert len(switched) == 1 + assert switched[0]["display_metadata"] == meta + diff --git a/tests/tui_gateway/test_model_switch_marker_role.py b/tests/tui_gateway/test_model_switch_marker_role.py index 1312eae2333..9153425e95c 100644 --- a/tests/tui_gateway/test_model_switch_marker_role.py +++ b/tests/tui_gateway/test_model_switch_marker_role.py @@ -102,4 +102,5 @@ class TestAppendModelSwitchMarkerRole: session_id="sess-1", role="user", content=marker["content"], + display_kind="model_switch", ) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 176239b2ba5..b8ac2301a01 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2949,7 +2949,7 @@ def _append_model_switch_marker(session: dict | None, *, model: str, provider: s # this marker after prior conversation turns, and strict OpenAI-compatible # providers (vLLM, Qwen) reject system messages that are not at the # beginning of the API message list (#48338). - entry = {"role": "user", "content": marker} + entry = {"role": "user", "content": marker, "display_kind": "model_switch"} lock = session.get("history_lock") if lock is not None: @@ -2964,14 +2964,22 @@ def _append_model_switch_marker(session: dict | None, *, model: str, provider: s agent = session.get("agent") db = getattr(agent, "_session_db", None) if agent is not None else None if db is not None: - db.append_message(session_id=session_key, role="user", content=marker) + db.append_message( + session_id=session_key, + role="user", + content=marker, + display_kind="model_switch", + ) return _ensure_session_db_row(session) with _session_db(session) as scoped_db: if scoped_db is not None: scoped_db.append_message( - session_id=session_key, role="user", content=marker + session_id=session_key, + role="user", + content=marker, + display_kind="model_switch", ) except Exception: logger.debug("failed to persist model switch marker", exc_info=True) @@ -5807,6 +5815,13 @@ def _history_to_messages(history: list[dict]) -> list[dict]: for key in reasoning_keys: if key in m and m.get(key) is not None: msg[key] = m.get(key) + # Forward display-only timeline metadata so the TUI can render + # model switches and delegation completions as events instead of + # opaque user messages, and hide compaction handoffs entirely. + if m.get("display_kind"): + msg["display_kind"] = m["display_kind"] + if m.get("display_metadata"): + msg["display_metadata"] = m["display_metadata"] messages.append(msg) return messages @@ -10315,7 +10330,17 @@ def _notification_poller_loop( continue try: _emit("message.start", sid) - _run_prompt_submit(rid, sid, session, text) + if evt.get("type") == "async_delegation": + _run_prompt_submit( + rid, + sid, + session, + text, + display_kind="async_delegation_complete", + display_metadata=_async_delegation_display_metadata(evt), + ) + else: + _run_prompt_submit(rid, sid, session, text) complete_event_delivery(evt, _claim) except Exception as exc: release_event_delivery(evt, _claim) @@ -10383,7 +10408,17 @@ def _notification_poller_loop( continue try: _emit("message.start", sid) - _run_prompt_submit(rid, sid, session, text) + if evt.get("type") == "async_delegation": + _run_prompt_submit( + rid, + sid, + session, + text, + display_kind="async_delegation_complete", + display_metadata=_async_delegation_display_metadata(evt), + ) + else: + _run_prompt_submit(rid, sid, session, text) complete_event_delivery(evt, _claim) except Exception as exc: release_event_delivery(evt, _claim) @@ -10400,6 +10435,33 @@ def _notification_poller_loop( process_registry.completion_queue.put(evt) +def _async_delegation_display_metadata(evt: dict) -> dict: + """Build display-only metadata before the completion event is formatted.""" + raw_results = evt.get("results") + results: list[dict] = [ + result for result in raw_results if isinstance(result, dict) + ] if isinstance(raw_results, list) else [] + task_count = len(results) or 1 + completed_count = sum( + 1 for result in results + if result.get("status") in {"completed", "success"} + ) + failed_count = sum( + 1 for result in results + if result.get("status") in {"failed", "error"} + ) + metadata = { + "delegation_id": str(evt.get("delegation_id") or ""), + "task_count": task_count, + "completed_count": completed_count or task_count - failed_count, + "failed_count": failed_count, + } + duration = evt.get("total_duration_seconds") or evt.get("duration_seconds") + if isinstance(duration, (int, float)): + metadata["duration_seconds"] = duration + return metadata + + def _wire_agent_terminal_output() -> None: """Idempotently route background-process output (and tab-close requests) to the desktop, keyed by process id. Read-only agent terminal tabs stream @@ -10481,7 +10543,10 @@ def _start_notification_poller(sid: str, session: dict) -> threading.Event: return stop -def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: +def _run_prompt_submit( + rid, sid: str, session: dict, text: Any, *, display_kind: str | None = None, + display_metadata: dict | None = None, +) -> None: with session["history_lock"]: history = list(session["history"]) history_version = int(session.get("history_version", 0)) @@ -10682,6 +10747,27 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: except (TypeError, ValueError): pass result = agent.run_conversation(run_message, **run_kwargs) + if display_kind and isinstance(text, str): + db = getattr(agent, "_session_db", None) + current_session_id = getattr(agent, "session_id", None) or session.get("session_key") + if db is not None: + try: + db.set_latest_matching_message_display_kind( + current_session_id, + role="user", + content=text, + display_kind=display_kind, + display_metadata=display_metadata, + ) + except Exception: + logger.debug("failed to stamp synthetic display kind", exc_info=True) + if isinstance(result, dict) and isinstance(result.get("messages"), list): + for message in reversed(result["messages"]): + if message.get("role") == "user" and message.get("content") == text: + message["display_kind"] = display_kind + if display_metadata: + message["display_metadata"] = display_metadata + break if "moa_one_shot_restore" in session: _restore = session.pop("moa_one_shot_restore", None) # Restore the model the user was on before the /moa one-shot. diff --git a/ui-tui/src/__tests__/messages.test.ts b/ui-tui/src/__tests__/messages.test.ts index fc577ab5804..e9a3d4fdb2b 100644 --- a/ui-tui/src/__tests__/messages.test.ts +++ b/ui-tui/src/__tests__/messages.test.ts @@ -26,6 +26,60 @@ describe('toTranscriptMessages', () => { ]) expect(toTranscriptMessages(rows)[1]?.tools?.[0]).toContain('Search Files') }) + + it('skips hidden display_kind rows entirely', () => { + const rows = [ + { role: 'user', text: 'visible prompt' }, + { role: 'user', text: '[CONTEXT COMPACTION — REFERENCE ONLY]', display_kind: 'hidden' }, + { role: 'assistant', text: 'visible reply' }, + ] + + const result = toTranscriptMessages(rows) + expect(result.map(msg => msg.text)).toEqual(['visible prompt', 'visible reply']) + expect(result.every(m => !m.text?.includes('COMPACTION'))).toBe(true) + }) + + it('projects model_switch as an event with replaced text', () => { + const rows = [ + { role: 'user', text: 'hello' }, + { role: 'user', text: '[System: model changed to gpt-5]', display_kind: 'model_switch' }, + { role: 'assistant', text: 'hi' }, + ] + + const result = toTranscriptMessages(rows) + expect(result.map(msg => [msg.kind, msg.role, msg.text])).toEqual([ + [undefined, 'user', 'hello'], + ['event', 'system', 'model changed'], + [undefined, 'assistant', 'hi'], + ]) + }) + + it('projects async_delegation_complete with task_count metadata', () => { + const rows = [ + { role: 'user', text: 'do work' }, + { role: 'assistant', text: 'done' }, + { role: 'user', text: '[IMPORTANT: delegation done]', display_kind: 'async_delegation_complete', display_metadata: { task_count: 3 } }, + { role: 'assistant', text: 'merged' }, + ] + + const result = toTranscriptMessages(rows) + expect(result.map(msg => [msg.kind, msg.text])).toEqual([ + [undefined, 'do work'], + [undefined, 'done'], + ['event', '3 background agents finished'], + [undefined, 'merged'], + ]) + }) + + it('projects async_delegation_complete without metadata as generic text', () => { + const rows = [ + { role: 'user', text: 'event', display_kind: 'async_delegation_complete' }, + ] + + const result = toTranscriptMessages(rows) + expect(result[0]?.kind).toBe('event') + expect(result[0]?.text).toBe('background agent work finished') + }) }) describe('MessageLine', () => { diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 18658a253f1..fc42d4dc3f7 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -121,6 +121,21 @@ export const MessageLine = memo(function MessageLine({ ) } + // Timeline events (model switches, delegation completions) render as + // dim ◈ markers with no gutter — not as opaque user messages. + if (msg.kind === 'event') { + const eventGutterWidth = transcriptGutterWidth('system', t.brand.prompt) + + return ( + + + + + ◈ {msg.text} + + ) + } + const { body, glyph, prefix } = ROLE[msg.role](t) const gutterWidth = transcriptGutterWidth(msg.role, t.brand.prompt) diff --git a/ui-tui/src/domain/blockLayout.ts b/ui-tui/src/domain/blockLayout.ts index 36c511e4c4d..544c84e078c 100644 --- a/ui-tui/src/domain/blockLayout.ts +++ b/ui-tui/src/domain/blockLayout.ts @@ -17,7 +17,7 @@ import { sectionMode } from './details.js' * slash — slash-command echoes (owns its margin) * intro — banner / panels (rendered out-of-band, never gapped here) */ -export type BlockGroup = 'diff' | 'intro' | 'model' | 'note' | 'slash' | 'trail' | 'user' +export type BlockGroup = 'diff' | 'event' | 'intro' | 'model' | 'note' | 'slash' | 'trail' | 'user' export const messageGroup = (msg: Pick): BlockGroup => { switch (msg.kind) { @@ -29,6 +29,9 @@ export const messageGroup = (msg: Pick): BlockGroup => { case 'slash': return 'slash' + case 'event': + return 'event' + case 'diff': return 'diff' @@ -51,12 +54,12 @@ export const messageGroup = (msg: Pick): BlockGroup => { // slash, the top+bottom margins for diff) or that are painted out-of-band // (intro). The grouping primitive only spaces the model working area — // model prose, reasoning/tool trails, and notes/errors. -const SELF_SPACED: ReadonlySet = new Set(['diff', 'intro', 'slash', 'user']) +const SELF_SPACED: ReadonlySet = new Set(['diff', 'event', 'intro', 'slash', 'user']) // Groups that already paint a trailing blank line beneath themselves // (marginBottom in MessageLine), so the block that follows must not add its // own leading gap or the single boundary would become a double gap. -const PAINTS_TRAILING_GAP: ReadonlySet = new Set(['diff', 'user']) +const PAINTS_TRAILING_GAP: ReadonlySet = new Set(['diff', 'event', 'user']) /** * Whether `cur` renders one blank line above it, given the block rendered diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts index 73f86c3e068..4f4ff426b95 100644 --- a/ui-tui/src/domain/messages.ts +++ b/ui-tui/src/domain/messages.ts @@ -44,7 +44,7 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { continue } - const { context, name, role, text } = row as TranscriptRow + const { context, display_kind, name, role, text } = row as TranscriptRow if (role === 'tool') { pending.push(buildToolTrailLine(name ?? 'tool', context ?? '')) @@ -56,6 +56,31 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { continue } + // Display-only timeline events: render as dim ◈ markers instead of + // opaque user messages. Hidden compaction handoffs are skipped entirely. + if (display_kind === 'hidden') { + continue + } + + if (display_kind === 'model_switch') { + out.push({ kind: 'event', role: 'system', text: 'model changed' }) + pending = [] + + continue + } + + if (display_kind === 'async_delegation_complete') { + const meta = (row as TranscriptRow).display_metadata + const count = meta && typeof meta.task_count === 'number' ? meta.task_count : undefined + const label = count === undefined + ? 'background agent work finished' + : `${count} background agent${count === 1 ? '' : 's'} finished` + out.push({ kind: 'event', role: 'system', text: label }) + pending = [] + + continue + } + if (role === 'assistant') { out.push({ role, text, ...(pending.length && { tools: pending }) }) pending = [] @@ -85,6 +110,8 @@ interface ImageMeta { interface TranscriptRow { context?: string + display_kind?: string + display_metadata?: { task_count?: number; [key: string]: unknown } name?: string role?: string text?: string diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index b9cc8f999cb..cf84abfddf7 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -15,6 +15,8 @@ export interface GatewayCompletionItem { export interface GatewayTranscriptMessage { context?: string + display_kind?: string + display_metadata?: Record name?: string role: 'assistant' | 'system' | 'tool' | 'user' text?: string diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 7ba16eda93d..f97506df7c9 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -115,7 +115,7 @@ export interface ClarifyReq { export interface Msg { info?: SessionInfo - kind?: 'diff' | 'intro' | 'panel' | 'slash' | 'trail' + kind?: 'diff' | 'event' | 'intro' | 'panel' | 'slash' | 'trail' panelData?: PanelData role: Role text: string