diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index ba493f55..38871269 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -936,10 +936,18 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setPrPickerOpen(true); }, []); + const getSubmitErrorMessage = (error: unknown, fallback: string) => { + const message = error instanceof Error ? error.message : ''; + return message.toLowerCase().includes('runtime changed') + ? t('chat.chatInput.toast.messageSendFailed') + : message || fallback; + }; + const handleSubmit = async (options?: SubmitOptions) => { const queuedOnly = options?.queuedOnly ?? false; const queuedMessageId = options?.queuedMessageId; const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; + const capturedTarget = messageQueueTarget; const inputSnapshot = options?.presetText != null ? { message: options.presetText, @@ -1012,7 +1020,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } - const sendMessageOptions = delivery ? { delivery } : undefined; + const sendMessageOptions = capturedTarget + ? { target: capturedTarget, ...(delivery ? { delivery } : {}) } + : delivery ? { delivery } : undefined; // Inline review comments and synthetic context are consumed before // assembly so a failed send can restore exactly what it took. @@ -1058,10 +1068,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (outgoing.isEmpty) return; // Clear queue and input - if (messageQueueTarget && queuedMessageId) { - removeFromQueue(messageQueueTarget, queuedMessageId); - } else if (messageQueueTarget && hasQueuedMessages) { - clearQueue(messageQueueTarget); + if (capturedTarget && queuedMessageId) { + removeFromQueue(capturedTarget, queuedMessageId); + } else if (capturedTarget && hasQueuedMessages) { + clearQueue(capturedTarget); } if (!queuedOnly) { setMessage(''); @@ -1111,7 +1121,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined; await opencodeClient.summarizeSession(currentSessionId, currentProviderId, currentModelId, compactDirectory); } catch (error) { - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed')); + toast.error(getSubmitErrorMessage(error, t('chat.chatInput.toast.compactFailed'))); } return; } @@ -1143,15 +1153,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ); scrollToBottom?.(); } catch (error) { - toast.error(error instanceof Error ? error.message : t(command.errorToastKey)); + toast.error(getSubmitErrorMessage(error, t(command.errorToastKey))); } return; } } - const currentSessionDirectory = currentSessionId - ? useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory - : currentDirectory; + const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory; const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false); if (shouldAddResponseStyle) { const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null); @@ -1258,6 +1266,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } + if (normalized.includes('runtime changed')) { + if (allAttachments.length > 0) { + useInputStore.getState().setAttachedFiles(allAttachments); + } + toast.error(t('chat.chatInput.toast.messageSendFailed')); + return; + } + if (allAttachments.length > 0) { useInputStore.getState().setAttachedFiles(allAttachments); } diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts index f5a6a343..c6438e36 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts @@ -272,7 +272,11 @@ describe('buildQueuedAutoSendPayload', () => { ]); expect(payload).not.toBeNull(); - await sendQueuedAutoSendPayload('session-original', '/repo', payload!, { + await sendQueuedAutoSendPayload({ + runtimeKey: 'runtime-original', + sessionId: 'session-original', + directory: '/repo', + }, payload!, { providerID: 'provider-1', modelID: 'model-1', agent: 'agent-1', @@ -290,7 +294,13 @@ describe('buildQueuedAutoSendPayload', () => { undefined, 'variant-1', 'normal', - { sessionId: 'session-original', directory: '/repo' }, + { + target: { + runtimeKey: 'runtime-original', + sessionId: 'session-original', + directory: '/repo', + }, + }, ]); }); }); diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index 38e95713..87bee797 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -103,8 +103,7 @@ type ResolvedQueuedSendConfig = { }; export const sendQueuedAutoSendPayload = ( - sessionId: string, - directory: string, + target: MessageQueueTarget, payload: QueuedAutoSendPayload, resolved: ResolvedQueuedSendConfig, ) => { @@ -118,7 +117,7 @@ export const sendQueuedAutoSendPayload = ( undefined, resolved.variant, 'normal', - { sessionId, directory }, + { target }, ); }; @@ -295,7 +294,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? useMessageQueueStore.getState().markSending(target, payload.queuedMessageId); try { - await sendQueuedAutoSendPayload(sessionId, target.directory, payload, { + await sendQueuedAutoSendPayload(target, payload, { providerID: resolved.providerID, modelID: resolved.modelID, agent: resolved.agent, diff --git a/packages/ui/src/lib/opencode/client.test.ts b/packages/ui/src/lib/opencode/client.test.ts index 4bd0d712..a691a79d 100644 --- a/packages/ui/src/lib/opencode/client.test.ts +++ b/packages/ui/src/lib/opencode/client.test.ts @@ -6,6 +6,7 @@ type ConfigResponse = { data: Record }; const configResolvers: Array<(response: ConfigResponse) => void> = []; let configCalls = 0; +let runtimeKey = 'test-runtime'; const promptAsyncCalls: unknown[][] = []; const promptAsyncResults: Array = []; @@ -44,7 +45,7 @@ mock.module('@/lib/runtime-url', () => ({ mock.module('@/lib/runtime-switch', () => ({ getRuntimeApiBaseUrl: mock(() => ''), - getRuntimeKey: mock(() => 'test-runtime'), + getRuntimeKey: mock(() => runtimeKey), })); mock.module('@/lib/runtime-fetch', () => ({ @@ -60,6 +61,7 @@ mock.module('@/lib/startupTrace', () => ({ const { opencodeClient } = await import(`./client?cache-test=${Date.now()}`); beforeEach(() => { + runtimeKey = 'test-runtime'; promptAsyncCalls.length = 0; promptAsyncResults.length = 0; }); @@ -160,4 +162,34 @@ describe('opencodeClient prompt retry behavior', () => { expect(promptAsyncCalls.length).toBe(1); expect(error instanceof Error ? error.message : String(error)).toContain('Failed to send message (503)'); }); + + test('does not dispatch after the runtime changes while preparing attachments', async () => { + runtimeKey = 'runtime-a'; + const pending = opencodeClient.sendMessage({ + id: 'ses_runtime_race', + providerID: 'runtime-race-provider', + modelID: 'model-a', + text: 'hello', + runtimeKey: 'runtime-a', + files: [{ + type: 'file', + mime: 'text/markdown', + filename: 'notes.md', + url: 'data:text/markdown,hello', + }], + }); + + runtimeKey = 'runtime-b'; + + let error: unknown = null; + try { + await pending; + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect(error instanceof Error ? error.message : String(error)).toContain('runtime changed'); + expect(promptAsyncCalls).toHaveLength(0); + }); }); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 7b618f4a..7697b8a0 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -269,6 +269,12 @@ class OpencodeService { this.client = createRuntimeOpencodeClient({ baseUrl: this.baseUrl }); } + private assertRuntimeUnchanged(runtimeKey?: string): void { + if (runtimeKey && runtimeKey !== getRuntimeKey()) { + throw new Error('Message was not sent because the runtime changed.'); + } + } + getBaseUrl(): string { return this.baseUrl; } @@ -744,6 +750,7 @@ class OpencodeService { } async sendMessage(params: { + runtimeKey?: string; id: string; providerID: string; modelID: string; @@ -769,6 +776,8 @@ class OpencodeService { }; directory?: string | null; }): Promise { + this.assertRuntimeUnchanged(params.runtimeKey); + // Use the optimistic/client-generated ID as the real user message ID so SSE // can reconcile the echoed server message in-place. const messageId = params.messageId ?? ascendingId("msg"); @@ -852,6 +861,7 @@ class OpencodeService { } assertProviderCircuitClosed(params.providerID); + this.assertRuntimeUnchanged(params.runtimeKey); let response: Response; @@ -918,6 +928,7 @@ class OpencodeService { } async sendCommand(params: { + runtimeKey?: string; id: string; providerID: string; modelID: string; @@ -929,6 +940,8 @@ class OpencodeService { messageId?: string; directory?: string | null; }): Promise { + this.assertRuntimeUnchanged(params.runtimeKey); + const tempMessageId = params.messageId ?? ascendingId("msg"); const parts: FilePartInput[] = []; @@ -939,6 +952,7 @@ class OpencodeService { } const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory; + this.assertRuntimeUnchanged(params.runtimeKey); const response = await this.client.session.command({ sessionID: params.id, @@ -968,6 +982,7 @@ class OpencodeService { } async shellSession(params: { + runtimeKey?: string; sessionId: string; command: string; agent: string; @@ -975,6 +990,7 @@ class OpencodeService { messageId?: string; directory?: string | null; }): Promise<{ info: Message; parts: Part[] }> { + this.assertRuntimeUnchanged(params.runtimeKey); const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory; const response = await this.client.session.shell({ sessionID: params.sessionId, diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 50202a71..e379a4e5 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -244,8 +244,9 @@ Rules: 2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct. 3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls. 4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected. -5. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session. -6. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message. +5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime. +6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session. +7. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message. Examples of global-store updates performed in `session-actions.ts`: diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index d815b6dd..4c96b473 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -1027,7 +1027,7 @@ describe("optimisticSend target directory", () => { expect(targetStore.getState().part.msg_2).toEqual([revertedPart]) }) - test("allows callers to block final send when runtime changes after optimistic insert", async () => { + test("rolls back a captured send when the runtime changes after optimistic insert", async () => { const targetStore = createStore({}) const childStores = createChildStores([["/target/project", targetStore]]) let optimisticAdd: OptimisticAddCall | null = null @@ -1052,15 +1052,15 @@ describe("optimisticSend target directory", () => { await optimisticSend({ sessionId: "session-race", directory: "/target/project", + runtimeKey: "runtime-a", content: "hello", providerID: "provider", modelID: "model", - beforeOptimisticInsert: () => { + onOptimisticInsert: () => { expect(getRuntimeKey()).toBe("runtime-a") + switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-b.test", runtimeKey: "runtime-b" }) }, send: async () => { - switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-b.test", runtimeKey: "runtime-b" }) - if (getRuntimeKey() !== "runtime-a") throw new Error("Auto-review stopped because the runtime changed.") finalSendCalled = true }, }) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index aa8cdb10..23f1f943 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -1257,6 +1257,7 @@ function ascendingId(prefix: string): string { * handles deduplication when the server echoes back the real message. */ export async function optimisticSend(input: { + runtimeKey?: string sessionId: string content: string providerID: string @@ -1273,9 +1274,20 @@ export async function optimisticSend(input: { if (!_optimisticAdd || !_optimisticRemove) { throw new Error("Optimistic refs not set — is useSync() mounted?") } + const optimisticAdd = _optimisticAdd + const optimisticRemove = _optimisticRemove + const optimisticConfirm = _optimisticConfirm + const assertRuntimeUnchanged = () => { + if (input.runtimeKey && input.runtimeKey !== getRuntimeKey()) { + throw new Error("Message was not sent because the runtime changed.") + } + } + + assertRuntimeUnchanged() await waitForConnectionOrThrow() input.beforeOptimisticInsert?.() + assertRuntimeUnchanged() const targetDirectory = input.directory ?? dir() const store = targetDirectory ? dirStoreForDirectory(targetDirectory) : dirStore() @@ -1341,7 +1353,7 @@ export async function optimisticSend(input: { } as unknown as Message // Insert into store + register in shadow Map (for mergeOptimisticPage cleanup) - _optimisticAdd({ + optimisticAdd({ sessionID: input.sessionId, directory: targetDirectory, message: optimisticMessage, @@ -1359,6 +1371,7 @@ export async function optimisticSend(input: { }) try { + assertRuntimeUnchanged() await input.send(messageID) } catch (error) { const status = getErrorStatus(error) @@ -1369,7 +1382,7 @@ export async function optimisticSend(input: { if (acceptedRecords) { materializeConfirmedSendRecords(store, input.sessionId, messageID, acceptedRecords) - _optimisticConfirm?.({ + optimisticConfirm?.({ sessionID: input.sessionId, directory: targetDirectory, messageID, @@ -1396,7 +1409,7 @@ export async function optimisticSend(input: { console.warn("[session-actions] prompt send rejected; rolling back optimistic message", failureRecord) // Rollback via optimistic infrastructure - _optimisticRemove({ + optimisticRemove({ sessionID: input.sessionId, directory: targetDirectory, messageID, diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index ce4cf88f..cd7dcdd1 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -8,6 +8,7 @@ import { setActionRefs, setOptimisticRefs } from './session-actions'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { useCommandsStore } from '@/stores/useCommandsStore'; import { useConfigStore } from '@/stores/useConfigStore'; +import { getRuntimeKey } from '@/lib/runtime-switch'; /** * Unit tests for session worktree routing through the authoritative store. @@ -228,6 +229,85 @@ describe('routeMessage directory scoping', () => { }); }); +describe('sendMessage captured target', () => { + let originalSendMessage; + const calls = []; + + beforeEach(() => { + calls.length = 0; + const childStore = { + getState: () => ({ session: [], message: {}, part: {}, session_status: {} }), + setState: () => {}, + }; + const childStores = { + children: new Map(), + ensureChild: () => childStore, + getChild: () => childStore, + }; + setActionRefs(opencodeClient, childStores, () => '/current/project'); + setOptimisticRefs(() => {}, () => {}); + useConfigStore.setState({ isConnected: true }); + useSessionUIStore.setState({ + currentSessionId: 'session-current', + currentSessionDirectory: '/current/project', + newSessionDraft: { open: false, directoryOverride: null, parentID: null }, + }); + + originalSendMessage = opencodeClient.sendMessage; + opencodeClient.sendMessage = async (params) => { + calls.push(params); + return 'msg'; + }; + }); + + afterEach(() => { + opencodeClient.sendMessage = originalSendMessage; + }); + + const sendToTarget = (target) => useSessionUIStore.getState().sendMessage( + 'queued message', + 'provider-a', + 'model-a', + undefined, + undefined, + undefined, + undefined, + undefined, + 'normal', + { target }, + ); + + test('uses the target captured before the active session changes', async () => { + await sendToTarget({ + runtimeKey: getRuntimeKey(), + sessionId: 'session-captured', + directory: '/captured/project', + }); + + expect(calls).toHaveLength(1); + expect(calls[0].runtimeKey).toBe(getRuntimeKey()); + expect(calls[0].id).toBe('session-captured'); + expect(calls[0].directory).toBe('/captured/project'); + }); + + test('does not send a captured target through a different runtime', async () => { + let error = null; + try { + await sendToTarget({ + runtimeKey: `${getRuntimeKey()}-stale`, + sessionId: 'session-captured', + directory: '/captured/project', + }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain('runtime changed'); + expect(calls).toHaveLength(0); + }); +}); + describe('slash-command goal objectives', () => { test('expands every $ARGUMENTS reference from the authoritative command template', () => { expect(expandSlashCommandGoalObjective('/issue--to-pr LIN-123 --draft', [{ @@ -370,7 +450,12 @@ describe('routeMessage skill invocation', () => { // Minimal optimistic + connection machinery so routeMessage can dispatch. const childStore = { - getState: () => ({ session_status: {} }), + getState: () => ({ + session: [], + message: {}, + part: {}, + session_status: {}, + }), setState: () => {}, }; const childStores = { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 4ecdb87d..429dcb4f 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -122,6 +122,7 @@ export function expandSlashCommandGoalObjective(content: string, commands: GoalC // --------------------------------------------------------------------------- export function routeMessage(params: { + runtimeKey?: string sessionId: string directory?: string | null content: string @@ -138,6 +139,7 @@ export function routeMessage(params: { const requestDirectory = params.directory ?? undefined if (params.inputMode === "shell") { return opencodeClient.shellSession({ + runtimeKey: params.runtimeKey, sessionId: params.sessionId, directory: requestDirectory, agent: params.agent ?? "", @@ -166,6 +168,7 @@ export function routeMessage(params: { if (isCommand) { return optimisticSend({ + runtimeKey: params.runtimeKey, sessionId: params.sessionId, content: params.content, providerID: params.providerID, @@ -174,6 +177,7 @@ export function routeMessage(params: { directory: requestDirectory, files: params.files, send: (messageID) => opencodeClient.sendCommand({ + runtimeKey: params.runtimeKey, id: params.sessionId, providerID: params.providerID, modelID: params.modelID, @@ -191,6 +195,7 @@ export function routeMessage(params: { // Normal prompt — optimistic insert so message appears instantly return optimisticSend({ + runtimeKey: params.runtimeKey, sessionId: params.sessionId, content: params.content, providerID: params.providerID, @@ -199,6 +204,7 @@ export function routeMessage(params: { directory: requestDirectory, files: params.files, send: (messageID) => opencodeClient.sendMessage({ + runtimeKey: params.runtimeKey, id: params.sessionId, providerID: params.providerID, modelID: params.modelID, @@ -215,7 +221,14 @@ export function routeMessage(params: { }) } +type CapturedSendTarget = { + runtimeKey: string + sessionId: string + directory: string +} + type SendMessageOptions = { + target?: CapturedSendTarget sessionId?: string directory?: string delivery?: 'steer' @@ -1198,8 +1211,13 @@ export const useSessionUIStore = create()((set, get) => ({ inputMode?: "normal" | "shell", options?: SendMessageOptions, ) => { + const capturedTarget = options?.target + if (capturedTarget && capturedTarget.runtimeKey !== getRuntimeKey()) { + throw new Error("Message was not sent because the runtime changed.") + } + // Clear non-Git changed-files bar on new user message for current session - const sid = options?.sessionId ?? get().currentSessionId; + const sid = capturedTarget?.sessionId ?? options?.sessionId ?? get().currentSessionId; if (sid) { const map = new Map(get().pendingChangesBarDismissed); map.delete(sid); @@ -1258,7 +1276,7 @@ export const useSessionUIStore = create()((set, get) => ({ } // ---- New session from draft ---- - if (!options?.sessionId && draft?.open) { + if (!capturedTarget && !options?.sessionId && draft?.open) { const createdDraftSession = await materializeOpenDraftSession({ providerID, modelID, @@ -1310,7 +1328,7 @@ export const useSessionUIStore = create()((set, get) => ({ } // ---- Existing session ---- - const targetSessionId = options?.sessionId ?? get().currentSessionId + const targetSessionId = capturedTarget?.sessionId ?? options?.sessionId ?? get().currentSessionId const sessionAgentSelection = targetSessionId ? useSelectionStore.getState().getSessionAgentSelection(targetSessionId) : null @@ -1345,7 +1363,7 @@ export const useSessionUIStore = create()((set, get) => ({ } const currentSessionDirectory = targetSessionId - ? normalizePath(options?.directory ?? get().getDirectoryForSession(targetSessionId)) + ? normalizePath(capturedTarget?.directory ?? options?.directory ?? get().getDirectoryForSession(targetSessionId)) : null if (targetSessionId) { notifyMessageSent(targetSessionId) @@ -1366,6 +1384,7 @@ export const useSessionUIStore = create()((set, get) => ({ await applyArmedGoal(targetSessionId, currentSessionDirectory) } await routeMessage({ + runtimeKey: capturedTarget?.runtimeKey, sessionId: targetSessionId || "", directory: currentSessionDirectory, content,