fix(desktop): re-upload a restored draft's image after a SESSION_OWNER_BUSY refusal

When the gateway owns a chat, `hermes serve` refuses an image send with
SESSION_OWNER_BUSY and now unstages the image (#140968). Desktop restored the
rejected draft with its chips still stamped `attachedSessionId` = the live
session, so the resend skipped image.attach / image.attach_bytes and the
image was silently dropped.

On that refusal (or any refusal whose message says the image was removed),
submit now clears `attachedSessionId` on the image chips and puts back the
original source path. The next submit re-uploads them. The tile scope gets
the same patch seam.

Red->green: owner-forward.test.tsx "re-uploads the image of a draft restored
after a SESSION_OWNER_BUSY refusal".

Live-pass finding (package 10)
Refs #134889 (RT-37)
Refs #140719

(cherry picked from commit 9cf43cd629577d60c689f32385384bbd185446af)
This commit is contained in:
Teknium committed 2026-10-18 08:03:53 -07:00
1 parent a63178330b
commit a40e0c7b15
4 files changed
+104

No files matched your search

@@ -314,6 +314,7 @@ export function useSessionTileActions({ requestGateway, runtimeId, scope, stored
updateSessionState: (sessionId, updater) => sessionTileDelegate()!.updateSession(sessionId, updater),
scope: {
removeAttachments: attachments => scope.attachments.removeOccurrences(attachments),
patchAttachment: (expected, patch) => void scope.attachments.updateIfCurrent(expected, patch),
readAttachments: () => scope.attachments.$attachments.get(),
// Busy/messages flow through updateSession -> the tile's state slice;
// the primary view atoms must never see a tile turn.
@@ -3,7 +3,9 @@ import { cleanup } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { errorRecoveryPlan, type ErrorSurface } from '@/lib/error-surface'
import { $composerAttachments } from '@/store/composer'
import { clearNotifications } from '@/store/notifications'
import { $connection, $currentCwd } from '@/store/session'
import { actRender, Harness, type HarnessHandle } from './index-test-utils'
@@ -12,6 +14,62 @@ describe('usePromptActions gateway-owner queue (#140385)', () => {
afterEach(() => {
cleanup()
clearNotifications()
$composerAttachments.set([])
})
it('re-uploads the image of a draft restored after a SESSION_OWNER_BUSY refusal: serve dropped it (#140968)', async () => {
$connection.set({ mode: 'local' } as never)
$currentCwd.set('/root')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:image/jpeg;base64,aGVsbG8=') }
})
$composerAttachments.set([
{
detail: '/home/alice/photo.jpg',
id: 'image:photo.jpg',
kind: 'image',
label: 'photo.jpg',
occurrenceId: 'occurrence-photo',
path: '/home/alice/photo.jpg'
}
])
const calls: string[] = []
let submits = 0
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'image.attach_bytes' || method === 'image.attach') {
return { attached: true, path: '/root/tmp/photo.jpg' } as never
}
if (method === 'prompt.submit' && ++submits === 1) {
throw new JsonRpcGatewayError(
'The gateway is running a turn in this chat and an attachment cannot join its queue. The image was removed. Nothing was sent.',
{ code: 4090, data: { reason: 'SESSION_OWNER_BUSY' } }
)
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
expect(await handle!.submitText('describe this')).toBe(false)
expect($composerAttachments.get()).toHaveLength(1)
await handle!.submitText('describe this')
const uploads = (m: string) => m === 'image.attach_bytes' || m === 'image.attach'
const firstRefusal = calls.indexOf('prompt.submit')
const resend = calls.lastIndexOf('prompt.submit')
expect(resend).toBeGreaterThan(firstRefusal)
expect(calls.slice(firstRefusal + 1, resend).some(uploads)).toBe(true)
})
it('turns a SESSION_OWNER_BUSY refusal into a retryable card, not "Start new session" (#140385)', async () => {
@@ -16,8 +16,10 @@ import {
import {
$composerAttachments,
type ComposerAttachment,
type ComposerAttachmentPatch,
freezeComposerTransportPayload,
mainComposerScope,
patchMainComposerAttachmentOccurrence,
revokeDiscardedAttachmentPreviews
} from '@/store/composer'
import { serverOwnsComposerQueue } from '@/store/composer-queue'
@@ -66,6 +68,7 @@ import {
isSessionBusyError,
isTargetSessionBusy,
ownershipErrorSurface,
refusalDroppedImages,
releaseSubmitInFlight,
SessionRecoveryAborted,
type SubmitTextOptions,
@@ -101,6 +104,8 @@ interface SubmitPromptDeps {
* the primary view's $busy/$messages or clears the main attachment chips. */
scope?: {
removeAttachments: (attachments: readonly ComposerAttachment[]) => void
/** Merge `patch` into the chip occurrence `expected` names, if it is still there. */
patchAttachment: (expected: ComposerAttachment, patch: ComposerAttachmentPatch) => void
readAttachments: () => ComposerAttachment[]
setAwaitingResponse: (awaiting: boolean) => void
setBusy: (busy: boolean) => void
@@ -108,10 +113,40 @@ interface SubmitPromptDeps {
}
}
// #140968: serve unstages the images of a send the gateway owner refused, so the restored chips must
// not claim the session still holds them. Un-stage them (original source path back, no session
// stamp) and the resend uploads them again.
function unstageRefusedImages(
err: unknown,
usingComposerAttachments: boolean,
scope: NonNullable<SubmitPromptDeps['scope']>,
attachments: readonly ComposerAttachment[]
): void {
if (!usingComposerAttachments || !refusalDroppedImages(err)) {
return
}
for (const original of attachments) {
// The chip as staged now (a legacy chip without an occurrence id was replaced by object).
const current = scope
.readAttachments()
.find(item => item.id === original.id && item.occurrenceId === original.occurrenceId)
if (current && original.kind === 'image' && original.path) {
scope.patchAttachment(current, {
attachedSessionId: undefined,
label: original.label,
path: original.attachedSessionId ? (original.detail ?? original.path) : original.path
})
}
}
}
// Stable identity — a fresh default object per render would churn the
// useCallback below on every render.
const MAIN_SUBMIT_SCOPE: NonNullable<SubmitPromptDeps['scope']> = {
removeAttachments: attachments => mainComposerScope.removeOccurrences(attachments),
patchAttachment: (expected, patch) => void patchMainComposerAttachmentOccurrence(expected, patch),
readAttachments: () => $composerAttachments.get(),
setAwaitingResponse,
setBusy,
@@ -1308,6 +1343,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
} catch (err) {
releaseBusy()
unstageRefusedImages(err, usingComposerAttachments, scope, attachments)
// A queued drain that raced a not-yet-settled turn gets a transient
// "session busy" (4009). Don't surface an error bubble/toast — the entry
// stays queued and the composer's bounded auto-drain retries when idle.
@@ -315,6 +315,14 @@ export function isSessionOwnerBusyError(error: unknown): boolean {
return gatewayReason(error) === 'SESSION_OWNER_BUSY'
}
// The refused send's staged images are gone server-side: SESSION_OWNER_BUSY unstages them (#140968),
// and any refusal whose prose says the image was removed did the same.
export function refusalDroppedImages(error: unknown): boolean {
return (
isSessionOwnerBusyError(error) || /\bimages?\b[^.]*\bremoved\b/i.test(error instanceof Error ? error.message : '')
)
}
function gatewayReason(error: unknown): unknown {
return error instanceof JsonRpcGatewayError ? (error.data as { reason?: unknown } | undefined)?.reason : undefined
}