@sean.holung/minicode 0.3.4 → 0.3.5

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 (52) hide show
  1. package/README.md +5 -3
  2. package/dist/scripts/run-benchmarks.js +73 -28
  3. package/dist/src/benchmark/runner.js +142 -59
  4. package/dist/src/indexer/project-index.js +49 -13
  5. package/dist/src/serve/agent-bridge.js +12 -3
  6. package/dist/src/serve/mcp-server.js +70 -21
  7. package/dist/src/serve/server.js +37 -4
  8. package/dist/src/shared/graph-symbols.js +82 -0
  9. package/dist/src/shared/symbol-resolution.js +33 -0
  10. package/dist/src/tools/find-path.js +15 -6
  11. package/dist/src/tools/find-references.js +7 -2
  12. package/dist/src/tools/get-dependencies.js +8 -3
  13. package/dist/src/tools/read-symbol.js +9 -3
  14. package/dist/src/tools/registry.js +4 -1
  15. package/dist/src/tools/search-code-map.js +18 -3
  16. package/dist/src/web/app.js +154 -33
  17. package/dist/tests/benchmark-harness.test.js +100 -0
  18. package/dist/tests/file-tools.test.js +34 -1
  19. package/dist/tests/find-path.test.js +43 -2
  20. package/dist/tests/find-references.test.js +49 -0
  21. package/dist/tests/get-dependencies.test.js +23 -0
  22. package/dist/tests/graph-symbols.test.js +45 -0
  23. package/dist/tests/indexer.test.js +6 -0
  24. package/dist/tests/read-symbol.test.js +35 -0
  25. package/dist/tests/request-tracker.test.js +15 -0
  26. package/dist/tests/run-benchmarks.test.js +117 -33
  27. package/dist/tests/search-code-map.test.js +2 -0
  28. package/dist/tests/serve.integration.test.js +109 -3
  29. package/dist/tests/session-ui.test.js +2 -0
  30. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  31. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +2 -1
  32. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  33. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +1 -1
  34. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
  35. package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
  36. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts +3 -0
  37. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts.map +1 -1
  38. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.d.ts +3 -0
  39. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.d.ts.map +1 -1
  40. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.js +4 -1
  41. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.js.map +1 -1
  42. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts +11 -1
  43. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts.map +1 -1
  44. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js +4 -1
  45. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js.map +1 -1
  46. package/node_modules/@minicode/agent-sdk/dist/src/tools/search.d.ts.map +1 -1
  47. package/node_modules/@minicode/agent-sdk/dist/src/tools/search.js +16 -8
  48. package/node_modules/@minicode/agent-sdk/dist/src/tools/search.js.map +1 -1
  49. package/node_modules/@minicode/agent-sdk/dist/tests/file-tools.test.js +19 -2
  50. package/node_modules/@minicode/agent-sdk/dist/tests/file-tools.test.js.map +1 -1
  51. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  52. package/package.json +1 -1
@@ -1443,6 +1443,86 @@ function buildStylesheet() {
1443
1443
  return styles;
1444
1444
  }
1445
1445
 
1446
+ // src/shared/graph-symbols.ts
1447
+ function dedupe(values) {
1448
+ return [...new Set(values.filter((value) => value.length > 0))];
1449
+ }
1450
+ function stripCollisionSuffix(value) {
1451
+ const hashIndex = value.indexOf("#");
1452
+ return hashIndex >= 0 ? value.slice(0, hashIndex) : value;
1453
+ }
1454
+ function stripDisplayKindSuffix(value) {
1455
+ return value.replace(/\s+\([^()]+\)$/, "");
1456
+ }
1457
+ function getGraphNodeId(node, fallbackId = "") {
1458
+ return node.qualifiedName || node.id || fallbackId || node.name || "";
1459
+ }
1460
+ function getGraphNodeLabel(node, fallbackId = "") {
1461
+ const label = node.name?.trim();
1462
+ if (label && label.length > 0) {
1463
+ return label;
1464
+ }
1465
+ const id = getGraphNodeId(node, fallbackId);
1466
+ return id.split(".").pop() || id;
1467
+ }
1468
+ function getGraphNodeAliases(node, fallbackId = "") {
1469
+ const id = getGraphNodeId(node, fallbackId);
1470
+ const label = getGraphNodeLabel(node, fallbackId);
1471
+ const shortId = id.split(".").pop() || id;
1472
+ return dedupe([
1473
+ id,
1474
+ node.id ?? "",
1475
+ node.qualifiedName ?? "",
1476
+ label,
1477
+ stripDisplayKindSuffix(label),
1478
+ shortId,
1479
+ stripCollisionSuffix(shortId),
1480
+ stripCollisionSuffix(id)
1481
+ ]);
1482
+ }
1483
+ function compareLabels(a, b2) {
1484
+ return a.localeCompare(b2, void 0, { sensitivity: "base" });
1485
+ }
1486
+ function compareGraphNodeIds(a, b2, nodes) {
1487
+ const nodeA = nodes.get(a);
1488
+ const nodeB = nodes.get(b2);
1489
+ const exportedA = nodeA ? Number(!!nodeA.exported) : 0;
1490
+ const exportedB = nodeB ? Number(!!nodeB.exported) : 0;
1491
+ if (exportedA !== exportedB) {
1492
+ return exportedB - exportedA;
1493
+ }
1494
+ const labelA = getGraphNodeLabel(nodeA ?? {}, a);
1495
+ const labelB = getGraphNodeLabel(nodeB ?? {}, b2);
1496
+ const labelComparison = compareLabels(labelA, labelB);
1497
+ if (labelComparison !== 0) {
1498
+ return labelComparison;
1499
+ }
1500
+ return compareLabels(a, b2);
1501
+ }
1502
+ function matchesGraphNodeQuery(query, node, fallbackId = "") {
1503
+ const normalizedQuery = query.trim().toLowerCase();
1504
+ if (normalizedQuery.length === 0) {
1505
+ return false;
1506
+ }
1507
+ return getGraphNodeAliases(node, fallbackId).some(
1508
+ (alias) => alias.toLowerCase().includes(normalizedQuery)
1509
+ );
1510
+ }
1511
+ function resolveGraphNodeIds(nodes, symbolName) {
1512
+ const query = symbolName.trim();
1513
+ if (query.length === 0) {
1514
+ return [];
1515
+ }
1516
+ if (nodes.has(query)) {
1517
+ return [query];
1518
+ }
1519
+ const exactMatches = [...nodes.entries()].filter(([id, node]) => getGraphNodeAliases(node, id).includes(query)).map(([id]) => id).sort((a, b2) => compareGraphNodeIds(a, b2, nodes));
1520
+ if (exactMatches.length > 0) {
1521
+ return exactMatches;
1522
+ }
1523
+ return [...nodes.entries()].filter(([id, node]) => matchesGraphNodeQuery(query, node, id)).map(([id]) => id).sort((a, b2) => compareGraphNodeIds(a, b2, nodes));
1524
+ }
1525
+
1446
1526
  // src/web/graph.ts
1447
1527
  var cy = null;
1448
1528
  var graphNodes = /* @__PURE__ */ new Map();
@@ -1537,7 +1617,7 @@ async function initGraph() {
1537
1617
  }
1538
1618
  function highlightAgentActivity(symbolName) {
1539
1619
  if (!cy) return;
1540
- void focusSymbolInGraph(symbolName, {
1620
+ void focusResolvedSymbolsInGraph(symbolName, {
1541
1621
  maxDegrees: 0,
1542
1622
  pulse: true,
1543
1623
  pulseDuration: 2e3,
@@ -1611,6 +1691,16 @@ function renderNodeNeighborhoodAndLayout(symbolId, maxDegrees = 1) {
1611
1691
  }
1612
1692
  }
1613
1693
  async function focusSymbolInGraph(symbolId, options = {}) {
1694
+ await focusSymbolsInGraph([symbolId], options);
1695
+ }
1696
+ async function focusResolvedSymbolsInGraph(symbolName, options = {}) {
1697
+ const matches = resolveGraphNodeIds(graphNodes, symbolName);
1698
+ if (matches.length === 0) {
1699
+ return;
1700
+ }
1701
+ await focusSymbolsInGraph(matches, options);
1702
+ }
1703
+ async function focusSymbolsInGraph(symbolIds, options = {}) {
1614
1704
  if (!cy) return;
1615
1705
  const {
1616
1706
  maxDegrees = 0,
@@ -1621,21 +1711,38 @@ async function focusSymbolInGraph(symbolId, options = {}) {
1621
1711
  flashDuration = 1200,
1622
1712
  openDetail = false
1623
1713
  } = options;
1624
- renderNodeNeighborhoodAndLayout(symbolId, maxDegrees);
1625
- const node = findNode(symbolId);
1626
- if (!node) return;
1627
- if (animate) {
1628
- cy.animate({ center: { eles: node }, zoom }, { duration: 300 });
1629
- }
1630
- if (pulse) {
1631
- node.addClass("agent-pulse");
1632
- setTimeout(() => node.removeClass("agent-pulse"), pulseDuration);
1633
- } else {
1634
- node.flashClass("highlighted", flashDuration);
1714
+ const uniqueIds = [...new Set(symbolIds)];
1715
+ let addedNodes = false;
1716
+ for (const symbolId of uniqueIds) {
1717
+ const beforeNodeCount = cy.nodes().length;
1718
+ addNodeNeighborhood(symbolId, maxDegrees);
1719
+ if (cy.nodes().length > beforeNodeCount) {
1720
+ addedNodes = true;
1721
+ }
1722
+ }
1723
+ connectExistingNodes();
1724
+ refreshAnalysisGraphState();
1725
+ if (addedNodes) {
1726
+ runLayout();
1727
+ }
1728
+ const nodes = uniqueIds.map((symbolId) => findNode(symbolId)).filter((node) => node !== null);
1729
+ if (nodes.length === 0) return;
1730
+ const primaryNode = nodes[0];
1731
+ if (animate && primaryNode) {
1732
+ cy.animate({ center: { eles: primaryNode }, zoom }, { duration: 300 });
1635
1733
  }
1636
- if (openDetail) {
1734
+ for (const node of nodes) {
1735
+ if (pulse) {
1736
+ node.addClass("agent-pulse");
1737
+ setTimeout(() => node.removeClass("agent-pulse"), pulseDuration);
1738
+ } else {
1739
+ node.flashClass("highlighted", flashDuration);
1740
+ }
1741
+ }
1742
+ if (openDetail && primaryNode && uniqueIds.length === 1) {
1637
1743
  const detailEl = document.getElementById("symbol-detail");
1638
1744
  if (detailEl) {
1745
+ const node = primaryNode;
1639
1746
  await showDetail(node, detailEl);
1640
1747
  }
1641
1748
  }
@@ -1650,7 +1757,7 @@ function addNodeToGraph(id) {
1650
1757
  const nodeData = graphNodes.get(id);
1651
1758
  if (!nodeData) return;
1652
1759
  const kind = (nodeData.kind || "function").toLowerCase();
1653
- const name = nodeData.name || id.split(".").pop() || id;
1760
+ const name = getGraphNodeLabel(nodeData, id);
1654
1761
  const file = nodeData.filePath || nodeData.file || "";
1655
1762
  cy.add({
1656
1763
  data: {
@@ -1691,11 +1798,10 @@ function findNode(name) {
1691
1798
  if (!cy) return null;
1692
1799
  const node = cy.getElementById(name);
1693
1800
  if (node.length > 0) return node;
1694
- const match = cy.nodes().filter((n) => {
1695
- const nName = n.data("name") || "";
1696
- const qName = n.data("qualifiedName") || "";
1697
- return nName === name || qName.endsWith("." + name);
1698
- });
1801
+ const matchingIds = new Set(resolveGraphNodeIds(graphNodes, name));
1802
+ const match = cy.nodes().filter(
1803
+ (n) => matchingIds.has(n.data("qualifiedName") || n.data("id") || "")
1804
+ );
1699
1805
  return match.length > 0 ? match : null;
1700
1806
  }
1701
1807
  function getAnalysisPanelEls() {
@@ -2335,16 +2441,7 @@ function setupToolbar() {
2335
2441
  dropdown.className = "search-dropdown hidden";
2336
2442
  searchInput.parentNode.style.position = "relative";
2337
2443
  searchInput.parentNode.appendChild(dropdown);
2338
- const rankedSymbols = allSymbolNames.slice().sort((a, b2) => {
2339
- const nodeA = graphNodes.get(a);
2340
- const nodeB = graphNodes.get(b2);
2341
- const expA = nodeA ? !!nodeA.exported : false;
2342
- const expB = nodeB ? !!nodeB.exported : false;
2343
- if (expA !== expB) return expA ? -1 : 1;
2344
- const nameA = (a.split(".").pop() || "").toLowerCase();
2345
- const nameB = (b2.split(".").pop() || "").toLowerCase();
2346
- return nameA.localeCompare(nameB);
2347
- });
2444
+ const rankedSymbols = allSymbolNames.slice().sort((a, b2) => compareGraphNodeIds(a, b2, graphNodes));
2348
2445
  function showDropdownResults(matches) {
2349
2446
  if (matches.length === 0) {
2350
2447
  dropdown.classList.add("hidden");
@@ -2353,7 +2450,7 @@ function setupToolbar() {
2353
2450
  dropdown.innerHTML = matches.map((name) => {
2354
2451
  const node = graphNodes.get(name);
2355
2452
  const kind = node ? (node.kind || "").toLowerCase() : "";
2356
- const shortName = name.split(".").pop() || name;
2453
+ const shortName = getGraphNodeLabel(node || {}, name);
2357
2454
  const kindColor = KIND_COLORS[kind] ? KIND_COLORS[kind].border : "#565f89";
2358
2455
  return `<div class="search-result" data-id="${escapeHtml(name)}">
2359
2456
  <span class="search-result-name">${escapeHtml(shortName)}</span>
@@ -2390,8 +2487,7 @@ function setupToolbar() {
2390
2487
  return;
2391
2488
  }
2392
2489
  const matches = rankedSymbols.filter((name) => {
2393
- const shortName = (name.split(".").pop() || "").toLowerCase();
2394
- return shortName.includes(query) || name.toLowerCase().includes(query);
2490
+ return matchesGraphNodeQuery(query, graphNodes.get(name) || {}, name);
2395
2491
  }).slice(0, 15);
2396
2492
  showDropdownResults(matches);
2397
2493
  }, 150);
@@ -2438,6 +2534,20 @@ function setupToolbar() {
2438
2534
  });
2439
2535
  }
2440
2536
 
2537
+ // src/web/request-tracker.ts
2538
+ function createLatestRequestTracker() {
2539
+ let latestToken = 0;
2540
+ return {
2541
+ begin() {
2542
+ latestToken += 1;
2543
+ return latestToken;
2544
+ },
2545
+ isCurrent(token) {
2546
+ return token === latestToken;
2547
+ }
2548
+ };
2549
+ }
2550
+
2441
2551
  // src/web/app.ts
2442
2552
  var messagesEl = document.getElementById("messages");
2443
2553
  var chatForm = document.getElementById("chat-form");
@@ -2473,6 +2583,7 @@ var assistantText = "";
2473
2583
  var hadToolCalls = false;
2474
2584
  var settingsPayload = null;
2475
2585
  var activeSavedSession = null;
2586
+ var sessionRefreshTracker = createLatestRequestTracker();
2476
2587
  var TOOL_RESULT_MAX = 500;
2477
2588
  function connect() {
2478
2589
  const protocol = location.protocol === "https:" ? "wss:" : "ws:";
@@ -3023,6 +3134,7 @@ saveBtn.addEventListener("click", async () => {
3023
3134
  const label = requestedLabel || activeSavedSession?.label || void 0;
3024
3135
  const isUpdatingCurrentSession = !!activeSavedSession && (requestedLabel.length === 0 || requestedLabel === activeSavedSession.label);
3025
3136
  try {
3137
+ saveBtn.setAttribute("disabled", "true");
3026
3138
  const res = await fetch("/api/sessions/save", {
3027
3139
  method: "POST",
3028
3140
  headers: { "Content-Type": "application/json" },
@@ -3035,9 +3147,11 @@ saveBtn.addEventListener("click", async () => {
3035
3147
  `${isUpdatingCurrentSession ? "Session updated" : "Session saved"}: "${data.label}"`,
3036
3148
  "thinking"
3037
3149
  );
3038
- void refreshSessionList();
3150
+ await refreshSessionList();
3039
3151
  }
3040
3152
  } catch {
3153
+ } finally {
3154
+ saveBtn.removeAttribute("disabled");
3041
3155
  }
3042
3156
  });
3043
3157
  saveLabelInput.addEventListener("keydown", (e) => {
@@ -3047,9 +3161,13 @@ saveLabelInput.addEventListener("keydown", (e) => {
3047
3161
  }
3048
3162
  });
3049
3163
  async function refreshSessionList() {
3164
+ const requestToken = sessionRefreshTracker.begin();
3050
3165
  try {
3051
3166
  const res = await fetch("/api/sessions");
3052
3167
  const data = await res.json();
3168
+ if (!sessionRefreshTracker.isCurrent(requestToken)) {
3169
+ return;
3170
+ }
3053
3171
  const sessions = data.sessions;
3054
3172
  activeSavedSession = sessions.find((session) => session.id === data.currentSessionId) ?? null;
3055
3173
  if (activeSavedSession) {
@@ -3075,6 +3193,9 @@ async function refreshSessionList() {
3075
3193
  sessionList.appendChild(el);
3076
3194
  }
3077
3195
  } catch {
3196
+ if (!sessionRefreshTracker.isCurrent(requestToken)) {
3197
+ return;
3198
+ }
3078
3199
  activeSavedSession = null;
3079
3200
  sessionUpdateRow.classList.add("hidden");
3080
3201
  sessionList.innerHTML = '<div class="dropdown-empty">Failed to load sessions</div>';
@@ -40,6 +40,8 @@ function makeTrace(overrides = {}) {
40
40
  model: "test-model",
41
41
  variant: "baseline",
42
42
  commitSha: "abc123",
43
+ sourceWorkspaceRoot: "/workspace/source",
44
+ workspaceRoot: "/tmp/minicode-benchmark/task",
43
45
  response: "Found foo in src/foo.ts at line 10.",
44
46
  toolCalls: [
45
47
  { name: "search", input: { query: "foo" }, output: "src/foo.ts:10", durationMs: 50 },
@@ -141,6 +143,24 @@ test("loadBenchmarkTask loads a single task by id", async () => {
141
143
  await rm(tmpDir, { recursive: true });
142
144
  }
143
145
  });
146
+ test("loadBenchmarkTask preserves workspaceRoot when provided", async () => {
147
+ const tmpDir = await mkdtemp(path.join(tmpdir(), "bench-workspace-root-"));
148
+ await mkdir(path.join(tmpDir, "navigation", "fixture-task"), { recursive: true });
149
+ await writeFile(path.join(tmpDir, "navigation", "fixture-task", "task.json"), JSON.stringify({
150
+ title: "Fixture task",
151
+ prompt: "Use a fixture workspace",
152
+ workspaceRoot: "test-programs/benchmark-index",
153
+ rubric: {},
154
+ }));
155
+ try {
156
+ const task = await loadBenchmarkTask(tmpDir, "navigation/fixture-task");
157
+ assert.ok(task);
158
+ assert.equal(task.workspaceRoot, "test-programs/benchmark-index");
159
+ }
160
+ finally {
161
+ await rm(tmpDir, { recursive: true, force: true });
162
+ }
163
+ });
144
164
  test("loadBenchmarkTask returns undefined for missing task", async () => {
145
165
  const tmpDir = await createTempTaskDir();
146
166
  try {
@@ -413,6 +433,86 @@ test("runBenchmarkTask tracks symbols from structural tools", async () => {
413
433
  });
414
434
  assert.ok(trace.symbolsQueried.includes("CodingAgent"));
415
435
  });
436
+ test("runBenchmarkTask resolves task workspace overrides and isolates the run", async () => {
437
+ const repoRoot = await mkdtemp(path.join(tmpdir(), "bench-repo-root-"));
438
+ const sourceWorkspace = path.join(repoRoot, "fixtures", "sample-project");
439
+ await mkdir(path.join(sourceWorkspace, "src"), { recursive: true });
440
+ await writeFile(path.join(sourceWorkspace, "src", "index.ts"), "export const answer = 42;\n");
441
+ const task = {
442
+ id: "navigation/workspace-root",
443
+ title: "Workspace root test",
444
+ category: "navigation",
445
+ prompt: "Inspect the fixture workspace",
446
+ workspaceRoot: "fixtures/sample-project",
447
+ rubric: {},
448
+ };
449
+ try {
450
+ const config = createTestAgentConfig(repoRoot);
451
+ const trace = await runBenchmarkTask(task, {
452
+ modelClient: new MockModelClient("done"),
453
+ config,
454
+ tools: [],
455
+ variant: "test",
456
+ repoRoot,
457
+ });
458
+ assert.equal(trace.sourceWorkspaceRoot, sourceWorkspace);
459
+ assert.notEqual(trace.workspaceRoot, sourceWorkspace);
460
+ assert.ok(trace.workspaceRoot.includes("minicode-benchmark-"));
461
+ }
462
+ finally {
463
+ await rm(repoRoot, { recursive: true, force: true });
464
+ }
465
+ });
466
+ test("runBenchmarkTask uses project index metadata to count read_symbol as a file read", async () => {
467
+ const workspaceRoot = path.resolve(import.meta.dirname, "..");
468
+ const task = {
469
+ id: "navigation/structural-file-tracking",
470
+ title: "Structural file tracking",
471
+ category: "navigation",
472
+ prompt: "Read the CodingAgent symbol",
473
+ rubric: {},
474
+ };
475
+ let callCount = 0;
476
+ const mockClient = {
477
+ async chat(params) {
478
+ void params;
479
+ callCount += 1;
480
+ if (callCount === 1) {
481
+ return {
482
+ text: "Looking up symbol",
483
+ toolCalls: [{ id: "t1", name: "read_symbol", input: { name: "CodingAgent" } }],
484
+ stopReason: "tool_use",
485
+ usage: { inputTokens: 100, outputTokens: 50 },
486
+ };
487
+ }
488
+ return {
489
+ text: "Found CodingAgent",
490
+ toolCalls: [],
491
+ stopReason: "end_turn",
492
+ usage: { inputTokens: 100, outputTokens: 50 },
493
+ };
494
+ },
495
+ };
496
+ const config = createTestAgentConfig(workspaceRoot);
497
+ const trace = await runBenchmarkTask(task, {
498
+ modelClient: mockClient,
499
+ config,
500
+ variant: "v1",
501
+ isolateWorkspace: false,
502
+ createToolset: async (taskConfig) => {
503
+ const { buildProjectIndex } = await import("../src/indexer/project-index.js");
504
+ const { createToolRegistry } = await import("../src/tools/registry.js");
505
+ const projectIndex = await buildProjectIndex(taskConfig.workspaceRoot);
506
+ const toolRegistry = createToolRegistry(taskConfig, projectIndex);
507
+ return {
508
+ tools: toolRegistry.getDefinitions(),
509
+ projectIndex,
510
+ };
511
+ },
512
+ });
513
+ assert.ok(trace.symbolsQueried.includes("CodingAgent"));
514
+ assert.ok(trace.filesRead.some((file) => file.endsWith("packages/agent-sdk/src/agent/agent.ts")), "read_symbol should count the owning file as read");
515
+ });
416
516
  // ─── Reporter Tests ────────────────────────────────────────────
417
517
  test("buildReport generates correct summary", () => {
418
518
  const tasks = [
@@ -3,7 +3,7 @@ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { test } from "node:test";
6
- import { createEditFileTool, createReadFileTool } from "@minicode/agent-sdk";
6
+ import { createEditFileTool, createReadFileTool, createRunCommandTool, createWriteFileTool } from "@minicode/agent-sdk";
7
7
  import { buildProjectIndex } from "../src/indexer/project-index.js";
8
8
  import { createTestAgentConfig } from "./test-utils.js";
9
9
  async function createTempWorkspace() {
@@ -58,6 +58,39 @@ test("edit_file triggers reindex when projectIndex provided", async () => {
58
58
  const after = index.getSymbol("add");
59
59
  assert.ok(after?.signature.includes("c?: number"), "index should reflect edit");
60
60
  });
61
+ test("write_file triggers reindex when projectIndex provided", async () => {
62
+ const workspaceRoot = await createTempWorkspace();
63
+ const index = await buildProjectIndex(workspaceRoot);
64
+ const writeTool = createWriteFileTool(createTestAgentConfig(workspaceRoot), { afterWrite: (relPath, content) => index.reindexFile(relPath, content) });
65
+ await writeTool.execute({
66
+ path: "src/util.ts",
67
+ content: `export function add(a: number, b: number): number {\n return a + b;\n}\n`,
68
+ });
69
+ const added = index.getSymbol("add");
70
+ assert.ok(added?.signature.includes("a: number, b: number"), "index should reflect newly written file");
71
+ });
72
+ test("run_command refreshes index after shell-created file changes", async () => {
73
+ const workspaceRoot = await createTempWorkspace();
74
+ const index = await buildProjectIndex(workspaceRoot);
75
+ const runTool = createRunCommandTool(createTestAgentConfig(workspaceRoot), { afterCommand: async () => index.refreshFromWorkspace() });
76
+ await runTool.execute({
77
+ command: "mkdir -p src && cat <<'EOF' > src/util.ts\nexport function add(a: number, b: number): number {\n return a + b;\n}\nEOF",
78
+ });
79
+ const added = index.getSymbol("add");
80
+ assert.ok(added?.signature.includes("a: number, b: number"), "index should reflect shell-created file");
81
+ });
82
+ test("run_command refresh removes deleted files from the index", async () => {
83
+ const workspaceRoot = await createTempWorkspace();
84
+ const { mkdir } = await import("node:fs/promises");
85
+ const filePath = path.join(workspaceRoot, "src", "util.ts");
86
+ await mkdir(path.dirname(filePath), { recursive: true });
87
+ await writeFile(filePath, `export function add(a: number, b: number): number {\n return a + b;\n}\n`, "utf8");
88
+ const index = await buildProjectIndex(workspaceRoot);
89
+ assert.ok(index.getSymbol("add"));
90
+ const runTool = createRunCommandTool(createTestAgentConfig(workspaceRoot), { afterCommand: async () => index.refreshFromWorkspace() });
91
+ await runTool.execute({ command: "rm src/util.ts" });
92
+ assert.equal(index.getSymbol("add"), undefined, "deleted file should be removed from the index");
93
+ });
61
94
  test("read_file supports negative offset and line limits", async () => {
62
95
  const workspaceRoot = await createTempWorkspace();
63
96
  const filePath = path.join(workspaceRoot, "lines.txt");
@@ -1,7 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import path from "node:path";
3
3
  import { test } from "node:test";
4
- import { createProjectIndex } from "../src/indexer/project-index.js";
4
+ import { mkdtemp, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { buildProjectIndex, createProjectIndex } from "../src/indexer/project-index.js";
5
7
  import { createFindPathTool } from "../src/tools/find-path.js";
6
8
  function makeSymbol(name, kind = "function") {
7
9
  return {
@@ -169,7 +171,6 @@ test("find_path tool reports no path when symbols are disconnected", async () =>
169
171
  assert.ok(result.includes("No path found"));
170
172
  });
171
173
  test("find_path tool works with real project index", async () => {
172
- const { buildProjectIndex } = await import("../src/indexer/project-index.js");
173
174
  const root = path.resolve(import.meta.dirname, "..");
174
175
  const projectIndex = await buildProjectIndex(root);
175
176
  const tool = createFindPathTool(projectIndex);
@@ -181,3 +182,43 @@ test("find_path tool works with real project index", async () => {
181
182
  assert.ok(result.includes("# Path from createModelClient to AgentConfig"));
182
183
  assert.ok(result.includes("symbols"));
183
184
  });
185
+ test("find_path returns disambiguation list for ambiguous bare target symbols", async () => {
186
+ const workspaceRoot = await mkdtemp(path.join(tmpdir(), "minicode-find-path-collisions-"));
187
+ await writeFile(path.join(workspaceRoot, "sample.ts"), `export type Review = { id: string };
188
+
189
+ export class Review {
190
+ constructor(public id: string) {}
191
+ }
192
+
193
+ export function createReview(id: string) {
194
+ return new Review(id);
195
+ }
196
+ `, "utf8");
197
+ const projectIndex = await buildProjectIndex(workspaceRoot);
198
+ const tool = createFindPathTool(projectIndex);
199
+ const result = await tool.execute({ from: "createReview", to: "Review" });
200
+ assert.ok(result.includes('Symbol "Review" is ambiguous'));
201
+ assert.ok(result.includes("Review (type)"));
202
+ assert.ok(result.includes("Review (class)"));
203
+ assert.ok(result.includes("qualified: Review#type"));
204
+ assert.ok(result.includes("qualified: Review#class"));
205
+ });
206
+ test("find_path accepts qualified names for colliding symbols", async () => {
207
+ const workspaceRoot = await mkdtemp(path.join(tmpdir(), "minicode-find-path-qualified-"));
208
+ await writeFile(path.join(workspaceRoot, "sample.ts"), `export type Review = { id: string };
209
+
210
+ export class Review {
211
+ constructor(public id: string) {}
212
+ }
213
+
214
+ export function createReview(id: string) {
215
+ return new Review(id);
216
+ }
217
+ `, "utf8");
218
+ const projectIndex = await buildProjectIndex(workspaceRoot);
219
+ const tool = createFindPathTool(projectIndex);
220
+ const result = await tool.execute({ from: "createReview", to: "Review#class" });
221
+ assert.ok(result.includes("# Path from createReview to Review (class)"));
222
+ assert.ok(result.includes("[function] createReview"));
223
+ assert.ok(result.includes("[class] Review (class)"));
224
+ });
@@ -1,6 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import path from "node:path";
3
3
  import { test } from "node:test";
4
+ import { mkdtemp, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
4
6
  import { buildProjectIndex } from "../src/indexer/project-index.js";
5
7
  import { createFindReferencesTool } from "../src/tools/find-references.js";
6
8
  test("find_references returns symbols that reference ProjectIndex", async () => {
@@ -28,3 +30,50 @@ test("find_references appears in tool registry when projectIndex provided", asyn
28
30
  const findRefs = schemas.find((s) => s.name === "find_references");
29
31
  assert.ok(findRefs);
30
32
  });
33
+ test("find_references returns disambiguation list for ambiguous bare symbol names", async () => {
34
+ const workspaceRoot = await mkdtemp(path.join(tmpdir(), "minicode-find-references-collisions-"));
35
+ await writeFile(path.join(workspaceRoot, "sample.ts"), `export type Review = { id: string };
36
+
37
+ export function serializeReview(review: Review) {
38
+ return review.id;
39
+ }
40
+
41
+ export class Review {
42
+ constructor(public id: string) {}
43
+ }
44
+
45
+ export function createReview(id: string) {
46
+ return new Review(id);
47
+ }
48
+ `, "utf8");
49
+ const projectIndex = await buildProjectIndex(workspaceRoot);
50
+ const tool = createFindReferencesTool(projectIndex);
51
+ const result = await tool.execute({ name: "Review" });
52
+ assert.ok(result.includes('Symbol "Review" is ambiguous'));
53
+ assert.ok(result.includes("Review (type)"));
54
+ assert.ok(result.includes("Review (class)"));
55
+ assert.ok(result.includes("qualified: Review#type"));
56
+ assert.ok(result.includes("qualified: Review#class"));
57
+ });
58
+ test("find_references accepts qualified names for colliding symbols", async () => {
59
+ const workspaceRoot = await mkdtemp(path.join(tmpdir(), "minicode-find-references-qualified-"));
60
+ await writeFile(path.join(workspaceRoot, "sample.ts"), `export type Review = { id: string };
61
+
62
+ export function serializeReview(review: Review) {
63
+ return review.id;
64
+ }
65
+
66
+ export class Review {
67
+ constructor(public id: string) {}
68
+ }
69
+
70
+ export function createReview(id: string) {
71
+ return new Review(id);
72
+ }
73
+ `, "utf8");
74
+ const projectIndex = await buildProjectIndex(workspaceRoot);
75
+ const tool = createFindReferencesTool(projectIndex);
76
+ const result = await tool.execute({ name: "Review#class" });
77
+ assert.ok(result.includes("# References to Review (class)"));
78
+ assert.ok(result.includes("createReview"));
79
+ });
@@ -1,6 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import path from "node:path";
3
3
  import { test } from "node:test";
4
+ import { mkdtemp, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
4
6
  import { buildProjectIndex } from "../src/indexer/project-index.js";
5
7
  import { createGetDependenciesTool } from "../src/tools/get-dependencies.js";
6
8
  test("get_dependencies returns dependency cone for createModelClient", async () => {
@@ -33,3 +35,24 @@ test("get_dependencies returns error for unknown symbol", async () => {
33
35
  const result = await tool.execute({ name: "NonExistent" });
34
36
  assert.ok(result.includes("not found"));
35
37
  });
38
+ test("get_dependencies returns disambiguation list for ambiguous bare symbol names", async () => {
39
+ const workspaceRoot = await mkdtemp(path.join(tmpdir(), "minicode-get-dependencies-collisions-"));
40
+ await writeFile(path.join(workspaceRoot, "sample.ts"), `export type Review = { id: string };
41
+
42
+ export class Review {
43
+ constructor(public id: string) {}
44
+ }
45
+
46
+ export function createReview(id: string) {
47
+ return new Review(id);
48
+ }
49
+ `, "utf8");
50
+ const projectIndex = await buildProjectIndex(workspaceRoot);
51
+ const tool = createGetDependenciesTool(projectIndex);
52
+ const result = await tool.execute({ name: "Review" });
53
+ assert.ok(result.includes('Symbol "Review" is ambiguous'));
54
+ assert.ok(result.includes("Review (type)"));
55
+ assert.ok(result.includes("Review (class)"));
56
+ assert.ok(result.includes("qualified: Review#type"));
57
+ assert.ok(result.includes("qualified: Review#class"));
58
+ });
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { compareGraphNodeIds, getGraphNodeLabel, matchesGraphNodeQuery, resolveGraphNodeIds, } from "../src/shared/graph-symbols.js";
4
+ test("getGraphNodeLabel prefers display names for graph collisions", () => {
5
+ assert.equal(getGraphNodeLabel({ name: "Review (class)", qualifiedName: "Review#class" }), "Review (class)");
6
+ assert.equal(getGraphNodeLabel({ qualifiedName: "Session.trim" }), "trim");
7
+ });
8
+ test("resolveGraphNodeIds returns all exact bare-name collisions", () => {
9
+ const nodes = new Map([
10
+ ["Review#type", { name: "Review (type)", qualifiedName: "Review#type", exported: true }],
11
+ ["Review#class", { name: "Review (class)", qualifiedName: "Review#class", exported: true }],
12
+ ["Review.constructor", { name: "Review.constructor", qualifiedName: "Review.constructor", exported: false }],
13
+ ]);
14
+ assert.deepEqual(resolveGraphNodeIds(nodes, "Review"), [
15
+ "Review#class",
16
+ "Review#type",
17
+ ]);
18
+ assert.deepEqual(resolveGraphNodeIds(nodes, "Review (class)"), [
19
+ "Review#class",
20
+ ]);
21
+ assert.deepEqual(resolveGraphNodeIds(nodes, "Review#type"), [
22
+ "Review#type",
23
+ ]);
24
+ });
25
+ test("matchesGraphNodeQuery supports disambiguated labels and bare collision names", () => {
26
+ const typeNode = { name: "Review (type)", qualifiedName: "Review#type" };
27
+ const classNode = { name: "Review (class)", qualifiedName: "Review#class" };
28
+ assert.equal(matchesGraphNodeQuery("Review", typeNode, "Review#type"), true);
29
+ assert.equal(matchesGraphNodeQuery("Review", classNode, "Review#class"), true);
30
+ assert.equal(matchesGraphNodeQuery("class", classNode, "Review#class"), true);
31
+ assert.equal(matchesGraphNodeQuery("type", classNode, "Review#class"), false);
32
+ });
33
+ test("compareGraphNodeIds sorts exported collisions by label", () => {
34
+ const nodes = new Map([
35
+ ["Review#type", { name: "Review (type)", qualifiedName: "Review#type", exported: true }],
36
+ ["Review#class", { name: "Review (class)", qualifiedName: "Review#class", exported: true }],
37
+ ["internalReviewHelper", { name: "internalReviewHelper", qualifiedName: "internalReviewHelper", exported: false }],
38
+ ]);
39
+ const sorted = [...nodes.keys()].sort((a, b) => compareGraphNodeIds(a, b, nodes));
40
+ assert.deepEqual(sorted, [
41
+ "Review#class",
42
+ "Review#type",
43
+ "internalReviewHelper",
44
+ ]);
45
+ });