fix(timeline): persist typed display events (#69771)

* 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).
This commit is contained in:
ethernet 2026-07-23 14:46:24 -04:00 committed by GitHub
parent beffbab3d7
commit a4bc1ca502
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1007 additions and 235 deletions

View file

@ -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

View file

@ -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<MockBackendFixture> {
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()
}
})

View file

@ -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<Seede
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
const seeded = spawnSync('python3', [SEED_SCRIPT, path.join(sandbox.hermesHome, 'state.db')], {
cwd: REPO_ROOT,
encoding: 'utf8',
env: { ...process.env, PYTHONPATH: REPO_ROOT },
})
if (seeded.status !== 0) {
throw new Error(`large-session seed failed:\n${seeded.stdout}\n${seeded.stderr}`)
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
try {
await builder.createSession({ title: SESSION_TITLE, turns: HISTORY_TURNS })
} finally {
await builder.close()
}
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
@ -210,6 +210,7 @@ test.describe('large session resume', () => {
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)
})
}
})

View file

@ -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<MockSe
const isSidebarTrigger = userText.includes('E2E_SIDEBAR_TRIGGER')
const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS')
const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER')
const isVerificationStopTrigger = messages.some(
message => 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<MockSe
return
}
if (isVerificationStopTrigger) {
const script = verificationStopScript(options.verificationWritePath ?? 'e2e-verification-target.py')
const turn = script[_verificationStopIndex] ?? script[script.length - 1]
_verificationStopIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isCorrectionSwitchTrigger) {
const turn = CORRECTION_SWITCH_SCRIPT[_correctionSwitchIndex] ?? CORRECTION_SWITCH_SCRIPT[CORRECTION_SWITCH_SCRIPT.length - 1]
_correctionSwitchIndex++

View file

@ -0,0 +1,226 @@
import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
import * as path from 'node:path'
import { createInterface } from 'node:readline'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const DEFAULT_TIMEOUT_MS = 60_000
interface JsonRpcError {
code?: number
message?: string
}
interface JsonRpcFrame {
error?: JsonRpcError
id?: number
method?: string
params?: {
payload?: unknown
session_id?: string
type?: string
}
result?: unknown
}
interface CreatedSession {
session_id: string
stored_session_id: string
}
export interface RealSessionSpec {
/** Human-visible sidebar title, persisted by the first completed turn. */
title: string
/** Each item becomes one real user prompt followed by the mock provider's reply. */
turns: readonly string[]
}
export interface RealSession {
/** Runtime-only TUI session id, valid only while the builder process is alive. */
runtimeId: string
/** Durable SessionDB id that desktop resumes after the builder exits. */
sessionId: string
}
/**
* Creates durable desktop session history through the real TUI gateway and
* AIAgent loop, using the E2E mock provider configured in `hermesHome`.
*
* This intentionally uses the shipped stdio JSON-RPC transport instead of
* importing SessionDB or launching Electron. The desktop's WebSocket backend
* dispatches the same `tui_gateway.server` methods.
*/
export class RealSessionBuilder {
private readonly child: ChildProcessWithoutNullStreams
private nextRequestId = 0
private readonly pending = new Map<number, { reject: (reason: Error) => 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<RealSessionBuilder> {
const builder = new RealSessionBuilder(hermesHome)
await builder.waitForEvent(frame => frame.params?.type === 'gateway.ready')
return builder
}
async createSession(spec: RealSessionSpec): Promise<RealSession> {
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<CreatedSession>('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<void> {
if (this.closed) return
this.closed = true
this.child.stdin.end()
await new Promise<void>(resolve => {
const timeout = setTimeout(() => {
this.child.kill('SIGTERM')
resolve()
}, 5_000)
this.child.once('exit', () => {
clearTimeout(timeout)
resolve()
})
})
}
private request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {
const id = ++this.nextRequestId
return this.withTimeout(new Promise<T>((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<JsonRpcFrame> {
const index = this.events.findIndex(predicate)
if (index >= 0) {
return Promise.resolve(this.events.splice(index, 1)[0])
}
return this.withTimeout(new Promise<JsonRpcFrame>((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<T>(promise: Promise<T>, operation: string): Promise<T> {
return new Promise<T>((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<string, unknown>)[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
}

View file

@ -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]} <state.db>")
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()

View file

@ -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 <state_db_path> <fixture_json_path>
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]} <state_db_path> <fixture_json_path>", 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()

View file

@ -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<MockBackendFixture> {
// 1. Start mock server
@ -140,28 +105,13 @@ async function setupSeededMockBackend(): Promise<MockBackendFixture> {
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

View file

@ -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', () => {

View file

@ -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
})

View file

@ -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

View file

@ -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()

View file

@ -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}"

View file

@ -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"]:

View file

@ -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."
'';

View file

@ -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

View file

@ -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"', "'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 (

View file

@ -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 ────────────────────

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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

View file

@ -102,4 +102,5 @@ class TestAppendModelSwitchMarkerRole:
session_id="sess-1",
role="user",
content=marker["content"],
display_kind="model_switch",
)

View file

@ -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.

View file

@ -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', () => {

View file

@ -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 (
<Box marginBottom={1} marginTop={leadGap ? 1 : 0}>
<NoSelect flexShrink={0} fromLeftEdge width={eventGutterWidth}>
<Text> </Text>
</NoSelect>
<Text color={t.color.muted} dimColor> {msg.text}</Text>
</Box>
)
}
const { body, glyph, prefix } = ROLE[msg.role](t)
const gutterWidth = transcriptGutterWidth(msg.role, t.brand.prompt)

View file

@ -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<Msg, 'kind' | 'role'>): BlockGroup => {
switch (msg.kind) {
@ -29,6 +29,9 @@ export const messageGroup = (msg: Pick<Msg, 'kind' | 'role'>): BlockGroup => {
case 'slash':
return 'slash'
case 'event':
return 'event'
case 'diff':
return 'diff'
@ -51,12 +54,12 @@ export const messageGroup = (msg: Pick<Msg, 'kind' | 'role'>): 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<BlockGroup> = new Set(['diff', 'intro', 'slash', 'user'])
const SELF_SPACED: ReadonlySet<BlockGroup> = 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<BlockGroup> = new Set(['diff', 'user'])
const PAINTS_TRAILING_GAP: ReadonlySet<BlockGroup> = new Set(['diff', 'event', 'user'])
/**
* Whether `cur` renders one blank line above it, given the block rendered

View file

@ -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

View file

@ -15,6 +15,8 @@ export interface GatewayCompletionItem {
export interface GatewayTranscriptMessage {
context?: string
display_kind?: string
display_metadata?: Record<string, unknown>
name?: string
role: 'assistant' | 'system' | 'tool' | 'user'
text?: string

View file

@ -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