mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user