@aefree/pi-unity 0.11.0 → 0.12.1

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
@@ -7,9 +7,9 @@ import { setTimeout as delay } from "node:timers/promises";
7
7
  import { getKeybindings, Text, truncateToWidth } from "@earendil-works/pi-tui";
8
8
  import {
9
9
  buildUnityBatchmodeAgentText,
10
- deriveUnityArtifactInspectionStatus,
11
10
  deriveUnityBatchmodeStatus,
12
11
  hasKnownPositiveExecutedTestCount,
12
+ hasConflictingUnityXmlTestEvidence,
13
13
  loadUnityBatchmodeArtifacts,
14
14
  parseUnityBatchmodeInvocation,
15
15
  parseUnityTestResultsXml,
@@ -21,13 +21,16 @@ import {
21
21
  } from "./src/unity-batchmode";
22
22
  import { formatPathForUser, hasUnityCommandLineFlag } from "./src/unity-core";
23
23
  import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, createUnityCliTestCommand, 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";
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";
30
- import { applyUnityCliRetrySummary, compactUnityTestSummary, defaultUnityTestReportFormats, deriveUnityCliEffectiveReportPath, determineUnityTestOutcome, getUnityTestRouteRequirements, normalizeUnityRunTestsRequest, parseUnityCliRetrySummary, writeNormalizedUnityTestArtifact, type NormalizedUnityTestResult, type UnityRunTestsRequest } from "./src/unity-tests";
31
+ import { applyUnityCliRetrySummary, compactUnityTestSummary, defaultUnityTestReportFormats, deriveUnityCliEffectiveReportPath, determineUnityTestOutcome, getUnityTestRouteRequirements, normalizeUnityRunTestsRequest, parseUnityCliRetrySummary, resolveUnityCliBackendReportPaths, writeNormalizedUnityTestArtifact, type NormalizedUnityTestResult, type UnityRunTestsRequest } from "./src/unity-tests";
32
+ import { validateNormalizedUnityTestArtifact } from "./src/unity-artifact-inspection";
33
+ import { UNITY_TEST_MAX_ARTIFACT_BYTES } from "./src/unity-tests";
31
34
  import { auditUnityGuidance, type UnityGuidanceAuditResult } from "./src/unity-guidance-audit";
32
35
  import { runUnityPipelineRecompile, runUnityPipelineTests, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
33
36
  import {
@@ -82,6 +85,10 @@ type UnityToolDetails = {
82
85
  invocation?: UnityBatchmodeInvocation;
83
86
  artifacts?: UnityBatchmodeArtifacts;
84
87
  parsedTestResults?: UnityParsedTestResults | null;
88
+ testOutcome?: NormalizedUnityTestResult["outcome"];
89
+ normalizedResultPath?: string;
90
+ normalizedResult?: Pick<NormalizedUnityTestResult, "source" | "platform" | "outcome" | "summary"> & { testRecordCount: number };
91
+ evidenceWarnings?: string[];
85
92
  status?: "passed" | "failed" | "killed";
86
93
  launcher?: "unity-cli" | "editor-executable";
87
94
  cliArgs?: string[];
@@ -103,26 +110,27 @@ type UnityLauncherPreference = "auto" | "unity-cli" | "editor-executable";
103
110
 
104
111
  const OPEN_EDITOR_PARAMS = Type.Object({
105
112
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
106
- unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
107
113
  automated: Type.Optional(Type.Boolean({ default: false, description: "Pass Unity Editor's -automated flag when opening the project. Defaults to false." })),
108
114
  launcher: LAUNCHER_SCHEMA,
109
- });
115
+ }, { additionalProperties: false });
110
116
 
111
117
  const LAUNCH_BATCHMODE_PARAMS = Type.Object({
112
118
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
113
- unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
119
+
114
120
  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." })),
115
121
  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." })),
116
122
  timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 14400, default: 3600, description: "Timeout in seconds for the batchmode process." })),
117
123
  launcher: LAUNCHER_SCHEMA,
118
124
  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." })),
119
- });
125
+ }, { additionalProperties: false });
126
+
127
+ const CONNECTED_TEST_SELECTOR_GUIDANCE = 'unity_run_tests connected execution supports one testFilters entry OR one testCategories entry per call. For independent, non-overlapping selections, use separate execution: "connected" calls with the same explicit path and platform; await and inspect each passing result before issuing the next. Stop the remaining sequence on failure or uncertainty; never retry, broaden the selection, close the Editor, or switch routes automatically. Do not split a mixed filter/category intersection into separate runs.';
120
128
 
121
129
  const RUN_TESTS_PARAMS = Type.Object({
122
130
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
123
131
  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 })),
132
+ testFilters: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "Connected: one test-name selector only; omit testCategories. For two independent fixtures, issue serial single-selector calls, inspecting each result first. No semicolon lists. Omitted/empty selectors select all tests, not a repair for rejected filters." })),
133
+ testCategories: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "Connected: one category only; omit testFilters. Multiple categories require separate non-overlapping selections or deliberate isolated execution; never split a filter/category intersection into separate runs." })),
126
134
  execution: Type.Optional(StringEnum(["auto", "connected", "isolated"] as const, { default: "auto" })),
127
135
  isolatedLauncher: Type.Optional(StringEnum(["auto", "unity-cli", "editor-executable"] as const, { default: "auto" })),
128
136
  retries: Type.Optional(Type.Integer({ minimum: 0, maximum: 20, default: 0 })),
@@ -137,18 +145,6 @@ const RUN_TESTS_PARAMS = Type.Object({
137
145
  closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false })),
138
146
  }, { additionalProperties: false });
139
147
 
140
- const RUN_TEST_BATCH_PARAMS = Type.Object({
141
- path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
142
- unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
143
- testPlatform: StringEnum(["EditMode", "PlayMode"] as const, { description: "Unity Test Framework platform. One batch runs exactly one test platform." }),
144
- 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." })),
145
- testCategories: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "Categories or category regex/negations. Values are normalized into one semicolon-separated -testCategory argument." })),
146
- useGraphics: Type.Optional(Type.Boolean({ default: false, description: "Set true only for graphics-dependent PlayMode tests or visual capture. Defaults to headless -nographics." })),
147
- timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 14400, default: 3600 })),
148
- launcher: LAUNCHER_SCHEMA,
149
- closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false, description: "Use guarded same-project Unity process closure only when piUnity.allowCloseRunningUnityProcess is enabled." })),
150
- });
151
-
152
148
  const PROJECT_STATUS_PARAMS = Type.Object({
153
149
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
154
150
  });
@@ -190,13 +186,13 @@ const INSPECT_ARTIFACTS_PARAMS = Type.Object({
190
186
  testResultsPath: Type.Optional(Type.String({ description: "Unity Test Framework XML results path. Relative paths are resolved against cwd and the Unity project root." })),
191
187
  normalizedResultPath: Type.Optional(Type.String({ description: "pi-unity normalized JSON test artifact path." })),
192
188
  logFilePath: Type.Optional(Type.String({ description: "Unity log file path. Relative paths are resolved against cwd and the Unity project root." })),
193
- 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." })),
189
+ latestFromLogs: Type.Optional(Type.Boolean({ default: true, description: "Only when all artifact paths are omitted, inspect the newest .json, .xml and .log files under Logs. Latest files are not proof of a shared run." })),
194
190
  maxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 60, description: "Maximum log/output lines to include." })),
195
191
  maxChars: Type.Optional(Type.Integer({ minimum: 500, maximum: 20000, default: 6000, description: "Maximum log/output characters to include." })),
196
192
  });
197
193
 
198
194
  function buildProjectChoiceLabel(cwd: string, candidate: UnityProjectCandidate): string {
199
- return `${candidate.projectName} (${candidate.unityVersion}) — ${formatPathForUser(cwd, candidate.projectRoot)}`;
195
+ return `${candidate.projectName} — ${formatPathForUser(cwd, candidate.projectRoot)}`;
200
196
  }
201
197
 
202
198
  async function chooseProjectCandidateWithWrappingNavigation(
@@ -267,7 +263,7 @@ async function chooseProjectCandidateWithWrappingNavigation(
267
263
 
268
264
  function formatCandidateList(cwd: string, candidates: UnityProjectCandidate[]): string {
269
265
  return candidates
270
- .map((candidate) => `- ${candidate.projectName} (${candidate.unityVersion}) — ${formatPathForUser(cwd, candidate.projectRoot)}`)
266
+ .map((candidate) => `- ${candidate.projectName} — ${formatPathForUser(cwd, candidate.projectRoot)}`)
271
267
  .join("\n");
272
268
  }
273
269
 
@@ -331,6 +327,13 @@ async function resolveProjectCandidate(
331
327
  return { candidate, discoveryWarning };
332
328
  }
333
329
 
330
+ async function requireManualUnityVersion(candidate: UnityProjectCandidate): Promise<string> {
331
+ if (candidate.unityVersion) return candidate.unityVersion;
332
+ const unityVersion = await readUnityVersion(candidate.projectRoot);
333
+ candidate.unityVersion = unityVersion;
334
+ return unityVersion;
335
+ }
336
+
334
337
  function joinWarnings(...warnings: Array<string | undefined>): string | undefined {
335
338
  const present = warnings.filter((warning): warning is string => Boolean(warning && warning.trim().length > 0));
336
339
  return present.length > 0 ? present.join("\n") : undefined;
@@ -446,11 +449,12 @@ async function closeBlockingUnityProcessesForBatchmode(
446
449
  );
447
450
  }
448
451
 
449
- const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate.unityVersion, { signal });
452
+ const unityVersion = await requireManualUnityVersion(candidate);
453
+ const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
450
454
  const canRequestGracefulExit = cliCapabilities.commandDiscoverySucceeded && cliCapabilities.advertisedCommands.includes("eval");
451
455
  if (canRequestGracefulExit) {
452
456
  const refreshedRunning = await listBlockingUnityProcesses(candidate.projectRoot);
453
- const refreshedCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate.unityVersion, { signal });
457
+ const refreshedCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
454
458
  const samePids = haveSameKnownProcessIds(running.processes, refreshedRunning.processes);
455
459
  const samePipelinePids = haveSameKnownProcessIds(cliCapabilities.matchingInstances, refreshedCapabilities.matchingInstances);
456
460
  if (refreshedRunning.warning || !samePids || !samePipelinePids || !refreshedCapabilities.advertisedCommands.includes("eval")) {
@@ -599,7 +603,8 @@ async function buildProjectStatusReport(
599
603
  const lockState = await inspectUnityProjectBusyState(candidate.projectRoot);
600
604
  const cliStatus = await listRunningUnityCliEditorsForProject(candidate.projectRoot);
601
605
  const processStatus = await listRunningUnityProcessesForProject(candidate.projectRoot);
602
- const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate.unityVersion, { signal });
606
+ const unityVersion = await requireManualUnityVersion(candidate);
607
+ const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
603
608
  const runningProcesses = dedupeRunningUnityProcesses([...cliStatus.processes, ...processStatus.processes]);
604
609
  const isBusy = runningProcesses.length > 0 || cliCapabilities.matchingInstances.length > 0;
605
610
  const staleLockSuspected = lockState.nativeLockfileExists && !isBusy && !processStatus.warning;
@@ -607,7 +612,7 @@ async function buildProjectStatusReport(
607
612
  const piUnitySettings = await loadPiUnitySettings(ctx);
608
613
 
609
614
  const lines = [
610
- `Unity project status for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using Unity ${candidate.unityVersion}.`,
615
+ `Unity project status for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using declared Unity ${unityVersion}.`,
611
616
  `- Native lockfile: ${lockState.nativeLockfileExists ? "present" : "absent"}`,
612
617
  `- Lockfile path: ${lockState.nativeLockfilePath}`,
613
618
  `- Running Unity processes targeting project: ${runningProcesses.length}`,
@@ -656,7 +661,7 @@ async function buildProjectStatusReport(
656
661
  details: {
657
662
  mode: "status",
658
663
  projectRoot: candidate.projectRoot,
659
- unityVersion: candidate.unityVersion,
664
+ unityVersion: await requireManualUnityVersion(candidate),
660
665
  editorPath: "",
661
666
  warning: combinedWarning,
662
667
  status: "passed",
@@ -708,7 +713,8 @@ async function buildArtifactInspectionReport(
708
713
  candidate: UnityProjectCandidate,
709
714
  params: { testResultsPath?: string; normalizedResultPath?: string; logFilePath?: string; latestFromLogs?: boolean; maxLines?: number; maxChars?: number },
710
715
  ): Promise<{ text: string; details: UnityToolDetails }> {
711
- const useLatest = params.latestFromLogs !== false;
716
+ // An exact artifact request must not silently recruit unrelated latest evidence.
717
+ const useLatest = params.latestFromLogs !== false && ![params.testResultsPath, params.logFilePath, params.normalizedResultPath].some(value => value?.trim());
712
718
  const logsRoot = join(candidate.projectRoot, "Logs");
713
719
  const testResultsPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.testResultsPath)
714
720
  ?? (useLatest ? await findNewestFile(logsRoot, [".xml"]) : undefined);
@@ -716,12 +722,16 @@ async function buildArtifactInspectionReport(
716
722
  ?? (useLatest ? await findNewestFile(logsRoot, [".log", ".txt"]) : undefined);
717
723
  const normalizedResultPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.normalizedResultPath)
718
724
  ?? (useLatest ? await findNewestFile(logsRoot, [".json"]) : undefined);
719
- let normalizedSummary: string | undefined;
725
+ let normalized: NormalizedUnityTestResult | undefined;
726
+ const evidenceErrors: string[] = [];
727
+ const evidenceWarnings: string[] = [];
720
728
  if (normalizedResultPath) {
721
729
  try {
722
- const normalized = JSON.parse(await readFile(normalizedResultPath, "utf8")) as Partial<NormalizedUnityTestResult>;
723
- if (normalized.schemaVersion === 1 && typeof normalized.outcome === "string") normalizedSummary = `Normalized test result: ${normalized.platform ?? "Unity"} ${normalized.outcome}; ${normalized.summary?.total ?? "unknown"} total.`;
724
- } catch { normalizedSummary = `Normalized test result JSON could not be parsed: ${normalizedResultPath}`; }
730
+ if ((await stat(normalizedResultPath)).size > UNITY_TEST_MAX_ARTIFACT_BYTES) throw new Error("Normalized artifact exceeds its size limit.");
731
+ normalized = validateNormalizedUnityTestArtifact(JSON.parse(await readFile(normalizedResultPath, "utf8")));
732
+ } catch (error) {
733
+ evidenceErrors.push(`Normalized test result could not be loaded/validated: ${normalizedResultPath}: ${error instanceof Error ? error.message : String(error)}`);
734
+ }
725
735
  }
726
736
  const invocation: UnityBatchmodeInvocation = {
727
737
  isTestRun: Boolean(testResultsPath),
@@ -734,16 +744,51 @@ async function buildArtifactInspectionReport(
734
744
  if (testResultsPath && artifacts.testResultsXml && !parsedTestResults) {
735
745
  artifacts.warnings.push(`Unity test results XML could not be parsed: ${artifacts.testResultsPath ?? testResultsPath}`);
736
746
  }
737
- const hasLoadedArtifacts = Boolean(artifacts.testResultsPath || artifacts.logFilePath);
738
- const status = deriveUnityArtifactInspectionStatus(hasLoadedArtifacts, invocation, parsedTestResults);
747
+ if (testResultsPath && !parsedTestResults) evidenceErrors.push(`Requested XML evidence is missing or malformed: ${testResultsPath}`);
748
+ if (parsedTestResults && hasConflictingUnityXmlTestEvidence(parsedTestResults)) {
749
+ evidenceErrors.push("Conflicting XML evidence: invalid or inconsistent counts/records.");
750
+ }
751
+ if (logFilePath && artifacts.logText === undefined) evidenceErrors.push(`Requested log evidence is missing: ${logFilePath}`);
752
+ const hasLoadedArtifacts = Boolean(normalized || parsedTestResults || artifacts.logText !== undefined);
753
+ if (!hasLoadedArtifacts) evidenceErrors.push("No valid Unity artifacts were loaded.");
754
+ let testOutcome = normalized?.outcome ?? (parsedTestResults ? determineUnityTestOutcome({ ...parsedTestResults, failed: parsedTestResults.failedTests.length > 0 ? Math.max(1, parsedTestResults.failed ?? 0) : parsedTestResults.failed }) : undefined);
755
+ if (normalized && parsedTestResults) {
756
+ if (hasConflictingUnityXmlTestEvidence(parsedTestResults, normalized.summary)) evidenceErrors.push("Conflicting normalized/XML evidence: combined counts/records disagree.");
757
+ for (const key of ["total", "passed", "failed", "skipped", "inconclusive"] as const) {
758
+ if (normalized.summary[key] !== undefined && parsedTestResults[key] !== undefined && normalized.summary[key] !== parsedTestResults[key]) evidenceErrors.push(`Conflicting normalized/XML evidence: ${key} differs.`);
759
+ }
760
+ if ((normalized.outcome === "passed" || normalized.outcome === "passed_with_flakes") && parsedTestResults.failedTests.length > 0) evidenceErrors.push("Conflicting normalized/XML evidence: XML contains failed tests.");
761
+ const linkedXml = normalized.backendArtifacts?.nunit;
762
+ if (linkedXml && resolve(candidate.projectRoot, linkedXml) !== resolve(testResultsPath!)) evidenceErrors.push("Conflicting artifact identity: selected XML is not the normalized artifact's nunit path.");
763
+ if (!linkedXml) {
764
+ evidenceWarnings.push("Normalized JSON and XML have no shared run identity; matching counts alone do not correlate these files.");
765
+ testOutcome = "uncertain";
766
+ }
767
+ }
768
+ if (parsedTestResults?.testRecordCounts?.other && (testOutcome === "passed" || testOutcome === "passed_with_flakes")) {
769
+ evidenceWarnings.push("XML contains test records with unknown outcomes; these are not passing evidence.");
770
+ testOutcome = "uncertain";
771
+ }
772
+ if (useLatest) evidenceWarnings.push("Latest artifact selection does not establish current-run identity; use exact paths for a particular run.");
773
+ if (evidenceErrors.length) testOutcome = "uncertain";
774
+ // Inspection succeeded even when valid evidence reports test failure or uncertainty.
775
+ const status = evidenceErrors.length ? "failed" : "passed";
739
776
  const lines = [
740
- `Unity artifacts inspected for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using Unity ${candidate.unityVersion}.`,
777
+ `Unity artifacts inspected for ${formatPathForUser(ctx.cwd, candidate.projectRoot)}; Unity CLI selects the project's declared Editor version when launching.`,
741
778
  testResultsPath ? `Requested test results: ${testResultsPath}` : "Requested test results: (none found)",
742
779
  logFilePath ? `Requested log file: ${logFilePath}` : "Requested log file: (none found)",
743
780
  normalizedResultPath ? `Requested normalized result: ${normalizedResultPath}` : "Requested normalized result: (none found)",
744
- ...(normalizedSummary ? [normalizedSummary] : []),
781
+ `Inspection: ${status}. Test outcome: ${testOutcome ?? "not established (log only)"}.`,
782
+ ...(normalized ? [compactUnityTestSummary(normalized), `Normalized source: ${normalized.source}; selection (bounded): ${summarizeTextForAgent(JSON.stringify(normalized.selection), 1, 2000)}; ${normalized.tests.length} retained test record(s).`] : []),
783
+ ...evidenceErrors,
784
+ ...evidenceWarnings,
745
785
  ];
746
786
 
787
+ if (normalized) {
788
+ for (const test of normalized.tests.filter(test => !["passed", "success"].includes(test.status.toLowerCase())).slice(0, 8)) {
789
+ lines.push(`- ${test.name.slice(0, 1000)}: ${test.status.slice(0, 100)}${test.message ? ` — ${test.message.slice(0, 1000)}` : ""}`);
790
+ }
791
+ }
747
792
  if (parsedTestResults) {
748
793
  lines.push(...formatParsedTestResultsForAgent(parsedTestResults));
749
794
  }
@@ -764,12 +809,16 @@ async function buildArtifactInspectionReport(
764
809
  details: {
765
810
  mode: "artifacts",
766
811
  projectRoot: candidate.projectRoot,
767
- unityVersion: candidate.unityVersion,
812
+ unityVersion: await requireManualUnityVersion(candidate),
768
813
  editorPath: "",
769
814
  invocation,
770
815
  artifacts: compactUnityArtifacts(artifacts),
771
816
  parsedTestResults,
772
817
  status,
818
+ testOutcome,
819
+ normalizedResultPath,
820
+ normalizedResult: normalized ? { source: normalized.source, platform: normalized.platform, outcome: normalized.outcome, summary: { total: normalized.summary.total, passed: normalized.summary.passed, failed: normalized.summary.failed, skipped: normalized.summary.skipped, inconclusive: normalized.summary.inconclusive }, testRecordCount: normalized.tests.length } : undefined,
821
+ evidenceWarnings,
773
822
  },
774
823
  };
775
824
  }
@@ -782,7 +831,9 @@ function buildEditorLaunchSummary(
782
831
  launcher: "unity-cli" | "editor-executable" = "editor-executable",
783
832
  ): string {
784
833
  return [
785
- `Launched Unity Editor GUI for ${formatPathForUser(cwd, candidate.projectRoot)} using Unity ${candidate.unityVersion}.`,
834
+ launcher === "unity-cli"
835
+ ? `Launched Unity Editor GUI for ${formatPathForUser(cwd, candidate.projectRoot)} with the project-declared Editor version selected by Unity CLI.`
836
+ : `Launched Unity Editor GUI for ${formatPathForUser(cwd, candidate.projectRoot)} using the project's declared Unity version through the direct Editor fallback.`,
786
837
  launcher === "unity-cli" ? `Launcher: unity open (${editorPath})` : `Editor: ${editorPath}`,
787
838
  GUI_WARNING,
788
839
  SINGLE_PROCESS_WARNING,
@@ -809,7 +860,7 @@ async function buildBatchmodeReport(
809
860
  const status = deriveUnityBatchmodeStatus(result.code, Boolean(result.killed), invocation, parsedTestResults);
810
861
  const text = buildUnityBatchmodeAgentText({
811
862
  displayProjectPath: formatPathForUser(ctx.cwd, candidate.projectRoot),
812
- unityVersion: candidate.unityVersion,
863
+ unityVersion: await requireManualUnityVersion(candidate),
813
864
  editorPath,
814
865
  exitCode: result.code,
815
866
  killed: Boolean(result.killed),
@@ -827,7 +878,7 @@ async function buildBatchmodeReport(
827
878
  details: {
828
879
  mode: "batchmode",
829
880
  projectRoot: candidate.projectRoot,
830
- unityVersion: candidate.unityVersion,
881
+ unityVersion: await requireManualUnityVersion(candidate),
831
882
  editorPath,
832
883
  command: editorPath,
833
884
  args,
@@ -936,16 +987,42 @@ function throwIfAborted(signal?: AbortSignal): void {
936
987
  }
937
988
  }
938
989
 
939
- async function canUseUnityCli(pi: ExtensionAPI, signal?: AbortSignal): Promise<boolean> {
990
+ const UNITY_CLI_WARNING_SYMBOL = Symbol.for("@aefree/pi-unity/unity-cli-warning/v1");
991
+ const unityCliRuntimeState = globalThis as Record<PropertyKey, unknown>;
992
+ 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.";
993
+
994
+ async function warnWhenUnityCliUnavailable(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
995
+ if (!ctx.hasUI || unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] === true) return;
996
+ try {
997
+ const result = await pi.exec(resolveUnityCliCommand(), ["--version"], { timeout: 5000 });
998
+ // Timeouts are uncertain and must neither warn nor select another backend.
999
+ if (result.killed || result.code === 0 || unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] === true) return;
1000
+ unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] = true;
1001
+ ctx.ui.notify(UNITY_CLI_WARNING, "warning");
1002
+ } catch (error) {
1003
+ if ((error instanceof Error && error.name === "AbortError") || isUnityCliProbeTimeout(error)) return;
1004
+ if (unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] === true) return;
1005
+ unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] = true;
1006
+ ctx.ui.notify(UNITY_CLI_WARNING, "warning");
1007
+ }
1008
+ }
1009
+ type UnityCliAvailability = "available" | "unavailable" | "uncertain";
1010
+
1011
+ function isUnityCliProbeTimeout(error: unknown): boolean {
1012
+ return Boolean(error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ETIMEDOUT");
1013
+ }
1014
+
1015
+ async function getUnityCliAvailability(pi: ExtensionAPI, signal?: AbortSignal): Promise<UnityCliAvailability> {
940
1016
  try {
941
1017
  throwIfAborted(signal);
942
1018
  const command = resolveUnityCliCommand();
943
1019
  const result = await pi.exec(command, ["--version"], { signal, timeout: 5000 });
944
1020
  throwIfAborted(signal);
945
- return !result.killed && result.code === 0;
1021
+ if (result.killed) return "uncertain";
1022
+ return result.code === 0 ? "available" : "unavailable";
946
1023
  } catch (error) {
947
1024
  if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw error;
948
- return false;
1025
+ return isUnityCliProbeTimeout(error) ? "uncertain" : "unavailable";
949
1026
  }
950
1027
  }
951
1028
 
@@ -995,16 +1072,18 @@ async function shouldUseUnityCli(
995
1072
  return false;
996
1073
  }
997
1074
 
998
- const available = await canUseUnityCli(pi, signal);
999
- if (preference === "unity-cli" && !available) {
1075
+ const availability = await getUnityCliAvailability(pi, signal);
1076
+ if (availability === "uncertain") {
1077
+ 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.");
1078
+ }
1079
+ if (preference === "unity-cli" && availability === "unavailable") {
1000
1080
  throw new Error("Unity CLI launcher was requested, but the `unity` command is not available. Set UNITY_CLI_PATH or use launcher='editor-executable'.");
1001
1081
  }
1002
1082
 
1003
- return available;
1083
+ return availability === "available";
1004
1084
  }
1005
1085
 
1006
1086
  type GuardedBatchmodeParams = {
1007
- unityEditorPath?: string;
1008
1087
  args?: string[];
1009
1088
  useGraphics?: boolean;
1010
1089
  timeoutSeconds?: number;
@@ -1036,17 +1115,14 @@ async function runGuardedUnityBatchmode(
1036
1115
  const invocation = parseUnityBatchmodeInvocation(createUnityCliBatchmodeReportArgs(candidate.projectRoot, extraArgs, { useGraphics }));
1037
1116
  const useUnityCli = await shouldUseUnityCli(pi, params.launcher, signal);
1038
1117
  throwIfAborted(signal);
1039
- const editorPath = useUnityCli
1040
- ? await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath }).catch(() => "Unity CLI resolved editor")
1041
- : await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath });
1042
- const command = useUnityCli
1043
- ? createUnityCliRunCommand(candidate.projectRoot, extraArgs, {
1044
- editorVersion: candidate.unityVersion,
1045
- editorPath: params.unityEditorPath,
1046
- timeoutSeconds,
1047
- useGraphics,
1048
- })
1049
- : createUnityBatchmodeCommand(editorPath, candidate.projectRoot, extraArgs, { useGraphics });
1118
+ let editorPath = "Unity CLI";
1119
+ let command: { command: string; args: string[] };
1120
+ if (useUnityCli) {
1121
+ command = createUnityCliRunCommand(candidate.projectRoot, extraArgs, { timeoutSeconds, useGraphics });
1122
+ } else {
1123
+ editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
1124
+ command = createUnityBatchmodeCommand(editorPath, candidate.projectRoot, extraArgs, { useGraphics });
1125
+ }
1050
1126
  const closeReport = await closeBlockingUnityProcessesForBatchmode(
1051
1127
  pi,
1052
1128
  ctx,
@@ -1123,19 +1199,19 @@ async function runUnifiedUnityTests(
1123
1199
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: UnityToolDetails & { testResult: NormalizedUnityTestResult; artifactPath: string; route: "connected" | "isolated" } }> {
1124
1200
  const request = normalizeUnityRunTestsRequest(raw);
1125
1201
  const requirements = getUnityTestRouteRequirements(request);
1126
- const capabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate.unityVersion, { signal, execute: createPipelineUnityCliExecutor(pi) });
1202
+ const capabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, await requireManualUnityVersion(candidate), { signal, execute: createPipelineUnityCliExecutor(pi) });
1127
1203
  const reachable = capabilities.matchingInstances.some(instance => instance.reachable === true);
1128
1204
  const busy = (await listBlockingUnityProcesses(candidate.projectRoot)).processes.length > 0 || capabilities.matchingInstances.length > 0;
1129
1205
  let route: "connected" | "isolated";
1130
1206
  if (request.execution === "connected") {
1131
- if (requirements.requiresIsolation) throw new Error(`Connected execution cannot honor this request: ${requirements.reasons.join("; ")}.`);
1207
+ if (requirements.requiresIsolation) throw new Error(`Connected execution cannot honor this request: ${requirements.reasons.join("; ")}. No tests were dispatched. ${CONNECTED_TEST_SELECTOR_GUIDANCE} Other isolated-only options still require a deliberate isolated-execution decision.`);
1132
1208
  if (!reachable) throw new Error("Connected execution requires an already-open exact-copy reachable Pipeline Editor; no Unity was launched.");
1133
1209
  route = "connected";
1134
1210
  } else if (request.execution === "isolated") {
1135
1211
  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.");
1136
1212
  route = "isolated";
1137
1213
  } else if (reachable) {
1138
- 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.`);
1214
+ 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. No tests were dispatched. ${CONNECTED_TEST_SELECTOR_GUIDANCE} Other isolated-only options still require a deliberate isolated-execution decision.`);
1139
1215
  route = "connected";
1140
1216
  } else {
1141
1217
  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.");
@@ -1144,7 +1220,7 @@ async function runUnifiedUnityTests(
1144
1220
  }
1145
1221
  const formats = request.reportFormats ?? defaultUnityTestReportFormats(route);
1146
1222
  if (route === "connected") {
1147
- const result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, 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 }] }) });
1223
+ 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 }] }) });
1148
1224
  const counts = result.details.counts!;
1149
1225
  const normalized: NormalizedUnityTestResult = {
1150
1226
  schemaVersion: 1, source: "pipeline", platform: request.testPlatform,
@@ -1153,11 +1229,11 @@ async function runUnifiedUnityTests(
1153
1229
  };
1154
1230
  const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
1155
1231
  const text = `${compactUnityTestSummary(normalized)}\nRoute: connected Pipeline. Normalized artifact: ${artifactPath}`;
1156
- return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, editorPath: "", status: normalized.outcome === "passed" ? "passed" : "failed", pipeline: result.details, testResult: { ...normalized, tests: [] }, artifactPath, route } };
1232
+ 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 } };
1157
1233
  }
1158
1234
  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.");
1159
1235
  const plan = createUnityTestBatchPlan({ projectRoot: candidate.projectRoot, testPlatform: request.testPlatform, testFilters: request.testFilters, testCategories: request.testCategories });
1160
- const cliAvailable = request.isolatedLauncher !== "editor-executable" && await canUseUnityCli(pi, signal);
1236
+ const cliAvailable = await shouldUseUnityCli(pi, request.isolatedLauncher, signal);
1161
1237
  if (!cliAvailable && request.isolatedLauncher === "unity-cli") throw new Error("Unity CLI was requested but is unavailable.");
1162
1238
  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.");
1163
1239
  if (!cliAvailable) {
@@ -1173,7 +1249,7 @@ async function runUnifiedUnityTests(
1173
1249
  const closeReport = await closeBlockingUnityProcessesForBatchmode(pi, ctx, candidate, invocation, request.closeBlockingUnityProcess, signal);
1174
1250
  await removeStaleLockfileAfterGuardedClose(candidate, closeReport);
1175
1251
  await enforceLaunchRouteSafety(candidate.projectRoot, "unity-cli");
1176
- 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: { nunit: formats.includes("nunit") ? plan.testResultsPath : undefined, junit: formats.includes("junit") ? plan.junitResultsPath : undefined, log: plan.logFilePath }, coverage: request.coverage, coverageOptions: request.coverageOptions, useGraphics: request.useGraphics, timeoutSeconds: request.timeoutSeconds, editorVersion: candidate.unityVersion });
1252
+ 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 });
1177
1253
  const execution = await pi.exec(command.command, command.args, { signal, timeout: (request.timeoutSeconds ?? 3600) * 1000 + 30_000 });
1178
1254
  const effectiveResultsPath = deriveUnityCliEffectiveReportPath(plan.testResultsPath, { rerunFailed: request.rerunFailed, shard: request.shard });
1179
1255
  let effectiveResultsXml: string | undefined;
@@ -1197,7 +1273,7 @@ async function runUnifiedUnityTests(
1197
1273
  }
1198
1274
  const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
1199
1275
  const text = `${compactUnityTestSummary(normalized)}\nRoute: isolated Unity CLI. Normalized artifact: ${artifactPath}`;
1200
- return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, 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 } };
1276
+ 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 } };
1201
1277
  });
1202
1278
  }
1203
1279
 
@@ -1309,6 +1385,17 @@ function formatUnityGuidanceAudit(result: UnityGuidanceAuditResult): string {
1309
1385
  }
1310
1386
 
1311
1387
  export default function freeUnityPi(pi: ExtensionAPI) {
1388
+ // Pi's documented tool_result patch marks native failure without discarding the
1389
+ // structured rejection details (throwing from execute retains only error text).
1390
+ // A top-level isError property returned from execute is NOT a native error.
1391
+ pi.on("tool_result", (event) => {
1392
+ const details = event.details as UnityToolDetails | undefined;
1393
+ if ((event.toolName === "unity_pipeline_eval" && details?.mode === "pipeline_eval" && details.pipelineEval?.outcome === "rejected")
1394
+ || (event.toolName === "unity_pipeline_inspect" && details?.mode === "pipeline_inspection" && details.pipelineInspection?.outcome === "rejected")) {
1395
+ return { isError: true };
1396
+ }
1397
+ });
1398
+
1312
1399
  type ScopeRegistrations = Readonly<{
1313
1400
  artifactProfile?: Readonly<{ registry: ScopedRegistryV1; token: RegistrationToken }>;
1314
1401
  fileDiscoveryFilter?: Readonly<{ registry: ScopedRegistryV1; token: RegistrationToken }>;
@@ -1338,6 +1425,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1338
1425
 
1339
1426
  pi.on("session_start", async (_event, ctx) => {
1340
1427
  restoreSessionSettings(ctx);
1428
+ await warnWhenUnityCliUnavailable(pi, ctx);
1341
1429
  const scope = ctx.sessionManager;
1342
1430
  unregisterScope(registrations.get(scope));
1343
1431
  // Optional package integrations resolve independently. The Unity extension and
@@ -1415,14 +1503,14 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1415
1503
  await withUnityProjectLaunchMutex(candidate.projectRoot, { mode: "gui", toolName: "unity-open" }, async () => {
1416
1504
  await enforceSingleProcessRule(candidate.projectRoot);
1417
1505
  let launcher: "unity-cli" | "editor-executable" = "editor-executable";
1418
- let editorPath = await resolveUnityEditorPath(candidate.unityVersion).catch(() => "Unity CLI resolved editor");
1506
+ let editorPath = "Unity CLI";
1419
1507
  let launch: { pid: number | undefined; args: string[]; command: string };
1420
- if (await canUseUnityCli(pi)) {
1508
+ if (await shouldUseUnityCli(pi, undefined)) {
1421
1509
  launcher = "unity-cli";
1422
- launch = launchUnityCliOpenDetached(candidate.projectRoot, { editorVersion: candidate.unityVersion });
1510
+ launch = launchUnityCliOpenDetached(candidate.projectRoot);
1423
1511
  } else {
1424
1512
  await assertUnityProjectNotBusy(candidate.projectRoot);
1425
- editorPath = await resolveUnityEditorPath(candidate.unityVersion);
1513
+ editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
1426
1514
  launch = launchUnityEditorDetached(editorPath, candidate.projectRoot);
1427
1515
  }
1428
1516
  const summary = buildEditorLaunchSummary(ctx.cwd, candidate, editorPath, discoveryWarning, launcher);
@@ -1521,6 +1609,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1521
1609
  promptSnippet: "Run Unity EditMode or PlayMode tests through one safe routed workflow with durable normalized evidence.",
1522
1610
  promptGuidelines: [
1523
1611
  "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.",
1612
+ CONNECTED_TEST_SELECTOR_GUIDANCE,
1524
1613
  "Do not use unity_launch_batchmode for ordinary tests; raw test flags there are an unsupported escape hatch.",
1525
1614
  "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.",
1526
1615
  "Timeout, malformed evidence, cancellation, or missing artifacts never cause a backend fallback or relaunch.",
@@ -1549,13 +1638,13 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1549
1638
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1550
1639
  throwIfAborted(signal);
1551
1640
  const { candidate } = await resolveProjectCandidate(ctx, params.path);
1552
- const result = await runUnityPipelineRecompile({ projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, timeoutSeconds: params.timeoutSeconds, allowAutonomousExitPlayMode: sessionAllowsAutonomousPlayModeExit(ctx) }, createPipelineDependencies(pi), {
1641
+ const result = await runUnityPipelineRecompile({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), timeoutSeconds: params.timeoutSeconds, allowAutonomousExitPlayMode: sessionAllowsAutonomousPlayModeExit(ctx) }, createPipelineDependencies(pi), {
1553
1642
  signal,
1554
1643
  onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }),
1555
1644
  });
1556
1645
  return {
1557
1646
  content: [{ type: "text", text: result.text }],
1558
- details: { mode: "pipeline", projectRoot: result.details.projectRoot, unityVersion: candidate.unityVersion, editorPath: "", status: "passed", pipeline: result.details } satisfies UnityToolDetails,
1647
+ details: { mode: "pipeline", projectRoot: result.details.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: "passed", pipeline: result.details } satisfies UnityToolDetails,
1559
1648
  };
1560
1649
  },
1561
1650
  renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_recompile", args, theme, context); },
@@ -1580,7 +1669,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1580
1669
  throwIfAborted(signal);
1581
1670
  const result = await dispatchUnityPlanningInspection({
1582
1671
  projectRoot: candidate.projectRoot,
1583
- unityVersion: candidate.unityVersion,
1672
+ unityVersion: await requireManualUnityVersion(candidate),
1584
1673
  command: "eval",
1585
1674
  evalSnippet: params.code,
1586
1675
  }, {
@@ -1597,7 +1686,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1597
1686
  details: {
1598
1687
  mode: "pipeline_eval",
1599
1688
  projectRoot: candidate.projectRoot,
1600
- unityVersion: candidate.unityVersion,
1689
+ unityVersion: await requireManualUnityVersion(candidate),
1601
1690
  editorPath: "",
1602
1691
  status: result.outcome === "dispatched" ? "passed" : "failed",
1603
1692
  pipelineEval: result,
@@ -1629,7 +1718,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1629
1718
  throwIfAborted(signal);
1630
1719
  const result = await dispatchUnityPlanningInspection({
1631
1720
  projectRoot: candidate.projectRoot,
1632
- unityVersion: candidate.unityVersion,
1721
+ unityVersion: await requireManualUnityVersion(candidate),
1633
1722
  command: params.command,
1634
1723
  args: params.args,
1635
1724
  }, {
@@ -1646,7 +1735,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1646
1735
  details: {
1647
1736
  mode: "pipeline_inspection",
1648
1737
  projectRoot: candidate.projectRoot,
1649
- unityVersion: candidate.unityVersion,
1738
+ unityVersion: await requireManualUnityVersion(candidate),
1650
1739
  editorPath: "",
1651
1740
  status: result.outcome === "dispatched" ? "passed" : "failed",
1652
1741
  pipelineInspection: result,
@@ -1664,13 +1753,13 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1664
1753
  pi.registerTool({
1665
1754
  name: "unity_inspect_artifacts",
1666
1755
  label: "Unity Inspect Artifacts",
1667
- description: "Summarize existing Unity log files and Unity Test Framework XML results without launching Unity.",
1668
- promptSnippet: "Inspect existing Unity logs or test result XML files without launching Unity.",
1756
+ description: "Inspect existing Unity normalized JSON, Test Framework XML and logs without launching Unity. Inspection success is separate from test outcome.",
1757
+ promptSnippet: "Inspect existing Unity normalized test evidence, XML or logs without launching Unity.",
1669
1758
  promptGuidelines: [
1670
1759
  "Use unity_inspect_artifacts after Unity failures when existing -testResults or -logFile artifacts need concise parsing without another Unity launch.",
1671
1760
  "Prefer unity_inspect_artifacts over ad hoc bash parsing of Unity XML/log files when paths are known or Logs/ contains recent artifacts.",
1672
1761
  "unity_inspect_artifacts does not launch Unity and is safe to use even when the Unity project is busy.",
1673
- "Treat selected test XML as passing evidence only when it is well formed, reports a known positive executed-test count, and reports no failures.",
1762
+ "Inspect details.testOutcome, not inspection status, for test success. Passing evidence needs consistent positive passing counts; missing explicit paths and conflicting artifacts fail inspection. Latest files are not current-run identity.",
1674
1763
  ],
1675
1764
  parameters: INSPECT_ARTIFACTS_PARAMS,
1676
1765
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -1715,18 +1804,14 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1715
1804
  const useUnityCli = await shouldUseUnityCli(pi, params.launcher as UnityLauncherPreference | undefined, signal);
1716
1805
  throwIfAborted(signal);
1717
1806
  let launcher: "unity-cli" | "editor-executable" = "editor-executable";
1718
- let editorPath = await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath }).catch(() => "Unity CLI resolved editor");
1807
+ let editorPath = "Unity CLI";
1719
1808
  let launch: { pid: number | undefined; args: string[]; command: string };
1720
1809
  if (useUnityCli) {
1721
1810
  launcher = "unity-cli";
1722
- launch = launchUnityCliOpenDetached(candidate.projectRoot, {
1723
- editorVersion: candidate.unityVersion,
1724
- editorPath: params.unityEditorPath,
1725
- automated: params.automated,
1726
- });
1811
+ launch = launchUnityCliOpenDetached(candidate.projectRoot, { automated: params.automated });
1727
1812
  } else {
1728
1813
  await assertUnityProjectNotBusy(candidate.projectRoot);
1729
- editorPath = await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath });
1814
+ editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
1730
1815
  launch = launchUnityEditorDetached(editorPath, candidate.projectRoot, { automated: params.automated });
1731
1816
  }
1732
1817
  const text = buildEditorLaunchSummary(ctx.cwd, candidate, editorPath, discoveryWarning, launcher);
@@ -1736,7 +1821,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1736
1821
  details: {
1737
1822
  mode: "gui",
1738
1823
  projectRoot: candidate.projectRoot,
1739
- unityVersion: candidate.unityVersion,
1824
+ unityVersion: await requireManualUnityVersion(candidate),
1740
1825
  editorPath,
1741
1826
  pid: launch.pid,
1742
1827
  command: launch.command,