mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
257 lines
9.7 KiB
TypeScript
257 lines
9.7 KiB
TypeScript
/**
|
|
* Tests the warm-reuse contract added so the first chat-open per backend
|
|
* doesn't pay a second subprocess spawn + initialize handshake. The probe
|
|
* subprocess survives `runProbe` and is handed to the manager via
|
|
* `takeWarm`; failures and shutdowns must dispose the still-warm proc.
|
|
*/
|
|
import { App, FileSystemAdapter } from "obsidian";
|
|
import type CopilotPlugin from "@/main";
|
|
import { AgentModelPreloader } from "./AgentModelPreloader";
|
|
import type { BackendDescriptor, BackendProcess, BackendState, ModelSelection } from "./types";
|
|
|
|
jest.mock("@/logger", () => ({
|
|
logInfo: jest.fn(),
|
|
logWarn: jest.fn(),
|
|
logError: jest.fn(),
|
|
}));
|
|
|
|
jest.mock("@/settings/model", () => ({
|
|
getSettings: jest.fn(() => ({ agentMode: {} })),
|
|
}));
|
|
|
|
function buildApp(): App {
|
|
const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => unknown)("/vault");
|
|
return { vault: { adapter } } as unknown as App;
|
|
}
|
|
|
|
function buildPlugin(): CopilotPlugin {
|
|
return { manifest: { version: "1.0.0" } } as unknown as CopilotPlugin;
|
|
}
|
|
|
|
interface MockProcHandle {
|
|
proc: BackendProcess;
|
|
start: jest.Mock;
|
|
shutdown: jest.Mock;
|
|
newSession: jest.Mock;
|
|
exitListeners: Set<() => void>;
|
|
emitExit: () => void;
|
|
}
|
|
|
|
function makeMockProc(opts?: { newSessionState?: BackendState }): MockProcHandle {
|
|
const start = jest.fn(async () => undefined);
|
|
const shutdown = jest.fn(async () => undefined);
|
|
const state: BackendState = opts?.newSessionState ?? {
|
|
model: {
|
|
current: { baseModelId: "claude-sonnet", effort: null } satisfies ModelSelection,
|
|
availableModels: [
|
|
{
|
|
baseModelId: "claude-sonnet",
|
|
name: "Claude Sonnet",
|
|
provider: "anthropic",
|
|
effortOptions: [],
|
|
},
|
|
],
|
|
},
|
|
mode: null,
|
|
};
|
|
const newSession = jest.fn(async () => ({ sessionId: "probe-1", state }));
|
|
const exitListeners = new Set<() => void>();
|
|
const proc: BackendProcess = {
|
|
start,
|
|
isRunning: () => true,
|
|
onExit: (fn) => {
|
|
exitListeners.add(fn);
|
|
return () => exitListeners.delete(fn);
|
|
},
|
|
setPermissionPrompter: jest.fn(),
|
|
registerSessionHandler: jest.fn(() => () => {}),
|
|
newSession,
|
|
prompt: jest.fn(),
|
|
cancel: jest.fn(),
|
|
setSessionModel: jest.fn(),
|
|
isSetSessionModelSupported: () => true,
|
|
setSessionMode: jest.fn(),
|
|
isSetSessionModeSupported: () => true,
|
|
setSessionConfigOption: jest.fn(),
|
|
isSetSessionConfigOptionSupported: () => true,
|
|
listSessions: jest.fn(),
|
|
resumeSession: jest.fn(),
|
|
loadSession: jest.fn(),
|
|
supportsMcpTransport: () => false,
|
|
shutdown,
|
|
};
|
|
return {
|
|
proc,
|
|
start,
|
|
shutdown,
|
|
newSession,
|
|
exitListeners,
|
|
emitExit: () => {
|
|
for (const fn of exitListeners) fn();
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildDescriptor(makeProc: () => MockProcHandle): {
|
|
descriptor: BackendDescriptor;
|
|
procHandle: MockProcHandle;
|
|
} {
|
|
const procHandle = makeProc();
|
|
const descriptor = {
|
|
id: "claude-sdk",
|
|
displayName: "Claude",
|
|
getInstallState: () => ({ kind: "ready", source: "managed" }) as const,
|
|
subscribeInstallState: jest.fn(() => () => {}),
|
|
openInstallUI: jest.fn(),
|
|
createBackendProcess: jest.fn(() => procHandle.proc),
|
|
} as unknown as BackendDescriptor;
|
|
return { descriptor, procHandle };
|
|
}
|
|
|
|
describe("AgentModelPreloader.takeWarm", () => {
|
|
it("retains the probe subprocess after a successful preload and hands it to the manager", async () => {
|
|
const { descriptor, procHandle } = buildDescriptor(() => makeMockProc());
|
|
const preloader = new AgentModelPreloader(buildApp(), buildPlugin(), () => descriptor);
|
|
|
|
await preloader.preload("claude-sdk");
|
|
|
|
// Probe started but was NOT shut down — that's the whole point.
|
|
expect(procHandle.start).toHaveBeenCalledTimes(1);
|
|
expect(procHandle.shutdown).not.toHaveBeenCalled();
|
|
|
|
// State cache populated so the picker can read it before the manager
|
|
// takes ownership.
|
|
const cached = preloader.getCachedBackendState("claude-sdk");
|
|
expect(cached?.model?.current.baseModelId).toBe("claude-sonnet");
|
|
|
|
// First takeWarm yields the running proc + probe sessionId + state.
|
|
const warm = preloader.takeWarm("claude-sdk");
|
|
expect(warm).not.toBeNull();
|
|
expect(warm?.proc).toBe(procHandle.proc);
|
|
expect(warm?.probeSessionId).toBe("probe-1");
|
|
expect(warm?.state.model?.current.baseModelId).toBe("claude-sonnet");
|
|
|
|
// Single-shot — second call returns null so the manager can never
|
|
// hand out the same warm proc to two sessions.
|
|
expect(preloader.takeWarm("claude-sdk")).toBeNull();
|
|
|
|
// The cached state survives consumption so the picker keeps rendering
|
|
// until the live session pushes its own state via `setCached`.
|
|
expect(preloader.getCachedBackendState("claude-sdk")).not.toBeNull();
|
|
});
|
|
|
|
it("shuts down a still-warm proc on dispose so the subprocess does not leak", async () => {
|
|
const { descriptor, procHandle } = buildDescriptor(() => makeMockProc());
|
|
const preloader = new AgentModelPreloader(buildApp(), buildPlugin(), () => descriptor);
|
|
|
|
await preloader.preload("claude-sdk");
|
|
expect(procHandle.shutdown).not.toHaveBeenCalled();
|
|
|
|
preloader.shutdown();
|
|
// shutdown is async-fire-and-forget; wait a tick.
|
|
await Promise.resolve();
|
|
|
|
expect(procHandle.shutdown).toHaveBeenCalledTimes(1);
|
|
expect(preloader.takeWarm("claude-sdk")).toBeNull();
|
|
});
|
|
|
|
it("drops the warm entry when the probe subprocess exits before adoption", async () => {
|
|
const { descriptor, procHandle } = buildDescriptor(() => makeMockProc());
|
|
const preloader = new AgentModelPreloader(buildApp(), buildPlugin(), () => descriptor);
|
|
|
|
await preloader.preload("claude-sdk");
|
|
expect(preloader.takeWarm("claude-sdk")).not.toBeNull();
|
|
// Re-prime — populate a second warm entry to exercise the onExit path.
|
|
await preloader.preload("claude-sdk");
|
|
procHandle.emitExit();
|
|
|
|
expect(preloader.takeWarm("claude-sdk")).toBeNull();
|
|
});
|
|
|
|
it("shuts down the probe proc when the agent reports no usable state", async () => {
|
|
const { descriptor, procHandle } = buildDescriptor(() =>
|
|
makeMockProc({ newSessionState: { model: null, mode: null } })
|
|
);
|
|
const preloader = new AgentModelPreloader(buildApp(), buildPlugin(), () => descriptor);
|
|
|
|
await preloader.preload("claude-sdk");
|
|
|
|
// No usable catalog → preloader discards the proc so we don't keep a
|
|
// useless subprocess around.
|
|
expect(procHandle.shutdown).toHaveBeenCalledTimes(1);
|
|
expect(preloader.takeWarm("claude-sdk")).toBeNull();
|
|
});
|
|
|
|
it("shuts down the probe proc when newSession throws", async () => {
|
|
const { descriptor, procHandle } = buildDescriptor(() => {
|
|
const handle = makeMockProc();
|
|
handle.newSession.mockRejectedValueOnce(new Error("agent unreachable"));
|
|
return handle;
|
|
});
|
|
const preloader = new AgentModelPreloader(buildApp(), buildPlugin(), () => descriptor);
|
|
|
|
await preloader.preload("claude-sdk");
|
|
|
|
expect(procHandle.shutdown).toHaveBeenCalledTimes(1);
|
|
expect(preloader.takeWarm("claude-sdk")).toBeNull();
|
|
});
|
|
|
|
it("refresh re-probes a warm backend against current settings", async () => {
|
|
const { descriptor } = buildDescriptor(() => makeMockProc());
|
|
const create = descriptor.createBackendProcess as jest.Mock;
|
|
const preloader = new AgentModelPreloader(buildApp(), buildPlugin(), () => descriptor);
|
|
|
|
await preloader.preload("claude-sdk");
|
|
expect(create).toHaveBeenCalledTimes(1);
|
|
|
|
// A config change after the warm probe settled: refresh drops the warm
|
|
// entry and runs a fresh probe.
|
|
await preloader.refresh("claude-sdk");
|
|
expect(create).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("refresh returns null when nothing is warm or in flight", async () => {
|
|
const { descriptor } = buildDescriptor(() => makeMockProc());
|
|
const preloader = new AgentModelPreloader(buildApp(), buildPlugin(), () => descriptor);
|
|
|
|
// Never preloaded → a config change must not spin a probe up from nothing.
|
|
expect(preloader.refresh("claude-sdk")).toBeNull();
|
|
});
|
|
|
|
it("coalesces a burst of refreshes into a single trailing re-probe", async () => {
|
|
// Models a BYOK save: several config writes call refresh in one synchronous
|
|
// burst. The 2nd/3rd land while the 1st's probe is still in flight (runProbe
|
|
// awaits proc.start before reading the catalog), so they fold into exactly
|
|
// one trailing re-probe against the settled settings — not one per write.
|
|
const { descriptor } = buildDescriptor(() => makeMockProc());
|
|
const create = descriptor.createBackendProcess as jest.Mock;
|
|
const preloader = new AgentModelPreloader(buildApp(), buildPlugin(), () => descriptor);
|
|
|
|
await preloader.preload("claude-sdk"); // probe #1 → something warm
|
|
expect(create).toHaveBeenCalledTimes(1);
|
|
|
|
const chain = preloader.refresh("claude-sdk"); // drops warm, starts probe #2
|
|
expect(chain).not.toBeNull();
|
|
void preloader.refresh("claude-sdk"); // in-flight → trailing-rerun flag
|
|
void preloader.refresh("claude-sdk"); // in-flight → already flagged
|
|
await chain;
|
|
|
|
// probe #2 (in-flight) + exactly one trailing probe #3 = 3 total.
|
|
expect(create).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("clearCached shuts down and drops a still-warm proc", async () => {
|
|
const { descriptor, procHandle } = buildDescriptor(() => makeMockProc());
|
|
const preloader = new AgentModelPreloader(buildApp(), buildPlugin(), () => descriptor);
|
|
|
|
await preloader.preload("claude-sdk");
|
|
expect(preloader.getCachedBackendState("claude-sdk")).not.toBeNull();
|
|
|
|
preloader.clearCached("claude-sdk");
|
|
await Promise.resolve();
|
|
|
|
expect(procHandle.shutdown).toHaveBeenCalledTimes(1);
|
|
expect(preloader.getCachedBackendState("claude-sdk")).toBeNull();
|
|
expect(preloader.takeWarm("claude-sdk")).toBeNull();
|
|
});
|
|
});
|