@aefree/pi-unity 0.10.0 → 0.12.0

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.
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type } from "typebox";
4
- import { mkdir, readdir, stat, unlink } from "node:fs/promises";
4
+ import { mkdir, readFile, readdir, stat, unlink } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, join, resolve } from "node:path";
6
6
  import { setTimeout as delay } from "node:timers/promises";
7
7
  import { getKeybindings, Text, truncateToWidth } from "@earendil-works/pi-tui";
@@ -20,13 +20,15 @@ import {
20
20
  type UnityParsedTestResults,
21
21
  } from "./src/unity-batchmode";
22
22
  import { formatPathForUser, hasUnityCommandLineFlag } from "./src/unity-core";
23
- import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
24
- import { createUnityBatchmodeCommand, launchUnityCliOpenDetached, launchUnityEditorDetached, resolveUnityEditorPath } from "./src/unity-launch";
23
+ import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, createUnityCliTestCommand, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
24
+ import { launchUnityCliOpenDetached } from "./src/unity-launch";
25
+ import { createUnityBatchmodeCommand, launchUnityEditorDetached, resolveUnityEditorPath } from "./src/unity-editor-fallback";
25
26
  import { loadPiUnitySettings, type PiUnitySettings } from "./src/pi-unity-settings";
26
27
  import { dedupeRunningUnityProcesses, listRunningUnityProcessesForProject, redactUnityProcessCommandLine, terminateRunningUnityProcesses, verifyUnityProcessIdentity, type RunningUnityProcess } from "./src/unity-processes";
27
28
  import { assertUnityProjectNotBusy, evaluateUnityLaunchSafety, getUnityNativeLockfilePath, inspectUnityProjectBusyState, withUnityProjectLaunchMutex } from "./src/unity-project-lock";
28
- import { resolveUnityProjectCandidates, type UnityProjectCandidate } from "./src/unity-projects";
29
+ import { readUnityVersion, resolveUnityProjectCandidates, type UnityProjectCandidate } from "./src/unity-projects";
29
30
  import { createUnityTestBatchPlan, type UnityTestBatchPlan, type UnityTestPlatform } from "./src/unity-test-batch";
31
+ import { applyUnityCliRetrySummary, compactUnityTestSummary, defaultUnityTestReportFormats, deriveUnityCliEffectiveReportPath, determineUnityTestOutcome, getUnityTestRouteRequirements, normalizeUnityRunTestsRequest, parseUnityCliRetrySummary, resolveUnityCliBackendReportPaths, writeNormalizedUnityTestArtifact, type NormalizedUnityTestResult, type UnityRunTestsRequest } from "./src/unity-tests";
30
32
  import { auditUnityGuidance, type UnityGuidanceAuditResult } from "./src/unity-guidance-audit";
31
33
  import { runUnityPipelineRecompile, runUnityPipelineTests, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
32
34
  import {
@@ -66,7 +68,7 @@ const GUI_WARNING = "This launches the full Unity Editor GUI and is not the same
66
68
  const SINGLE_PROCESS_WARNING = "Unity allows only one process per project folder. GUI Editor and batchmode/headless both count as that one process.";
67
69
 
68
70
  type UnityToolDetails = {
69
- mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline";
71
+ mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline" | "tests";
70
72
  projectRoot: string;
71
73
  unityVersion: string;
72
74
  editorPath: string;
@@ -102,32 +104,38 @@ type UnityLauncherPreference = "auto" | "unity-cli" | "editor-executable";
102
104
 
103
105
  const OPEN_EDITOR_PARAMS = Type.Object({
104
106
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
105
- unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
106
107
  automated: Type.Optional(Type.Boolean({ default: false, description: "Pass Unity Editor's -automated flag when opening the project. Defaults to false." })),
107
108
  launcher: LAUNCHER_SCHEMA,
108
- });
109
+ }, { additionalProperties: false });
109
110
 
110
111
  const LAUNCH_BATCHMODE_PARAMS = Type.Object({
111
112
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
112
- unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
113
+
113
114
  args: Type.Optional(Type.Array(Type.String(), { description: "Additional Unity command-line arguments appended after -batchmode -projectPath <project> for direct editor launch, or forwarded after `unity run <project> --` for Unity CLI launch. pi-unity adds -nographics by default unless useGraphics=true." })),
114
115
  useGraphics: Type.Optional(Type.Boolean({ default: false, description: "Set true only when the requested Unity batchmode work requires an active graphics device, such as screenshots, rendering, or visual PlayMode tests. Defaults to false, which adds -nographics." })),
115
116
  timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 14400, default: 3600, description: "Timeout in seconds for the batchmode process." })),
116
117
  launcher: LAUNCHER_SCHEMA,
117
118
  closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false, description: "When true, pi-unity may close a running Unity process for the resolved project before launch, but only if piUnity.allowCloseRunningUnityProcess is enabled in Pi settings. The process is selected by project matching, not by model-supplied PID." })),
118
- });
119
+ }, { additionalProperties: false });
119
120
 
120
- const RUN_TEST_BATCH_PARAMS = Type.Object({
121
+ const RUN_TESTS_PARAMS = Type.Object({
121
122
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
122
- unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
123
- testPlatform: StringEnum(["EditMode", "PlayMode"] as const, { description: "Unity Test Framework platform. One batch runs exactly one test platform." }),
124
- testFilters: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "Full test names or regex filters. Values are normalized into one semicolon-separated -testFilter argument." })),
125
- testCategories: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "Categories or category regex/negations. Values are normalized into one semicolon-separated -testCategory argument." })),
126
- useGraphics: Type.Optional(Type.Boolean({ default: false, description: "Set true only for graphics-dependent PlayMode tests or visual capture. Defaults to headless -nographics." })),
123
+ testPlatform: StringEnum(["EditMode", "PlayMode"] as const),
124
+ testFilters: Type.Optional(Type.Array(Type.String(), { maxItems: 50 })),
125
+ testCategories: Type.Optional(Type.Array(Type.String(), { maxItems: 50 })),
126
+ execution: Type.Optional(StringEnum(["auto", "connected", "isolated"] as const, { default: "auto" })),
127
+ isolatedLauncher: Type.Optional(StringEnum(["auto", "unity-cli", "editor-executable"] as const, { default: "auto" })),
128
+ retries: Type.Optional(Type.Integer({ minimum: 0, maximum: 20, default: 0 })),
129
+ rerunFailed: Type.Optional(Type.Boolean({ default: false })),
130
+ shard: Type.Optional(Type.String({ maxLength: 500 })),
131
+ shardInventoryPath: Type.Optional(Type.String({ maxLength: 1000 })),
132
+ reportFormats: Type.Optional(Type.Array(StringEnum(["json", "nunit", "junit"] as const), { maxItems: 3 })),
133
+ coverage: Type.Optional(Type.Boolean({ default: false })),
134
+ coverageOptions: Type.Optional(Type.String({ maxLength: 1000 })),
135
+ useGraphics: Type.Optional(Type.Boolean({ default: false })),
127
136
  timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 14400, default: 3600 })),
128
- launcher: LAUNCHER_SCHEMA,
129
- closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false, description: "Use guarded same-project Unity process closure only when piUnity.allowCloseRunningUnityProcess is enabled." })),
130
- });
137
+ closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false })),
138
+ }, { additionalProperties: false });
131
139
 
132
140
  const PROJECT_STATUS_PARAMS = Type.Object({
133
141
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
@@ -168,6 +176,7 @@ const GUIDANCE_AUDIT_PARAMS = Type.Object({
168
176
  const INSPECT_ARTIFACTS_PARAMS = Type.Object({
169
177
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
170
178
  testResultsPath: Type.Optional(Type.String({ description: "Unity Test Framework XML results path. Relative paths are resolved against cwd and the Unity project root." })),
179
+ normalizedResultPath: Type.Optional(Type.String({ description: "pi-unity normalized JSON test artifact path." })),
171
180
  logFilePath: Type.Optional(Type.String({ description: "Unity log file path. Relative paths are resolved against cwd and the Unity project root." })),
172
181
  latestFromLogs: Type.Optional(Type.Boolean({ default: true, description: "When paths are omitted, inspect the newest .xml and .log files under the project's Logs folder." })),
173
182
  maxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 60, description: "Maximum log/output lines to include." })),
@@ -175,7 +184,7 @@ const INSPECT_ARTIFACTS_PARAMS = Type.Object({
175
184
  });
176
185
 
177
186
  function buildProjectChoiceLabel(cwd: string, candidate: UnityProjectCandidate): string {
178
- return `${candidate.projectName} (${candidate.unityVersion}) — ${formatPathForUser(cwd, candidate.projectRoot)}`;
187
+ return `${candidate.projectName} — ${formatPathForUser(cwd, candidate.projectRoot)}`;
179
188
  }
180
189
 
181
190
  async function chooseProjectCandidateWithWrappingNavigation(
@@ -246,7 +255,7 @@ async function chooseProjectCandidateWithWrappingNavigation(
246
255
 
247
256
  function formatCandidateList(cwd: string, candidates: UnityProjectCandidate[]): string {
248
257
  return candidates
249
- .map((candidate) => `- ${candidate.projectName} (${candidate.unityVersion}) — ${formatPathForUser(cwd, candidate.projectRoot)}`)
258
+ .map((candidate) => `- ${candidate.projectName} — ${formatPathForUser(cwd, candidate.projectRoot)}`)
250
259
  .join("\n");
251
260
  }
252
261
 
@@ -310,6 +319,13 @@ async function resolveProjectCandidate(
310
319
  return { candidate, discoveryWarning };
311
320
  }
312
321
 
322
+ async function requireManualUnityVersion(candidate: UnityProjectCandidate): Promise<string> {
323
+ if (candidate.unityVersion) return candidate.unityVersion;
324
+ const unityVersion = await readUnityVersion(candidate.projectRoot);
325
+ candidate.unityVersion = unityVersion;
326
+ return unityVersion;
327
+ }
328
+
313
329
  function joinWarnings(...warnings: Array<string | undefined>): string | undefined {
314
330
  const present = warnings.filter((warning): warning is string => Boolean(warning && warning.trim().length > 0));
315
331
  return present.length > 0 ? present.join("\n") : undefined;
@@ -425,11 +441,12 @@ async function closeBlockingUnityProcessesForBatchmode(
425
441
  );
426
442
  }
427
443
 
428
- const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate.unityVersion, { signal });
444
+ const unityVersion = await requireManualUnityVersion(candidate);
445
+ const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
429
446
  const canRequestGracefulExit = cliCapabilities.commandDiscoverySucceeded && cliCapabilities.advertisedCommands.includes("eval");
430
447
  if (canRequestGracefulExit) {
431
448
  const refreshedRunning = await listBlockingUnityProcesses(candidate.projectRoot);
432
- const refreshedCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate.unityVersion, { signal });
449
+ const refreshedCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
433
450
  const samePids = haveSameKnownProcessIds(running.processes, refreshedRunning.processes);
434
451
  const samePipelinePids = haveSameKnownProcessIds(cliCapabilities.matchingInstances, refreshedCapabilities.matchingInstances);
435
452
  if (refreshedRunning.warning || !samePids || !samePipelinePids || !refreshedCapabilities.advertisedCommands.includes("eval")) {
@@ -578,7 +595,8 @@ async function buildProjectStatusReport(
578
595
  const lockState = await inspectUnityProjectBusyState(candidate.projectRoot);
579
596
  const cliStatus = await listRunningUnityCliEditorsForProject(candidate.projectRoot);
580
597
  const processStatus = await listRunningUnityProcessesForProject(candidate.projectRoot);
581
- const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate.unityVersion, { signal });
598
+ const unityVersion = await requireManualUnityVersion(candidate);
599
+ const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
582
600
  const runningProcesses = dedupeRunningUnityProcesses([...cliStatus.processes, ...processStatus.processes]);
583
601
  const isBusy = runningProcesses.length > 0 || cliCapabilities.matchingInstances.length > 0;
584
602
  const staleLockSuspected = lockState.nativeLockfileExists && !isBusy && !processStatus.warning;
@@ -586,7 +604,7 @@ async function buildProjectStatusReport(
586
604
  const piUnitySettings = await loadPiUnitySettings(ctx);
587
605
 
588
606
  const lines = [
589
- `Unity project status for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using Unity ${candidate.unityVersion}.`,
607
+ `Unity project status for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using declared Unity ${unityVersion}.`,
590
608
  `- Native lockfile: ${lockState.nativeLockfileExists ? "present" : "absent"}`,
591
609
  `- Lockfile path: ${lockState.nativeLockfilePath}`,
592
610
  `- Running Unity processes targeting project: ${runningProcesses.length}`,
@@ -635,7 +653,7 @@ async function buildProjectStatusReport(
635
653
  details: {
636
654
  mode: "status",
637
655
  projectRoot: candidate.projectRoot,
638
- unityVersion: candidate.unityVersion,
656
+ unityVersion: await requireManualUnityVersion(candidate),
639
657
  editorPath: "",
640
658
  warning: combinedWarning,
641
659
  status: "passed",
@@ -685,7 +703,7 @@ function compactUnityArtifacts(artifacts: UnityBatchmodeArtifacts): UnityBatchmo
685
703
  async function buildArtifactInspectionReport(
686
704
  ctx: ExtensionContext,
687
705
  candidate: UnityProjectCandidate,
688
- params: { testResultsPath?: string; logFilePath?: string; latestFromLogs?: boolean; maxLines?: number; maxChars?: number },
706
+ params: { testResultsPath?: string; normalizedResultPath?: string; logFilePath?: string; latestFromLogs?: boolean; maxLines?: number; maxChars?: number },
689
707
  ): Promise<{ text: string; details: UnityToolDetails }> {
690
708
  const useLatest = params.latestFromLogs !== false;
691
709
  const logsRoot = join(candidate.projectRoot, "Logs");
@@ -693,6 +711,15 @@ async function buildArtifactInspectionReport(
693
711
  ?? (useLatest ? await findNewestFile(logsRoot, [".xml"]) : undefined);
694
712
  const logFilePath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.logFilePath)
695
713
  ?? (useLatest ? await findNewestFile(logsRoot, [".log", ".txt"]) : undefined);
714
+ const normalizedResultPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.normalizedResultPath)
715
+ ?? (useLatest ? await findNewestFile(logsRoot, [".json"]) : undefined);
716
+ let normalizedSummary: string | undefined;
717
+ if (normalizedResultPath) {
718
+ try {
719
+ const normalized = JSON.parse(await readFile(normalizedResultPath, "utf8")) as Partial<NormalizedUnityTestResult>;
720
+ if (normalized.schemaVersion === 1 && typeof normalized.outcome === "string") normalizedSummary = `Normalized test result: ${normalized.platform ?? "Unity"} ${normalized.outcome}; ${normalized.summary?.total ?? "unknown"} total.`;
721
+ } catch { normalizedSummary = `Normalized test result JSON could not be parsed: ${normalizedResultPath}`; }
722
+ }
696
723
  const invocation: UnityBatchmodeInvocation = {
697
724
  isTestRun: Boolean(testResultsPath),
698
725
  usesNoGraphics: false,
@@ -707,9 +734,11 @@ async function buildArtifactInspectionReport(
707
734
  const hasLoadedArtifacts = Boolean(artifacts.testResultsPath || artifacts.logFilePath);
708
735
  const status = deriveUnityArtifactInspectionStatus(hasLoadedArtifacts, invocation, parsedTestResults);
709
736
  const lines = [
710
- `Unity artifacts inspected for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using Unity ${candidate.unityVersion}.`,
737
+ `Unity artifacts inspected for ${formatPathForUser(ctx.cwd, candidate.projectRoot)}; Unity CLI selects the project's declared Editor version when launching.`,
711
738
  testResultsPath ? `Requested test results: ${testResultsPath}` : "Requested test results: (none found)",
712
739
  logFilePath ? `Requested log file: ${logFilePath}` : "Requested log file: (none found)",
740
+ normalizedResultPath ? `Requested normalized result: ${normalizedResultPath}` : "Requested normalized result: (none found)",
741
+ ...(normalizedSummary ? [normalizedSummary] : []),
713
742
  ];
714
743
 
715
744
  if (parsedTestResults) {
@@ -732,7 +761,7 @@ async function buildArtifactInspectionReport(
732
761
  details: {
733
762
  mode: "artifacts",
734
763
  projectRoot: candidate.projectRoot,
735
- unityVersion: candidate.unityVersion,
764
+ unityVersion: await requireManualUnityVersion(candidate),
736
765
  editorPath: "",
737
766
  invocation,
738
767
  artifacts: compactUnityArtifacts(artifacts),
@@ -750,7 +779,9 @@ function buildEditorLaunchSummary(
750
779
  launcher: "unity-cli" | "editor-executable" = "editor-executable",
751
780
  ): string {
752
781
  return [
753
- `Launched Unity Editor GUI for ${formatPathForUser(cwd, candidate.projectRoot)} using Unity ${candidate.unityVersion}.`,
782
+ launcher === "unity-cli"
783
+ ? `Launched Unity Editor GUI for ${formatPathForUser(cwd, candidate.projectRoot)} with the project-declared Editor version selected by Unity CLI.`
784
+ : `Launched Unity Editor GUI for ${formatPathForUser(cwd, candidate.projectRoot)} using the project's declared Unity version through the direct Editor fallback.`,
754
785
  launcher === "unity-cli" ? `Launcher: unity open (${editorPath})` : `Editor: ${editorPath}`,
755
786
  GUI_WARNING,
756
787
  SINGLE_PROCESS_WARNING,
@@ -777,7 +808,7 @@ async function buildBatchmodeReport(
777
808
  const status = deriveUnityBatchmodeStatus(result.code, Boolean(result.killed), invocation, parsedTestResults);
778
809
  const text = buildUnityBatchmodeAgentText({
779
810
  displayProjectPath: formatPathForUser(ctx.cwd, candidate.projectRoot),
780
- unityVersion: candidate.unityVersion,
811
+ unityVersion: await requireManualUnityVersion(candidate),
781
812
  editorPath,
782
813
  exitCode: result.code,
783
814
  killed: Boolean(result.killed),
@@ -795,7 +826,7 @@ async function buildBatchmodeReport(
795
826
  details: {
796
827
  mode: "batchmode",
797
828
  projectRoot: candidate.projectRoot,
798
- unityVersion: candidate.unityVersion,
829
+ unityVersion: await requireManualUnityVersion(candidate),
799
830
  editorPath,
800
831
  command: editorPath,
801
832
  args,
@@ -904,16 +935,42 @@ function throwIfAborted(signal?: AbortSignal): void {
904
935
  }
905
936
  }
906
937
 
907
- async function canUseUnityCli(pi: ExtensionAPI, signal?: AbortSignal): Promise<boolean> {
938
+ const UNITY_CLI_WARNING_SYMBOL = Symbol.for("@aefree/pi-unity/unity-cli-warning/v1");
939
+ const unityCliRuntimeState = globalThis as Record<PropertyKey, unknown>;
940
+ const UNITY_CLI_WARNING = "pi-unity capability warning: Unity CLI is unavailable or UNITY_CLI_PATH is invalid. Install Unity CLI, then restart or reload Pi to restore pi-unity's primary project-launch workflow.";
941
+
942
+ async function warnWhenUnityCliUnavailable(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
943
+ if (!ctx.hasUI || unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] === true) return;
944
+ try {
945
+ const result = await pi.exec(resolveUnityCliCommand(), ["--version"], { timeout: 5000 });
946
+ // Timeouts are uncertain and must neither warn nor select another backend.
947
+ if (result.killed || result.code === 0 || unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] === true) return;
948
+ unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] = true;
949
+ ctx.ui.notify(UNITY_CLI_WARNING, "warning");
950
+ } catch (error) {
951
+ if ((error instanceof Error && error.name === "AbortError") || isUnityCliProbeTimeout(error)) return;
952
+ if (unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] === true) return;
953
+ unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] = true;
954
+ ctx.ui.notify(UNITY_CLI_WARNING, "warning");
955
+ }
956
+ }
957
+ type UnityCliAvailability = "available" | "unavailable" | "uncertain";
958
+
959
+ function isUnityCliProbeTimeout(error: unknown): boolean {
960
+ return Boolean(error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ETIMEDOUT");
961
+ }
962
+
963
+ async function getUnityCliAvailability(pi: ExtensionAPI, signal?: AbortSignal): Promise<UnityCliAvailability> {
908
964
  try {
909
965
  throwIfAborted(signal);
910
966
  const command = resolveUnityCliCommand();
911
967
  const result = await pi.exec(command, ["--version"], { signal, timeout: 5000 });
912
968
  throwIfAborted(signal);
913
- return !result.killed && result.code === 0;
969
+ if (result.killed) return "uncertain";
970
+ return result.code === 0 ? "available" : "unavailable";
914
971
  } catch (error) {
915
972
  if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw error;
916
- return false;
973
+ return isUnityCliProbeTimeout(error) ? "uncertain" : "unavailable";
917
974
  }
918
975
  }
919
976
 
@@ -963,16 +1020,18 @@ async function shouldUseUnityCli(
963
1020
  return false;
964
1021
  }
965
1022
 
966
- const available = await canUseUnityCli(pi, signal);
967
- if (preference === "unity-cli" && !available) {
1023
+ const availability = await getUnityCliAvailability(pi, signal);
1024
+ if (availability === "uncertain") {
1025
+ throw new Error("Unity CLI availability could not be confirmed because its bounded probe timed out. Retry the request; pi-unity will not select the direct Editor fallback after an uncertain Unity CLI probe.");
1026
+ }
1027
+ if (preference === "unity-cli" && availability === "unavailable") {
968
1028
  throw new Error("Unity CLI launcher was requested, but the `unity` command is not available. Set UNITY_CLI_PATH or use launcher='editor-executable'.");
969
1029
  }
970
1030
 
971
- return available;
1031
+ return availability === "available";
972
1032
  }
973
1033
 
974
1034
  type GuardedBatchmodeParams = {
975
- unityEditorPath?: string;
976
1035
  args?: string[];
977
1036
  useGraphics?: boolean;
978
1037
  timeoutSeconds?: number;
@@ -1004,17 +1063,14 @@ async function runGuardedUnityBatchmode(
1004
1063
  const invocation = parseUnityBatchmodeInvocation(createUnityCliBatchmodeReportArgs(candidate.projectRoot, extraArgs, { useGraphics }));
1005
1064
  const useUnityCli = await shouldUseUnityCli(pi, params.launcher, signal);
1006
1065
  throwIfAborted(signal);
1007
- const editorPath = useUnityCli
1008
- ? await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath }).catch(() => "Unity CLI resolved editor")
1009
- : await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath });
1010
- const command = useUnityCli
1011
- ? createUnityCliRunCommand(candidate.projectRoot, extraArgs, {
1012
- editorVersion: candidate.unityVersion,
1013
- editorPath: params.unityEditorPath,
1014
- timeoutSeconds,
1015
- useGraphics,
1016
- })
1017
- : createUnityBatchmodeCommand(editorPath, candidate.projectRoot, extraArgs, { useGraphics });
1066
+ let editorPath = "Unity CLI";
1067
+ let command: { command: string; args: string[] };
1068
+ if (useUnityCli) {
1069
+ command = createUnityCliRunCommand(candidate.projectRoot, extraArgs, { timeoutSeconds, useGraphics });
1070
+ } else {
1071
+ editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
1072
+ command = createUnityBatchmodeCommand(editorPath, candidate.projectRoot, extraArgs, { useGraphics });
1073
+ }
1018
1074
  const closeReport = await closeBlockingUnityProcessesForBatchmode(
1019
1075
  pi,
1020
1076
  ctx,
@@ -1079,6 +1135,96 @@ async function runGuardedUnityBatchmode(
1079
1135
  );
1080
1136
  }
1081
1137
 
1138
+ async function runUnifiedUnityTests(
1139
+ pi: ExtensionAPI,
1140
+ ctx: ExtensionContext,
1141
+ candidate: UnityProjectCandidate,
1142
+ discoveryWarning: string | undefined,
1143
+ raw: UnityRunTestsRequest,
1144
+ signal: AbortSignal | undefined,
1145
+ onUpdate?: (update: { content: Array<{ type: "text"; text: string }> }) => void,
1146
+ allowAutonomousExitPlayMode = true,
1147
+ ): Promise<{ content: Array<{ type: "text"; text: string }>; details: UnityToolDetails & { testResult: NormalizedUnityTestResult; artifactPath: string; route: "connected" | "isolated" } }> {
1148
+ const request = normalizeUnityRunTestsRequest(raw);
1149
+ const requirements = getUnityTestRouteRequirements(request);
1150
+ const capabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, await requireManualUnityVersion(candidate), { signal, execute: createPipelineUnityCliExecutor(pi) });
1151
+ const reachable = capabilities.matchingInstances.some(instance => instance.reachable === true);
1152
+ const busy = (await listBlockingUnityProcesses(candidate.projectRoot)).processes.length > 0 || capabilities.matchingInstances.length > 0;
1153
+ let route: "connected" | "isolated";
1154
+ if (request.execution === "connected") {
1155
+ if (requirements.requiresIsolation) throw new Error(`Connected execution cannot honor this request: ${requirements.reasons.join("; ")}.`);
1156
+ if (!reachable) throw new Error("Connected execution requires an already-open exact-copy reachable Pipeline Editor; no Unity was launched.");
1157
+ route = "connected";
1158
+ } else if (request.execution === "isolated") {
1159
+ if (reachable && !request.closeBlockingUnityProcess) throw new Error("Isolated execution will not close a reachable Pipeline Editor automatically. Close it first or use the explicitly guarded close option.");
1160
+ route = "isolated";
1161
+ } else if (reachable) {
1162
+ if (requirements.requiresIsolation) throw new Error(`This request requires isolated execution (${requirements.reasons.join("; ")}), but the exact project copy is open in reachable Pipeline. pi-unity will not close it automatically.`);
1163
+ route = "connected";
1164
+ } else {
1165
+ if (capabilities.pipelineDiscovery === "timeout" || (capabilities.projectSupportsPipeline && capabilities.pipelineDiscovery !== "absent" && capabilities.pipelineDiscovery !== "available")) throw new Error("Pipeline discovery is uncertain for this exact project copy; refusing to start an isolated Editor until project state is known.");
1166
+ if (busy) throw new Error("A Unity process is already open for this exact project copy without reachable Pipeline; refusing to launch a second process.");
1167
+ route = "isolated";
1168
+ }
1169
+ const formats = request.reportFormats ?? defaultUnityTestReportFormats(route);
1170
+ if (route === "connected") {
1171
+ const result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), testPlatform: request.testPlatform, testFilter: request.testFilters[0], testCategory: request.testCategories[0], timeoutSeconds: request.timeoutSeconds, allowAutonomousExitPlayMode }, createPipelineDependencies(pi), { signal, onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }) });
1172
+ const counts = result.details.counts!;
1173
+ const normalized: NormalizedUnityTestResult = {
1174
+ schemaVersion: 1, source: "pipeline", platform: request.testPlatform,
1175
+ selection: { testFilters: request.testFilters, testCategories: request.testCategories },
1176
+ durationSeconds: result.details.elapsedSeconds, outcome: determineUnityTestOutcome(counts), summary: counts, tests: result.testRecords ?? [],
1177
+ };
1178
+ const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
1179
+ const text = `${compactUnityTestSummary(normalized)}\nRoute: connected Pipeline. Normalized artifact: ${artifactPath}`;
1180
+ return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: normalized.outcome === "passed" ? "passed" : "failed", pipeline: result.details, testResult: { ...normalized, tests: [] }, artifactPath, route } };
1181
+ }
1182
+ if (request.isolatedLauncher === "editor-executable" && (request.retries || request.rerunFailed || request.shard || request.coverage || formats.includes("junit"))) throw new Error("The direct Editor fallback cannot honor CLI-only retry, rerun, shard, coverage, or JUnit options.");
1183
+ const plan = createUnityTestBatchPlan({ projectRoot: candidate.projectRoot, testPlatform: request.testPlatform, testFilters: request.testFilters, testCategories: request.testCategories });
1184
+ const cliAvailable = await shouldUseUnityCli(pi, request.isolatedLauncher, signal);
1185
+ if (!cliAvailable && request.isolatedLauncher === "unity-cli") throw new Error("Unity CLI was requested but is unavailable.");
1186
+ if (!cliAvailable && (request.retries || request.rerunFailed || request.shard || request.coverage || formats.includes("junit"))) throw new Error("Unity CLI is unavailable and the requested options have no direct Editor fallback.");
1187
+ if (!cliAvailable) {
1188
+ const fallback = await runGuardedUnityBatchmode(pi, ctx, candidate, discoveryWarning, { args: plan.args, useGraphics: request.useGraphics, timeoutSeconds: request.timeoutSeconds, launcher: "editor-executable", closeBlockingUnityProcess: request.closeBlockingUnityProcess }, signal, "unity_run_test_batch");
1189
+ const parsed = fallback.details.parsedTestResults;
1190
+ const summary = { total: parsed?.total, passed: parsed?.passed, failed: parsed?.failed, skipped: parsed?.skipped };
1191
+ const normalized: NormalizedUnityTestResult = { schemaVersion: 1, source: "editor-executable", platform: request.testPlatform, selection: { testFilters: request.testFilters, testCategories: request.testCategories }, outcome: determineUnityTestOutcome(summary), summary, tests: parsed?.tests ?? [] };
1192
+ const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
1193
+ return { content: [{ type: "text", text: `${compactUnityTestSummary(normalized)}\nRoute: isolated direct Editor. Normalized artifact: ${artifactPath}` }], details: { ...fallback.details, mode: "tests", testResult: { ...normalized, tests: [] }, artifactPath, route } };
1194
+ }
1195
+ return await withUnityProjectLaunchMutex(candidate.projectRoot, { mode: "batchmode", toolName: "unity_run_tests" }, async () => {
1196
+ const invocation = parseUnityBatchmodeInvocation(plan.args);
1197
+ const closeReport = await closeBlockingUnityProcessesForBatchmode(pi, ctx, candidate, invocation, request.closeBlockingUnityProcess, signal);
1198
+ await removeStaleLockfileAfterGuardedClose(candidate, closeReport);
1199
+ await enforceLaunchRouteSafety(candidate.projectRoot, "unity-cli");
1200
+ const command = createUnityCliTestCommand(candidate.projectRoot, { testPlatform: request.testPlatform, testFilters: request.testFilters, testCategories: request.testCategories, retries: request.retries, rerunFailed: request.rerunFailed, shard: request.shard, shardInventoryPath: request.shardInventoryPath, reportPaths: resolveUnityCliBackendReportPaths({ nunit: plan.testResultsPath, junit: plan.junitResultsPath, log: plan.logFilePath }, formats), coverage: request.coverage, coverageOptions: request.coverageOptions, useGraphics: request.useGraphics, timeoutSeconds: request.timeoutSeconds });
1201
+ const execution = await pi.exec(command.command, command.args, { signal, timeout: (request.timeoutSeconds ?? 3600) * 1000 + 30_000 });
1202
+ const effectiveResultsPath = deriveUnityCliEffectiveReportPath(plan.testResultsPath, { rerunFailed: request.rerunFailed, shard: request.shard });
1203
+ let effectiveResultsXml: string | undefined;
1204
+ try { effectiveResultsXml = await readFile(effectiveResultsPath, "utf8"); } catch { /* Missing current-run evidence is handled below. */ }
1205
+ const parsed = effectiveResultsXml ? parseUnityTestResultsXml(effectiveResultsXml) : null;
1206
+ const summary = { total: parsed?.total, passed: parsed?.passed, failed: parsed?.failed, skipped: parsed?.skipped };
1207
+ const outcome = execution.killed ? "timed_out" : execution.code === 8 ? "tests_failed" : execution.code === 6 ? "run_error" : determineUnityTestOutcome(summary);
1208
+ let normalized: NormalizedUnityTestResult = { schemaVersion: 1, source: "unity-cli", platform: request.testPlatform, selection: { testFilters: request.testFilters, testCategories: request.testCategories }, outcome: (request.rerunFailed || request.shard) && execution.code === 0 && !parsed ? "empty_selection" : outcome, summary, tests: parsed?.tests ?? [], backendArtifacts: { ...(parsed && formats.includes("nunit") ? { nunit: `Logs/${effectiveResultsPath.split(/[\\/]/).pop()}` } : {}), ...(parsed && formats.includes("junit") ? { junit: `Logs/${deriveUnityCliEffectiveReportPath(plan.junitResultsPath, { rerunFailed: request.rerunFailed, shard: request.shard }).split(/[\\/]/).pop()}` } : {}), log: `Logs/${plan.logFilePath.split(/[\\/]/).pop()}` } };
1209
+ if (request.retries > 0 && formats.includes("nunit")) {
1210
+ const retryPath = effectiveResultsPath.replace(/\.[^.\\/]+$/, ".retries.json");
1211
+ try {
1212
+ const retry = parseUnityCliRetrySummary(JSON.parse(await readFile(retryPath, "utf8")));
1213
+ if (!retry) normalized = { ...normalized, outcome: "uncertain" };
1214
+ else {
1215
+ normalized = applyUnityCliRetrySummary(normalized, retry);
1216
+ normalized.backendArtifacts = { ...normalized.backendArtifacts, retrySummary: `Logs/${retryPath.split(/[\\/]/).pop()}` };
1217
+ }
1218
+ } catch {
1219
+ normalized = { ...normalized, outcome: "uncertain" };
1220
+ }
1221
+ }
1222
+ const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
1223
+ const text = `${compactUnityTestSummary(normalized)}\nRoute: isolated Unity CLI. Normalized artifact: ${artifactPath}`;
1224
+ return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "Unity CLI", status: outcome === "passed" || outcome === "passed_with_flakes" || outcome === "empty_selection" ? "passed" : "failed", command: command.command, cliArgs: command.args, testBatch: plan, testResult: { ...normalized, tests: [] }, artifactPath, route } };
1225
+ });
1226
+ }
1227
+
1082
1228
  function renderUnityPipelineResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: any, context: { lastComponent?: unknown }): Text {
1083
1229
  const details = result.details as UnityToolDetails | undefined;
1084
1230
  const primaryText = getToolTextContent(result);
@@ -1216,6 +1362,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1216
1362
 
1217
1363
  pi.on("session_start", async (_event, ctx) => {
1218
1364
  restoreSessionSettings(ctx);
1365
+ await warnWhenUnityCliUnavailable(pi, ctx);
1219
1366
  const scope = ctx.sessionManager;
1220
1367
  unregisterScope(registrations.get(scope));
1221
1368
  // Optional package integrations resolve independently. The Unity extension and
@@ -1293,14 +1440,14 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1293
1440
  await withUnityProjectLaunchMutex(candidate.projectRoot, { mode: "gui", toolName: "unity-open" }, async () => {
1294
1441
  await enforceSingleProcessRule(candidate.projectRoot);
1295
1442
  let launcher: "unity-cli" | "editor-executable" = "editor-executable";
1296
- let editorPath = await resolveUnityEditorPath(candidate.unityVersion).catch(() => "Unity CLI resolved editor");
1443
+ let editorPath = "Unity CLI";
1297
1444
  let launch: { pid: number | undefined; args: string[]; command: string };
1298
- if (await canUseUnityCli(pi)) {
1445
+ if (await shouldUseUnityCli(pi, undefined)) {
1299
1446
  launcher = "unity-cli";
1300
- launch = launchUnityCliOpenDetached(candidate.projectRoot, { editorVersion: candidate.unityVersion });
1447
+ launch = launchUnityCliOpenDetached(candidate.projectRoot);
1301
1448
  } else {
1302
1449
  await assertUnityProjectNotBusy(candidate.projectRoot);
1303
- editorPath = await resolveUnityEditorPath(candidate.unityVersion);
1450
+ editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
1304
1451
  launch = launchUnityEditorDetached(editorPath, candidate.projectRoot);
1305
1452
  }
1306
1453
  const summary = buildEditorLaunchSummary(ctx.cwd, candidate, editorPath, discoveryWarning, launcher);
@@ -1392,6 +1539,27 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1392
1539
  },
1393
1540
  });
1394
1541
 
1542
+ pi.registerTool({
1543
+ name: "unity_run_tests",
1544
+ label: "Unity Run Tests",
1545
+ description: "Run Unity Test Framework tests through one intent-oriented workflow. It reuses a reachable exact-copy Pipeline Editor when compatible, otherwise uses isolated `unity test` execution.",
1546
+ promptSnippet: "Run Unity EditMode or PlayMode tests through one safe routed workflow with durable normalized evidence.",
1547
+ promptGuidelines: [
1548
+ "Use unity_run_tests for ordinary Unity Test Framework runs. It selects connected Pipeline only for compatible requests and isolated unity test only when the exact project copy is closed.",
1549
+ "Do not use unity_launch_batchmode for ordinary tests; raw test flags there are an unsupported escape hatch.",
1550
+ "A reachable Editor is never closed merely to obtain isolated-only options. Requests needing retries, sharding, reruns, coverage, multiple selectors, or XML reports are rejected before dispatch when it is open.",
1551
+ "Timeout, malformed evidence, cancellation, or missing artifacts never cause a backend fallback or relaunch.",
1552
+ ],
1553
+ parameters: RUN_TESTS_PARAMS,
1554
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
1555
+ throwIfAborted(signal);
1556
+ const { candidate, discoveryWarning } = await resolveProjectCandidate(ctx, params.path);
1557
+ return await runUnifiedUnityTests(pi, ctx, candidate, discoveryWarning, params as UnityRunTestsRequest, signal, onUpdate, sessionAllowsAutonomousPlayModeExit(ctx));
1558
+ },
1559
+ renderCall(args, theme, context) { return renderUnityToolCall("unity_run_tests", args, theme, "tests", `${args.testPlatform ?? "Unity"} • ${compactUnityRendererValue(args.testFilters?.[0] ?? args.testFilter ?? args.execution ?? "auto", 100)}`, context); },
1560
+ renderResult(result, { expanded }, theme) { return renderUnityToolResult(result, expanded, theme); },
1561
+ });
1562
+
1395
1563
  pi.registerTool({
1396
1564
  name: "unity_pipeline_recompile",
1397
1565
  label: "Unity Pipeline Recompile",
@@ -1406,47 +1574,19 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1406
1574
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1407
1575
  throwIfAborted(signal);
1408
1576
  const { candidate } = await resolveProjectCandidate(ctx, params.path);
1409
- const result = await runUnityPipelineRecompile({ projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, timeoutSeconds: params.timeoutSeconds, allowAutonomousExitPlayMode: sessionAllowsAutonomousPlayModeExit(ctx) }, createPipelineDependencies(pi), {
1577
+ const result = await runUnityPipelineRecompile({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), timeoutSeconds: params.timeoutSeconds, allowAutonomousExitPlayMode: sessionAllowsAutonomousPlayModeExit(ctx) }, createPipelineDependencies(pi), {
1410
1578
  signal,
1411
1579
  onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }),
1412
1580
  });
1413
1581
  return {
1414
1582
  content: [{ type: "text", text: result.text }],
1415
- details: { mode: "pipeline", projectRoot: result.details.projectRoot, unityVersion: candidate.unityVersion, editorPath: "", status: "passed", pipeline: result.details } satisfies UnityToolDetails,
1583
+ details: { mode: "pipeline", projectRoot: result.details.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: "passed", pipeline: result.details } satisfies UnityToolDetails,
1416
1584
  };
1417
1585
  },
1418
1586
  renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_recompile", args, theme, context); },
1419
1587
  renderResult(result, options, theme, context) { return renderUnityPipelineResult(result, options, theme, context); },
1420
1588
  });
1421
1589
 
1422
- pi.registerTool({
1423
- name: "unity_pipeline_run_tests",
1424
- label: "Unity Pipeline Run Tests",
1425
- description: "Run one focused EditMode or PlayMode test selection through an already-open exact Unity Pipeline Editor, with internal bounded polling and aggregate output.",
1426
- promptSnippet: "Run focused connected Unity EditMode or PlayMode tests in one bounded call without shell polling; aggregate passing results stay compact.",
1427
- promptGuidelines: [
1428
- "Use unity_pipeline_run_tests for one focused connected Unity test platform when the exact Editor is already open and reachable.",
1429
- "unity_pipeline_run_tests may exit Play Mode through advertised editor_stop when needed, then verifies Edit Mode before dispatching tests.",
1430
- "Use unity_run_test_batch instead of unity_pipeline_run_tests for closed projects, isolation, complex filters/categories, or required NUnit XML/log evidence.",
1431
- "unity_pipeline_run_tests does not cancel uncertain work or switch to batchmode after timeout; report that the connected run may still be running.",
1432
- ],
1433
- parameters: PIPELINE_TEST_PARAMS,
1434
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1435
- throwIfAborted(signal);
1436
- const { candidate } = await resolveProjectCandidate(ctx, params.path);
1437
- const result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, testPlatform: params.testPlatform, testFilter: params.testFilter, timeoutSeconds: params.timeoutSeconds, allowAutonomousExitPlayMode: sessionAllowsAutonomousPlayModeExit(ctx) }, createPipelineDependencies(pi), {
1438
- signal,
1439
- onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }),
1440
- });
1441
- return {
1442
- content: [{ type: "text", text: result.text }],
1443
- details: { mode: "pipeline", projectRoot: result.details.projectRoot, unityVersion: candidate.unityVersion, editorPath: "", status: "passed", pipeline: result.details } satisfies UnityToolDetails,
1444
- };
1445
- },
1446
- renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_run_tests", args, theme, context); },
1447
- renderResult(result, options, theme, context) { return renderUnityPipelineResult(result, options, theme, context); },
1448
- });
1449
-
1450
1590
  pi.registerTool({
1451
1591
  name: "unity_pipeline_eval",
1452
1592
  label: "Unity Pipeline Eval",
@@ -1465,7 +1605,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1465
1605
  throwIfAborted(signal);
1466
1606
  const result = await dispatchUnityPlanningInspection({
1467
1607
  projectRoot: candidate.projectRoot,
1468
- unityVersion: candidate.unityVersion,
1608
+ unityVersion: await requireManualUnityVersion(candidate),
1469
1609
  command: "eval",
1470
1610
  evalSnippet: params.code,
1471
1611
  }, {
@@ -1482,7 +1622,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1482
1622
  details: {
1483
1623
  mode: "pipeline_eval",
1484
1624
  projectRoot: candidate.projectRoot,
1485
- unityVersion: candidate.unityVersion,
1625
+ unityVersion: await requireManualUnityVersion(candidate),
1486
1626
  editorPath: "",
1487
1627
  status: result.outcome === "dispatched" ? "passed" : "failed",
1488
1628
  pipelineEval: result,
@@ -1514,7 +1654,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1514
1654
  throwIfAborted(signal);
1515
1655
  const result = await dispatchUnityPlanningInspection({
1516
1656
  projectRoot: candidate.projectRoot,
1517
- unityVersion: candidate.unityVersion,
1657
+ unityVersion: await requireManualUnityVersion(candidate),
1518
1658
  command: params.command,
1519
1659
  args: params.args,
1520
1660
  }, {
@@ -1531,7 +1671,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1531
1671
  details: {
1532
1672
  mode: "pipeline_inspection",
1533
1673
  projectRoot: candidate.projectRoot,
1534
- unityVersion: candidate.unityVersion,
1674
+ unityVersion: await requireManualUnityVersion(candidate),
1535
1675
  editorPath: "",
1536
1676
  status: result.outcome === "dispatched" ? "passed" : "failed",
1537
1677
  pipelineInspection: result,
@@ -1600,18 +1740,14 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1600
1740
  const useUnityCli = await shouldUseUnityCli(pi, params.launcher as UnityLauncherPreference | undefined, signal);
1601
1741
  throwIfAborted(signal);
1602
1742
  let launcher: "unity-cli" | "editor-executable" = "editor-executable";
1603
- let editorPath = await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath }).catch(() => "Unity CLI resolved editor");
1743
+ let editorPath = "Unity CLI";
1604
1744
  let launch: { pid: number | undefined; args: string[]; command: string };
1605
1745
  if (useUnityCli) {
1606
1746
  launcher = "unity-cli";
1607
- launch = launchUnityCliOpenDetached(candidate.projectRoot, {
1608
- editorVersion: candidate.unityVersion,
1609
- editorPath: params.unityEditorPath,
1610
- automated: params.automated,
1611
- });
1747
+ launch = launchUnityCliOpenDetached(candidate.projectRoot, { automated: params.automated });
1612
1748
  } else {
1613
1749
  await assertUnityProjectNotBusy(candidate.projectRoot);
1614
- editorPath = await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath });
1750
+ editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
1615
1751
  launch = launchUnityEditorDetached(editorPath, candidate.projectRoot, { automated: params.automated });
1616
1752
  }
1617
1753
  const text = buildEditorLaunchSummary(ctx.cwd, candidate, editorPath, discoveryWarning, launcher);
@@ -1621,7 +1757,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1621
1757
  details: {
1622
1758
  mode: "gui",
1623
1759
  projectRoot: candidate.projectRoot,
1624
- unityVersion: candidate.unityVersion,
1760
+ unityVersion: await requireManualUnityVersion(candidate),
1625
1761
  editorPath,
1626
1762
  pid: launch.pid,
1627
1763
  command: launch.command,
@@ -1640,52 +1776,6 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1640
1776
  },
1641
1777
  });
1642
1778
 
1643
- pi.registerTool({
1644
- name: "unity_run_test_batch",
1645
- label: "Unity Test Batch",
1646
- description: "Run one bundled Unity Test Framework platform with normalized filters/categories and generated absolute XML/log paths under the project Logs directory.",
1647
- promptSnippet: "Run a bundled Unity EditMode or PlayMode test batch with safe generated artifact paths",
1648
- promptGuidelines: [
1649
- "Before choosing a test route, call unity_project_status for the exact project copy. If it is already open with reachable Pipeline run_tests/test_status commands, use the connected workflow without closing the Editor.",
1650
- "Prefer unity_run_test_batch over unity_launch_batchmode only for isolated or report-producing Unity Test Framework runs: closed projects, unavailable/unsupported connected testing, intentional CI isolation, unsupported filters, or required NUnit XML/log artifacts.",
1651
- "Do not set closeBlockingUnityProcess merely to switch a reachable Pipeline Editor into batchmode; use it only after isolated execution is deliberately required and the guarded setting is enabled.",
1652
- "Pass unity_run_test_batch exactly one testPlatform. Multiple test platforms require separate user-authorized launches.",
1653
- "An empty unity_run_test_batch testFilters/testCategories selection runs all tests for that testPlatform; use narrow arrays when focused evidence is sufficient.",
1654
- "Do not call unity_run_test_batch for PlayMode when user/project guidance says to skip PlayMode tests.",
1655
- "Use unity_run_test_batch useGraphics=true only for graphics-dependent PlayMode tests or visual capture; ordinary EditMode and non-visual PlayMode remain headless.",
1656
- "After unity_run_test_batch infrastructure failure, inspect the exact generated paths reported by the failed call once and do not repeat an unchanged launch.",
1657
- ],
1658
- parameters: RUN_TEST_BATCH_PARAMS,
1659
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1660
- throwIfAborted(signal);
1661
- const { candidate, discoveryWarning } = await resolveProjectCandidate(ctx, params.path);
1662
- const plan = createUnityTestBatchPlan({
1663
- projectRoot: candidate.projectRoot,
1664
- testPlatform: params.testPlatform as UnityTestPlatform,
1665
- testFilters: params.testFilters,
1666
- testCategories: params.testCategories,
1667
- });
1668
- await mkdir(dirname(plan.testResultsPath), { recursive: true });
1669
- throwIfAborted(signal);
1670
- const result = await runGuardedUnityBatchmode(pi, ctx, candidate, discoveryWarning, {
1671
- unityEditorPath: params.unityEditorPath,
1672
- args: plan.args,
1673
- useGraphics: params.useGraphics,
1674
- timeoutSeconds: params.timeoutSeconds,
1675
- launcher: params.launcher as UnityLauncherPreference | undefined,
1676
- closeBlockingUnityProcess: params.closeBlockingUnityProcess,
1677
- }, signal, "unity_run_test_batch");
1678
- result.details = { ...result.details, testBatch: plan };
1679
- return result;
1680
- },
1681
- renderCall(args, theme) {
1682
- return renderUnityToolCall("unity_run_test_batch", args, theme, "batchmode", `${args.testPlatform} test batch`);
1683
- },
1684
- renderResult(result, { expanded }, theme) {
1685
- return renderUnityToolResult(result, expanded, theme);
1686
- },
1687
- });
1688
-
1689
1779
  pi.registerTool({
1690
1780
  name: "unity_launch_batchmode",
1691
1781
  label: "Unity CLI",