1
0

explore-session-state.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. /**
  2. * Session-scoped explore call state (CG-17).
  3. *
  4. * The tracker is the foundation for cross-call dedup (CG-18) and budget decay
  5. * (CG-19), so what it must get right is what those two will trust: the count of
  6. * calls, the line ranges already served, and — above all — WHOSE they are. Two
  7. * agents on one daemon share a ToolHandler and a worker pool; if their histories
  8. * blend, a dedup built on this would withhold source from an agent that never
  9. * saw it, and the agent Reads the file. That is the failure this suite guards.
  10. *
  11. * Three layers:
  12. * 1. the state container itself — keying, monotonic call index, bounds;
  13. * 2. the handler seam — a real explore against a real index records real
  14. * ranges, and the emission side-channel NEVER reaches the response;
  15. * 3. the session seam — separate sessions on one engine, separate state.
  16. */
  17. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  18. import * as fs from 'fs';
  19. import * as path from 'path';
  20. import * as os from 'os';
  21. import CodeGraph from '../src/index';
  22. import { ToolHandler } from '../src/mcp/tools';
  23. import { MCPSession } from '../src/mcp/session';
  24. import type { MCPEngine } from '../src/mcp/engine';
  25. import type { JsonRpcTransport, JsonRpcRequest, JsonRpcNotification } from '../src/mcp/transport';
  26. import {
  27. EXPLORE_EMISSION_KEY,
  28. EXPLORE_SESSION_LIMITS,
  29. EXPLORE_SESSION_VIEW_ARG,
  30. ExploreSessionState,
  31. coalesceRanges,
  32. exploreProjectKey,
  33. rangesCover,
  34. readExploreSessionView,
  35. viewForProject,
  36. type ExploreEmission,
  37. } from '../src/mcp/explore-session-state';
  38. const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go');
  39. const QUERY = 'how does payroll cycle create and calculate payslips?';
  40. /** An emission shaped like a real one, for the container-level tests. */
  41. function emission(root: string, over: Partial<ExploreEmission> = {}): ExploreEmission {
  42. return {
  43. projectRoot: root,
  44. query: 'q',
  45. files: [{ path: 'a.ts', ranges: [{ start: 1, end: 10 }], bytes: 100 }],
  46. sourceBytes: 100,
  47. responseBytes: 400,
  48. ...over,
  49. };
  50. }
  51. describe('ExploreSessionState — the container', () => {
  52. it('counts calls per project and hands back a 1-based session index', () => {
  53. const state = new ExploreSessionState();
  54. expect(state.record(emission('/repo/a'))?.index).toBe(1);
  55. expect(state.record(emission('/repo/a'))?.index).toBe(2);
  56. expect(state.callCount('/repo/a')).toBe(2);
  57. expect(state.forProject('/repo/a')?.responseBytes).toBe(800);
  58. });
  59. it('keys state per project — a second project starts its own count', () => {
  60. const state = new ExploreSessionState();
  61. state.record(emission('/repo/a'));
  62. state.record(emission('/repo/a'));
  63. expect(state.record(emission('/repo/b'))?.index).toBe(1);
  64. expect(state.callCount('/repo/a')).toBe(2);
  65. expect(state.callCount('/repo/b')).toBe(1);
  66. expect(state.forProject('/repo/b')?.calls).toHaveLength(1);
  67. });
  68. it('treats trailing slashes and `.` segments as the same project', () => {
  69. const state = new ExploreSessionState();
  70. state.record(emission('/repo/a'));
  71. state.record(emission('/repo/a/'));
  72. state.record(emission('/repo/a/./'));
  73. expect(state.callCount('/repo/a')).toBe(3);
  74. expect(state.snapshot()).toHaveLength(1);
  75. });
  76. it('never reports a project it was never told about', () => {
  77. const state = new ExploreSessionState();
  78. expect(state.forProject('/never/queried')).toBeNull();
  79. expect(state.callCount('/never/queried')).toBe(0);
  80. });
  81. it('keeps counting past the retained-call bound — decay must not reset itself', () => {
  82. const state = new ExploreSessionState();
  83. const total = EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED + 5;
  84. for (let i = 0; i < total; i++) state.record(emission('/repo/a'));
  85. const project = state.forProject('/repo/a')!;
  86. expect(project.callCount).toBe(total);
  87. expect(project.calls).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED);
  88. // Detail is dropped from the OLDEST end; the newest call is always retained.
  89. expect(project.calls[project.calls.length - 1]!.index).toBe(total);
  90. expect(project.calls[0]!.index).toBe(total - EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED + 1);
  91. });
  92. it('bounds the number of projects, evicting the least recently used', () => {
  93. const state = new ExploreSessionState();
  94. const roots = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_PROJECTS + 2 }, (_, i) => `/repo/${i}`);
  95. for (const root of roots) state.record(emission(root));
  96. expect(state.snapshot()).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_PROJECTS);
  97. expect(state.forProject(roots[0]!)).toBeNull();
  98. expect(state.forProject(roots[roots.length - 1]!)).not.toBeNull();
  99. });
  100. it('keeps a re-queried project alive past newer ones', () => {
  101. const state = new ExploreSessionState();
  102. const roots = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_PROJECTS }, (_, i) => `/repo/${i}`);
  103. for (const root of roots) state.record(emission(root));
  104. state.record(emission(roots[0]!)); // touch the oldest
  105. state.record(emission('/repo/newcomer')); // forces one eviction
  106. expect(state.forProject(roots[0]!)?.callCount).toBe(2);
  107. expect(state.forProject(roots[1]!)).toBeNull();
  108. });
  109. it('bounds files per call, keeping the ones that got the most source', () => {
  110. const state = new ExploreSessionState();
  111. const files = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_FILES_PER_CALL + 6 }, (_, i) => ({
  112. path: `f${i}.ts`,
  113. ranges: [{ start: 1, end: 5 }],
  114. bytes: i + 1,
  115. }));
  116. state.record(emission('/repo/a', { files }));
  117. const kept = state.forProject('/repo/a')!.calls[0]!.files;
  118. expect(kept).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_FILES_PER_CALL);
  119. expect(kept.map((f) => f.path)).toContain(`f${files.length - 1}.ts`);
  120. expect(kept.map((f) => f.path)).not.toContain('f0.ts');
  121. });
  122. it('ignores an emission with no project root rather than filing it under ""', () => {
  123. const state = new ExploreSessionState();
  124. expect(state.record({ ...emission(''), projectRoot: '' })).toBeNull();
  125. expect(state.snapshot()).toHaveLength(0);
  126. });
  127. it('hands out copies — a caller cannot mutate the record it read', () => {
  128. const state = new ExploreSessionState();
  129. state.record(emission('/repo/a'));
  130. const snap = state.forProject('/repo/a')!;
  131. snap.calls[0]!.files[0]!.ranges.push({ start: 999, end: 1000 });
  132. expect(state.forProject('/repo/a')!.calls[0]!.files[0]!.ranges).toHaveLength(1);
  133. });
  134. it('view() carries only the most recent calls per project', () => {
  135. const state = new ExploreSessionState();
  136. for (let i = 0; i < EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED; i++) state.record(emission('/repo/a'));
  137. const view = state.view();
  138. expect(view.projects[0]!.callCount).toBe(EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED);
  139. expect(view.projects[0]!.calls).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_VIEW_CALLS);
  140. expect(viewForProject(view, '/repo/a')?.callCount).toBe(EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED);
  141. // A tracked session that hasn't touched this project yet reads as EMPTY,
  142. // not untracked — only a missing view (nobody tracking) is null.
  143. expect(viewForProject(view, '/repo/other')?.callCount).toBe(0);
  144. expect(viewForProject(null, '/repo/a')).toBeNull();
  145. });
  146. });
  147. describe('range bookkeeping', () => {
  148. it('merges overlapping and adjacent spans into one', () => {
  149. const { ranges, truncated } = coalesceRanges([
  150. { start: 10, end: 20 },
  151. { start: 15, end: 25 }, // overlaps
  152. { start: 26, end: 30 }, // adjacent — one contiguous block of source
  153. { start: 60, end: 61 },
  154. ]);
  155. expect(ranges).toEqual([{ start: 10, end: 30 }, { start: 60, end: 61 }]);
  156. expect(truncated).toBe(false);
  157. });
  158. it('drops junk spans instead of recording a range that was never served', () => {
  159. const { ranges } = coalesceRanges([
  160. { start: 5, end: 1 }, // inverted
  161. { start: 0, end: 3 }, // before line 1
  162. { start: NaN, end: 4 },
  163. { start: 7, end: 9 },
  164. ]);
  165. expect(ranges).toEqual([{ start: 7, end: 9 }]);
  166. });
  167. it('caps the range list by KEEPING the largest spans, and says it truncated', () => {
  168. // Spaced far enough apart that none of them merge — this is about the cap.
  169. const many = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_RANGES_PER_FILE + 5 }, (_, i) => ({
  170. start: i * 200 + 1,
  171. end: i * 200 + 2 + i, // later spans are longer
  172. }));
  173. const { ranges, truncated } = coalesceRanges(many);
  174. expect(truncated).toBe(true);
  175. expect(ranges).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_RANGES_PER_FILE);
  176. // Still in line order, and the biggest span survived.
  177. expect(ranges.map((r) => r.start)).toEqual([...ranges.map((r) => r.start)].sort((a, b) => a - b));
  178. expect(ranges.some((r) => r.start === many[many.length - 1]!.start)).toBe(true);
  179. });
  180. it('flags truncation on the stored record so a consumer knows it under-knows', () => {
  181. const state = new ExploreSessionState();
  182. const ranges = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_RANGES_PER_FILE + 3 }, (_, i) => ({
  183. start: i * 10 + 1, end: i * 10 + 4,
  184. }));
  185. state.record(emission('/repo/a', { files: [{ path: 'big.ts', ranges, bytes: 900 }] }));
  186. expect(state.forProject('/repo/a')!.calls[0]!.files[0]!.rangesTruncated).toBe(true);
  187. });
  188. it('answers whether a line was already served', () => {
  189. const ranges = [{ start: 10, end: 20 }, { start: 40, end: 41 }];
  190. expect(rangesCover(ranges, 10)).toBe(true);
  191. expect(rangesCover(ranges, 20)).toBe(true);
  192. expect(rangesCover(ranges, 21)).toBe(false);
  193. expect(rangesCover(ranges, 40)).toBe(true);
  194. });
  195. it('folds case only on the case-insensitive platforms', () => {
  196. const insensitive = process.platform === 'darwin' || process.platform === 'win32';
  197. expect(exploreProjectKey('/Repo/A') === exploreProjectKey('/repo/a')).toBe(insensitive);
  198. });
  199. });
  200. describe('session view arriving on tool args', () => {
  201. it('reads a well-formed view and ignores anything else', () => {
  202. const state = new ExploreSessionState();
  203. state.record(emission('/repo/a'));
  204. expect(readExploreSessionView({ [EXPLORE_SESSION_VIEW_ARG]: state.view() })?.projects).toHaveLength(1);
  205. expect(readExploreSessionView({})).toBeNull();
  206. expect(readExploreSessionView({ [EXPLORE_SESSION_VIEW_ARG]: 'nope' })).toBeNull();
  207. expect(readExploreSessionView({ [EXPLORE_SESSION_VIEW_ARG]: { projects: 'nope' } })).toBeNull();
  208. });
  209. it('drops malformed project entries rather than trusting them', () => {
  210. const view = readExploreSessionView({
  211. [EXPLORE_SESSION_VIEW_ARG]: { projects: [{ projectRoot: '/repo/a', calls: [] }, { nope: 1 }, null] },
  212. });
  213. expect(view?.projects).toHaveLength(1);
  214. });
  215. });
  216. describe('explore records what it actually served', () => {
  217. let testDir: string;
  218. let cg: CodeGraph;
  219. let handler: ToolHandler;
  220. beforeAll(async () => {
  221. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg17-'));
  222. fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
  223. fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
  224. cg = CodeGraph.initSync(testDir);
  225. await cg.indexAll();
  226. handler = new ToolHandler(cg);
  227. }, 120_000);
  228. afterAll(() => {
  229. if (cg) cg.destroy();
  230. if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  231. });
  232. it('files one record per call, with the files and line ranges it emitted', async () => {
  233. const session = new ExploreSessionState();
  234. await handler.execute('codegraph_explore', { query: QUERY }, session);
  235. const project = session.forProject(cg.getProjectRoot());
  236. expect(project).not.toBeNull();
  237. expect(project!.callCount).toBe(1);
  238. const call = project!.calls[0]!;
  239. expect(call.files.length).toBeGreaterThan(0);
  240. expect(call.sourceBytes).toBeGreaterThan(0);
  241. expect(call.responseBytes).toBeGreaterThan(call.sourceBytes);
  242. for (const file of call.files) {
  243. expect(file.ranges.length).toBeGreaterThan(0);
  244. for (const r of file.ranges) {
  245. expect(r.start).toBeGreaterThanOrEqual(1);
  246. expect(r.end).toBeGreaterThanOrEqual(r.start);
  247. }
  248. }
  249. }, 60_000);
  250. it('records only files whose source is really in the response', async () => {
  251. const session = new ExploreSessionState();
  252. const result = await handler.execute('codegraph_explore', { query: QUERY }, session);
  253. const text = result.content[0]!.text;
  254. for (const file of session.forProject(cg.getProjectRoot())!.calls[0]!.files) {
  255. expect(text).toContain(file.path);
  256. }
  257. }, 60_000);
  258. it('the recorded ranges name lines that are really in the emitted source', async () => {
  259. const session = new ExploreSessionState();
  260. await handler.execute('codegraph_explore', { query: QUERY }, session);
  261. for (const file of session.forProject(cg.getProjectRoot())!.calls[0]!.files) {
  262. const lineCount = fs.readFileSync(path.join(testDir, file.path), 'utf-8').split('\n').length;
  263. for (const r of file.ranges) expect(r.end).toBeLessThanOrEqual(lineCount);
  264. }
  265. }, 60_000);
  266. it('leaves the agent-facing response untouched — no side-channel on the wire', async () => {
  267. const session = new ExploreSessionState();
  268. const tracked = await handler.execute('codegraph_explore', { query: QUERY }, session);
  269. const untracked = await handler.execute('codegraph_explore', { query: QUERY });
  270. expect(tracked.content[0]!.text).toBe(untracked.content[0]!.text);
  271. for (const result of [tracked, untracked]) {
  272. expect(EXPLORE_EMISSION_KEY in result).toBe(false);
  273. expect(JSON.stringify(result)).not.toContain(EXPLORE_EMISSION_KEY);
  274. }
  275. }, 60_000);
  276. it('ignores a session view a client spelled itself — the record is the server\'s', async () => {
  277. const forged = {
  278. projects: [{ projectRoot: cg.getProjectRoot(), callCount: 99, responseBytes: 1e6, calls: [] }],
  279. };
  280. const result = await handler.execute('codegraph_explore', {
  281. query: QUERY,
  282. [EXPLORE_SESSION_VIEW_ARG]: forged,
  283. });
  284. const clean = await handler.execute('codegraph_explore', { query: QUERY });
  285. expect(result.content[0]!.text).toBe(clean.content[0]!.text);
  286. }, 60_000);
  287. it('counts an empty answer as a call, since it still spends the tier budget', async () => {
  288. const session = new ExploreSessionState();
  289. await handler.execute('codegraph_explore', { query: 'zzqqxx_no_such_symbol_anywhere' }, session);
  290. const project = session.forProject(cg.getProjectRoot());
  291. expect(project?.callCount).toBe(1);
  292. expect(project?.calls[0]!.files).toHaveLength(0);
  293. }, 60_000);
  294. it('two sessions on ONE handler never see each other\'s calls', async () => {
  295. const a = new ExploreSessionState();
  296. const b = new ExploreSessionState();
  297. await handler.execute('codegraph_explore', { query: QUERY }, a);
  298. await handler.execute('codegraph_explore', { query: QUERY }, a);
  299. await handler.execute('codegraph_explore', { query: QUERY }, b);
  300. expect(a.callCount(cg.getProjectRoot())).toBe(2);
  301. expect(b.callCount(cg.getProjectRoot())).toBe(1);
  302. }, 90_000);
  303. it('a caller that tracks nothing still gets a clean result', async () => {
  304. const result = await handler.execute('codegraph_explore', { query: QUERY });
  305. expect(result.isError).toBeFalsy();
  306. expect(result.content[0]!.text.length).toBeGreaterThan(0);
  307. }, 60_000);
  308. it('reports the session state through the CG-4 diagnostic', async () => {
  309. const sidecar = path.join(testDir, 'cg17-diagnostic.jsonl');
  310. const session = new ExploreSessionState();
  311. const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
  312. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  313. try {
  314. await handler.execute('codegraph_explore', { query: QUERY }, session);
  315. await handler.execute('codegraph_explore', { query: QUERY }, session);
  316. } finally {
  317. if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  318. else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
  319. }
  320. const reports = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').map((l) => JSON.parse(l));
  321. expect(reports).toHaveLength(2);
  322. // The first call is the session's first: nothing served before it.
  323. expect(reports[0].session).toEqual({
  324. callIndex: 1, priorCalls: 0, priorResponseChars: 0, priorFiles: [],
  325. });
  326. // The second sees the first call's files and their ranges.
  327. expect(reports[1].session.callIndex).toBe(2);
  328. expect(reports[1].session.priorCalls).toBe(1);
  329. expect(reports[1].session.priorResponseChars).toBeGreaterThan(0);
  330. expect(reports[1].session.priorFiles.length).toBeGreaterThan(0);
  331. expect(reports[1].session.priorFiles[0].ranges[0]).toHaveLength(2);
  332. }, 90_000);
  333. it('omits the session block entirely when the caller tracks no state', async () => {
  334. const sidecar = path.join(testDir, 'cg17-untracked.jsonl');
  335. const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
  336. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  337. try {
  338. await handler.execute('codegraph_explore', { query: QUERY });
  339. } finally {
  340. if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  341. else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
  342. }
  343. const report = JSON.parse(fs.readFileSync(sidecar, 'utf-8').trim());
  344. expect(report.session).toBeUndefined();
  345. }, 60_000);
  346. it('keys on the RESOLVED root, not the path the agent typed', async () => {
  347. // The same project reached two ways — bare, and via a `projectPath` pointing
  348. // at a subdirectory. Both resolve to one index, so both must land in one
  349. // bucket; keying on the typed path would split a session's history in two
  350. // and hand a later call a half-empty record.
  351. //
  352. // (Two genuinely DIFFERENT projects can't be exercised here: opening a
  353. // second index inside vitest fails on the lazy `require('../index')` — see
  354. // the ToolHandler cache notes. The container-level tests above cover the
  355. // multi-project keying itself.)
  356. const session = new ExploreSessionState();
  357. await handler.execute('codegraph_explore', { query: QUERY }, session);
  358. await handler.execute(
  359. 'codegraph_explore',
  360. { query: QUERY, projectPath: path.join(testDir, 'internal') },
  361. session,
  362. );
  363. expect(session.snapshot()).toHaveLength(1);
  364. expect(session.callCount(cg.getProjectRoot())).toBe(2);
  365. }, 90_000);
  366. });
  367. describe('sessions sharing a daemon', () => {
  368. /** Minimal transport: captures the message handler so a test can drive it. */
  369. function fakeTransport(): JsonRpcTransport & { deliver: (m: JsonRpcRequest) => Promise<void>; results: unknown[] } {
  370. let handle: ((m: JsonRpcRequest | JsonRpcNotification) => Promise<void>) | null = null;
  371. const results: unknown[] = [];
  372. return {
  373. start(h) { handle = h as typeof handle; },
  374. stop() { /* nothing to tear down */ },
  375. send() { /* unused */ },
  376. notify() { /* unused */ },
  377. async request() { return {}; },
  378. sendResult(_id, result) { results.push(result); },
  379. sendError() { /* unused */ },
  380. results,
  381. async deliver(m: JsonRpcRequest) { await handle?.(m); },
  382. };
  383. }
  384. it('give each session its own state, and one session\'s calls stay there', async () => {
  385. const calls: Array<ExploreSessionState | undefined> = [];
  386. // A ToolHandler stand-in: the point here is WHICH state object arrives, not
  387. // what explore returns, so a real index would only slow the assertion down.
  388. const handler = {
  389. getTools: () => [],
  390. execute: async (_tool: string, _args: Record<string, unknown>, state?: ExploreSessionState) => {
  391. calls.push(state);
  392. state?.record(emission('/repo/shared'));
  393. return { content: [{ type: 'text' as const, text: 'ok' }] };
  394. },
  395. };
  396. const engine = {
  397. ensureInitialized: async () => { /* already open */ },
  398. hasDefaultCodeGraph: () => true,
  399. getProjectPath: () => '/repo/shared',
  400. retryInitializeSync: () => { /* nothing to retry */ },
  401. getToolHandler: () => handler,
  402. } as unknown as MCPEngine;
  403. const transportA = fakeTransport();
  404. const transportB = fakeTransport();
  405. const sessionA = new MCPSession(transportA, engine);
  406. const sessionB = new MCPSession(transportB, engine);
  407. sessionA.start();
  408. sessionB.start();
  409. expect(sessionA.getExploreSessionState()).not.toBe(sessionB.getExploreSessionState());
  410. const call = (id: number): JsonRpcRequest => ({
  411. jsonrpc: '2.0', id, method: 'tools/call',
  412. params: { name: 'codegraph_explore', arguments: { query: 'q' } },
  413. });
  414. await transportA.deliver(call(1));
  415. await transportA.deliver(call(2));
  416. await transportB.deliver(call(3));
  417. expect(calls[0]).toBe(sessionA.getExploreSessionState());
  418. expect(calls[2]).toBe(sessionB.getExploreSessionState());
  419. expect(sessionA.getExploreSessionState().callCount('/repo/shared')).toBe(2);
  420. expect(sessionB.getExploreSessionState().callCount('/repo/shared')).toBe(1);
  421. });
  422. });