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:
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user