MCP status and skills were cleared on a switch but their in-flight requests were not, so a response for the previous instance could still write itself over the new one's — the same race the quota store already guards. Both now carry a generation. Usage also claimed an instance as loaded before it had answered, so a load that failed on a cold or briefly unreachable instance was never attempted again, and the previous instance's display mode and provider selection survived a switch — which decided what the new instance was even asked for.
93 lines
4.1 KiB
TypeScript
93 lines
4.1 KiB
TypeScript
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
import type { McpStatus } from '@opencode-ai/sdk/v2';
|
|
import type { McpStatusMap } from './useMcpStore';
|
|
|
|
type Deferred<T> = { promise: Promise<T>; resolve: (value: T) => void };
|
|
const deferred = <T>(): Deferred<T> => {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((res) => { resolve = res; });
|
|
return { promise, resolve };
|
|
};
|
|
|
|
type McpStatusResult = Awaited<ReturnType<ReturnType<typeof opencodeModule.opencodeClient.getApiClient>['mcp']['status']>>;
|
|
let mcpStatusResponse: Deferred<McpStatusResult> = deferred();
|
|
const opencodeModule = await import('@/lib/opencode/client');
|
|
// Derived from the real client rather than spread from it: the client is a
|
|
// class instance, so a spread drops every prototype method the other modules
|
|
// loaded in this process call at import time.
|
|
// SAFETY: `Object.create` returns `any`; the object delegates to the real
|
|
// client for everything the two overrides below do not define.
|
|
const opencodeClientStub = Object.create(opencodeModule.opencodeClient) as typeof opencodeModule.opencodeClient;
|
|
// The SDK client is derived the same way, so only `mcp.status` is replaced and
|
|
// every other endpoint keeps its real implementation and type.
|
|
type McpApiClient = ReturnType<typeof opencodeModule.opencodeClient.getApiClient>;
|
|
const realApiClient = opencodeModule.opencodeClient.getApiClient();
|
|
const mcpApiStub: McpApiClient = Object.create(realApiClient, {
|
|
mcp: { value: { ...realApiClient.mcp, status: () => mcpStatusResponse.promise } },
|
|
});
|
|
opencodeClientStub.getApiClient = () => mcpApiStub;
|
|
opencodeClientStub.getScopedApiClient = () => mcpApiStub;
|
|
mock.module('@/lib/opencode/client', () => ({ ...opencodeModule, opencodeClient: opencodeClientStub }));
|
|
|
|
let skillsResponse: Deferred<Response> = deferred();
|
|
const runtimeFetchModule = await import('@/lib/runtime-fetch');
|
|
mock.module('@/lib/runtime-fetch', () => ({
|
|
...runtimeFetchModule,
|
|
runtimeFetch: () => skillsResponse.promise,
|
|
}));
|
|
|
|
const { useMcpStore } = await import('./useMcpStore');
|
|
const { useSkillsStore } = await import('./useSkillsStore');
|
|
|
|
const mcpStatusResult = (data: McpStatusMap): McpStatusResult => ({
|
|
data,
|
|
request: new Request('http://localhost/mcp'),
|
|
response: new Response(),
|
|
});
|
|
|
|
const connectedServer = (name: string): McpStatusMap => ({
|
|
// SAFETY: the store only reads `status` off each entry; the SDK type carries
|
|
// fields no consumer in this test path touches.
|
|
[name]: { status: 'connected' } as McpStatus,
|
|
});
|
|
|
|
describe('instance-scoped stores reject responses from the previous instance', () => {
|
|
beforeEach(() => {
|
|
mcpStatusResponse = deferred();
|
|
skillsResponse = deferred();
|
|
useMcpStore.getState().resetForRuntimeSwitch();
|
|
useSkillsStore.getState().resetForRuntimeSwitch();
|
|
});
|
|
|
|
test('an MCP status in flight during a switch does not land in the new instance', async () => {
|
|
const refresh = useMcpStore.getState().refresh({ directory: '/repo', silent: true });
|
|
|
|
useMcpStore.getState().resetForRuntimeSwitch();
|
|
mcpStatusResponse.resolve(mcpStatusResult(connectedServer('from-instance-a')));
|
|
await refresh;
|
|
|
|
expect(useMcpStore.getState().getStatusForDirectory('/repo')).toEqual({});
|
|
});
|
|
|
|
test('an MCP status that arrives with no switch is stored', async () => {
|
|
const refresh = useMcpStore.getState().refresh({ directory: '/repo', silent: true });
|
|
mcpStatusResponse.resolve(mcpStatusResult(connectedServer('server-a')));
|
|
await refresh;
|
|
|
|
expect(Object.keys(useMcpStore.getState().getStatusForDirectory('/repo'))).toEqual(['server-a']);
|
|
});
|
|
|
|
test('a skills load in flight during a switch does not land in the new instance', async () => {
|
|
const load = useSkillsStore.getState().loadSkills('/repo');
|
|
|
|
useSkillsStore.getState().resetForRuntimeSwitch();
|
|
skillsResponse.resolve(new Response(
|
|
JSON.stringify({ skills: [{ name: 'from-instance-a', path: '/repo/.agents/skills/a/SKILL.md' }] }),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
));
|
|
await load;
|
|
|
|
expect(useSkillsStore.getState().skillsByDirectory['/repo']).toBe(undefined);
|
|
});
|
|
});
|