mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
beffbab3d7
commit
a4bc1ca502
28 changed files with 1007 additions and 235 deletions
|
|
@ -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
|
||||
|
|
|
|||
147
apps/desktop/e2e/hidden-history-messages.spec.ts
Normal file
147
apps/desktop/e2e/hidden-history-messages.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
|
|
|
|||
226
apps/desktop/e2e/real-session-builder.ts
Normal file
226
apps/desktop/e2e/real-session-builder.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
'';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -102,4 +102,5 @@ class TestAppendModelSwitchMarkerRole:
|
|||
session_id="sess-1",
|
||||
role="user",
|
||||
content=marker["content"],
|
||||
display_kind="model_switch",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue