fix(chat): pin existing-session sends to captured target (#2424)

* fix(chat): pin sends to captured session

* fix(chat): handle runtime cancellation consistently
This commit is contained in:
Wsyjq
2026-08-07 00:46:21 +03:00
committed by GitHub
parent 834d2edb87
commit 70226149ce
10 changed files with 222 additions and 31 deletions
+26 -10
View File
@@ -936,10 +936,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setPrPickerOpen(true); 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 handleSubmit = async (options?: SubmitOptions) => {
const queuedOnly = options?.queuedOnly ?? false; const queuedOnly = options?.queuedOnly ?? false;
const queuedMessageId = options?.queuedMessageId; const queuedMessageId = options?.queuedMessageId;
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
const capturedTarget = messageQueueTarget;
const inputSnapshot = options?.presetText != null const inputSnapshot = options?.presetText != null
? { ? {
message: options.presetText, message: options.presetText,
@@ -1012,7 +1020,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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 // Inline review comments and synthetic context are consumed before
// assembly so a failed send can restore exactly what it took. // assembly so a failed send can restore exactly what it took.
@@ -1058,10 +1068,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (outgoing.isEmpty) return; if (outgoing.isEmpty) return;
// Clear queue and input // Clear queue and input
if (messageQueueTarget && queuedMessageId) { if (capturedTarget && queuedMessageId) {
removeFromQueue(messageQueueTarget, queuedMessageId); removeFromQueue(capturedTarget, queuedMessageId);
} else if (messageQueueTarget && hasQueuedMessages) { } else if (capturedTarget && hasQueuedMessages) {
clearQueue(messageQueueTarget); clearQueue(capturedTarget);
} }
if (!queuedOnly) { if (!queuedOnly) {
setMessage(''); setMessage('');
@@ -1111,7 +1121,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined; const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined;
await opencodeClient.summarizeSession(currentSessionId, currentProviderId, currentModelId, compactDirectory); await opencodeClient.summarizeSession(currentSessionId, currentProviderId, currentModelId, compactDirectory);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed')); toast.error(getSubmitErrorMessage(error, t('chat.chatInput.toast.compactFailed')));
} }
return; return;
} }
@@ -1143,15 +1153,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
); );
scrollToBottom?.(); scrollToBottom?.();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : t(command.errorToastKey)); toast.error(getSubmitErrorMessage(error, t(command.errorToastKey)));
} }
return; return;
} }
} }
const currentSessionDirectory = currentSessionId const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory;
? useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory
: currentDirectory;
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false); const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false);
if (shouldAddResponseStyle) { if (shouldAddResponseStyle) {
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null); const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
@@ -1258,6 +1266,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
return; 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) { if (allAttachments.length > 0) {
useInputStore.getState().setAttachedFiles(allAttachments); useInputStore.getState().setAttachedFiles(allAttachments);
} }
@@ -272,7 +272,11 @@ describe('buildQueuedAutoSendPayload', () => {
]); ]);
expect(payload).not.toBeNull(); expect(payload).not.toBeNull();
await sendQueuedAutoSendPayload('session-original', '/repo', payload!, { await sendQueuedAutoSendPayload({
runtimeKey: 'runtime-original',
sessionId: 'session-original',
directory: '/repo',
}, payload!, {
providerID: 'provider-1', providerID: 'provider-1',
modelID: 'model-1', modelID: 'model-1',
agent: 'agent-1', agent: 'agent-1',
@@ -290,7 +294,13 @@ describe('buildQueuedAutoSendPayload', () => {
undefined, undefined,
'variant-1', 'variant-1',
'normal', 'normal',
{ sessionId: 'session-original', directory: '/repo' }, {
target: {
runtimeKey: 'runtime-original',
sessionId: 'session-original',
directory: '/repo',
},
},
]); ]);
}); });
}); });
@@ -103,8 +103,7 @@ type ResolvedQueuedSendConfig = {
}; };
export const sendQueuedAutoSendPayload = ( export const sendQueuedAutoSendPayload = (
sessionId: string, target: MessageQueueTarget,
directory: string,
payload: QueuedAutoSendPayload, payload: QueuedAutoSendPayload,
resolved: ResolvedQueuedSendConfig, resolved: ResolvedQueuedSendConfig,
) => { ) => {
@@ -118,7 +117,7 @@ export const sendQueuedAutoSendPayload = (
undefined, undefined,
resolved.variant, resolved.variant,
'normal', 'normal',
{ sessionId, directory }, { target },
); );
}; };
@@ -295,7 +294,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
useMessageQueueStore.getState().markSending(target, payload.queuedMessageId); useMessageQueueStore.getState().markSending(target, payload.queuedMessageId);
try { try {
await sendQueuedAutoSendPayload(sessionId, target.directory, payload, { await sendQueuedAutoSendPayload(target, payload, {
providerID: resolved.providerID, providerID: resolved.providerID,
modelID: resolved.modelID, modelID: resolved.modelID,
agent: resolved.agent, agent: resolved.agent,
+33 -1
View File
@@ -6,6 +6,7 @@ type ConfigResponse = { data: Record<string, unknown> };
const configResolvers: Array<(response: ConfigResponse) => void> = []; const configResolvers: Array<(response: ConfigResponse) => void> = [];
let configCalls = 0; let configCalls = 0;
let runtimeKey = 'test-runtime';
const promptAsyncCalls: unknown[][] = []; const promptAsyncCalls: unknown[][] = [];
const promptAsyncResults: Array<unknown> = []; const promptAsyncResults: Array<unknown> = [];
@@ -44,7 +45,7 @@ mock.module('@/lib/runtime-url', () => ({
mock.module('@/lib/runtime-switch', () => ({ mock.module('@/lib/runtime-switch', () => ({
getRuntimeApiBaseUrl: mock(() => ''), getRuntimeApiBaseUrl: mock(() => ''),
getRuntimeKey: mock(() => 'test-runtime'), getRuntimeKey: mock(() => runtimeKey),
})); }));
mock.module('@/lib/runtime-fetch', () => ({ mock.module('@/lib/runtime-fetch', () => ({
@@ -60,6 +61,7 @@ mock.module('@/lib/startupTrace', () => ({
const { opencodeClient } = await import(`./client?cache-test=${Date.now()}`); const { opencodeClient } = await import(`./client?cache-test=${Date.now()}`);
beforeEach(() => { beforeEach(() => {
runtimeKey = 'test-runtime';
promptAsyncCalls.length = 0; promptAsyncCalls.length = 0;
promptAsyncResults.length = 0; promptAsyncResults.length = 0;
}); });
@@ -160,4 +162,34 @@ describe('opencodeClient prompt retry behavior', () => {
expect(promptAsyncCalls.length).toBe(1); expect(promptAsyncCalls.length).toBe(1);
expect(error instanceof Error ? error.message : String(error)).toContain('Failed to send message (503)'); 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);
});
}); });
+16
View File
@@ -269,6 +269,12 @@ class OpencodeService {
this.client = createRuntimeOpencodeClient({ baseUrl: this.baseUrl }); 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 { getBaseUrl(): string {
return this.baseUrl; return this.baseUrl;
} }
@@ -744,6 +750,7 @@ class OpencodeService {
} }
async sendMessage(params: { async sendMessage(params: {
runtimeKey?: string;
id: string; id: string;
providerID: string; providerID: string;
modelID: string; modelID: string;
@@ -769,6 +776,8 @@ class OpencodeService {
}; };
directory?: string | null; directory?: string | null;
}): Promise<string> { }): Promise<string> {
this.assertRuntimeUnchanged(params.runtimeKey);
// Use the optimistic/client-generated ID as the real user message ID so SSE // Use the optimistic/client-generated ID as the real user message ID so SSE
// can reconcile the echoed server message in-place. // can reconcile the echoed server message in-place.
const messageId = params.messageId ?? ascendingId("msg"); const messageId = params.messageId ?? ascendingId("msg");
@@ -852,6 +861,7 @@ class OpencodeService {
} }
assertProviderCircuitClosed(params.providerID); assertProviderCircuitClosed(params.providerID);
this.assertRuntimeUnchanged(params.runtimeKey);
let response: Response; let response: Response;
@@ -918,6 +928,7 @@ class OpencodeService {
} }
async sendCommand(params: { async sendCommand(params: {
runtimeKey?: string;
id: string; id: string;
providerID: string; providerID: string;
modelID: string; modelID: string;
@@ -929,6 +940,8 @@ class OpencodeService {
messageId?: string; messageId?: string;
directory?: string | null; directory?: string | null;
}): Promise<string> { }): Promise<string> {
this.assertRuntimeUnchanged(params.runtimeKey);
const tempMessageId = params.messageId ?? ascendingId("msg"); const tempMessageId = params.messageId ?? ascendingId("msg");
const parts: FilePartInput[] = []; const parts: FilePartInput[] = [];
@@ -939,6 +952,7 @@ class OpencodeService {
} }
const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory; const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory;
this.assertRuntimeUnchanged(params.runtimeKey);
const response = await this.client.session.command({ const response = await this.client.session.command({
sessionID: params.id, sessionID: params.id,
@@ -968,6 +982,7 @@ class OpencodeService {
} }
async shellSession(params: { async shellSession(params: {
runtimeKey?: string;
sessionId: string; sessionId: string;
command: string; command: string;
agent: string; agent: string;
@@ -975,6 +990,7 @@ class OpencodeService {
messageId?: string; messageId?: string;
directory?: string | null; directory?: string | null;
}): Promise<{ info: Message; parts: Part[] }> { }): Promise<{ info: Message; parts: Part[] }> {
this.assertRuntimeUnchanged(params.runtimeKey);
const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory; const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory;
const response = await this.client.session.shell({ const response = await this.client.session.shell({
sessionID: params.sessionId, sessionID: params.sessionId,
+3 -2
View File
@@ -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. 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. 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. 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. 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. 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. 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`: Examples of global-store updates performed in `session-actions.ts`:
+4 -4
View File
@@ -1027,7 +1027,7 @@ describe("optimisticSend target directory", () => {
expect(targetStore.getState().part.msg_2).toEqual([revertedPart]) 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 targetStore = createStore({})
const childStores = createChildStores([["/target/project", targetStore]]) const childStores = createChildStores([["/target/project", targetStore]])
let optimisticAdd: OptimisticAddCall | null = null let optimisticAdd: OptimisticAddCall | null = null
@@ -1052,15 +1052,15 @@ describe("optimisticSend target directory", () => {
await optimisticSend({ await optimisticSend({
sessionId: "session-race", sessionId: "session-race",
directory: "/target/project", directory: "/target/project",
runtimeKey: "runtime-a",
content: "hello", content: "hello",
providerID: "provider", providerID: "provider",
modelID: "model", modelID: "model",
beforeOptimisticInsert: () => { onOptimisticInsert: () => {
expect(getRuntimeKey()).toBe("runtime-a") expect(getRuntimeKey()).toBe("runtime-a")
switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-b.test", runtimeKey: "runtime-b" })
}, },
send: async () => { 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 finalSendCalled = true
}, },
}) })
+16 -3
View File
@@ -1257,6 +1257,7 @@ function ascendingId(prefix: string): string {
* handles deduplication when the server echoes back the real message. * handles deduplication when the server echoes back the real message.
*/ */
export async function optimisticSend(input: { export async function optimisticSend(input: {
runtimeKey?: string
sessionId: string sessionId: string
content: string content: string
providerID: string providerID: string
@@ -1273,9 +1274,20 @@ export async function optimisticSend(input: {
if (!_optimisticAdd || !_optimisticRemove) { if (!_optimisticAdd || !_optimisticRemove) {
throw new Error("Optimistic refs not set — is useSync() mounted?") 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() await waitForConnectionOrThrow()
input.beforeOptimisticInsert?.() input.beforeOptimisticInsert?.()
assertRuntimeUnchanged()
const targetDirectory = input.directory ?? dir() const targetDirectory = input.directory ?? dir()
const store = targetDirectory ? dirStoreForDirectory(targetDirectory) : dirStore() const store = targetDirectory ? dirStoreForDirectory(targetDirectory) : dirStore()
@@ -1341,7 +1353,7 @@ export async function optimisticSend(input: {
} as unknown as Message } as unknown as Message
// Insert into store + register in shadow Map (for mergeOptimisticPage cleanup) // Insert into store + register in shadow Map (for mergeOptimisticPage cleanup)
_optimisticAdd({ optimisticAdd({
sessionID: input.sessionId, sessionID: input.sessionId,
directory: targetDirectory, directory: targetDirectory,
message: optimisticMessage, message: optimisticMessage,
@@ -1359,6 +1371,7 @@ export async function optimisticSend(input: {
}) })
try { try {
assertRuntimeUnchanged()
await input.send(messageID) await input.send(messageID)
} catch (error) { } catch (error) {
const status = getErrorStatus(error) const status = getErrorStatus(error)
@@ -1369,7 +1382,7 @@ export async function optimisticSend(input: {
if (acceptedRecords) { if (acceptedRecords) {
materializeConfirmedSendRecords(store, input.sessionId, messageID, acceptedRecords) materializeConfirmedSendRecords(store, input.sessionId, messageID, acceptedRecords)
_optimisticConfirm?.({ optimisticConfirm?.({
sessionID: input.sessionId, sessionID: input.sessionId,
directory: targetDirectory, directory: targetDirectory,
messageID, messageID,
@@ -1396,7 +1409,7 @@ export async function optimisticSend(input: {
console.warn("[session-actions] prompt send rejected; rolling back optimistic message", failureRecord) console.warn("[session-actions] prompt send rejected; rolling back optimistic message", failureRecord)
// Rollback via optimistic infrastructure // Rollback via optimistic infrastructure
_optimisticRemove({ optimisticRemove({
sessionID: input.sessionId, sessionID: input.sessionId,
directory: targetDirectory, directory: targetDirectory,
messageID, messageID,
+86 -1
View File
@@ -8,6 +8,7 @@ import { setActionRefs, setOptimisticRefs } from './session-actions';
import { useSkillsStore } from '@/stores/useSkillsStore'; import { useSkillsStore } from '@/stores/useSkillsStore';
import { useCommandsStore } from '@/stores/useCommandsStore'; import { useCommandsStore } from '@/stores/useCommandsStore';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
/** /**
* Unit tests for session worktree routing through the authoritative store. * 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', () => { describe('slash-command goal objectives', () => {
test('expands every $ARGUMENTS reference from the authoritative command template', () => { test('expands every $ARGUMENTS reference from the authoritative command template', () => {
expect(expandSlashCommandGoalObjective('/issue--to-pr LIN-123 --draft', [{ expect(expandSlashCommandGoalObjective('/issue--to-pr LIN-123 --draft', [{
@@ -370,7 +450,12 @@ describe('routeMessage skill invocation', () => {
// Minimal optimistic + connection machinery so routeMessage can dispatch. // Minimal optimistic + connection machinery so routeMessage can dispatch.
const childStore = { const childStore = {
getState: () => ({ session_status: {} }), getState: () => ({
session: [],
message: {},
part: {},
session_status: {},
}),
setState: () => {}, setState: () => {},
}; };
const childStores = { const childStores = {
+23 -4
View File
@@ -122,6 +122,7 @@ export function expandSlashCommandGoalObjective(content: string, commands: GoalC
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function routeMessage(params: { export function routeMessage(params: {
runtimeKey?: string
sessionId: string sessionId: string
directory?: string | null directory?: string | null
content: string content: string
@@ -138,6 +139,7 @@ export function routeMessage(params: {
const requestDirectory = params.directory ?? undefined const requestDirectory = params.directory ?? undefined
if (params.inputMode === "shell") { if (params.inputMode === "shell") {
return opencodeClient.shellSession({ return opencodeClient.shellSession({
runtimeKey: params.runtimeKey,
sessionId: params.sessionId, sessionId: params.sessionId,
directory: requestDirectory, directory: requestDirectory,
agent: params.agent ?? "", agent: params.agent ?? "",
@@ -166,6 +168,7 @@ export function routeMessage(params: {
if (isCommand) { if (isCommand) {
return optimisticSend({ return optimisticSend({
runtimeKey: params.runtimeKey,
sessionId: params.sessionId, sessionId: params.sessionId,
content: params.content, content: params.content,
providerID: params.providerID, providerID: params.providerID,
@@ -174,6 +177,7 @@ export function routeMessage(params: {
directory: requestDirectory, directory: requestDirectory,
files: params.files, files: params.files,
send: (messageID) => opencodeClient.sendCommand({ send: (messageID) => opencodeClient.sendCommand({
runtimeKey: params.runtimeKey,
id: params.sessionId, id: params.sessionId,
providerID: params.providerID, providerID: params.providerID,
modelID: params.modelID, modelID: params.modelID,
@@ -191,6 +195,7 @@ export function routeMessage(params: {
// Normal prompt — optimistic insert so message appears instantly // Normal prompt — optimistic insert so message appears instantly
return optimisticSend({ return optimisticSend({
runtimeKey: params.runtimeKey,
sessionId: params.sessionId, sessionId: params.sessionId,
content: params.content, content: params.content,
providerID: params.providerID, providerID: params.providerID,
@@ -199,6 +204,7 @@ export function routeMessage(params: {
directory: requestDirectory, directory: requestDirectory,
files: params.files, files: params.files,
send: (messageID) => opencodeClient.sendMessage({ send: (messageID) => opencodeClient.sendMessage({
runtimeKey: params.runtimeKey,
id: params.sessionId, id: params.sessionId,
providerID: params.providerID, providerID: params.providerID,
modelID: params.modelID, modelID: params.modelID,
@@ -215,7 +221,14 @@ export function routeMessage(params: {
}) })
} }
type CapturedSendTarget = {
runtimeKey: string
sessionId: string
directory: string
}
type SendMessageOptions = { type SendMessageOptions = {
target?: CapturedSendTarget
sessionId?: string sessionId?: string
directory?: string directory?: string
delivery?: 'steer' delivery?: 'steer'
@@ -1198,8 +1211,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
inputMode?: "normal" | "shell", inputMode?: "normal" | "shell",
options?: SendMessageOptions, 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 // 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) { if (sid) {
const map = new Map(get().pendingChangesBarDismissed); const map = new Map(get().pendingChangesBarDismissed);
map.delete(sid); map.delete(sid);
@@ -1258,7 +1276,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
} }
// ---- New session from draft ---- // ---- New session from draft ----
if (!options?.sessionId && draft?.open) { if (!capturedTarget && !options?.sessionId && draft?.open) {
const createdDraftSession = await materializeOpenDraftSession({ const createdDraftSession = await materializeOpenDraftSession({
providerID, providerID,
modelID, modelID,
@@ -1310,7 +1328,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
} }
// ---- Existing session ---- // ---- Existing session ----
const targetSessionId = options?.sessionId ?? get().currentSessionId const targetSessionId = capturedTarget?.sessionId ?? options?.sessionId ?? get().currentSessionId
const sessionAgentSelection = targetSessionId const sessionAgentSelection = targetSessionId
? useSelectionStore.getState().getSessionAgentSelection(targetSessionId) ? useSelectionStore.getState().getSessionAgentSelection(targetSessionId)
: null : null
@@ -1345,7 +1363,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
} }
const currentSessionDirectory = targetSessionId const currentSessionDirectory = targetSessionId
? normalizePath(options?.directory ?? get().getDirectoryForSession(targetSessionId)) ? normalizePath(capturedTarget?.directory ?? options?.directory ?? get().getDirectoryForSession(targetSessionId))
: null : null
if (targetSessionId) { if (targetSessionId) {
notifyMessageSent(targetSessionId) notifyMessageSent(targetSessionId)
@@ -1366,6 +1384,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
await applyArmedGoal(targetSessionId, currentSessionDirectory) await applyArmedGoal(targetSessionId, currentSessionDirectory)
} }
await routeMessage({ await routeMessage({
runtimeKey: capturedTarget?.runtimeKey,
sessionId: targetSessionId || "", sessionId: targetSessionId || "",
directory: currentSessionDirectory, directory: currentSessionDirectory,
content, content,