* feat: apply auto-refresh setting live + diff-aware list-changed indicator (#1444) Two follow-ups to #1402. 1. Live setting application — InspectorClient gains setServerSettings() (on the protocol + fake); App pushes edited settings onto the live client when the Server Settings modal closes for the connected server, so toggling auto-refresh-on-list-changed takes effect without a reconnect (the managed state reads getServerSettings() at notification time). 2. Diff-aware indicator — in flag-only mode, on */list_changed the managed Tools/Prompts/Resources state now fetches the new list and compares it to the current one, lighting the indicator only when it actually differs. The displayed list stays put until the user pulls via Refresh (pull-on-demand preserved). Servers like everything re-send identical lists on list_changed; this suppresses the false-positive indicator. Refactors each manager's refresh into fetch + apply helpers shared with the peek path. Trade-off: item 2 reintroduces a fetch on */list_changed (to compare), but unlike the pre-#1402 behavior it never replaces the displayed list under the user — only the indicator reflects a real change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core): make the diff-aware peek clear the indicator on revert (#1444 review) peekForChange now sets the list-changed flag to whether the server list differs from the displayed one (setListChanged(differs)) instead of only ever setting it true. So a later notification that reverts the server back to the displayed list clears the indicator — nothing left to pull. Adds a clear-on-revert regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core): consolidate managed-list state managers onto a shared base (#1444) The four per-primitive list managers (tools, prompts, resources, resource templates) were near-identical copies of the same fetch/apply/peek/connect/ disconnect/list_changed machinery, a standing drift risk. Extract a generic ManagedListState<T, M> base parameterized by a config (change event, list_changed notification, capability key, page fetcher, and whether the list drives an indicator). Each manager is now a ~40-line subclass: its config plus a typed getter alias. Public contracts are unchanged — same per-manager *Change / listChangedChange events and get*/refresh/getListChanged/clearListChanged methods — so the hooks and all existing tests pass untouched. Templates opt out of the indicator (supportsIndicator: false): a list_changed auto-refreshes only when the server opts in, else does nothing. Net -245 lines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(core): coalesce bursts of list_changed peeks (#1444 review) A rapid burst of */list_changed notifications fired one concurrent paginated fetch per notification. Guard peekForChange with an in-flight flag: while a peek is fetching, a new notification queues a single re-run instead of launching a concurrent fetch, and the running peek loops once more on completion to capture the latest server state. Collapses a burst into sequential peeks. Adds a coalescing test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(core): debounce list_changed bursts into a single fetch (#1444) The in-flight guard only coalesced fetches that actually overlapped — when a server (e.g. everything) emits a burst of */list_changed spaced just far enough apart that each list call returns before the next notification, we still fired one list call per notification. Add a real debounce in the ManagedListState base: a notification (re)starts a timer and the refresh/peek runs once the burst settles, collapsing N notifications into one fetch. The in-flight coalescing guard stays as a second line of defense for a post-debounce notification landing during a slow paginated fetch. The delay is injectable (DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250; subclasses take an optional constructor arg) so tests run with 0 for fast, determinist ic behavior. Timer is cleared on disconnect, unsubscribe, and destroy. Adds debounce + in-flight regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core): extend the in-flight guard to the auto-refresh path (#1444 review) The overlap guard was only on the peek path; a burst-during-a-slow auto- refresh could let a slower older fetch clobber a newer list (applyItems is last-write-wins). Lift the guard from peekForChange up to runListChanged so it wraps both the auto-refresh and peek actions: while one is fetching, a new post-debounce notification queues a single re-run. Adds an auto-refresh coalescing test alongside the peek one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(core): light the indicator without fetching when auto-refresh is off (#1444) Per maintainer feedback: with auto-refresh off, a list_changed should not issue any list call. The diff-aware peek (fetch-to-compare) meant one tools/list went out per settled burst even with auto-refresh off, which the maintainer doesn't want. Revert flag-only mode to lighting the indicator blindly on a (debounced) list_changed — no network — and the user pulls the new list via Refresh. Trade-off (accepted): the indicator lights even when the server re-sent an identical list (the everything-server case), since we no longer fetch to compare. Keeps the debounce (one indicator light per burst), the auto-refresh path + its in-flight guard, the live setServerSettings, and the ManagedListState consolidation. Removes peekForChange and its tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(core): scrub stale peek docstrings after the blind-indicator revert (#1445 review) The diff-aware revert removed peekForChange's body but left its docstring, which orphaned onto destroy() and described fetch-to-compare/clear-on-revert behavior that no longer exists. Replace it with a one-line destroy() doc, and update clearListChanged's doc (peek -> blind light). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(core): finish scrubbing stale peek references after the revert (#1445 review) Round-7's scrub missed four more peek/fetch-to-compare mentions in managedListState.ts (supportsIndicator, fetchAll, the debounce-default and onListChanged comments) plus six 'async peek' comments in the state tests. Update them all to the blind-indicator semantics. Doc/comment-only; no behavior change. grep -i peek over core/mcp/state now returns nothing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b0a024269e
commit
721dae08c6
12 changed files with 597 additions and 560 deletions
|
|
@ -69,6 +69,7 @@ vi.mock("@inspector/core/mcp/index.js", () => {
|
|||
getPendingElicitations = vi.fn().mockReturnValue([]);
|
||||
getRoots = vi.fn().mockReturnValue([]);
|
||||
setRoots = vi.fn().mockResolvedValue(undefined);
|
||||
setServerSettings = vi.fn();
|
||||
}
|
||||
const instances: FakeInspectorClient[] = [];
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1989,6 +1989,11 @@ function App() {
|
|||
settingsModalTargetId === activeServerId &&
|
||||
settingsDraft
|
||||
) {
|
||||
// Push the edited settings onto the live client so settings the managed
|
||||
// state reads at notification time (auto-refresh-on-list-changed) take
|
||||
// effect without a reconnect (#1444). Connection-time inputs (transport,
|
||||
// OAuth, timeouts) still only apply on the next connect.
|
||||
inspectorClient.setServerSettings(settingsDraft);
|
||||
const nextRoots = cleanRoots(settingsDraft.roots);
|
||||
const currentRoots = cleanRoots(inspectorClient.getRoots());
|
||||
if (JSON.stringify(nextRoots) !== JSON.stringify(currentRoots)) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ function waitForPromptsChange(state: ManagedPromptsState): Promise<Prompt[]> {
|
|||
});
|
||||
}
|
||||
|
||||
function waitForListChanged(state: ManagedPromptsState): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("listChangedChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("ManagedPromptsState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedPromptsState;
|
||||
|
|
@ -35,7 +43,7 @@ describe("ManagedPromptsState", () => {
|
|||
// exercise the live `listPrompts` path; capability-absent tests below
|
||||
// override this.
|
||||
client = new FakeInspectorClient({ capabilities: { prompts: {} } });
|
||||
state = new ManagedPromptsState(client);
|
||||
state = new ManagedPromptsState(client, 0);
|
||||
});
|
||||
|
||||
it("starts with empty prompts", () => {
|
||||
|
|
@ -61,7 +69,7 @@ describe("ManagedPromptsState", () => {
|
|||
capabilities: { tools: {}, resources: {} },
|
||||
});
|
||||
promptless.setStatus("connected");
|
||||
const promptlessState = new ManagedPromptsState(promptless);
|
||||
const promptlessState = new ManagedPromptsState(promptless, 0);
|
||||
|
||||
const result = await promptlessState.refresh();
|
||||
expect(result).toEqual([]);
|
||||
|
|
@ -73,7 +81,7 @@ describe("ManagedPromptsState", () => {
|
|||
// there, not only the publicly-callable refresh().
|
||||
const promptless = new FakeInspectorClient({ capabilities: { tools: {} } });
|
||||
promptless.setStatus("connected");
|
||||
const promptlessState = new ManagedPromptsState(promptless);
|
||||
const promptlessState = new ManagedPromptsState(promptless, 0);
|
||||
|
||||
promptless.dispatchTypedEvent("connect");
|
||||
// Yield so the async refresh chained off connect runs.
|
||||
|
|
@ -135,15 +143,15 @@ describe("ManagedPromptsState", () => {
|
|||
expect(next.map((p) => p.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("promptsListChanged does NOT auto-refresh by default (the user pulls via Refresh)", async () => {
|
||||
it("promptsListChanged lights the indicator without fetching by default (#1444)", async () => {
|
||||
// Auto-refresh off: a list_changed lights the indicator with NO list call;
|
||||
// the user pulls the new list via Refresh.
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
|
||||
const changed = waitForListChanged(state);
|
||||
client.dispatchTypedEvent("promptsListChanged");
|
||||
// Yield so a stray refresh would have landed.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(client.listPrompts).not.toHaveBeenCalled();
|
||||
expect(state.getPrompts()).toEqual([]);
|
||||
expect(await changed).toBe(true);
|
||||
expect(client.listPrompts).not.toHaveBeenCalled(); // no automatic fetch
|
||||
expect(state.getPrompts()).toEqual([]); // displayed list untouched
|
||||
});
|
||||
|
||||
it("promptsListChanged auto-refreshes when the server opts in", async () => {
|
||||
|
|
@ -152,7 +160,7 @@ describe("ManagedPromptsState", () => {
|
|||
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||
});
|
||||
autoClient.setStatus("connected");
|
||||
const autoState = new ManagedPromptsState(autoClient);
|
||||
const autoState = new ManagedPromptsState(autoClient, 0);
|
||||
autoClient.queuePromptPages({ prompts: [prompt("a")] });
|
||||
const changed = waitForPromptsChange(autoState);
|
||||
autoClient.dispatchTypedEvent("promptsListChanged");
|
||||
|
|
@ -206,14 +214,6 @@ describe("ManagedPromptsState", () => {
|
|||
});
|
||||
|
||||
describe("listChanged (#1402)", () => {
|
||||
function waitForListChanged(s: ManagedPromptsState): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
s.addEventListener("listChangedChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("starts cleared", () => {
|
||||
expect(state.getListChanged()).toBe(false);
|
||||
});
|
||||
|
|
@ -230,7 +230,9 @@ describe("ManagedPromptsState", () => {
|
|||
it("clearListChanged resets the flag and dispatches false", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
const set = waitForListChanged(state);
|
||||
client.dispatchTypedEvent("promptsListChanged");
|
||||
await set; // wait for the debounced notification to set the flag
|
||||
expect(state.getListChanged()).toBe(true);
|
||||
|
||||
const changed = waitForListChanged(state);
|
||||
|
|
@ -251,7 +253,9 @@ describe("ManagedPromptsState", () => {
|
|||
it("disconnect clears the flag", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
const set = waitForListChanged(state);
|
||||
client.dispatchTypedEvent("promptsListChanged");
|
||||
await set; // wait for the debounced notification to set the flag
|
||||
expect(state.getListChanged()).toBe(true);
|
||||
|
||||
const changed = waitForListChanged(state);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
// tests below override this. (Templates are gated on the `resources`
|
||||
// capability — the spec defines no separate `resourceTemplates` one.)
|
||||
client = new FakeInspectorClient({ capabilities: { resources: {} } });
|
||||
state = new ManagedResourceTemplatesState(client);
|
||||
state = new ManagedResourceTemplatesState(client, 0);
|
||||
});
|
||||
|
||||
it("starts with empty resource templates", () => {
|
||||
|
|
@ -67,7 +67,10 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
capabilities: { tools: {}, prompts: {} },
|
||||
});
|
||||
resourceless.setStatus("connected");
|
||||
const resourcelessState = new ManagedResourceTemplatesState(resourceless);
|
||||
const resourcelessState = new ManagedResourceTemplatesState(
|
||||
resourceless,
|
||||
0,
|
||||
);
|
||||
|
||||
const result = await resourcelessState.refresh();
|
||||
expect(result).toEqual([]);
|
||||
|
|
@ -81,7 +84,10 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
capabilities: { tools: {} },
|
||||
});
|
||||
resourceless.setStatus("connected");
|
||||
const resourcelessState = new ManagedResourceTemplatesState(resourceless);
|
||||
const resourcelessState = new ManagedResourceTemplatesState(
|
||||
resourceless,
|
||||
0,
|
||||
);
|
||||
|
||||
resourceless.dispatchTypedEvent("connect");
|
||||
// Yield so the async refresh chained off connect runs.
|
||||
|
|
@ -166,7 +172,7 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||
});
|
||||
autoClient.setStatus("connected");
|
||||
const autoState = new ManagedResourceTemplatesState(autoClient);
|
||||
const autoState = new ManagedResourceTemplatesState(autoClient, 0);
|
||||
autoClient.queueResourceTemplatePages({
|
||||
resourceTemplates: [template("a")],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ function waitForResourcesChange(
|
|||
});
|
||||
}
|
||||
|
||||
function waitForListChanged(state: ManagedResourcesState): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("listChangedChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("ManagedResourcesState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedResourcesState;
|
||||
|
|
@ -37,7 +45,7 @@ describe("ManagedResourcesState", () => {
|
|||
// tests exercise the live `listResources` path; capability-absent tests
|
||||
// below override this.
|
||||
client = new FakeInspectorClient({ capabilities: { resources: {} } });
|
||||
state = new ManagedResourcesState(client);
|
||||
state = new ManagedResourcesState(client, 0);
|
||||
});
|
||||
|
||||
it("starts with empty resources", () => {
|
||||
|
|
@ -64,7 +72,7 @@ describe("ManagedResourcesState", () => {
|
|||
capabilities: { tools: {}, prompts: {} },
|
||||
});
|
||||
resourceless.setStatus("connected");
|
||||
const resourcelessState = new ManagedResourcesState(resourceless);
|
||||
const resourcelessState = new ManagedResourcesState(resourceless, 0);
|
||||
|
||||
const result = await resourcelessState.refresh();
|
||||
expect(result).toEqual([]);
|
||||
|
|
@ -78,7 +86,7 @@ describe("ManagedResourcesState", () => {
|
|||
capabilities: { tools: {} },
|
||||
});
|
||||
resourceless.setStatus("connected");
|
||||
const resourcelessState = new ManagedResourcesState(resourceless);
|
||||
const resourcelessState = new ManagedResourcesState(resourceless, 0);
|
||||
|
||||
resourceless.dispatchTypedEvent("connect");
|
||||
// Yield so the async refresh chained off connect runs.
|
||||
|
|
@ -142,17 +150,15 @@ describe("ManagedResourcesState", () => {
|
|||
expect(next.map((r) => r.uri)).toEqual(["a://1"]);
|
||||
});
|
||||
|
||||
it("resourcesListChanged does NOT auto-refresh by default (the user pulls via Refresh)", async () => {
|
||||
it("resourcesListChanged lights the indicator without fetching by default (#1444)", async () => {
|
||||
// Auto-refresh off: a list_changed lights the indicator with NO list call;
|
||||
// the user pulls the new list via Refresh.
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({
|
||||
resources: [resource("a://1"), resource("a://2")],
|
||||
});
|
||||
const changed = waitForListChanged(state);
|
||||
client.dispatchTypedEvent("resourcesListChanged");
|
||||
// Yield so a stray refresh would have landed.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(client.listResources).not.toHaveBeenCalled();
|
||||
expect(state.getResources()).toEqual([]);
|
||||
expect(await changed).toBe(true);
|
||||
expect(client.listResources).not.toHaveBeenCalled(); // no automatic fetch
|
||||
expect(state.getResources()).toEqual([]); // displayed list untouched
|
||||
});
|
||||
|
||||
it("resourcesListChanged auto-refreshes when the server opts in", async () => {
|
||||
|
|
@ -161,7 +167,7 @@ describe("ManagedResourcesState", () => {
|
|||
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||
});
|
||||
autoClient.setStatus("connected");
|
||||
const autoState = new ManagedResourcesState(autoClient);
|
||||
const autoState = new ManagedResourcesState(autoClient, 0);
|
||||
autoClient.queueResourcePages({ resources: [resource("a://1")] });
|
||||
const changed = waitForResourcesChange(autoState);
|
||||
autoClient.dispatchTypedEvent("resourcesListChanged");
|
||||
|
|
@ -215,14 +221,6 @@ describe("ManagedResourcesState", () => {
|
|||
});
|
||||
|
||||
describe("listChanged (#1402)", () => {
|
||||
function waitForListChanged(s: ManagedResourcesState): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
s.addEventListener("listChangedChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("starts cleared", () => {
|
||||
expect(state.getListChanged()).toBe(false);
|
||||
});
|
||||
|
|
@ -239,7 +237,9 @@ describe("ManagedResourcesState", () => {
|
|||
it("clearListChanged resets the flag and dispatches false", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
const set = waitForListChanged(state);
|
||||
client.dispatchTypedEvent("resourcesListChanged");
|
||||
await set; // wait for the debounced notification to set the flag
|
||||
expect(state.getListChanged()).toBe(true);
|
||||
|
||||
const changed = waitForListChanged(state);
|
||||
|
|
@ -260,7 +260,9 @@ describe("ManagedResourcesState", () => {
|
|||
it("disconnect clears the flag", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
const set = waitForListChanged(state);
|
||||
client.dispatchTypedEvent("resourcesListChanged");
|
||||
await set; // wait for the debounced notification to set the flag
|
||||
expect(state.getListChanged()).toBe(true);
|
||||
|
||||
const changed = waitForListChanged(state);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ function waitForToolsChange(state: ManagedToolsState): Promise<Tool[]> {
|
|||
});
|
||||
}
|
||||
|
||||
function waitForListChanged(state: ManagedToolsState): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("listChangedChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("ManagedToolsState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedToolsState;
|
||||
|
|
@ -35,7 +43,7 @@ describe("ManagedToolsState", () => {
|
|||
// exercise the live `listTools` path; capability-absent tests below
|
||||
// override this.
|
||||
client = new FakeInspectorClient({ capabilities: { tools: {} } });
|
||||
state = new ManagedToolsState(client);
|
||||
state = new ManagedToolsState(client, 0);
|
||||
});
|
||||
|
||||
it("starts with empty tools", () => {
|
||||
|
|
@ -61,7 +69,7 @@ describe("ManagedToolsState", () => {
|
|||
capabilities: { prompts: {}, resources: {} },
|
||||
});
|
||||
toolless.setStatus("connected");
|
||||
const toollessState = new ManagedToolsState(toolless);
|
||||
const toollessState = new ManagedToolsState(toolless, 0);
|
||||
|
||||
const result = await toollessState.refresh();
|
||||
expect(result).toEqual([]);
|
||||
|
|
@ -73,7 +81,7 @@ describe("ManagedToolsState", () => {
|
|||
// there, not only the publicly-callable refresh().
|
||||
const toolless = new FakeInspectorClient({ capabilities: { prompts: {} } });
|
||||
toolless.setStatus("connected");
|
||||
const toollessState = new ManagedToolsState(toolless);
|
||||
const toollessState = new ManagedToolsState(toolless, 0);
|
||||
|
||||
toolless.dispatchTypedEvent("connect");
|
||||
// Yield so the async refresh chained off connect runs.
|
||||
|
|
@ -133,15 +141,65 @@ describe("ManagedToolsState", () => {
|
|||
expect(next.map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("toolsListChanged does NOT auto-refresh by default (the user pulls via Refresh)", async () => {
|
||||
it("toolsListChanged lights the indicator without fetching by default (#1444)", async () => {
|
||||
// Auto-refresh off: a list_changed lights the indicator with NO list call;
|
||||
// the user pulls the new list via Refresh.
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a"), tool("b")] });
|
||||
const changed = waitForListChanged(state);
|
||||
client.dispatchTypedEvent("toolsListChanged");
|
||||
// Yield so a stray refresh would have landed.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(await changed).toBe(true);
|
||||
expect(client.listTools).not.toHaveBeenCalled(); // no automatic fetch
|
||||
expect(state.getTools()).toEqual([]); // displayed list untouched
|
||||
});
|
||||
|
||||
it("debounces a burst of list_changed into a single indicator light, no fetch (#1444)", async () => {
|
||||
// The everything-server case: a rapid burst collapses to one indicator
|
||||
// light once it settles, and never fetches in flag-only mode.
|
||||
client.setStatus("connected");
|
||||
let fired = 0;
|
||||
state.addEventListener("listChangedChange", () => {
|
||||
fired++;
|
||||
});
|
||||
client.dispatchTypedEvent("toolsListChanged");
|
||||
client.dispatchTypedEvent("toolsListChanged");
|
||||
client.dispatchTypedEvent("toolsListChanged");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(fired).toBe(1); // one debounced flip
|
||||
expect(state.getListChanged()).toBe(true);
|
||||
expect(client.listTools).not.toHaveBeenCalled();
|
||||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("coalesces an auto-refresh that fires while an earlier one is still fetching (#1444)", async () => {
|
||||
// The guard covers the auto-refresh path too, so a slow refresh can't be
|
||||
// clobbered by an overlapping one from a later notification.
|
||||
const autoClient = new FakeInspectorClient({
|
||||
capabilities: { tools: {} },
|
||||
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||
});
|
||||
autoClient.setStatus("connected");
|
||||
const autoState = new ManagedToolsState(autoClient, 0);
|
||||
let release: (value: { tools: Tool[] }) => void = () => {};
|
||||
autoClient.listTools.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ tools: Tool[] }>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
autoClient.queueToolPages({ tools: [tool("a")] });
|
||||
|
||||
autoClient.dispatchTypedEvent("toolsListChanged");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(autoClient.listTools).toHaveBeenCalledTimes(1);
|
||||
|
||||
autoClient.dispatchTypedEvent("toolsListChanged");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(autoClient.listTools).toHaveBeenCalledTimes(1); // coalesced
|
||||
|
||||
release({ tools: [tool("a")] });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(autoClient.listTools).toHaveBeenCalledTimes(2);
|
||||
// The (coalesced) auto-refresh applied the new list.
|
||||
expect(autoState.getTools().map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("toolsListChanged auto-refreshes when the server opts in", async () => {
|
||||
|
|
@ -150,7 +208,7 @@ describe("ManagedToolsState", () => {
|
|||
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||
});
|
||||
autoClient.setStatus("connected");
|
||||
const autoState = new ManagedToolsState(autoClient);
|
||||
const autoState = new ManagedToolsState(autoClient, 0);
|
||||
autoClient.queueToolPages({ tools: [tool("a")] });
|
||||
const changed = waitForToolsChange(autoState);
|
||||
autoClient.dispatchTypedEvent("toolsListChanged");
|
||||
|
|
@ -158,6 +216,18 @@ describe("ManagedToolsState", () => {
|
|||
expect(autoClient.listTools).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors a live setServerSettings toggle without a reconnect (#1444)", async () => {
|
||||
// Starts in flag-only mode (no settings); flip auto-refresh on live.
|
||||
client.setStatus("connected");
|
||||
client.setServerSettings(AUTO_REFRESH_SETTINGS);
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
const changed = waitForToolsChange(state);
|
||||
client.dispatchTypedEvent("toolsListChanged");
|
||||
// The list is applied (auto-refresh), proving the manager read the new
|
||||
// setting at notification time rather than a connect-time snapshot.
|
||||
expect((await changed).map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears tools and dispatches toolsChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
|
|
@ -207,14 +277,6 @@ describe("ManagedToolsState", () => {
|
|||
});
|
||||
|
||||
describe("listChanged (#1402)", () => {
|
||||
function waitForListChanged(s: ManagedToolsState): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
s.addEventListener("listChangedChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("starts cleared", () => {
|
||||
expect(state.getListChanged()).toBe(false);
|
||||
});
|
||||
|
|
@ -231,7 +293,9 @@ describe("ManagedToolsState", () => {
|
|||
it("clearListChanged resets the flag and dispatches false", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
const set = waitForListChanged(state);
|
||||
client.dispatchTypedEvent("toolsListChanged");
|
||||
await set; // wait for the debounced notification to set the flag
|
||||
expect(state.getListChanged()).toBe(true);
|
||||
|
||||
const changed = waitForListChanged(state);
|
||||
|
|
@ -252,7 +316,9 @@ describe("ManagedToolsState", () => {
|
|||
it("disconnect clears the flag", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
const set = waitForListChanged(state);
|
||||
client.dispatchTypedEvent("toolsListChanged");
|
||||
await set; // wait for the debounced notification to set the flag
|
||||
expect(state.getListChanged()).toBe(true);
|
||||
|
||||
const changed = waitForListChanged(state);
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ describe("ResourceSubscriptionsState", () => {
|
|||
});
|
||||
|
||||
it("resolves Resource references via ManagedResourcesState when provided", async () => {
|
||||
const resourcesState = new ManagedResourcesState(client);
|
||||
const resourcesState = new ManagedResourcesState(client, 0);
|
||||
client.queueResourcePages({
|
||||
resources: [resource("file:///a", { name: "Alpha", title: "Title A" })],
|
||||
});
|
||||
|
|
@ -154,7 +154,7 @@ describe("ResourceSubscriptionsState", () => {
|
|||
});
|
||||
|
||||
it("re-resolves Resource references when ManagedResourcesState refreshes", async () => {
|
||||
const resourcesState = new ManagedResourcesState(client);
|
||||
const resourcesState = new ManagedResourcesState(client, 0);
|
||||
const state = new ResourceSubscriptionsState(client, resourcesState);
|
||||
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
|
||||
expect(state.getSubscriptions()[0].resource.name).toBe("file:///a");
|
||||
|
|
@ -169,7 +169,7 @@ describe("ResourceSubscriptionsState", () => {
|
|||
});
|
||||
|
||||
it("does not re-emit on resourcesChange when no URIs are subscribed", async () => {
|
||||
const resourcesState = new ManagedResourcesState(client);
|
||||
const resourcesState = new ManagedResourcesState(client, 0);
|
||||
const state = new ResourceSubscriptionsState(client, resourcesState);
|
||||
const handler = vi.fn();
|
||||
state.addEventListener("subscriptionsChange", handler);
|
||||
|
|
@ -199,7 +199,7 @@ describe("ResourceSubscriptionsState", () => {
|
|||
});
|
||||
|
||||
it("destroy unsubscribes from client and resources state events", () => {
|
||||
const resourcesState = new ManagedResourcesState(client);
|
||||
const resourcesState = new ManagedResourcesState(client, 0);
|
||||
const state = new ResourceSubscriptionsState(client, resourcesState);
|
||||
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
|
||||
expect(state.getSubscriptions()).toHaveLength(1);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ describe("useManagedPrompts", () => {
|
|||
status: "connected",
|
||||
capabilities: { prompts: {} },
|
||||
});
|
||||
state = new ManagedPromptsState(client);
|
||||
state = new ManagedPromptsState(client, 0);
|
||||
});
|
||||
|
||||
it("returns the initial prompts snapshot from the state", async () => {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ describe("useManagedResourceTemplates", () => {
|
|||
status: "connected",
|
||||
capabilities: { resources: {} },
|
||||
});
|
||||
state = new ManagedResourceTemplatesState(client);
|
||||
state = new ManagedResourceTemplatesState(client, 0);
|
||||
});
|
||||
|
||||
it("returns the initial templates snapshot from the state", async () => {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ describe("useManagedResources", () => {
|
|||
status: "connected",
|
||||
capabilities: { resources: {} },
|
||||
});
|
||||
state = new ManagedResourcesState(client);
|
||||
state = new ManagedResourcesState(client, 0);
|
||||
});
|
||||
|
||||
it("returns the initial resources snapshot from the state", async () => {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ describe("useManagedTools", () => {
|
|||
status: "connected",
|
||||
capabilities: { tools: {} },
|
||||
});
|
||||
state = new ManagedToolsState(client);
|
||||
state = new ManagedToolsState(client, 0);
|
||||
});
|
||||
|
||||
it("returns the initial tools snapshot from the state", async () => {
|
||||
|
|
|
|||
|
|
@ -654,6 +654,32 @@ describe("InspectorClient", () => {
|
|||
});
|
||||
|
||||
describe("Server Data Management", () => {
|
||||
it("getServerSettings returns the constructor settings; setServerSettings replaces them live (#1444)", () => {
|
||||
const initial = {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
autoRefreshOnListChanged: false,
|
||||
roots: [],
|
||||
};
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: "stdio",
|
||||
command: serverCommand.command,
|
||||
args: serverCommand.args,
|
||||
},
|
||||
{
|
||||
environment: { transport: createTransportNode },
|
||||
serverSettings: initial,
|
||||
},
|
||||
);
|
||||
expect(client.getServerSettings()?.autoRefreshOnListChanged).toBe(false);
|
||||
client.setServerSettings({ ...initial, autoRefreshOnListChanged: true });
|
||||
expect(client.getServerSettings()?.autoRefreshOnListChanged).toBe(true);
|
||||
});
|
||||
|
||||
it("should auto-fetch server contents when enabled", async () => {
|
||||
client = new InspectorClient(
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue