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