fix: send queued messages to the original session

Prevents queued messages from being sent to a newly opened session
Adds explicit session targeting for queued auto-send
Covers the behavior with a unit test
This commit is contained in:
Bohdan Triapitsyn
2026-05-25 00:29:52 +03:00
parent 16d8fcd408
commit 73ab36bc91
4 changed files with 173 additions and 88 deletions
@@ -3,6 +3,7 @@ import type { Agent } from '@opencode-ai/sdk/v2';
import type { QueuedMessage } from '../stores/messageQueueStore'; import type { QueuedMessage } from '../stores/messageQueueStore';
let visibleAgents: Agent[] = []; let visibleAgents: Agent[] = [];
const sendMessageCalls: unknown[][] = [];
const getVisibleAgentsMock = mock(() => visibleAgents); const getVisibleAgentsMock = mock(() => visibleAgents);
@@ -14,11 +15,24 @@ mock.module('@/stores/useConfigStore', () => ({
}, },
})); }));
import { buildQueuedAutoSendPayload } from './useQueuedMessageAutoSend'; mock.module('@/sync/session-ui-store', () => ({
useSessionUIStore: {
getState: () => ({
sendMessage: (...args: unknown[]) => {
sendMessageCalls.push(args);
return Promise.resolve();
},
sessionAbortFlags: new Map(),
}),
},
}));
import { buildQueuedAutoSendPayload, sendQueuedAutoSendPayload } from './useQueuedMessageAutoSend';
describe('buildQueuedAutoSendPayload', () => { describe('buildQueuedAutoSendPayload', () => {
beforeEach(() => { beforeEach(() => {
visibleAgents = []; visibleAgents = [];
sendMessageCalls.length = 0;
}); });
test('returns only the first queued message for auto-send', () => { test('returns only the first queued message for auto-send', () => {
@@ -101,4 +115,36 @@ describe('buildQueuedAutoSendPayload', () => {
expect(payload?.primaryAttachments).toHaveLength(1); expect(payload?.primaryAttachments).toHaveLength(1);
expect(payload?.primaryAttachments[0]?.filename).toBe('notes.txt'); expect(payload?.primaryAttachments[0]?.filename).toBe('notes.txt');
}); });
test('auto-send targets the queued session explicitly', async () => {
const payload = buildQueuedAutoSendPayload([
{
id: 'queued-1',
content: 'queued message',
createdAt: 1,
},
]);
expect(payload).not.toBeNull();
await sendQueuedAutoSendPayload('session-original', payload!, {
providerID: 'provider-1',
modelID: 'model-1',
agent: 'agent-1',
variant: 'variant-1',
});
expect(sendMessageCalls.length).toBe(1);
expect(sendMessageCalls[0]).toEqual([
'queued message',
'provider-1',
'model-1',
'agent-1',
[],
undefined,
undefined,
'variant-1',
'normal',
{ sessionId: 'session-original' },
]);
});
}); });
@@ -38,6 +38,33 @@ export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => {
}; };
}; };
type QueuedAutoSendPayload = NonNullable<ReturnType<typeof buildQueuedAutoSendPayload>>;
type ResolvedQueuedSendConfig = {
providerID: string;
modelID: string;
agent?: string;
variant?: string;
};
export const sendQueuedAutoSendPayload = (
sessionId: string,
payload: QueuedAutoSendPayload,
resolved: ResolvedQueuedSendConfig,
) => {
return useSessionUIStore.getState().sendMessage(
payload.primaryText,
resolved.providerID,
resolved.modelID,
resolved.agent,
payload.primaryAttachments,
payload.agentMentionName,
undefined,
resolved.variant,
'normal',
{ sessionId },
);
};
const resolveSessionSendConfig = (sessionId: string) => { const resolveSessionSendConfig = (sessionId: string) => {
const context = useContextStore.getState(); const context = useContextStore.getState();
const config = useConfigStore.getState(); const config = useConfigStore.getState();
@@ -128,17 +155,12 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
inFlightSessionsRef.current.add(sessionId); inFlightSessionsRef.current.add(sessionId);
try { try {
await useSessionUIStore.getState().sendMessage( await sendQueuedAutoSendPayload(sessionId, payload, {
payload.primaryText, providerID: resolved.providerID,
resolved.providerID, modelID: resolved.modelID,
resolved.modelID, agent: resolved.agent,
resolved.agent, variant: resolved.variant,
payload.primaryAttachments, });
payload.agentMentionName,
undefined,
resolved.variant,
'normal'
);
const removeFromQueue = useMessageQueueStore.getState().removeFromQueue; const removeFromQueue = useMessageQueueStore.getState().removeFromQueue;
removeFromQueue(sessionId, payload.queuedMessageId); removeFromQueue(sessionId, payload.queuedMessageId);
+1 -1
View File
@@ -246,7 +246,7 @@ export interface SessionStore {
unshareSession: (id: string) => Promise<Session | null>; unshareSession: (id: string) => Promise<Session | null>;
setCurrentSession: (id: string | null) => void; setCurrentSession: (id: string | null) => void;
loadMessages: (sessionId: string, limit?: number) => Promise<void>; loadMessages: (sessionId: string, limit?: number) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell') => Promise<void>; sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell', options?: { sessionId?: string }) => Promise<void>;
abortCurrentOperation: (sessionIdOverride?: string) => Promise<void>; abortCurrentOperation: (sessionIdOverride?: string) => Promise<void>;
acknowledgeSessionAbort: (sessionId: string) => void; acknowledgeSessionAbort: (sessionId: string) => void;
armAbortPrompt: (durationMs?: number) => number | null; armAbortPrompt: (durationMs?: number) => number | null;
+92 -75
View File
@@ -63,6 +63,7 @@ export type { AttachedFile }
function routeMessage(params: { function routeMessage(params: {
sessionId: string sessionId: string
directory?: string | null
content: string content: string
providerID: string providerID: string
modelID: string modelID: string
@@ -73,74 +74,86 @@ function routeMessage(params: {
files?: Array<{ type: "file"; mime: string; url: string; filename: string }> files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }> additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }>
}): Promise<void> { }): Promise<void> {
if (params.inputMode === "shell") { const run = (): Promise<void> => {
const sdk = opencodeClient.getSdkClient() if (params.inputMode === "shell") {
const dir = opencodeClient.getDirectory() || undefined const sdk = opencodeClient.getSdkClient()
return sdk.session.shell({ const dir = opencodeClient.getDirectory() || undefined
sessionID: params.sessionId, return sdk.session.shell({
directory: dir, sessionID: params.sessionId,
agent: params.agent, directory: dir,
model: { providerID: params.providerID, modelID: params.modelID },
command: params.content,
}).then(() => {})
}
// Slash commands — fire and forget, SSE delivers messages and status
if (params.content.startsWith("/")) {
const [head, ...tail] = params.content.split(" ")
const cmdName = head.slice(1)
const dirState = getDirectoryState()
const syncCommands = dirState?.command ?? []
const storeCommands = useCommandsStore.getState().commands
const isCommand = syncCommands.find((c) => c.name === cmdName)
|| storeCommands.find((c) => c.name === cmdName)
if (isCommand) {
return optimisticSend({
sessionId: params.sessionId,
content: params.content,
providerID: params.providerID,
modelID: params.modelID,
agent: params.agent, agent: params.agent,
files: params.files, model: { providerID: params.providerID, modelID: params.modelID },
send: (messageID) => opencodeClient.sendCommand({ command: params.content,
id: params.sessionId, }).then(() => {})
}
// Slash commands — fire and forget, SSE delivers messages and status
if (params.content.startsWith("/")) {
const [head, ...tail] = params.content.split(" ")
const cmdName = head.slice(1)
const dirState = getDirectoryState(params.directory ?? undefined)
const syncCommands = dirState?.command ?? []
const storeCommands = useCommandsStore.getState().commands
const isCommand = syncCommands.find((c) => c.name === cmdName)
|| storeCommands.find((c) => c.name === cmdName)
if (isCommand) {
return optimisticSend({
sessionId: params.sessionId,
content: params.content,
providerID: params.providerID, providerID: params.providerID,
modelID: params.modelID, modelID: params.modelID,
command: cmdName,
arguments: tail.join(" "),
agent: params.agent, agent: params.agent,
variant: params.variant,
files: params.files, files: params.files,
messageId: messageID, send: (messageID) => opencodeClient.sendCommand({
}).then(() => {}), id: params.sessionId,
}) providerID: params.providerID,
modelID: params.modelID,
command: cmdName,
arguments: tail.join(" "),
agent: params.agent,
variant: params.variant,
files: params.files,
messageId: messageID,
}).then(() => {}),
})
}
} }
}
// Normal prompt — optimistic insert so message appears instantly // Normal prompt — optimistic insert so message appears instantly
return optimisticSend({ return optimisticSend({
sessionId: params.sessionId, sessionId: params.sessionId,
content: params.content, content: params.content,
providerID: params.providerID,
modelID: params.modelID,
agent: params.agent,
files: params.files,
send: (messageID) => opencodeClient.sendMessage({
id: params.sessionId,
providerID: params.providerID, providerID: params.providerID,
modelID: params.modelID, modelID: params.modelID,
text: params.content,
agent: params.agent, agent: params.agent,
agentMentions: params.agentMentionName ? [{ name: params.agentMentionName }] : undefined,
variant: params.variant,
files: params.files, files: params.files,
additionalParts: params.additionalParts, send: (messageID) => opencodeClient.sendMessage({
messageId: messageID, id: params.sessionId,
}).then(() => {}), providerID: params.providerID,
}) modelID: params.modelID,
text: params.content,
agent: params.agent,
agentMentions: params.agentMentionName ? [{ name: params.agentMentionName }] : undefined,
variant: params.variant,
files: params.files,
additionalParts: params.additionalParts,
messageId: messageID,
}).then(() => {}),
})
}
if (params.directory !== undefined) {
return opencodeClient.withDirectory(params.directory, run)
}
return run()
}
type SendMessageOptions = {
sessionId?: string
} }
function notifyMessageSent(sessionId: string): void { function notifyMessageSent(sessionId: string): void {
@@ -239,6 +252,7 @@ export type SessionUIState = {
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>,
variant?: string, variant?: string,
inputMode?: "normal" | "shell", inputMode?: "normal" | "shell",
options?: SendMessageOptions,
) => Promise<void> ) => Promise<void>
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null> createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>
@@ -703,9 +717,10 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>,
variant?: string, variant?: string,
inputMode?: "normal" | "shell", inputMode?: "normal" | "shell",
options?: SendMessageOptions,
) => { ) => {
// Clear non-Git changed-files bar on new user message for current session // Clear non-Git changed-files bar on new user message for current session
const sid = get().currentSessionId; const sid = options?.sessionId ?? get().currentSessionId;
if (sid) { if (sid) {
const map = new Map(get().pendingChangesBarDismissed); const map = new Map(get().pendingChangesBarDismissed);
map.delete(sid); map.delete(sid);
@@ -716,7 +731,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const trimmedAgent = typeof agent === "string" && agent.trim().length > 0 ? agent.trim() : undefined const trimmedAgent = typeof agent === "string" && agent.trim().length > 0 ? agent.trim() : undefined
// ---- New session from draft ---- // ---- New session from draft ----
if (draft?.open) { if (!options?.sessionId && draft?.open) {
const draftTargetFolderId = draft.targetFolderId const draftTargetFolderId = draft.targetFolderId
let draftDirectoryOverride = draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null let draftDirectoryOverride = draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null
const draftProjectId = draft.selectedProjectId ?? null const draftProjectId = draft.selectedProjectId ?? null
@@ -788,6 +803,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
await routeMessage({ await routeMessage({
sessionId: created.id, sessionId: created.id,
directory: createdDirectory,
content, content,
providerID, providerID,
modelID, modelID,
@@ -811,24 +827,24 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
} }
// ---- Existing session ---- // ---- Existing session ----
const currentSessionId = get().currentSessionId const targetSessionId = options?.sessionId ?? get().currentSessionId
const sessionAgentSelection = currentSessionId const sessionAgentSelection = targetSessionId
? useSelectionStore.getState().getSessionAgentSelection(currentSessionId) ? useSelectionStore.getState().getSessionAgentSelection(targetSessionId)
: null : null
const configAgentName = useConfigStore.getState().currentAgentName const configAgentName = useConfigStore.getState().currentAgentName
const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined
if (currentSessionId && effectiveAgent) { if (targetSessionId && effectiveAgent) {
useSelectionStore.getState().saveSessionAgentSelection(currentSessionId, effectiveAgent) useSelectionStore.getState().saveSessionAgentSelection(targetSessionId, effectiveAgent)
useSelectionStore.getState().saveAgentModelVariantForSession(currentSessionId, effectiveAgent, providerID, modelID, variant) useSelectionStore.getState().saveAgentModelVariantForSession(targetSessionId, effectiveAgent, providerID, modelID, variant)
} }
if (currentSessionId) { if (targetSessionId) {
const viewportState = useViewportStore.getState() const viewportState = useViewportStore.getState()
const memState = viewportState.sessionMemoryState.get(currentSessionId) const memState = viewportState.sessionMemoryState.get(targetSessionId)
if (!memState || !memState.lastUserMessageAt) { if (!memState || !memState.lastUserMessageAt) {
const newMemState = new Map(viewportState.sessionMemoryState) const newMemState = new Map(viewportState.sessionMemoryState)
newMemState.set(currentSessionId, { newMemState.set(targetSessionId, {
viewportAnchor: 0, viewportAnchor: 0,
isStreaming: false, isStreaming: false,
lastAccessedAt: Date.now(), lastAccessedAt: Date.now(),
@@ -840,19 +856,19 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
} }
} }
const currentSessionDirectory = currentSessionId const currentSessionDirectory = targetSessionId
? normalizePath(get().getDirectoryForSession(currentSessionId)) ? normalizePath(get().getDirectoryForSession(targetSessionId))
: null : null
if (currentSessionDirectory) { if (currentSessionDirectory) {
await waitForWorktreeBootstrap(currentSessionDirectory) await waitForWorktreeBootstrap(currentSessionDirectory)
} }
if (currentSessionId) { if (targetSessionId) {
notifyMessageSent(currentSessionId) notifyMessageSent(targetSessionId)
} }
if (currentSessionId) { if (targetSessionId) {
markPendingUserSendAnimation(currentSessionId) markPendingUserSendAnimation(targetSessionId)
} }
const files = attachments?.map((a) => ({ const files = attachments?.map((a) => ({
@@ -863,7 +879,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
})) }))
await routeMessage({ await routeMessage({
sessionId: currentSessionId || "", sessionId: targetSessionId || "",
directory: currentSessionDirectory,
content, content,
providerID, providerID,
modelID, modelID,