Merge pull request #71121 from NousResearch/bb/desktop-image-persist

fix(desktop): keep attached images renderable across session switches and restarts
This commit is contained in:
brooklyn! 2026-07-24 22:23:42 -05:00 committed by GitHub
commit 666824261a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 892 additions and 61 deletions

View file

@ -19,6 +19,7 @@ REFERENCE_PATTERN = re.compile(
rf"(?<![\w/])@(?:(?P<simple>diff|staged)\b|(?P<kind>file|folder|git|url):(?P<value>{_QUOTED_REFERENCE_VALUE}(?::\d+(?:-\d+)?)?|\S+))"
)
TRAILING_PUNCTUATION = ",.;!?"
_NEEDS_QUOTING = re.compile(r"""[\s()\[\]{}<>"'`]""")
_SENSITIVE_HOME_DIRS = (".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure", ".config/gh")
_SENSITIVE_HERMES_DIRS = (Path("skills") / ".hub",)
_SENSITIVE_HOME_FILES = (
@ -60,6 +61,21 @@ class ContextReferenceResult:
blocked: bool = False
def format_reference_value(value: str) -> str:
"""Quote a reference value so ``REFERENCE_PATTERN`` reads it back whole.
The unquoted alternative in the pattern is ``\\S+``, so a path containing a
space parses as a truncated ref with the tail left behind as loose text.
Mirrors ``formatRefValue`` in the desktop's directive-text.tsx.
"""
if not _NEEDS_QUOTING.search(value):
return value
for quote in ("`", '"', "'"):
if quote not in value:
return f"{quote}{value}{quote}"
return value
def parse_context_references(message: str) -> list[ContextReference]:
refs: list[ContextReference] = []
if not message:

View file

@ -0,0 +1,172 @@
/**
* Regression coverage for an attached image in a durable session. The gateway
* persists the turn, the builder exits, and desktop renders it from SessionDB
* for the first time the "quit and relaunch" case, where the transcript used
* to come back as vision-enrichment prose instead of a thumbnail.
*
* The fixture pins `image_input_mode: native` because that is the majority
* routing path (any vision-capable model) and the one where a text-only
* persist override is silently dropped. The image also sits behind directory
* and file names containing spaces, mirroring the macOS composer's
* `~/Library/Application Support/...` staging path.
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
import {
buildAppEnv,
createSandbox,
launchDesktop,
type Sandbox,
waitForAppReady,
writeEnvFile,
writeMockProviderConfig,
} from './fixtures'
import { type MockServer, startMockServer } from './mock-server'
import { RealSessionBuilder } from './real-session-builder'
import { type ElectronApplication, expect, type Page, test } from './test'
// A seeded session has no generated title, so every label falls back to the
// session preview — the first 60 characters of the first user message.
const SESSION_TITLE = 'E2E attached image session'
const CAPTION = 'E2E attached image must survive a relaunch'
const IMAGE_DIR = 'Application Support/e2e shots'
const IMAGE_NAME = 'e2e capture.png'
const NATIVE_IMAGE_CONFIG = 'agent:\n image_input_mode: native'
/** A 160x100 framed magenta block — small, but visible in the screenshots. */
const PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAKAAAABkCAIAAACO1KzYAAAA30lEQVR42u3dwQ2AIBAAQTAWAx1iBXYI7diCuWhEMvP2dZsj+CL30hLr2oxAYARGYARGYARGYIERGIH53n7nozpOk5rQqIcNdkQjMAIjMNPeomP3N54V+5exwY5oBEZgBEZgBEZggREYgREYgREYgQVGYARGYARGYARGYIERGIERGIERGIEFRmAERmAERmAEFhiBERiBERiBERiBBUZgBEZgBEZgBBYYgREYgREYgRFYYCMQGIERmPSj94Njb9ligxEYgRFYYJaQe2mmYIMRGIERGIERGIEFRmAERmDedAFtjAtAGWDnoAAAAABJRU5ErkJggg=='
interface SeededFixture {
app: ElectronApplication
mock: MockServer
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
function writeImage(sandbox: Sandbox): string {
const dir = path.join(sandbox.root, IMAGE_DIR)
fs.mkdirSync(dir, { recursive: true })
const imagePath = path.join(dir, IMAGE_NAME)
fs.writeFileSync(imagePath, Buffer.from(PNG_BASE64, 'base64'))
return imagePath
}
async function setupSeededDesktop(): Promise<SeededFixture> {
const mock = await startMockServer()
const sandbox = createSandbox('image-attachment')
writeMockProviderConfig(sandbox.hermesHome, mock.url, undefined, NATIVE_IMAGE_CONFIG)
writeEnvFile(sandbox.hermesHome)
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
try {
await builder.createSession({
title: SESSION_TITLE,
turns: [{ images: [writeImage(sandbox)], text: CAPTION }],
})
} finally {
await builder.close()
}
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
return {
app,
mock,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
}
function sessionRow(page: Page) {
return page.locator('[data-slot="sidebar"] button').filter({ hasText: CAPTION }).first()
}
async function openSeededSession(page: Page): Promise<void> {
const row = sessionRow(page)
await row.waitFor({ state: 'visible', timeout: 60_000 })
await row.click()
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
CAPTION,
{ timeout: 30_000 },
)
}
async function openNewSession(page: Page): Promise<void> {
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
await page.waitForFunction(
expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
CAPTION,
{ timeout: 15_000 },
)
}
async function transcriptText(page: Page): Promise<string> {
return page.evaluate(() => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '')
}
async function assertRendersThumbnail(page: Page, label: string): Promise<void> {
const thumbnail = page.locator('[data-slot="aui_directive-image"] img')
await expect(thumbnail, `${label}: the attachment should render as an image`).toHaveCount(1)
await expect(thumbnail, `${label}: the thumbnail should resolve off disk`).toHaveAttribute('src', /^data:image\//)
const text = await transcriptText(page)
expect(text, `${label}: the caption should survive alongside the image`).toContain(CAPTION)
// A broken ref falls back to a chip whose label leaks the path, and a
// flattened multimodal turn leaves the agent's placeholder behind.
expect(text, `${label}: the raw image path should not leak into the transcript`).not.toContain(IMAGE_NAME)
expect(text, `${label}: the image directive should not render literally`).not.toContain('@image:')
expect(text, `${label}: the flattening placeholder should not render`).not.toContain('[screenshot]')
}
test.describe('attached image resume', () => {
let fixture: SeededFixture | null = null
test.afterEach(async () => {
await fixture?.cleanup()
fixture = null
})
test('renders a persisted attachment as a thumbnail on first open and after a cold reload', async ({}, testInfo) => {
// Seeding through the real gateway plus two full app boots does not fit the
// default per-test budget on a cold runner.
test.slow()
fixture = await setupSeededDesktop()
await waitForAppReady(fixture, 120_000)
// The sidebar labels a session by its preview, so the caption has to lead
// the persisted turn — a leading directive reads as a truncated file path.
const row = sessionRow(fixture.page)
await row.waitFor({ state: 'visible', timeout: 60_000 })
const label = (await row.textContent())?.trim() ?? ''
expect(label.startsWith(CAPTION), `sidebar label should open with the caption: ${label}`).toBe(true)
await openSeededSession(fixture.page)
await assertRendersThumbnail(fixture.page, 'first open')
await fixture.page.screenshot({ path: testInfo.outputPath('attachment-first-open.png') })
// A reload drops every cached attachment ref, so the transcript has to come
// back from the persisted turn alone.
await fixture.page.reload()
await waitForAppReady(fixture, 120_000)
await openNewSession(fixture.page)
await openSeededSession(fixture.page)
await assertRendersThumbnail(fixture.page, 'cold reload')
await fixture.page.screenshot({ path: testInfo.outputPath('attachment-cold-reload.png') })
})
})

View file

@ -28,11 +28,18 @@ interface CreatedSession {
stored_session_id: string
}
export interface RealSessionTurn {
/** Local image paths attached before the prompt, as the composer would. */
images?: readonly string[]
text: string
}
export interface RealSessionSpec {
/** Human-visible sidebar title, persisted by the first completed turn. */
/** Session label. The durable row stores no title, so clients fall back to
* the preview (the first 60 characters of the first user message). */
title: string
/** Each item becomes one real user prompt followed by the mock provider's reply. */
turns: readonly string[]
turns: readonly (RealSessionTurn | string)[]
}
export interface RealSession {
@ -107,7 +114,13 @@ export class RealSessionBuilder {
const runtimeId = requireString(created, 'session_id')
const sessionId = requireString(created, 'stored_session_id')
for (const text of spec.turns) {
for (const turn of spec.turns) {
const { images = [], text } = typeof turn === 'string' ? { text: turn } : turn
for (const image of images) {
await this.request('image.attach', { session_id: runtimeId, path: image })
}
const completion = this.waitForEvent(
frame => frame.params?.type === 'message.complete' && frame.params.session_id === runtimeId,
)

View file

@ -1307,6 +1307,76 @@ describe('resumeSession warm-cache mapping integrity', () => {
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
})
it('preserves cached image attachments through an idle persisted transcript refresh', async () => {
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
const state = clientState('stored-A')
state.messages = [
{
id: 'cached-user',
role: 'user',
parts: [{ type: 'text', text: 'describe this image' }],
attachmentRefs: ['@image:/tmp/photo.png']
},
{
id: 'cached-assistant',
role: 'assistant',
parts: [{ type: 'text', text: 'It is a photo.' }]
}
]
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-A', state]])
}
const persistedMessages = [
{ content: 'describe this image', role: 'user', timestamp: 1 },
{ content: 'It is a photo.', role: 'assistant', timestamp: 2 }
]
vi.mocked(getSessionMessages).mockResolvedValue({
messages: persistedMessages,
session_id: 'stored-A'
} as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.activate') {
return {
session_id: 'rt-A',
session_key: 'stored-A',
resumed: 'stored-A',
message_count: persistedMessages.length,
messages: persistedMessages,
running: false,
info: {}
} as never
}
return {} as never
})
let resumedState: ClientSessionState | undefined
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={ready => (resume = ready)}
onStateUpdate={(_sessionId, next) => (resumedState = next)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-A', true)
expect(requestGateway.mock.calls.map(([method]) => method)).toContain('session.activate')
expect(getSessionMessages).toHaveBeenCalledWith('stored-A', undefined)
expect(resumedState?.messages[0]?.attachmentRefs).toEqual(['@image:/tmp/photo.png'])
})
it('repairs an idle warm cache from a divergent equal-length persisted transcript', async () => {
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])

View file

@ -317,6 +317,52 @@ describe('reconcileResumeMessages', () => {
const [out] = reconcileResumeMessages(next, previous)
expect(out.parts.some(p => p.type === 'reasoning')).toBe(true)
})
it('preserves attachment refs for a matching user turn', () => {
const next = [msg('stored-user', 'user', 'describe this image')]
const previous = [
msg('live-user', 'user', 'describe this image', {
attachmentRefs: ['@image:/tmp/photo.png']
})
]
const [out] = reconcileResumeMessages(next, previous)
expect(out.attachmentRefs).toEqual(['@image:/tmp/photo.png'])
})
it('does not overwrite attachment refs already present on the resumed message', () => {
const next = [
msg('stored-user', 'user', 'describe this image', {
attachmentRefs: ['@image:/tmp/authoritative.png']
})
]
const previous = [
msg('live-user', 'user', 'describe this image', {
attachmentRefs: ['@image:/tmp/cached.png']
})
]
const [out] = reconcileResumeMessages(next, previous)
expect(out.attachmentRefs).toEqual(['@image:/tmp/authoritative.png'])
})
it('does not preserve attachment refs when the user text differs', () => {
const next = [msg('stored-user', 'user', 'a different prompt')]
const previous = [
msg('live-user', 'user', 'describe this image', {
attachmentRefs: ['@image:/tmp/photo.png']
})
]
const [out] = reconcileResumeMessages(next, previous)
expect(out.attachmentRefs).toBeUndefined()
})
})
describe('preserveLocalPendingTurnMessages', () => {
@ -440,9 +486,91 @@ describe('preserveLocalPendingTurnMessages', () => {
'user-optimistic'
])
})
// #70720: the gateway persists an attached image as a leading `@image:<path>`
// directive line, while the local optimistic composer keeps it as separate
// `attachmentRefs`. A naive text compare (chatMessageText a === b) therefore
// always mismatched whenever an image was attached and re-appended the
// optimistic row as a distinct, duplicate user bubble. Both sides must now
// reduce to the same visible text via textWithoutImageRefs.
it('does not duplicate the optimistic image turn when the persisted turn carries @image refs', () => {
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'what is in this photo?', {
attachmentRefs: ['@image:/tmp/cat.png']
})
]
const next = [
msg('1-user-stored', 'user', 'first'),
msg('2-assistant-stored', 'assistant', 'first answer'),
msg('3-user-stored', 'user', '@image:/tmp/cat.png\nwhat is in this photo?')
]
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
})
it('still keeps a genuinely uncommitted optimistic image turn when the persisted text differs', () => {
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'a different caption', {
attachmentRefs: ['@image:/tmp/cat.png']
})
]
// Persisted turn has a different caption — the optimistic row is still
// uncommitted and must survive (image-aware compare must not over-correct).
const next = [
msg('1-user-stored', 'user', 'first'),
msg('2-assistant-stored', 'assistant', 'first answer'),
msg('3-user-stored', 'user', '@image:/tmp/cat.png\nwhat is in this photo?')
]
expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
'1-user-stored',
'2-assistant-stored',
'3-user-stored',
'user-optimistic'
])
})
})
describe('appendLiveSessionProjection', () => {
it('does not duplicate the inflight user when the persisted turn carries @image refs', () => {
// By the time a stored transcript reaches appendLiveSessionProjection it
// has already been run through toChatMessages, so the @image directive has
// been lifted into attachmentRefs and the visible text is the bare caption.
const stored = [
msg('stored-user', 'user', 'current running prompt', {
attachmentRefs: ['@image:/tmp/cat.png']
}),
msg('stored-assistant', 'assistant', 'earlier answer')
]
const restored = appendLiveSessionProjection(stored, {
session_id: 'runtime-1',
inflight: {
user: 'current running prompt',
assistant: 'partial answer',
streaming: true
}
})
// The persisted user already carries the same visible text (the attachment
// lives in attachmentRefs on both sides), so the inflight *user* projection
// must be suppressed — exactly one user row, no duplicated bubble stacked
// on top of the persisted one. The live assistant tail is still projected.
const userRows = restored.filter(message => message.role === 'user')
expect(userRows).toHaveLength(1)
expect(userRows[0].id).toBe('stored-user')
const userText = userRows[0].parts
.map(part => ('text' in part ? part.text : ''))
.join('')
expect(userText).toBe('current running prompt')
})
it('restores the running turn and accepted queued prompt after a renderer restart', () => {
const stored = [msg('stored-user', 'user', 'earlier'), msg('stored-assistant', 'assistant', 'earlier answer')]

View file

@ -1,7 +1,7 @@
import { getSession } from '@/hermes'
import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { embeddedImageUrls, textWithoutEmbeddedImages, textWithoutImageRefs } from '@/lib/embedded-images'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
@ -211,6 +211,10 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
if (nextText === previousVisibleText || nextText === previousText.trim()) {
preserved = preserveReasoningParts(preserved, previous)
if (message.role === 'user' && preserved.attachmentRefs === undefined && previous.attachmentRefs?.length) {
preserved = { ...preserved, attachmentRefs: [...previous.attachmentRefs] }
}
}
const previousImages = embeddedImageUrls(previousText)
@ -306,7 +310,7 @@ export function preserveLocalPendingTurnMessages(
if (
isOptimisticUser &&
latestAuthoritativeUser &&
chatMessageText(latestAuthoritativeUser).trim() === chatMessageText(message).trim()
textWithoutImageRefs(chatMessageText(latestAuthoritativeUser)) === textWithoutImageRefs(chatMessageText(message))
) {
continue
}
@ -318,7 +322,7 @@ export function preserveLocalPendingTurnMessages(
continue
}
if (chatMessageText(authoritative).trim() === chatMessageText(message).trim()) {
if (textWithoutImageRefs(chatMessageText(authoritative)) === textWithoutImageRefs(chatMessageText(message))) {
continue
}
}
@ -359,7 +363,8 @@ export function appendLiveSessionProjection(
// Only suppress the projection when the latest authoritative user row is the
// same turn — older identical prompts must not hide a newly accepted repeat.
const latestUser = [...messages].reverse().find(message => message.role === 'user')
const inflightUserAlreadyPersisted = latestUser && chatMessageText(latestUser).trim() === inflightUser
const inflightUserAlreadyPersisted =
latestUser && textWithoutImageRefs(chatMessageText(latestUser)) === textWithoutImageRefs(inflightUser)
if (inflightUser && !inflightUserAlreadyPersisted) {
projected.push({

View file

@ -351,19 +351,32 @@ export function DirectiveContent({ text }: { text: string }) {
const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
// `@image:<path>` directives render as a block-level thumbnail row (like
// embedded base64 images below), not inline mid-text — otherwise a large
// thumbnail gets wedged between words and breaks the text's line flow.
const imageSegments = useMemo(
() =>
segments.filter(
(segment): segment is Extract<Unstable_DirectiveSegment, { kind: 'mention' }> =>
segment.kind === 'mention' && segment.type === 'image'
),
[segments]
)
return (
<span className="whitespace-pre-line" data-slot="aui_directive-text">
{segments.map((segment, index) =>
segment.kind === 'text' ? (
<Fragment key={`t-${index}`}>{segment.text}</Fragment>
) : segment.type === 'image' ? (
<DirectiveImage id={segment.id} key={`img-${index}-${segment.id}`} label={segment.label} />
) : (
) : segment.type === 'image' ? null : (
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
)
)}
{images.length > 0 && (
{(imageSegments.length > 0 || images.length > 0) && (
<span className="mt-2 flex flex-wrap gap-2" data-slot="aui_embedded-images">
{imageSegments.map((segment, index) => (
<DirectiveImage id={segment.id} key={`img-ref-${index}-${segment.id}`} label={segment.label} />
))}
{images.map((src, index) => (
<ZoomableImage
alt=""
@ -430,7 +443,7 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
return (
<ZoomableImage
alt={label}
className="max-h-32 max-w-48 rounded-md border border-border/40 object-contain"
className="max-h-48 max-w-full rounded-lg border border-border/60 object-contain"
draggable={false}
slot="aui_directive-image"
src={src}

View file

@ -133,6 +133,65 @@ describe('toChatMessages', () => {
expect(chatMessageText(message)).toBe('Here you go.')
})
it('lifts @image directive lines into attachmentRefs instead of inline text', () => {
const [message] = toChatMessages([
{
role: 'user',
content: '@image:/tmp/cat.png\nwhat is in this photo?',
timestamp: 1
}
])
expect(chatMessageText(message)).toBe('what is in this photo?')
expect((message as { attachmentRefs?: string[] }).attachmentRefs).toEqual(['@image:/tmp/cat.png'])
})
it('keeps a user turn that carried only an attached image (no caption)', () => {
const [message] = toChatMessages([
{
role: 'user',
content: '@image:/tmp/cat.png',
timestamp: 1
}
])
// The bubble has no visible text, but must survive the empty-turn filter
// because it carries attachment refs — otherwise a stand-alone attachment
// vanishes from the transcript after a session switch / restart.
expect(chatMessageText(message)).toBe('')
expect((message as { attachmentRefs?: string[] }).attachmentRefs).toEqual(['@image:/tmp/cat.png'])
})
it('renders a native-vision turn as caption plus thumbnail, not raw placeholder text', () => {
// How a turn sent to a natively-vision-capable model comes back out of the
// session store: a backtick-quoted ref (the path has spaces) and the
// `[screenshot]` stand-in left by flattening the parts list.
const ref = '@image:`/Users/me/Library/Application Support/Hermes/composer-images/a.png`'
const [message] = toChatMessages([
{
role: 'user',
content: `${ref}\nwhat is in this photo?\n[screenshot]`,
timestamp: 1
}
])
expect(chatMessageText(message)).toBe('what is in this photo?')
expect((message as { attachmentRefs?: string[] }).attachmentRefs).toEqual([ref])
})
it('leaves a plain user prompt without attachment refs untouched', () => {
const [message] = toChatMessages([
{
role: 'user',
content: 'just a question',
timestamp: 1
}
])
expect((message as { attachmentRefs?: string[] }).attachmentRefs).toBeUndefined()
})
it('coerces non-string message content without throwing', () => {
const [message] = toChatMessages([
{

View file

@ -1,6 +1,7 @@
import type { ThreadMessageLike } from '@assistant-ui/react'
import type { BillingBlock } from '@hermes/shared'
import { extractImageRefs } from '@/lib/embedded-images'
import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
import { normalize } from '@/lib/text'
@ -931,7 +932,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
const content = message.content || message.text || message.context || message.name
const displayContent = transcriptContent(
const rawDisplayContent = transcriptContent(
message.display_kind,
timelineDisplayContent(message, displayContentForMessage(message.role, content))
)
@ -941,6 +942,17 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
? 'system'
: message.role
// Persisted user turns carry `@image:<path>` directive lines inline in
// the text (see tui_gateway/server.py's persist-time rewrite). The
// read-only bubble clamps its body to ~2 lines, and a large inline image
// thumbnail pushes any caption text below the clamp's visible area — so
// pull image refs out into `attachmentRefs` (same shape the local
// optimistic composer already uses) and render them via the dedicated
// attachments row below the bubble instead.
const imageRefExtraction = displayRole === 'user' && rawDisplayContent ? extractImageRefs(rawDisplayContent) : null
const displayContent = imageRefExtraction ? imageRefExtraction.cleanedText : rawDisplayContent
const extractedAttachmentRefs = imageRefExtraction?.refs.length ? imageRefExtraction.refs : undefined
const parts: ChatMessagePart[] = []
const reasoning =
@ -960,7 +972,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
parts.push(...message.tool_calls.map((call, callIndex) => toolPartFromStoredCall(call, callIndex)))
}
if (!parts.length) {
if (!parts.length && !extractedAttachmentRefs?.length) {
if (message.role !== 'assistant') {
flushPendingTools(index)
activeAssistantIndex = null
@ -1010,7 +1022,8 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
id: `${message.timestamp || Date.now()}-${index}-${displayRole}`,
role: displayRole,
parts,
timestamp: message.timestamp
timestamp: message.timestamp,
...(extractedAttachmentRefs ? { attachmentRefs: extractedAttachmentRefs } : {})
})
activeAssistantIndex = message.role === 'assistant' ? result.length - 1 : null
@ -1022,7 +1035,9 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
)
return withUniqueToolCallIds(
withoutGeneratedImageEchoes.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
withoutGeneratedImageEchoes.filter(
m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text') || m.attachmentRefs?.length
)
)
}

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { extractEmbeddedImages } from './embedded-images'
import { extractEmbeddedImages, extractImageRefs, textWithoutImageRefs } from './embedded-images'
const SAMPLE_PNG_DATA_URL = 'data:image/png;base64,' + 'A'.repeat(120)
@ -42,3 +42,72 @@ describe('extractEmbeddedImages', () => {
expect(result.images[0]).toHaveLength(hugeDataUrl.length)
})
})
describe('textWithoutImageRefs', () => {
it('leaves plain text untouched', () => {
expect(textWithoutImageRefs('just a question')).toBe('just a question')
})
it('strips a single leading @image directive line', () => {
expect(textWithoutImageRefs('@image:/tmp/cat.png\nwhat is this?')).toBe('what is this?')
})
it('strips multiple @image directive lines and trims', () => {
const input = '@image:/tmp/a.png\n@image:/tmp/b.png\n describe both '
expect(textWithoutImageRefs(input)).toBe('describe both')
})
it('does not treat an inline @image mention as a directive line', () => {
// Only full-line leading directives are stripped, matching the gateway's
// persist-time rewrite. A bare mention mid-prose is preserved.
expect(textWithoutImageRefs('see @image:/tmp/cat.png here')).toBe('see @image:/tmp/cat.png here')
})
})
describe('extractImageRefs', () => {
it('returns the text untouched and no refs when there are no directives', () => {
expect(extractImageRefs('a normal prompt')).toEqual({ cleanedText: 'a normal prompt', refs: [] })
})
it('lifts leading @image directive lines into refs and clears the text', () => {
const result = extractImageRefs('@image:/tmp/cat.png\nwhat do you see?')
expect(result).toEqual({ cleanedText: 'what do you see?', refs: ['@image:/tmp/cat.png'] })
})
it('collects multiple refs in order', () => {
const result = extractImageRefs('@image:/tmp/a.png\n@image:/tmp/b.png\ncompare them')
expect(result.cleanedText).toBe('compare them')
expect(result.refs).toEqual(['@image:/tmp/a.png', '@image:/tmp/b.png'])
})
it('keeps only the directive lines when there is no trailing text', () => {
const result = extractImageRefs('@image:/tmp/only.png')
expect(result).toEqual({ cleanedText: '', refs: ['@image:/tmp/only.png'] })
})
it('lifts a backtick-quoted ref so a path with spaces survives intact', () => {
const ref = '@image:`/Users/me/Library/Application Support/Hermes/composer-images/a.png`'
const result = extractImageRefs(`${ref}\nwhat is this?`)
expect(result).toEqual({ cleanedText: 'what is this?', refs: [ref] })
})
it('drops the [screenshot] placeholder a native-vision turn leaves behind', () => {
// Flattening a parts list replaces each image part with `[screenshot]`; the
// lifted ref already renders that same attachment.
const result = extractImageRefs('@image:/tmp/cat.png\nwhat is in this photo?\n[screenshot]')
expect(result).toEqual({ cleanedText: 'what is in this photo?', refs: ['@image:/tmp/cat.png'] })
})
it('keeps [screenshot] when the message carries no image refs', () => {
expect(extractImageRefs('[screenshot]\nlook at the attached capture')).toEqual({
cleanedText: '[screenshot]\nlook at the attached capture',
refs: []
})
})
})

View file

@ -160,3 +160,50 @@ export function embeddedImageUrls(text: string): string[] {
export function textWithoutEmbeddedImages(text: string): string {
return extractEmbeddedImages(text).cleanedText
}
// The gateway persists attached images as `@image:<path>` directive lines
// (see tui_gateway/server.py's persist-time rewrite), prepended before the
// user's own text. The composer's own optimistic/local turn never carries
// this prefix — it keeps the attachment as separate `attachmentRefs`
// metadata, not inline text. Comparing raw chatMessageText between the
// optimistic turn and the authoritative (persisted) turn therefore always
// mismatches whenever an image was attached, which defeats the "is this the
// same turn" checks in preserveLocalPendingTurnMessages / appendLiveSessionProjection
// and re-appends the optimistic row as if it were a distinct, unconfirmed
// turn — a duplicated user bubble. Strip the directive line(s) before any
// such equality comparison so both sides reduce to the same visible text.
const IMAGE_REF_LINE_RE = /^@image:[^\n]*\n?/gm
export function textWithoutImageRefs(text: string): string {
return text.replace(IMAGE_REF_LINE_RE, '').trim()
}
// Same directive lines as textWithoutImageRefs, but keeps them instead of
// discarding — used when converting persisted server messages into
// ChatMessage/ThreadMessageLike shape, where `@image:<path>` refs need to
// move from inline text into the `attachmentRefs` metadata field (mirroring
// how the local optimistic composer represents attachments) rather than stay
// embedded in the bubble's clamped text body, where a large inline thumbnail
// pushes the caption text out of the clamp's visible area.
// Native-vision turns are stored as a parts list, which the session store
// flattens by replacing each image part with a literal `[screenshot]` line. The
// `@image:` ref describes that same attachment, so keeping both renders the
// placeholder as stray text under the thumbnail. Drop it only when a ref was
// actually lifted, so a `[screenshot]` in a message without attachments stays.
const SCREENSHOT_PLACEHOLDER_LINE_RE = /^\[screenshot\]\n?/gm
export function extractImageRefs(text: string): { cleanedText: string; refs: string[] } {
const refs: string[] = []
let cleanedText = text.replace(IMAGE_REF_LINE_RE, match => {
refs.push(match.trim())
return ''
})
if (refs.length) {
cleanedText = cleanedText.replace(SCREENSHOT_PLACEHOLDER_LINE_RE, '')
}
return { cleanedText: cleanedText.trim(), refs }
}

View file

@ -445,3 +445,25 @@ async def test_canonical_guard_fails_closed_when_lookup_raises(tmp_path: Path, m
"credential deny-list" in warning or "sensitive credential" in warning
for warning in result.warnings
)
@pytest.mark.parametrize(
"value",
[
"/tmp/plain.png",
"/Users/me/Library/Application Support/Hermes/composer-images/a.png",
r"C:\Users\John Doe\Pictures\cat.png",
"/tmp/report (final).pdf",
"/tmp/it's here.png",
'/tmp/say "hi".png',
],
)
def test_format_reference_value_round_trips_through_the_parser(value):
"""Whatever the path contains, the formatted ref must parse back whole —
an unquoted value stops at the first space and strands the tail as text."""
from agent.context_references import REFERENCE_PATTERN, format_reference_value
match = REFERENCE_PATTERN.search(f"@file:{format_reference_value(value)}")
assert match is not None
assert match.group("value").strip("`\"'") == value

View file

@ -140,7 +140,7 @@ class TestSyncSessionKeyAfterAutoCompress:
self.session_id = "pre-compress-key"
self._cached_system_prompt = ""
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
# Simulate what _compress_context does: rotate session_id
self.session_id = "post-compress-key"
return {

View file

@ -3313,7 +3313,7 @@ def test_prompt_submit_empty_truncation_allowed_with_confirm(monkeypatch):
class _Agent:
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
self, prompt, conversation_history=None, stream_callback=None, **_kwargs
):
seen["prompt"] = prompt
seen["history"] = conversation_history
@ -3709,9 +3709,7 @@ class _RecordingAgent:
def clear_interrupt(self):
return None
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
self._turns.append(prompt)
return {"final_response": "", "messages": []}
@ -3820,9 +3818,7 @@ def test_run_prompt_submit_requeues_all_unstarted_notifications_with_real_thread
return thread
class _BlockingNotificationAgent(_RecordingAgent):
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
turns.append(prompt)
if "proc_batch_1" in prompt:
nested_started.set()
@ -6608,9 +6604,7 @@ def test_prompt_submit_sets_approval_session_key(monkeypatch):
captured = {}
class _Agent:
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
captured["session_key"] = get_current_session_key(default="")
return {
"final_response": "ok",
@ -6650,9 +6644,7 @@ def test_prompt_submit_expands_context_refs(monkeypatch):
base_url = ""
api_key = ""
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
captured["prompt"] = prompt
return {
"final_response": "ok",
@ -7521,9 +7513,7 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch):
session_ref = {"s": None}
class _RacyAgent:
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
# Simulate: something external bumped history_version
# while we were running.
with session_ref["s"]["history_lock"]:
@ -7584,9 +7574,7 @@ def test_prompt_submit_sanitizes_bracketed_paste_before_agent(monkeypatch):
captured: dict[str, str] = {}
class _Agent:
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
captured["prompt"] = prompt
return {
"final_response": "ok",
@ -7628,9 +7616,7 @@ def test_prompt_submit_history_version_match_persists_normally(monkeypatch):
"""Regression guard: the backstop does not affect the happy path."""
class _Agent:
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
return {
"final_response": "reply",
"messages": [{"role": "assistant", "content": "reply"}],
@ -7681,9 +7667,7 @@ def test_prompt_submit_can_truncate_before_user_ordinal(monkeypatch):
seen = {}
class _Agent:
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
seen["prompt"] = prompt
seen["history"] = conversation_history
return {
@ -9671,9 +9655,7 @@ def test_prompt_submit_auto_titles_session_on_complete(monkeypatch):
api_key = object()
api_mode = "codex_responses"
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
return {
"final_response": "Rome was founded in 753 BC.",
"messages": [
@ -9716,9 +9698,7 @@ def test_prompt_submit_skips_auto_title_when_interrupted(monkeypatch):
"""maybe_auto_title must NOT be called when the agent was interrupted."""
class _Agent:
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
return {
"final_response": "partial answer",
"interrupted": True,
@ -9748,9 +9728,7 @@ def test_prompt_submit_skips_auto_title_when_response_empty(monkeypatch):
"""maybe_auto_title must NOT be called when the agent returns an empty reply."""
class _Agent:
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
return {
"final_response": "",
"messages": [],
@ -9781,9 +9759,7 @@ def test_prompt_submit_surfaces_backend_error_as_visible_text(monkeypatch):
instead of emitting a blank message.complete turn."""
class _Agent:
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
return {
"final_response": None,
"messages": [],
@ -9828,9 +9804,7 @@ def test_prompt_submit_preserves_empty_response_without_error(monkeypatch):
semantics owned by downstream handlers."""
class _Agent:
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
return {
"final_response": None,
"messages": [],
@ -9993,7 +9967,7 @@ def test_session_activate_returns_inflight_stream_before_completion(monkeypatch)
class _Agent:
model = "model-live"
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
assert prompt == "write a long answer"
assert conversation_history == []
stream_callback("partial ")
@ -11317,7 +11291,7 @@ def test_notification_poller_delivers_completion(monkeypatch):
emitted = []
class _Agent:
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
turns.append(prompt)
return {
"final_response": "ok",
@ -11388,7 +11362,7 @@ def test_notification_poller_skips_consumed(monkeypatch):
turns = []
class _Agent:
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
turns.append(prompt)
return {"final_response": "ok", "messages": []}
@ -13229,3 +13203,185 @@ def test_clarify_timeout_seconds_maps_non_positive_to_unlimited(monkeypatch, con
monkeypatch.setattr("tools.clarify_gateway.get_clarify_timeout", lambda: configured)
assert server._clarify_timeout_seconds() == expected
def test_build_persist_message_with_image_refs_without_images_returns_text(monkeypatch):
"""#70720: when no images are attached the persisted message is the raw
prompt no @image directive prefix is introduced."""
assert server._build_persist_message_with_image_refs("what is this?", []) == "what is this?"
assert server._build_persist_message_with_image_refs("", []) == ""
def test_build_persist_message_with_image_refs_appends_existing_paths(monkeypatch, tmp_path):
"""Attached images that still exist on disk are persisted as trailing
``@image:<path>`` directive lines so the desktop renders them after a
restart (instead of the vision-only enrichment that silently breaks)."""
img = tmp_path / "cat.png"
img.write_bytes(b"\x89PNG")
result = server._build_persist_message_with_image_refs("what is in this photo?", [str(img)])
assert result == f"what is in this photo?\n@image:{img}"
def test_build_persist_message_keeps_the_caption_on_the_first_line(tmp_path):
"""Session previews are the first 60 characters of the first user message,
so a leading directive would title the session with a truncated file path
in the sidebar, switcher, and command palette."""
img = tmp_path / "cat.png"
img.write_bytes(b"png")
result = server._build_persist_message_with_image_refs("what is in this photo?", [str(img)])
assert result.split("\n", 1)[0] == "what is in this photo?"
def test_build_persist_message_with_image_refs_skips_missing_paths(monkeypatch, tmp_path):
"""Only paths that still exist are persisted; a missing file must not
inject a dangling @image ref into the transcript."""
existing = tmp_path / "a.png"
existing.write_bytes(b"png")
missing = str(tmp_path / "gone.png")
result = server._build_persist_message_with_image_refs("compare them", [str(existing), missing])
assert result == f"compare them\n@image:{existing}"
def test_build_persist_message_with_image_refs_without_text_is_refs_only(monkeypatch, tmp_path):
"""A stand-alone attachment (no caption) persists as just the directive
line, so a bare image survives in history and is not dropped as empty."""
img = tmp_path / "only.png"
img.write_bytes(b"png")
assert server._build_persist_message_with_image_refs("", [str(img)]) == f"@image:{img}"
def test_build_persist_message_quotes_paths_containing_spaces(tmp_path):
"""The unquoted alternative in the directive pattern is ``\\S+``, so a path
with a space parses as a truncated ref with the tail left as loose text.
Desktop composer images live in the app's userData dir, which on macOS is
``~/Library/Application Support/...`` a space every time."""
img_dir = tmp_path / "Application Support" / "Hermes" / "composer-images"
img_dir.mkdir(parents=True)
img = img_dir / "cat.png"
img.write_bytes(b"png")
result = server._build_persist_message_with_image_refs("what is this?", [str(img)])
assert result == f"what is this?\n@image:`{img}`"
def test_persist_user_message_mirrors_the_shape_sent_to_the_model(tmp_path):
"""A native-vision turn sends ``content`` as a parts list, and the session
store ignores a plain-string override for a list payload. The override must
mirror the list shape (ref text + the original image parts) or it is
silently dropped and the attachment never reaches history."""
img = tmp_path / "cat.png"
img.write_bytes(b"png")
image_part = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
native_parts = [{"type": "text", "text": "api-only text"}, image_part]
override = server._build_persist_user_message("what is this?", [str(img)], native_parts)
assert override == [{"type": "text", "text": f"what is this?\n@image:{img}"}, image_part]
def test_persist_user_message_stays_a_string_for_text_mode(tmp_path):
"""Text-mode (vision-preprocessed) turns send a string, so the override
stays a string the shape the session store rewrites directly."""
img = tmp_path / "cat.png"
img.write_bytes(b"png")
override = server._build_persist_user_message("what is this?", [str(img)], "enriched api-only text")
assert override == f"what is this?\n@image:{img}"
def test_native_vision_turn_persists_a_renderable_image_ref(tmp_path):
"""End to end through the real session-store flush: whichever image input
mode the turn used, the durable row carries an ``@image:`` ref the desktop
can render after a restart."""
from unittest.mock import MagicMock
from agent.image_routing import build_native_content_parts
from run_agent import AIAgent
img_dir = tmp_path / "Application Support" / "composer-images"
img_dir.mkdir(parents=True)
img = img_dir / "cat.png"
img.write_bytes(
bytes.fromhex(
"89504e470d0a1a0a0000000d494844520000000100000001080600000"
"01f15c4890000000a49444154789c6360000002000100ffff0300000600"
"0557bfabd40000000049454e44ae426082"
)
)
native_parts, skipped = build_native_content_parts("what is in this photo?", [str(img)])
assert not skipped
agent = AIAgent.__new__(AIAgent)
agent._session_db = MagicMock()
agent._session_db_created = True
agent.session_id = "s-1"
agent._last_flushed_db_idx = 0
agent._persist_disabled = False
agent._flushed_db_message_ids = set()
agent._flushed_db_message_session_id = None
agent._pending_cli_user_message = None
agent._persist_user_message_timestamp = None
agent._persist_user_message_idx = 0
agent._persist_user_message_override = server._build_persist_user_message(
"what is in this photo?", [str(img)], native_parts
)
agent._flush_messages_to_session_db([{"role": "user", "content": native_parts}], [])
written = agent._session_db.append_message.call_args.kwargs["content"]
assert f"@image:`{img}`" in written
assert "what is in this photo?" in written
# The model keeps the pixels for the rest of the session.
assert any(part.get("type") == "image_url" for part in agent._persist_user_message_override)
def test_prompt_submit_passes_persist_user_message_to_agent(monkeypatch):
"""#70720: _run_prompt_submit must forward the (image-ref-aware) persisted
user message to run_conversation via persist_user_message, so the gateway
stores the UI-recognizable form instead of the vision enrichment."""
captured = {}
class _Agent:
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
captured["persist_user_message"] = _kwargs.get("persist_user_message")
return {
"final_response": "reply",
"messages": [{"role": "assistant", "content": "reply"}],
}
class _ImmediateThread:
def __init__(self, target=None, daemon=None):
self._target = target
def start(self):
self._target()
server._sessions["sid"] = _session(agent=_Agent())
try:
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
monkeypatch.setattr(server, "_get_usage", lambda _a: {})
monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
monkeypatch.setattr(server, "_emit", lambda *a: None)
resp = server.handle_request(
{
"id": "1",
"method": "prompt.submit",
"params": {"session_id": "sid", "text": "hi"},
}
)
assert resp.get("result")
# Without attachments the persist form equals the raw prompt.
assert captured.get("persist_user_message") == "hi"
finally:
server._sessions.pop("sid", None)

View file

@ -5739,6 +5739,49 @@ def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str:
return text or "What do you see in this image?"
def _build_persist_message_with_image_refs(user_text: str, image_paths: list[str]) -> str:
"""Build the clean, UI-recognizable version of the user's message for
persisting to session history. Uses ``@image:<path>`` directives the
format the desktop client (directive-text.tsx / HERMES_DIRECTIVE_RE)
actually parses and renders as an image unlike
``_enrich_with_attached_images``, which embeds a vision description and
an ``image_url:`` hint meant only for the model and must never be
persisted as-is (it silently breaks image rendering after a full
restart, and reorders image/text on live session-switch reconciliation).
The caption leads and the directives trail: session previews are the first
60 characters of the first user message (``list_sessions_rich``), so a
leading directive would label the session with a truncated file path in the
sidebar, switcher, and command palette. Clients lift the refs out of the
body by line, so their position does not affect how the turn renders.
"""
from agent.context_references import format_reference_value
text = user_text or ""
refs = "\n".join(f"@image:{format_reference_value(p)}" for p in image_paths if Path(p).exists())
if not refs:
return text
return f"{text}\n{refs}" if text else refs
def _build_persist_user_message(user_text: str, image_paths: list[str], run_message: Any) -> Any:
"""Shape the persisted user turn to match what was sent to the model.
Native-vision turns send ``content`` as a parts list, and
``_flush_messages_to_session_db`` deliberately ignores a plain-string
override for a list payload (a text override must not erase a turn's
image/audio summary). So mirror the shape: replace only the text part with
the ``@image:`` ref form and keep the image parts, so the model still has
the pixels for the rest of the session. Any API-only text part (the
barge-in note) is dropped along the way, which is the point of the override.
"""
persist_text = _build_persist_message_with_image_refs(user_text, image_paths)
if not isinstance(run_message, list):
return persist_text
image_parts = [p for p in run_message if not (isinstance(p, dict) and p.get("type") == "text")]
return [{"type": "text", "text": persist_text}, *image_parts]
def _content_display_text(content: Any) -> str:
if content is None:
return ""
@ -10997,6 +11040,9 @@ def _run_prompt_submit(
run_kwargs = {
"conversation_history": list(history),
"stream_callback": _stream,
"persist_user_message": (
_build_persist_user_message(prompt, images, run_message) if images else prompt
),
}
try:
if "task_id" in inspect.signature(agent.run_conversation).parameters: