@apollo/client-ai-apps 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/core/ApolloClient.d.ts +1 -1
  3. package/dist/core/ApolloClient.d.ts.map +1 -1
  4. package/dist/core/ApolloClient.js +1 -1
  5. package/dist/core/ApolloClient.js.map +1 -1
  6. package/dist/mcp/core/ApolloClient.d.ts +6 -1
  7. package/dist/mcp/core/ApolloClient.d.ts.map +1 -1
  8. package/dist/mcp/core/ApolloClient.js +36 -3
  9. package/dist/mcp/core/ApolloClient.js.map +1 -1
  10. package/dist/mcp/core/McpAppManager.d.ts +2 -2
  11. package/dist/mcp/core/McpAppManager.d.ts.map +1 -1
  12. package/dist/mcp/core/McpAppManager.js +3 -3
  13. package/dist/mcp/core/McpAppManager.js.map +1 -1
  14. package/dist/mcp/react/hooks/createHydrationUtils.d.ts +15 -0
  15. package/dist/mcp/react/hooks/createHydrationUtils.d.ts.map +1 -0
  16. package/dist/mcp/react/hooks/createHydrationUtils.js +113 -0
  17. package/dist/mcp/react/hooks/createHydrationUtils.js.map +1 -0
  18. package/dist/mcp/react/index.d.ts +1 -0
  19. package/dist/mcp/react/index.d.ts.map +1 -1
  20. package/dist/mcp/react/index.js +1 -0
  21. package/dist/mcp/react/index.js.map +1 -1
  22. package/dist/openai/core/ApolloClient.d.ts +6 -1
  23. package/dist/openai/core/ApolloClient.d.ts.map +1 -1
  24. package/dist/openai/core/ApolloClient.js +37 -3
  25. package/dist/openai/core/ApolloClient.js.map +1 -1
  26. package/dist/openai/core/McpAppManager.d.ts +2 -2
  27. package/dist/openai/core/McpAppManager.d.ts.map +1 -1
  28. package/dist/openai/core/McpAppManager.js +5 -5
  29. package/dist/openai/core/McpAppManager.js.map +1 -1
  30. package/dist/openai/react/hooks/createHydrationUtils.d.ts +15 -0
  31. package/dist/openai/react/hooks/createHydrationUtils.d.ts.map +1 -0
  32. package/dist/openai/react/hooks/createHydrationUtils.js +113 -0
  33. package/dist/openai/react/hooks/createHydrationUtils.js.map +1 -0
  34. package/dist/openai/react/index.d.ts +1 -0
  35. package/dist/openai/react/index.d.ts.map +1 -1
  36. package/dist/openai/react/index.js +1 -0
  37. package/dist/openai/react/index.js.map +1 -1
  38. package/dist/react/ApolloProvider.js +1 -1
  39. package/dist/react/ApolloProvider.js.map +1 -1
  40. package/dist/react/index.d.ts +4 -0
  41. package/dist/react/index.d.ts.map +1 -1
  42. package/dist/react/index.js +3 -0
  43. package/dist/react/index.js.map +1 -1
  44. package/dist/react/index.mcp.d.ts +1 -1
  45. package/dist/react/index.mcp.d.ts.map +1 -1
  46. package/dist/react/index.mcp.js +1 -1
  47. package/dist/react/index.mcp.js.map +1 -1
  48. package/dist/react/index.openai.d.ts +1 -1
  49. package/dist/react/index.openai.d.ts.map +1 -1
  50. package/dist/react/index.openai.js +1 -1
  51. package/dist/react/index.openai.js.map +1 -1
  52. package/dist/react/reactive.d.ts +9 -0
  53. package/dist/react/reactive.d.ts.map +1 -0
  54. package/dist/react/reactive.js +11 -0
  55. package/dist/react/reactive.js.map +1 -0
  56. package/dist/utilities/getToolNamesFromDocument.d.ts +3 -0
  57. package/dist/utilities/getToolNamesFromDocument.d.ts.map +1 -0
  58. package/dist/utilities/getToolNamesFromDocument.js +12 -0
  59. package/dist/utilities/getToolNamesFromDocument.js.map +1 -0
  60. package/dist/utilities/getVariableNamesFromDocument.d.ts +3 -0
  61. package/dist/utilities/getVariableNamesFromDocument.d.ts.map +1 -0
  62. package/dist/utilities/getVariableNamesFromDocument.js +6 -0
  63. package/dist/utilities/getVariableNamesFromDocument.js.map +1 -0
  64. package/dist/utilities/index.d.ts +3 -0
  65. package/dist/utilities/index.d.ts.map +1 -1
  66. package/dist/utilities/index.js +3 -0
  67. package/dist/utilities/index.js.map +1 -1
  68. package/dist/utilities/warnOnVariableMismatch.d.ts +3 -0
  69. package/dist/utilities/warnOnVariableMismatch.d.ts.map +1 -0
  70. package/dist/utilities/warnOnVariableMismatch.js +10 -0
  71. package/dist/utilities/warnOnVariableMismatch.js.map +1 -0
  72. package/package.json +2 -1
  73. package/src/core/ApolloClient.ts +1 -1
  74. package/src/mcp/core/ApolloClient.ts +67 -2
  75. package/src/mcp/core/McpAppManager.ts +3 -3
  76. package/src/mcp/core/__tests__/ApolloClient.test.ts +109 -6
  77. package/src/mcp/link/__tests__/ToolCallLink.test.ts +1 -1
  78. package/src/mcp/react/hooks/__tests__/createHydrationUtils.test.tsx +1228 -0
  79. package/src/mcp/react/hooks/createHydrationUtils.ts +182 -0
  80. package/src/mcp/react/index.ts +1 -0
  81. package/src/openai/core/ApolloClient.ts +68 -2
  82. package/src/openai/core/McpAppManager.ts +5 -5
  83. package/src/openai/core/__tests__/ApolloClient.test.ts +113 -7
  84. package/src/openai/react/hooks/__tests__/createHydrationUtils.test.tsx +1333 -0
  85. package/src/openai/react/hooks/createHydrationUtils.ts +182 -0
  86. package/src/openai/react/index.ts +1 -0
  87. package/src/react/ApolloProvider.tsx +1 -1
  88. package/src/react/index.mcp.ts +1 -0
  89. package/src/react/index.openai.ts +1 -0
  90. package/src/react/index.ts +7 -0
  91. package/src/react/reactive.ts +19 -0
  92. package/src/testing/internal/mcp/graphqlToolResult.ts +5 -5
  93. package/src/utilities/getToolNamesFromDocument.ts +15 -0
  94. package/src/utilities/getVariableNamesFromDocument.ts +9 -0
  95. package/src/utilities/index.ts +3 -0
  96. package/src/utilities/warnOnVariableMismatch.ts +20 -0
@@ -0,0 +1,182 @@
1
+ import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react";
2
+ import type {
3
+ DocumentNode,
4
+ OperationVariables,
5
+ TypedDocumentNode,
6
+ } from "@apollo/client";
7
+ import { useApolloClient } from "./useApolloClient.js";
8
+ import { useToolName } from "./useToolName.js";
9
+ import { isReactive } from "../../../react/reactive.js";
10
+ import type { Reactive } from "../../../react/reactive.js";
11
+ import { equal } from "@wry/equality";
12
+ import { __DEV__ } from "@apollo/client/utilities/environment";
13
+ import {
14
+ getToolNamesFromDocument,
15
+ getVariableNamesFromDocument,
16
+ } from "../../../utilities/index.js";
17
+
18
+ type HydratedVariablesInput<TVariables> = {
19
+ [K in keyof TVariables]: TVariables[K] | Reactive<TVariables[K]>;
20
+ };
21
+
22
+ type StateVariables<TVariables, Input> = {
23
+ [K in keyof TVariables as K extends keyof Input ?
24
+ Input[K] extends Reactive<any> ?
25
+ never
26
+ : K
27
+ : K]: K extends keyof TVariables ? TVariables[K] : never;
28
+ };
29
+
30
+ type SetVariables<TState> = (
31
+ update: Partial<TState> | ((prev: TState) => Partial<TState>)
32
+ ) => void;
33
+
34
+ /** @experimental */
35
+ export function createHydrationUtils<
36
+ TVariables extends OperationVariables = OperationVariables,
37
+ >(document: TypedDocumentNode<any, TVariables> | DocumentNode) {
38
+ const documentToolNames = getToolNamesFromDocument(document);
39
+ const variableNames = getVariableNamesFromDocument(document);
40
+
41
+ function useHydratedVariables<
42
+ TInputVariables extends HydratedVariablesInput<TVariables>,
43
+ >(
44
+ variables: TInputVariables &
45
+ Record<Exclude<keyof TInputVariables, keyof TVariables>, never>
46
+ ): [
47
+ variables: TVariables,
48
+ setVariables: SetVariables<StateVariables<TVariables, TInputVariables>>,
49
+ ] {
50
+ const client = useApolloClient();
51
+ const toolName = useToolName();
52
+ const [toolInput] = useState(() => client.toolInput);
53
+
54
+ const toolMatches =
55
+ toolInput !== undefined &&
56
+ toolName !== undefined &&
57
+ documentToolNames.has(toolName);
58
+
59
+ const [stateVars, setStateVars] = useState<Record<string, unknown>>(() => {
60
+ const values: Record<string, unknown> = {};
61
+
62
+ for (const [key, value] of Object.entries(
63
+ toolMatches ? toolInput : variables
64
+ )) {
65
+ if (variableNames.has(key) && !isReactive(value)) {
66
+ values[key] = value;
67
+ }
68
+ }
69
+
70
+ return values;
71
+ });
72
+
73
+ const [initialReactiveVars] = useState<Record<string, unknown>>(() => {
74
+ const values: Record<string, unknown> = {};
75
+
76
+ for (const [key, value] of Object.entries(variables)) {
77
+ if (variableNames.has(key) && isReactive(value)) {
78
+ values[key] = value.value;
79
+ }
80
+ }
81
+
82
+ return values;
83
+ });
84
+
85
+ const [reactiveVars, setReactiveVars] = useState(() => {
86
+ const values: Record<string, unknown> = {};
87
+
88
+ for (const [key, value] of Object.entries(initialReactiveVars)) {
89
+ if (toolMatches && key in toolInput) {
90
+ values[key] = toolInput[key];
91
+ } else if (!toolMatches) {
92
+ values[key] = value;
93
+ }
94
+ }
95
+
96
+ return values;
97
+ });
98
+
99
+ const changedKeysRef = useRef(new Set<string>());
100
+ const nextReactiveVars: Record<string, unknown> = {};
101
+
102
+ for (const [key, value] of Object.entries(variables)) {
103
+ if (!variableNames.has(key) || !isReactive(value)) continue;
104
+
105
+ const hasChanged =
106
+ changedKeysRef.current.has(key) ||
107
+ !equal(value.value, initialReactiveVars[key]);
108
+
109
+ if (toolMatches && !hasChanged) {
110
+ if (key in toolInput) {
111
+ nextReactiveVars[key] = toolInput[key];
112
+ }
113
+ } else {
114
+ nextReactiveVars[key] = value.value;
115
+ }
116
+ }
117
+
118
+ if (!equal(nextReactiveVars, reactiveVars)) {
119
+ setReactiveVars(nextReactiveVars);
120
+ }
121
+
122
+ // Clear the tool input after first mount so that remounting the component
123
+ // uses the user-provided variables rather than the hydrated tool input.
124
+ // This runs once on mount; watchQuery also clears it when useQuery is
125
+ // present, so both paths are idempotent.
126
+ useLayoutEffect(() => {
127
+ if (toolMatches) {
128
+ client.clearToolInput();
129
+ }
130
+ // eslint-disable-next-line react-hooks/exhaustive-deps
131
+ }, []);
132
+
133
+ useLayoutEffect(() => {
134
+ for (const [key, value] of Object.entries(variables)) {
135
+ if (
136
+ variableNames.has(key) &&
137
+ isReactive(value) &&
138
+ !changedKeysRef.current.has(key) &&
139
+ !equal(value.value, initialReactiveVars[key])
140
+ ) {
141
+ changedKeysRef.current.add(key);
142
+ }
143
+ }
144
+ });
145
+
146
+ const resolvedVariables = useMemo(() => {
147
+ return { ...stateVars, ...reactiveVars } as TVariables;
148
+ }, [stateVars, reactiveVars]);
149
+
150
+ const setVariables = useCallback<
151
+ SetVariables<StateVariables<TVariables, TInputVariables>>
152
+ >((update) => {
153
+ setStateVars((prev) => {
154
+ const updates =
155
+ typeof update === "function" ? update(prev as any) : update;
156
+
157
+ const filtered = Object.fromEntries(
158
+ Object.entries(updates).filter(([key]) => {
159
+ if (key in initialReactiveVars) {
160
+ if (__DEV__) {
161
+ console.warn(
162
+ `Attempted to set reactive variable "${key}" via setVariables. ` +
163
+ `Reactive variables are read-only and are ignored. `
164
+ );
165
+ }
166
+ return false;
167
+ }
168
+ return true;
169
+ })
170
+ );
171
+
172
+ if (Object.keys(filtered).length === 0) return prev;
173
+
174
+ return { ...prev, ...filtered };
175
+ });
176
+ }, []);
177
+
178
+ return [resolvedVariables, setVariables];
179
+ }
180
+
181
+ return { useHydratedVariables };
182
+ }
@@ -2,3 +2,4 @@ export { useApp } from "./hooks/useApp.js";
2
2
  export { useToolName } from "./hooks/useToolName.js";
3
3
  export { useToolMetadata } from "./hooks/useToolMetadata.js";
4
4
  export { useToolInput } from "./hooks/useToolInput.js";
5
+ export { createHydrationUtils } from "./hooks/createHydrationUtils.js";
@@ -1,17 +1,26 @@
1
1
  import type { ApolloLink } from "@apollo/client";
2
2
  import { ApolloClient as BaseApolloClient } from "@apollo/client";
3
3
  import { DocumentTransform } from "@apollo/client";
4
+ import type {
5
+ WatchQueryOptions,
6
+ ObservableQuery,
7
+ OperationVariables,
8
+ } from "@apollo/client";
4
9
  import { removeDirectivesFromDocument } from "@apollo/client/utilities/internal";
5
10
  import { parse } from "graphql";
11
+ import { equal } from "@wry/equality";
6
12
  import { __DEV__ } from "@apollo/client/utilities/environment";
7
13
  import type { ApplicationManifest } from "../../types/application-manifest.js";
8
14
  import { ToolCallLink } from "../link/ToolCallLink.js";
9
15
  import {
10
16
  aiClientSymbol,
11
17
  cacheAsync,
18
+ getToolNamesFromDocument,
12
19
  getVariablesForOperationFromToolInput,
20
+ warnOnVariableMismatch,
13
21
  } from "../../utilities/index.js";
14
22
  import { McpAppManager } from "./McpAppManager.js";
23
+ import { getVariableNamesFromDocument } from "../../utilities/getVariableNamesFromDocument.js";
15
24
 
16
25
  export declare namespace ApolloClient {
17
26
  export interface Options extends Omit<BaseApolloClient.Options, "link"> {
@@ -27,6 +36,8 @@ export class ApolloClient extends BaseApolloClient {
27
36
  /** @internal */
28
37
  readonly [aiClientSymbol] = true;
29
38
 
39
+ #toolInput: Record<string, unknown> | undefined;
40
+
30
41
  constructor(options: ApolloClient.Options) {
31
42
  const link = options.link ?? new ToolCallLink();
32
43
 
@@ -55,9 +66,64 @@ export class ApolloClient extends BaseApolloClient {
55
66
  this.appManager.close().catch(() => {});
56
67
  }
57
68
 
58
- waitForInitialization = cacheAsync(async () => {
69
+ get toolInput() {
70
+ return this.#toolInput;
71
+ }
72
+
73
+ clearToolInput() {
74
+ this.#toolInput = undefined;
75
+ }
76
+
77
+ watchQuery<
78
+ T = any,
79
+ TVariables extends OperationVariables = OperationVariables,
80
+ >(options: WatchQueryOptions<TVariables, T>): ObservableQuery<T, TVariables> {
81
+ if (__DEV__) {
82
+ const toolInput = this.#toolInput;
83
+
84
+ if (toolInput) {
85
+ const toolName = this.appManager.toolName;
86
+ const hasMatchingTool =
87
+ !!toolName && getToolNamesFromDocument(options.query).has(toolName);
88
+
89
+ if (hasMatchingTool) {
90
+ // Clear after first matching comparison so this only fires once and
91
+ // remounting doesn't produce spurious warnings.
92
+ this.#toolInput = undefined;
93
+
94
+ const variableNames = getVariableNamesFromDocument(options.query);
95
+
96
+ if (variableNames.size > 0) {
97
+ const { variables } = options;
98
+
99
+ const toolInputVariables = Object.entries(toolInput).filter(
100
+ ([key]) => variableNames.has(key)
101
+ );
102
+
103
+ const hasToolInputMismatch = toolInputVariables.some(
104
+ ([key, value]) => !equal(value, variables?.[key])
105
+ );
106
+
107
+ if (hasToolInputMismatch) {
108
+ warnOnVariableMismatch(
109
+ options.query,
110
+ Object.fromEntries(toolInputVariables),
111
+ variables
112
+ );
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ return super.watchQuery(options);
120
+ }
121
+
122
+ connect = cacheAsync(async () => {
59
123
  const { prefetch, result, toolName, args } =
60
- await this.appManager.waitForInitialization();
124
+ await this.appManager.connect();
125
+
126
+ this.#toolInput = args;
61
127
 
62
128
  this.manifest.operations.forEach((operation) => {
63
129
  if (operation.prefetchID && prefetch?.[operation.prefetchID]) {
@@ -35,7 +35,7 @@ export class McpAppManager {
35
35
  return this.#toolInput;
36
36
  }
37
37
 
38
- waitForInitialization = cacheAsync(async () => {
38
+ connect = cacheAsync(async () => {
39
39
  let toolResult = promiseWithResolvers<ApolloMcpServerApps.CallToolResult>();
40
40
 
41
41
  this.app.ontoolresult = (params) => {
@@ -44,7 +44,7 @@ export class McpAppManager {
44
44
  );
45
45
  };
46
46
 
47
- await this.connect();
47
+ await this.connectToHost();
48
48
 
49
49
  const { structuredContent } = await toolResult.promise;
50
50
 
@@ -55,12 +55,12 @@ export class McpAppManager {
55
55
  // before we get the tool result (which should always happen and at most
56
56
  // once according to the spec). Rather than relying on the
57
57
  // `ui/notifications/tool-input` notification to set the tool input value,
58
- // we read from `window.openai.toolInput so that we hvae the most recent
58
+ // we read from `window.openai.toolInput so that we have the most recent
59
59
  // set value.
60
60
  //
61
61
  // When OpenAI fixes this issue and sends `ui/notifications/tool-input`
62
62
  // consistently according to the MCP Apps specification, this can be
63
- // reverrted to use the `app.ontoolinput` callback.
63
+ // reverted to use the `app.ontoolinput` callback.
64
64
  this.#toolInput = window.openai.toolInput;
65
65
 
66
66
  // OpenAI doesn't provide access to `_meta`, so we need to use
@@ -93,7 +93,7 @@ export class McpAppManager {
93
93
  return result.structuredContent;
94
94
  }
95
95
 
96
- private async connect() {
96
+ private async connectToHost() {
97
97
  try {
98
98
  return await this.app.connect(
99
99
  new PostMessageTransport(window.parent, window.parent)
@@ -1,9 +1,10 @@
1
1
  import { expect, test, describe, vi } from "vitest";
2
2
  import { ApolloClient } from "../ApolloClient.js";
3
3
  import { parse } from "graphql";
4
- import { ApolloLink, HttpLink, InMemoryCache } from "@apollo/client";
4
+ import { ApolloLink, HttpLink, InMemoryCache, gql } from "@apollo/client";
5
5
  import { ToolCallLink } from "../../link/ToolCallLink.js";
6
6
  import {
7
+ graphqlToolResult,
7
8
  minimalHostContextWithToolName,
8
9
  mockApplicationManifest,
9
10
  mockMcpHost,
@@ -30,7 +31,7 @@ describe("Client Basics", () => {
30
31
  structuredContent: {},
31
32
  });
32
33
 
33
- await client.waitForInitialization();
34
+ await client.connect();
34
35
 
35
36
  host.mockToolCall("execute", () => ({
36
37
  content: [],
@@ -124,7 +125,7 @@ describe("prefetchData", () => {
124
125
  },
125
126
  });
126
127
 
127
- await client.waitForInitialization();
128
+ await client.connect();
128
129
 
129
130
  expect(client.extract()).toMatchInlineSnapshot(`
130
131
  {
@@ -202,7 +203,7 @@ describe("prefetchData", () => {
202
203
  },
203
204
  });
204
205
 
205
- await client.waitForInitialization();
206
+ await client.connect();
206
207
 
207
208
  expect(client.extract()).toMatchInlineSnapshot(`
208
209
  {
@@ -307,7 +308,7 @@ describe("prefetchData", () => {
307
308
  },
308
309
  });
309
310
 
310
- await client.waitForInitialization();
311
+ await client.connect();
311
312
 
312
313
  expect(client.extract()).toMatchInlineSnapshot(`
313
314
  {
@@ -375,7 +376,7 @@ describe("prefetchData", () => {
375
376
  },
376
377
  });
377
378
 
378
- await client.waitForInitialization();
379
+ await client.connect();
379
380
 
380
381
  expect(client.extract()).toMatchInlineSnapshot(`
381
382
  {
@@ -440,7 +441,7 @@ describe("custom links", () => {
440
441
  },
441
442
  }));
442
443
 
443
- await client.waitForInitialization();
444
+ await client.connect();
444
445
 
445
446
  const variables = { id: "1" };
446
447
  const query = parse(manifest.operations[0].body);
@@ -535,3 +536,108 @@ describe("custom links", () => {
535
536
  }).not.toThrow();
536
537
  });
537
538
  });
539
+
540
+ describe("watchQuery dev warnings", () => {
541
+ const query = gql`
542
+ query Products($category: String!, $page: Int!, $sortBy: String!)
543
+ @tool(name: "GetProductsByCategory") {
544
+ products(category: $category, page: $page, sortBy: $sortBy) {
545
+ id
546
+ }
547
+ }
548
+ `;
549
+
550
+ async function setupClient({
551
+ toolInput,
552
+ }: {
553
+ toolInput: Record<string, unknown>;
554
+ }) {
555
+ stubOpenAiGlobals({ toolInput });
556
+ const client = new ApolloClient({
557
+ cache: new InMemoryCache(),
558
+ manifest: mockApplicationManifest(),
559
+ });
560
+ using host = await mockMcpHost({
561
+ hostContext: minimalHostContextWithToolName("GetProductsByCategory"),
562
+ });
563
+ host.onCleanup(() => client.stop());
564
+ host.sendToolResult(graphqlToolResult({ data: { products: [] } }));
565
+ host.sendToolInput({
566
+ arguments: toolInput,
567
+ });
568
+ await client.connect();
569
+ return client;
570
+ }
571
+
572
+ test("warns when variables don't match tool input", async () => {
573
+ using _ = spyOnConsole("debug", "warn");
574
+ const client = await setupClient({
575
+ toolInput: { category: "electronics", page: 1, sortBy: "title" },
576
+ });
577
+
578
+ client.watchQuery({
579
+ query,
580
+ variables: { category: "music", page: 1, sortBy: "name" },
581
+ });
582
+
583
+ expect(console.warn).toHaveBeenCalledWith(
584
+ expect.stringContaining("useHydratedVariables"),
585
+ { category: "electronics", page: 1, sortBy: "title" },
586
+ { category: "music", page: 1, sortBy: "name" }
587
+ );
588
+ });
589
+
590
+ test("does not warn when variables match tool input", async () => {
591
+ using _ = spyOnConsole("debug", "warn");
592
+ const client = await setupClient({
593
+ toolInput: { category: "electronics", page: 1, sortBy: "title" },
594
+ });
595
+
596
+ client.watchQuery({
597
+ query,
598
+ variables: { category: "electronics", page: 1, sortBy: "title" },
599
+ });
600
+
601
+ expect(console.warn).not.toHaveBeenCalled();
602
+ });
603
+
604
+ test("does not warn when query has no matching @tool directive", async () => {
605
+ using _ = spyOnConsole("debug", "warn");
606
+ const client = await setupClient({
607
+ toolInput: { category: "electronics", page: 1, sortBy: "title" },
608
+ });
609
+
610
+ const queryWithoutTool = gql`
611
+ query Products($category: String!) {
612
+ products(category: $category) {
613
+ id
614
+ }
615
+ }
616
+ `;
617
+
618
+ client.watchQuery({
619
+ query: queryWithoutTool,
620
+ variables: { category: "music" },
621
+ });
622
+
623
+ expect(console.warn).not.toHaveBeenCalled();
624
+ });
625
+
626
+ test("warning fires at most once (subsequent calls don't re-warn)", async () => {
627
+ using _ = spyOnConsole("debug", "warn");
628
+ const client = await setupClient({
629
+ toolInput: { category: "electronics", page: 1, sortBy: "title" },
630
+ });
631
+
632
+ client.watchQuery({
633
+ query,
634
+ variables: { category: "music", page: 1, sortBy: "name" },
635
+ });
636
+ client.watchQuery({
637
+ query,
638
+ variables: { category: "music", page: 1, sortBy: "name" },
639
+ });
640
+
641
+ expect(console.warn).toHaveBeenCalledTimes(1);
642
+ });
643
+ });