From 462cebdf95fa86def7c1c3011cd4f00245ce50e8 Mon Sep 17 00:00:00 2001 From: lyxxx <127183348+lyxxx708@users.noreply.github.com> Date: Wed, 13 May 2026 15:56:55 +0800 Subject: [PATCH] Fix(queue): auto-send queued messages FIFO (#1254) * fix(queue): auto-send queued messages FIFO * test(queue): cover FIFO queued auto-send payload * test(queue): stub config store in auto-send tests * fix(test): complete Agent type in queued auto-send test --------- Co-authored-by: Bohdan Triapitsyn --- .../hooks/useQueuedMessageAutoSend.test.ts | 104 ++++++++++++++++++ .../ui/src/hooks/useQueuedMessageAutoSend.ts | 56 ++++------ 2 files changed, 124 insertions(+), 36 deletions(-) create mode 100644 packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts new file mode 100644 index 00000000..9ab36aa5 --- /dev/null +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { Agent } from '@opencode-ai/sdk/v2'; +import type { QueuedMessage } from '../stores/messageQueueStore'; + +let visibleAgents: Agent[] = []; + +const getVisibleAgentsMock = mock(() => visibleAgents); + +mock.module('@/stores/useConfigStore', () => ({ + useConfigStore: { + getState: () => ({ + getVisibleAgents: getVisibleAgentsMock, + }), + }, +})); + +import { buildQueuedAutoSendPayload } from './useQueuedMessageAutoSend'; + +describe('buildQueuedAutoSendPayload', () => { + beforeEach(() => { + visibleAgents = []; + }); + + test('returns only the first queued message for auto-send', () => { + const queue: QueuedMessage[] = [ + { + id: 'queued-1', + content: 'first queued message', + createdAt: 1, + }, + { + id: 'queued-2', + content: 'second queued message', + createdAt: 2, + }, + ]; + + const payload = buildQueuedAutoSendPayload(queue); + + expect(payload).not.toBeNull(); + expect(payload?.queuedMessageId).toBe('queued-1'); + expect(payload?.primaryText).toBe('first queued message'); + expect(payload?.primaryAttachments).toEqual([]); + }); + + test('uses the configured visible agents when parsing queued mentions', () => { + visibleAgents = [ + { + name: 'Builder', + mode: 'subagent', + permission: [], + options: {}, + } as Agent, + ]; + + const queue: QueuedMessage[] = [ + { + id: 'queued-mention', + content: '@Builder please take this', + createdAt: 1, + }, + ]; + + const payload = buildQueuedAutoSendPayload(queue); + + expect(payload).not.toBeNull(); + expect(payload?.agentMentionName).toBe('Builder'); + expect(payload?.primaryText).toBe('@Builder please take this'); + }); + + test('preserves attachment-only queued messages as sendable payloads', () => { + const queue: QueuedMessage[] = [ + { + id: 'queued-attachments', + content: '', + createdAt: 1, + attachments: [ + { + id: 'file-1', + filename: 'notes.txt', + mimeType: 'text/plain', + size: 5, + source: 'local', + file: new File(['hello'], 'notes.txt', { type: 'text/plain' }), + dataUrl: 'data:text/plain;base64,aGVsbG8=', + }, + ], + }, + { + id: 'queued-2', + content: 'later queued message', + createdAt: 2, + }, + ]; + + const payload = buildQueuedAutoSendPayload(queue); + + expect(payload).not.toBeNull(); + expect(payload?.queuedMessageId).toBe('queued-attachments'); + expect(payload?.primaryText).toBe(''); + expect(payload?.primaryAttachments).toHaveLength(1); + expect(payload?.primaryAttachments[0]?.filename).toBe('notes.txt'); + }); +}); diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index 13567444..3ca6a7ed 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -1,5 +1,4 @@ import React from 'react'; -import type { AttachedFile } from '@/stores/types/sessionTypes'; import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; @@ -21,37 +20,21 @@ const hasRecentAbort = (sessionId: string): boolean => { return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS; }; -const buildQueuedPayload = (queue: QueuedMessage[]) => { - const agents = useConfigStore.getState().getVisibleAgents(); - let primaryText = ''; - let primaryAttachments: AttachedFile[] = []; - let agentMentionName: string | undefined; - const additionalParts: Array<{ text: string; attachments?: AttachedFile[] }> = []; - - for (let i = 0; i < queue.length; i += 1) { - const queued = queue[i]; - const { sanitizedText, mention } = parseAgentMentions(queued.content, agents); - - if (!agentMentionName && mention?.name) { - agentMentionName = mention.name; - } - - if (i === 0) { - primaryText = sanitizedText; - primaryAttachments = queued.attachments ?? []; - } else { - additionalParts.push({ - text: sanitizedText, - attachments: queued.attachments, - }); - } +export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => { + const queued = queue[0]; + if (!queued) { + return null; } + const agents = useConfigStore.getState().getVisibleAgents(); + const { sanitizedText, mention } = parseAgentMentions(queued.content, agents); + return { - primaryText, - primaryAttachments, - agentMentionName, - additionalParts: additionalParts.length > 0 ? additionalParts : undefined, + queuedMessageId: queued.id, + primaryText: sanitizedText, + primaryAttachments: queued.attachments ?? [], + agentMentionName: mention?.name, + sendConfig: queued.sendConfig, }; }; @@ -125,13 +108,16 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? return; } - const payload = buildQueuedPayload(queueSnapshot); - if (!payload.primaryText && payload.primaryAttachments.length === 0 && !payload.additionalParts?.length) { + const payload = buildQueuedAutoSendPayload(queueSnapshot); + if (!payload) { + return; + } + if (!payload.primaryText && payload.primaryAttachments.length === 0) { return; } // Use send config captured at queue time; fall back to current config - const captured = queueSnapshot[0]?.sendConfig; + const captured = payload.sendConfig; const resolved = captured?.providerID && captured?.modelID ? captured : resolveSessionSendConfig(sessionId); @@ -149,15 +135,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? resolved.agent, payload.primaryAttachments, payload.agentMentionName, - payload.additionalParts, + undefined, resolved.variant, 'normal' ); const removeFromQueue = useMessageQueueStore.getState().removeFromQueue; - queueSnapshot.forEach((item) => { - removeFromQueue(sessionId, item.id); - }); + removeFromQueue(sessionId, payload.queuedMessageId); } catch (error) { console.warn('[queue] queued auto-send failed:', error); } finally {