diff --git a/.gitignore b/.gitignore index 8c57465bf..0ddbdb8cc 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ pi-*.html # Generated files packages/coding-agent/src/internal-urls/docs-index.generated.ts +packages/typescript-edit-benchmark/runs/ + +all_models_results.json diff --git a/bun.lock b/bun.lock index 8410fac93..1918af9a6 100644 --- a/bun.lock +++ b/bun.lock @@ -90,30 +90,6 @@ "@types/bun": "^1.3", }, }, - "packages/react-edit-benchmark": { - "name": "@oh-my-pi/react-edit-benchmark", - "version": "0.0.1", - "bin": { - "react-edit-benchmark": "src/index.ts", - }, - "dependencies": { - "@babel/generator": "^7.29", - "@babel/parser": "^7.29", - "@babel/traverse": "^7.29", - "@babel/types": "^7.29", - "@oh-my-pi/pi-agent-core": "workspace:*", - "@oh-my-pi/pi-coding-agent": "workspace:*", - "@oh-my-pi/pi-utils": "workspace:*", - "diff": "^8.0", - "prettier": "^3.8", - "regexp-tree": "^0.1", - }, - "devDependencies": { - "@types/babel__generator": "^7.27", - "@types/babel__traverse": "^7.28", - "@types/bun": "^1.3", - }, - }, "packages/stats": { "name": "@oh-my-pi/omp-stats", "version": "13.19.0", @@ -165,6 +141,30 @@ "chalk": "^5.6", }, }, + "packages/typescript-edit-benchmark": { + "name": "@oh-my-pi/typescript-edit-benchmark", + "version": "0.0.1", + "bin": { + "typescript-edit-benchmark": "src/index.ts", + }, + "dependencies": { + "@babel/generator": "^7.29", + "@babel/parser": "^7.29", + "@babel/traverse": "^7.29", + "@babel/types": "^7.29", + "@oh-my-pi/pi-agent-core": "workspace:*", + "@oh-my-pi/pi-coding-agent": "workspace:*", + "@oh-my-pi/pi-utils": "workspace:*", + "diff": "^8.0", + "prettier": "^3.8", + "regexp-tree": "^0.1", + }, + "devDependencies": { + "@types/babel__generator": "^7.27", + "@types/babel__traverse": "^7.28", + "@types/bun": "^1.3", + }, + }, "packages/utils": { "name": "@oh-my-pi/pi-utils", "version": "13.19.0", @@ -331,10 +331,10 @@ "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@workspace:packages/utils"], - "@oh-my-pi/react-edit-benchmark": ["@oh-my-pi/react-edit-benchmark@workspace:packages/react-edit-benchmark"], - "@oh-my-pi/swarm-extension": ["@oh-my-pi/swarm-extension@workspace:packages/swarm-extension"], + "@oh-my-pi/typescript-edit-benchmark": ["@oh-my-pi/typescript-edit-benchmark@workspace:packages/typescript-edit-benchmark"], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], diff --git a/package.json b/package.json index d591ded5e..bfd8e0723 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ "fix:rs": "cargo fmt --all && cargo clippy --fix --allow-dirty --all-targets --no-deps --allow-staged --broken-code --allow-no-vcs", "build:native": "bun --cwd=packages/natives run build:native", "dev:native": "bun --cwd=packages/natives run dev:native", - "bench:gen-fixtures": "bun --cwd=packages/react-edit-benchmark run src/generate.ts --react-dir /tmp/react-source --count-per-type 8", - "bench:edit": "bun --cwd=packages/react-edit-benchmark run start", + "bench:gen-fixtures": "bun --cwd=packages/typescript-edit-benchmark run src/generate.ts --typescript-dir /tmp/typescript-source --count-per-type 8", + "bench:edit": "bun --cwd=packages/typescript-edit-benchmark run start", "prepublishOnly": "bun run check", "prepare": "bun --cwd=packages/coding-agent run generate-docs-index", "publish": "bun run prepublishOnly && npm publish -ws --access public", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e1477c733..7403c40c1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,14 +3,36 @@ ## [Unreleased] ### Added +- Autoresearch `init_experiment` options `from_autoresearch_md` (load contract from `autoresearch.md` with only `name`) and `abandon_unlogged_runs` (stamp pending run artifacts abandoned) +- Autoresearch `run_experiment` option `force` to override benchmark command equality and the direct-`autoresearch.sh` invocation rule (with warnings when used) +- `abandonUnloggedAutoresearchRuns` helper and `abandonedAt` run metadata so abandoned artifacts are ignored as pending runs + +### Changed + +- Autoresearch `log_experiment` refreshes benchmark/scope/constraints from `autoresearch.md` after resolving a pending run (so keep validation matches disk); success output includes a short refresh notice +- Autoresearch `log_experiment` `force` also skips ASI requirements and allows keeping a primary-metric regression versus the best kept run in the segment + +### Added + - Support for LSP diagnostic versioning to track document versions and suppress stale diagnostics - Options parameter to `waitForDiagnostics` for controlling diagnostic freshness validation with `expectedDocumentVersion` and `allowUnversioned` flags ### Changed +- `/autoresearch` with no `autoresearch.md` matches `/plan`-style flow: bare command enables mode and waits for the next composer message when off, or disables when already on; non-empty slash args are submitted as the user message only; setup protocol lives in the autoresearch system prompt (`command-initialize.md` removed) - Enabled `versionSupport` in LSP client capabilities to receive diagnostic version information from servers - Diagnostics storage now tracks both diagnostics and their associated document version for freshness validation - Updated `getDiagnosticsForFile` to accept options object instead of positional parameters for better extensibility +- Redesigned chunk-mode `read` output to use a single recursive chunk rendering rule with `$XXXX` checksum suffixes and inline large-chunk previews +- Normalized copied `#chunk_path$XXXX` selectors across chunk-mode `read` and `edit` inputs so pasted chunk headers resolve without manual cleanup + +### Fixed +- `/autoresearch` with no arguments toggles off when mode is already on (same idea as `/plan`), and slash-argument completion no longer offers `off`/`clear` on an empty prefix so Tab after the command does not auto-insert a subcommand. +- Fixed chunk-mode edit/read edge cases around zero-width gap splices, stale batch diagnostics, grouped Go receiver rendering, rendered line-count headers, and parse rejection location details. + +### Removed + +- Autoresearch segment fingerprint hashing and `segmentFingerprint` on experiment state / `autoresearch.jsonl` config lines ## [13.19.0] - 2026-04-05 ### Added @@ -6635,4 +6657,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections \ No newline at end of file +- HTML export with syntax highlighting and collapsible sections diff --git a/packages/coding-agent/src/exec/idle-timeout-watchdog.ts b/packages/coding-agent/src/exec/idle-timeout-watchdog.ts new file mode 100644 index 000000000..fa7b4d715 --- /dev/null +++ b/packages/coding-agent/src/exec/idle-timeout-watchdog.ts @@ -0,0 +1,126 @@ +export type ExecutionAbortReason = "idle-timeout" | "signal"; + +export interface IdleTimeoutWatchdogOptions { + timeoutMs?: number; + signal?: AbortSignal; + hardTimeoutGraceMs: number; + onAbort?: (reason: ExecutionAbortReason) => void; +} + +export class IdleTimeoutWatchdog { + #abortController = new AbortController(); + #abortReason?: ExecutionAbortReason; + #hardTimeoutDeferred = Promise.withResolvers<"hard-timeout">(); + #hardTimeoutGraceMs: number; + #hardTimeoutTimer?: NodeJS.Timeout; + #idleTimer?: NodeJS.Timeout; + #onAbort?: (reason: ExecutionAbortReason) => void; + #signal?: AbortSignal; + #signalAbortHandler?: () => void; + #timeoutMs?: number; + + constructor(options: IdleTimeoutWatchdogOptions) { + this.#timeoutMs = options.timeoutMs; + this.#hardTimeoutGraceMs = options.hardTimeoutGraceMs; + this.#onAbort = options.onAbort; + this.#signal = options.signal; + + if (this.#signal) { + if (this.#signal.aborted) { + this.#abort("signal"); + return; + } + + this.#signalAbortHandler = () => { + this.#abort("signal"); + }; + this.#signal.addEventListener("abort", this.#signalAbortHandler, { once: true }); + } + + this.touch(); + } + + get abortedBySignal(): boolean { + return this.#abortReason === "signal"; + } + + get hardTimeoutPromise(): Promise<"hard-timeout"> { + return this.#hardTimeoutDeferred.promise; + } + + get signal(): AbortSignal { + return this.#abortController.signal; + } + + get timedOut(): boolean { + return this.#abortReason === "idle-timeout"; + } + + touch(): void { + if (this.#abortReason || this.#timeoutMs === undefined || this.#timeoutMs <= 0) { + return; + } + + if (this.#idleTimer) { + clearTimeout(this.#idleTimer); + } + + this.#idleTimer = setTimeout(() => { + this.#abort("idle-timeout"); + }, this.#timeoutMs); + } + + dispose(): void { + if (this.#idleTimer) { + clearTimeout(this.#idleTimer); + this.#idleTimer = undefined; + } + if (this.#hardTimeoutTimer) { + clearTimeout(this.#hardTimeoutTimer); + this.#hardTimeoutTimer = undefined; + } + if (this.#signal && this.#signalAbortHandler) { + this.#signal.removeEventListener("abort", this.#signalAbortHandler); + this.#signalAbortHandler = undefined; + } + } + + #abort(reason: ExecutionAbortReason): void { + if (this.#abortReason) { + return; + } + + this.#abortReason = reason; + + if (this.#idleTimer) { + clearTimeout(this.#idleTimer); + this.#idleTimer = undefined; + } + + if (!this.#abortController.signal.aborted) { + this.#abortController.abort(reason); + } + + this.#onAbort?.(reason); + this.#armHardTimeout(); + } + + #armHardTimeout(): void { + if (this.#hardTimeoutTimer || this.#hardTimeoutGraceMs <= 0) { + return; + } + + this.#hardTimeoutTimer = setTimeout(() => { + this.#hardTimeoutDeferred.resolve("hard-timeout"); + }, this.#hardTimeoutGraceMs); + } +} + +export function formatIdleTimeoutMessage(timeoutMs?: number): string { + if (timeoutMs === undefined) { + return "Command timed out without output"; + } + + const seconds = Math.max(1, Math.round(timeoutMs / 1000)); + return `Command timed out after ${seconds} seconds without output`; +} diff --git a/packages/coding-agent/src/tools/fs-cache-invalidation.ts b/packages/coding-agent/src/tools/fs-cache-invalidation.ts index 91fd88d3a..c65849801 100644 --- a/packages/coding-agent/src/tools/fs-cache-invalidation.ts +++ b/packages/coding-agent/src/tools/fs-cache-invalidation.ts @@ -1,10 +1,12 @@ import { invalidateFsScanCache } from "@oh-my-pi/pi-natives"; +import { invalidateChunkTreeCache } from "./chunk-tree"; /** * Invalidate shared filesystem scan caches after a content write/update. */ export function invalidateFsScanAfterWrite(path: string): void { invalidateFsScanCache(path); + invalidateChunkTreeCache(path); } /** @@ -12,6 +14,7 @@ export function invalidateFsScanAfterWrite(path: string): void { */ export function invalidateFsScanAfterDelete(path: string): void { invalidateFsScanCache(path); + invalidateChunkTreeCache(path); } /** @@ -22,7 +25,9 @@ export function invalidateFsScanAfterDelete(path: string): void { */ export function invalidateFsScanAfterRename(oldPath: string, newPath: string): void { invalidateFsScanCache(oldPath); + invalidateChunkTreeCache(oldPath); if (newPath !== oldPath) { invalidateFsScanCache(newPath); + invalidateChunkTreeCache(newPath); } } diff --git a/packages/coding-agent/src/tools/output-meta.ts b/packages/coding-agent/src/tools/output-meta.ts index 4c819c677..171cd6cfd 100644 --- a/packages/coding-agent/src/tools/output-meta.ts +++ b/packages/coding-agent/src/tools/output-meta.ts @@ -337,7 +337,7 @@ export function formatTruncationMetaNotice(truncation: TruncationMeta): string { } if (truncation.nextOffset != null) { - notice += `. Use offset=${truncation.nextOffset} to continue`; + notice += `. Use sel=L${truncation.nextOffset} to continue`; } if (truncation.artifactId != null) { diff --git a/packages/coding-agent/src/utils/file-display-mode.ts b/packages/coding-agent/src/utils/file-display-mode.ts index 4c5ad17d9..95840bb21 100644 --- a/packages/coding-agent/src/utils/file-display-mode.ts +++ b/packages/coding-agent/src/utils/file-display-mode.ts @@ -2,6 +2,8 @@ * Resolve line-display mode for file-like outputs (read, grep, @file mentions). */ +import { resolveEditMode } from "./edit-mode"; + export interface FileDisplayMode { lineNumbers: boolean; hashLines: boolean; @@ -24,11 +26,7 @@ export interface FileDisplayModeSession { export function resolveFileDisplayMode(session: FileDisplayModeSession): FileDisplayMode { const { settings } = session; const hasEditTool = session.hasEditTool ?? true; - const hashLines = - hasEditTool && - (settings.get("readHashLines") === true || - settings.get("edit.mode") === "hashline" || - Bun.env.PI_EDIT_VARIANT === "hashline"); + const hashLines = hasEditTool && resolveEditMode(session) === "hashline" && settings.get("readHashLines") !== false; return { hashLines, lineNumbers: hashLines || settings.get("readLineNumbers") === true, diff --git a/packages/coding-agent/test/streaming-output.test.ts b/packages/coding-agent/test/streaming-output.test.ts index 56d719d47..9de2e490c 100644 --- a/packages/coding-agent/test/streaming-output.test.ts +++ b/packages/coding-agent/test/streaming-output.test.ts @@ -335,7 +335,7 @@ describe("truncation notice formatting", () => { test("formatHeadTruncationNotice formats head truncation range", () => { const lineTruncation = truncateHead("l1\nl2\nl3", { maxLines: 2, maxBytes: 100 }); - expect(formatHeadTruncationNotice(lineTruncation)).toBe("\n\n[Showing lines 1-2 of 3. Use offset=3 to continue]"); + expect(formatHeadTruncationNotice(lineTruncation)).toBe("\n\n[Showing lines 1-2 of 3. Use sel=L3 to continue]"); const byteTruncation = truncateHead("12345\nabc\nz", { maxLines: 10, maxBytes: 7 }); expect( @@ -343,6 +343,6 @@ describe("truncation notice formatting", () => { startLine: 100, totalFileLines: 500, }), - ).toBe("\n\n[Showing lines 100-100 of 500. Use offset=101 to continue]"); + ).toBe("\n\n[Showing lines 100-100 of 500. Use sel=L101 to continue]"); }); }); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 11119be53..f14c190f9 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -262,7 +262,7 @@ describe("Coding Agent Tools", () => { expect(output).toContain("Line 2"); expect(output).toContain("Line 3"); // No truncation message since file fits within limits - expect(getTextOutput(result)).not.toContain("Use offset="); + expect(getTextOutput(result)).not.toContain("Use sel="); expect(result.details?.truncation).toBeUndefined(); }); @@ -325,7 +325,7 @@ describe("Coding Agent Tools", () => { expect(output).toContain(`Line ${defaultLimit}`); expect(output).not.toContain(`Line ${defaultLimit + 1}`); expect(output).toContain( - `[Showing lines 1-${defaultLimit} of 3500. Use offset=${defaultLimit + 1} to continue]`, + `[Showing lines 1-${defaultLimit} of 3500. Use sel=L${defaultLimit + 1} to continue]`, ); }); @@ -341,7 +341,7 @@ describe("Coding Agent Tools", () => { expect(output).toContain("Line 1:"); // Should show byte limit message expect(output).toMatch( - /\[Showing lines 1-\d+ of 1000 \(\d+(\.\d+)?\s*KB limit\)\. Use offset=\d+ to continue\]/, + /\[Showing lines 1-\d+ of 1000 \(\d+(\.\d+)?\s*KB limit\)\. Use sel=L\d+ to continue\]/, ); }); @@ -350,14 +350,14 @@ describe("Coding Agent Tools", () => { const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); fs.writeFileSync(testFile, lines.join("\n")); - const result = await readTool.execute("test-call-5", { path: testFile, offset: 51 }); + const result = await readTool.execute("test-call-5", { path: testFile, sel: "L51" }); const output = getTextOutput(result); expect(output).not.toContain("Line 50"); expect(output).toContain("Line 51"); expect(output).toContain("Line 100"); // No truncation message since file fits within limits - expect(output).not.toContain("Use offset="); + expect(output).not.toContain("Use sel="); }); it("should handle limit parameter", async () => { @@ -365,13 +365,13 @@ describe("Coding Agent Tools", () => { const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); fs.writeFileSync(testFile, lines.join("\n")); - const result = await readTool.execute("test-call-6", { path: testFile, limit: 10 }); + const result = await readTool.execute("test-call-6", { path: testFile, sel: "L1-L10" }); const output = getTextOutput(result); expect(output).toContain("Line 1"); expect(output).toContain("Line 10"); expect(output).not.toContain("Line 11"); - expect(output).toContain("[Showing lines 1-10 of 100. Use offset=11 to continue]"); + expect(output).toContain("[Showing lines 1-10 of 100. Use sel=L11 to continue]"); }); it("should handle offset + limit together", async () => { @@ -381,8 +381,7 @@ describe("Coding Agent Tools", () => { const result = await readTool.execute("test-call-7", { path: testFile, - offset: 41, - limit: 20, + sel: "L41-L60", }); const output = getTextOutput(result); @@ -390,18 +389,18 @@ describe("Coding Agent Tools", () => { expect(output).toContain("Line 41"); expect(output).toContain("Line 60"); expect(output).not.toContain("Line 61"); - expect(output).toContain("[Showing lines 41-60 of 100. Use offset=61 to continue]"); + expect(output).toContain("[Showing lines 41-60 of 100. Use sel=L61 to continue]"); }); it("should show error when offset is beyond file length", async () => { const testFile = path.join(testDir, "short.txt"); fs.writeFileSync(testFile, "Line 1\nLine 2\nLine 3"); - const result = await readTool.execute("test-call-8", { path: testFile, offset: 100 }); + const result = await readTool.execute("test-call-8", { path: testFile, sel: "L100" }); const output = getTextOutput(result); - expect(output).toContain("Offset 100 is beyond end of file (3 lines total)"); - expect(output).toContain("Use offset=1 to read from the start, or offset=3 to read the last line."); + expect(output).toContain("Line 100 is beyond end of file (3 lines total)"); + expect(output).toContain("Use sel=L1 to read from the start, or sel=L3 to read the last line."); }); it("should include truncation details when truncated", async () => { @@ -492,14 +491,14 @@ describe("Coding Agent Tools", () => { const result = await readTool.execute("test-call-archive-subpath", { path: `${archivePath}:pkg/README.md`, - limit: 2, + sel: "L1-L2", }); const output = getTextOutput(result); expect(output).toContain("# Archive README"); expect(output).toContain("Line 2"); expect(output).not.toContain("Line 3"); - expect(output).toContain("Use offset=3"); + expect(output).toContain("Use sel=L3"); }); } @@ -1010,7 +1009,8 @@ function b() { const output = getTextOutput(result); expect(output).not.toContain("# example.txt"); - expect(output).toMatch(/>>\s*2#[ZPMQVRWSNKTXJBYH]{2}:match line/); + // PI_EDIT_VARIANT=replace in beforeEach disables hashlines; expect line-number mode + expect(output).toMatch(/\b2:match line/); }); it("should accept wildcard patterns in the path parameter", async () => { @@ -1069,9 +1069,9 @@ function b() { const output = getTextOutput(result); expect(output).not.toContain("# context.txt"); - expect(output).toMatch(/\b1#[ZPMQVRWSNKTXJBYH]{2}:before/); - expect(output).toMatch(/>>\s*2#[ZPMQVRWSNKTXJBYH]{2}:match one/); - expect(output).toMatch(/\b3#[ZPMQVRWSNKTXJBYH]{2}:after/); + expect(output).toMatch(/\b1-before/); + expect(output).toMatch(/\b2:match one/); + expect(output).toMatch(/\b3-after/); expect(output).toContain("[1 matches limit reached. Use limit=2 for more]"); // Ensure second match is not present expect(output).not.toContain("match two"); diff --git a/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts b/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts index 2747e0271..dc6ef5a0f 100644 --- a/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts +++ b/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts @@ -261,7 +261,7 @@ describe("read tool URL handling", () => { content: "not really an image", }); - const result = await tool.execute("fetch-html-png-path", { path: "https://example.com/foo.png", raw: true }); + const result = await tool.execute("fetch-html-png-path", { path: "https://example.com/foo.png", sel: "raw" }); const imageBlock = result.content.find(content => content.type === "image"); const textBlock = result.content.find(content => content.type === "text"); @@ -603,8 +603,7 @@ describe("read tool URL handling", () => { const pagedResult = await tool.execute("fetch-offset-page", { path: pageUrl, - offset: 7, - limit: 2, + sel: "L7-L8", }); const pagedText = pagedResult.content.find(content => content.type === "text"); expect(pagedText?.type).toBe("text"); diff --git a/packages/coding-agent/test/tools/lsp-regressions.test.ts b/packages/coding-agent/test/tools/lsp-regressions.test.ts index 8df4cecda..b3e651ce0 100644 --- a/packages/coding-agent/test/tools/lsp-regressions.test.ts +++ b/packages/coding-agent/test/tools/lsp-regressions.test.ts @@ -1,11 +1,15 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core"; +import { getServersForFile, loadConfig } from "@oh-my-pi/pi-coding-agent/lsp/config"; import { renderCall, renderResult } from "@oh-my-pi/pi-coding-agent/lsp/render"; import type { CodeAction, SymbolInformation } from "@oh-my-pi/pi-coding-agent/lsp/types"; import { applyCodeAction, collectGlobMatches, dedupeWorkspaceSymbols, + detectLanguageId, filterWorkspaceSymbols, hasGlobPattern, resolveSymbolColumn, @@ -223,4 +227,30 @@ describe("lsp regressions", () => { expect(normalizedResultText).toContain("occurrence: 2"); expect(resultText).not.toContain("\t"); }); + + it("detects tlaplus files for LSP startup and language ids", async () => { + const tempDir = TempDir.createSync("@omp-lsp-tlaplus-"); + const specPath = path.join(tempDir.path(), "Spec.tla"); + const aliasPath = path.join(tempDir.path(), "Spec.tlaplus"); + + await Bun.write(specPath, "---- MODULE Spec ----\n====\n"); + + const whichSpy = vi + .spyOn(Bun, "which") + .mockImplementation(command => (command === "tlapm_lsp" ? "/usr/local/bin/tlapm_lsp" : null)); + const existsSpy = vi + .spyOn(fs, "existsSync") + .mockImplementation(candidate => typeof candidate === "string" && candidate === specPath); + + try { + const config = loadConfig(tempDir.path()); + expect(getServersForFile(config, specPath).map(([name]) => name)).toEqual(["tlaplus"]); + expect(whichSpy).toHaveBeenCalledWith("tlapm_lsp"); + expect(existsSpy).toHaveBeenCalled(); + expect(detectLanguageId(specPath)).toBe("tlaplus"); + expect(detectLanguageId(aliasPath)).toBe("tlaplus"); + } finally { + tempDir.removeSync(); + } + }); });