@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/CHANGELOG.md +29 -0
- package/README.md +8 -4
- package/index.ts +175 -90
- package/package.json +9 -9
- package/skills/unity-batchmode-tests/SKILL.md +9 -9
- package/skills/unity-pipeline-workflows/SKILL.md +19 -1
- package/src/unity-artifact-inspection.ts +59 -0
- package/src/unity-batchmode.ts +44 -9
- package/src/unity-cli.ts +6 -10
- package/src/unity-core.ts +4 -12
- package/src/unity-editor-fallback.ts +36 -0
- package/src/unity-launch.ts +3 -83
- package/src/unity-projects.ts +2 -3
- package/src/unity-tests.ts +13 -0
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 {
|
|
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
|
-
|
|
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: "
|
|
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}
|
|
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}
|
|
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
|
|
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,
|
|
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
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
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
|
|
725
|
+
let normalized: NormalizedUnityTestResult | undefined;
|
|
726
|
+
const evidenceErrors: string[] = [];
|
|
727
|
+
const evidenceWarnings: string[] = [];
|
|
720
728
|
if (normalizedResultPath) {
|
|
721
729
|
try {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
} catch
|
|
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
|
-
|
|
738
|
-
|
|
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)}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
999
|
-
if (
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
|
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 =
|
|
1506
|
+
let editorPath = "Unity CLI";
|
|
1419
1507
|
let launch: { pid: number | undefined; args: string[]; command: string };
|
|
1420
|
-
if (await
|
|
1508
|
+
if (await shouldUseUnityCli(pi, undefined)) {
|
|
1421
1509
|
launcher = "unity-cli";
|
|
1422
|
-
launch = launchUnityCliOpenDetached(candidate.projectRoot
|
|
1510
|
+
launch = launchUnityCliOpenDetached(candidate.projectRoot);
|
|
1423
1511
|
} else {
|
|
1424
1512
|
await assertUnityProjectNotBusy(candidate.projectRoot);
|
|
1425
|
-
editorPath = await resolveUnityEditorPath(candidate
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
1668
|
-
promptSnippet: "Inspect existing 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
|
-
"
|
|
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 =
|
|
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
|
|
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
|
|
1824
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1740
1825
|
editorPath,
|
|
1741
1826
|
pid: launch.pid,
|
|
1742
1827
|
command: launch.command,
|