@aefree/pi-unity 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +5 -3
- package/index.ts +65 -17
- package/package.json +2 -2
- package/skills/unity-pipeline-workflows/SKILL.md +1 -1
- package/src/unity-artifact-inspection.ts +13 -9
- package/src/unity-cli.ts +104 -12
- package/src/unity-pipeline.ts +36 -11
- package/src/unity-tests.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project follows semantic versioning for public package releases.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.14.0 - 2026-09-14
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Preserve durable normalized connected-test evidence for terminal failed, cancelled, runner-error, and incomplete Pipeline results without replaying dispatch, overriding active Pipeline state from partial records, or misreporting inconsistent counts/records as passing.
|
|
15
|
+
- Select one primary latest normalized result and only its validated project-contained backend links when inspecting historical artifacts, including canonical contained aliases.
|
|
16
|
+
- Forward optional `unity_pipeline_eval.handlerTimeoutMilliseconds` only when the exact reachable Pipeline descriptor confirms raw argv support and the documented `code`/`timeout` signature. Preserve `timeoutSeconds` as the separate host/CLI wait; a dispatcher expiry cannot cancel eval code already started on Unity's main thread, so effects remain uncertain and are never retried or rerouted.
|
|
17
|
+
- Treat Pipeline 0.7's explicit `compilationFailed: true` response as a failed recompile even when its status is `up_to_date` or `completed` and no compiler-error array is present.
|
|
18
|
+
|
|
10
19
|
## 0.13.0 - 2026-09-10
|
|
11
20
|
|
|
12
21
|
### Added
|
package/README.md
CHANGED
|
@@ -34,13 +34,13 @@ Use these tools with an already-open exact Unity project copy that has a reachab
|
|
|
34
34
|
- `unity_project_status` — inspect lockfiles, matching Unity processes, Pipeline reachability, package version, and advertised commands without launching Unity.
|
|
35
35
|
- `unity_pipeline_recompile` — recompile through Pipeline with exact-copy preflight, bounded polling, and compact compiler evidence.
|
|
36
36
|
- `unity_run_tests` — one intent-oriented EditMode or PlayMode workflow. It reuses compatible connected Pipeline execution or selects isolated `unity test` when the exact project copy is closed.
|
|
37
|
-
- `unity_pipeline_eval` — execute bounded project-specific C# through Pipeline's Roslyn REPL.
|
|
37
|
+
- `unity_pipeline_eval` — execute bounded project-specific C# through Pipeline's Roslyn REPL. `timeoutSeconds` bounds pi-unity and Unity CLI waits (1–86,400 seconds). Optional `handlerTimeoutMilliseconds` is forwarded only when the exact reachable Pipeline advertises raw argv plus the verified eval timeout signature; it bounds that Pipeline dispatcher wait, not code already running on Unity's main thread. A timeout is uncertain and does not cancel or retry Editor work.
|
|
38
38
|
- `unity_pipeline_inspect` — dispatch supported package-owned inspection commands (including read-only `get_runtime_pipeline_settings`) and return structured evidence. Runtime settings are refused by Pipeline in Play Mode; pi-unity never exits Play Mode to read them.
|
|
39
39
|
- `unity_pipeline_run_script` — compile one existing project `.cs` file in Pipeline's ephemeral in-memory mode and invoke a named static entry point; supports bounded JSON arguments and compile-only `dryRun`. It deliberately does not expose hotpatch.
|
|
40
40
|
|
|
41
41
|
Connected recompilation follows Unity's Script Changes While Playing policy and never preemptively sends `editor_stop`. Connected tests may exit Play Mode through advertised `editor_stop` when necessary, then verify Edit Mode before dispatch. Play Mode exit is allowed by default; `/unity-playmode-exit allow|disallow|status` controls the current session.
|
|
42
42
|
|
|
43
|
-
A timeout is uncertain: work may still be running. The tools do not silently cancel, retry, launch another Editor, or switch to batchmode. Pipeline 0.6 can report busy for a modal dialog as well as startup settling, so ambiguous busy responses are surfaced and never blindly retried.
|
|
43
|
+
A timeout is uncertain: work may still be running. The tools do not silently cancel, retry, launch another Editor, or switch to batchmode. Pipeline 0.6 can report busy for a modal dialog as well as startup settling, so ambiguous busy responses are surfaced and never blindly retried. Pipeline 0.7 improves recovery of standing compile errors and console output. For non-development Player builds, Pipeline also requires the `ENABLE_RUNTIME_PIPELINE` scripting define; enabling its runtime setting alone is insufficient.
|
|
44
44
|
|
|
45
45
|
### Editor and batchmode
|
|
46
46
|
|
|
@@ -48,7 +48,7 @@ A timeout is uncertain: work may still be running. The tools do not silently can
|
|
|
48
48
|
- `unity_launch_batchmode` — run a bounded batchmode command through Unity CLI or the direct Editor executable.
|
|
49
49
|
- `unity_inspect_artifacts` — validate existing normalized JSON test artifacts, Unity Test Framework XML and Unity logs without launching Unity. `details.status` describes inspection; `details.testOutcome` describes the tests, including failures and uncertainty. A valid failed-test artifact is a successful inspection, not a passing run.
|
|
50
50
|
|
|
51
|
-
Pass `normalizedResultPath` for standalone JSON evidence. Any explicit artifact path disables implicit latest-file selection; missing requested files fail inspection. With all paths omitted, `latestFromLogs`
|
|
51
|
+
Pass `normalizedResultPath` for standalone JSON evidence. Any explicit artifact path disables implicit latest-file selection and link expansion; missing requested files fail inspection. With all paths omitted, `latestFromLogs` selects one newest top-level JSON (mtime then filename) and only its contained declared NUnit/log links. Without JSON it selects XML alone, then log context. Historical selection cannot establish current-run identity. Mixed JSON/XML evidence must agree; without an explicit native artifact link, matching counts alone leave correlation uncertain.
|
|
52
52
|
|
|
53
53
|
Use `unity_run_tests` for all ordinary Unity Test Framework work. It writes a durable normalized JSON result under `Logs/`; isolated `unity test` runs retain requested native reports. Connected Pipeline is selected only for compatible requests. A reachable Editor is never closed automatically to obtain isolated-only features.
|
|
54
54
|
|
|
@@ -113,6 +113,8 @@ Another connected client is not a project lock. When Pipeline returns stable cor
|
|
|
113
113
|
{ code: "var s = UnityEngine.Application.dataPath; return s.Length;" }
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
`timeoutSeconds` remains the host/CLI wait. On exact copies that advertise raw argv and the verified eval `code`/integer-`timeout` signature, `handlerTimeoutMilliseconds` (1–86,400,000) also sets Pipeline's dispatcher wait through its positional command argument. A shorter host timeout may still win. This server wait can prevent queued work from starting, but cannot cancel eval code that already began on Unity's main thread; treat expiry as uncertain and never retry or fall back.
|
|
117
|
+
|
|
116
118
|
Use `unity_pipeline_inspect` when a purpose-built structured command fits. Use eval for bounded project-specific work that matches the user's intent. Prefer typed tools when they provide stronger lifecycle, polling, validation, or recovery semantics.
|
|
117
119
|
|
|
118
120
|
### Pipeline run_script
|
package/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import { mkdir, readFile, readdir, stat, unlink } from "node:fs/promises";
|
|
5
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { mkdir, readFile, readdir, realpath, stat, unlink } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
7
7
|
import { getKeybindings, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
8
|
import {
|
|
@@ -32,7 +32,7 @@ import { applyUnityCliRetrySummary, compactUnityTestSummary, defaultUnityTestRep
|
|
|
32
32
|
import { validateNormalizedUnityTestArtifact } from "./src/unity-artifact-inspection";
|
|
33
33
|
import { UNITY_TEST_MAX_ARTIFACT_BYTES } from "./src/unity-tests";
|
|
34
34
|
import { auditUnityGuidance, type UnityGuidanceAuditResult } from "./src/unity-guidance-audit";
|
|
35
|
-
import { runUnityPipelineRecompile, runUnityPipelineTests, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
|
|
35
|
+
import { runUnityPipelineRecompile, runUnityPipelineTests, UnityPipelineTerminalTestEvidenceError, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
|
|
36
36
|
import {
|
|
37
37
|
createOptionalIntegrationRegistryV1,
|
|
38
38
|
isOptionalIntegrationActive,
|
|
@@ -103,6 +103,9 @@ type UnityToolDetails = {
|
|
|
103
103
|
pipelineEval?: { outcome: "dispatched"; command: string; output: string; truncated: boolean } | { outcome: "rejected"; code: string; message: string };
|
|
104
104
|
pipelineRunScript?: { outcome: "dispatched"; command: string; output: string; truncated: boolean } | { outcome: "rejected"; code: string; message: string };
|
|
105
105
|
pipeline?: UnityPipelineOperationDetails;
|
|
106
|
+
testResult?: NormalizedUnityTestResult;
|
|
107
|
+
artifactPath?: string;
|
|
108
|
+
route?: "connected" | "isolated";
|
|
106
109
|
};
|
|
107
110
|
|
|
108
111
|
const LAUNCHER_SCHEMA = Type.Optional(StringEnum(["auto", "unity-cli", "editor-executable"] as const, { description: "Launch backend. Defaults to auto, which prefers the Unity CLI and falls back to direct editor executable launch when the CLI is unavailable." }));
|
|
@@ -165,7 +168,8 @@ const PIPELINE_TEST_PARAMS = Type.Object({
|
|
|
165
168
|
const PIPELINE_EVAL_PARAMS = Type.Object({
|
|
166
169
|
path: Type.Optional(Type.String({ maxLength: 1000, description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
167
170
|
code: Type.String({ minLength: 1, maxLength: 4000, description: "Bounded C# source for advertised Pipeline eval. Roslyn compiles it on the connected Editor main thread; include an explicit return value when evidence is needed." }),
|
|
168
|
-
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400, default: 12, description: "Connected eval deadline in seconds (maximum 24 hours). A timeout is uncertain and does not retry or cancel Unity work." })),
|
|
171
|
+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400, default: 12, description: "Connected eval host/CLI deadline in seconds (maximum 24 hours). A timeout is uncertain and does not retry or cancel Unity work." })),
|
|
172
|
+
handlerTimeoutMilliseconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400000, description: "Optional advertised Pipeline eval dispatcher wait in milliseconds. Forwarded only when the exact copy advertises raw argv and the verified code/timeout signature; it cannot cancel code already started on Unity's main thread." })),
|
|
169
173
|
}, { additionalProperties: false });
|
|
170
174
|
|
|
171
175
|
const PIPELINE_RUN_SCRIPT_PARAMS = Type.Object({
|
|
@@ -196,7 +200,7 @@ const INSPECT_ARTIFACTS_PARAMS = Type.Object({
|
|
|
196
200
|
testResultsPath: Type.Optional(Type.String({ description: "Unity Test Framework XML results path. Relative paths are resolved against cwd and the Unity project root." })),
|
|
197
201
|
normalizedResultPath: Type.Optional(Type.String({ description: "pi-unity normalized JSON test artifact path." })),
|
|
198
202
|
logFilePath: Type.Optional(Type.String({ description: "Unity log file path. Relative paths are resolved against cwd and the Unity project root." })),
|
|
199
|
-
latestFromLogs: Type.Optional(Type.Boolean({ default: true, description: "Only when all artifact paths are omitted,
|
|
203
|
+
latestFromLogs: Type.Optional(Type.Boolean({ default: true, description: "Only when all artifact paths are omitted, select one newest top-level Logs JSON (mtime then filename) and only its declared contained NUnit/log links. Without JSON, select newest XML alone, otherwise newest log context. Latest files are not proof of a shared run." })),
|
|
200
204
|
maxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 60, description: "Maximum log/output lines to include." })),
|
|
201
205
|
maxChars: Type.Optional(Type.Integer({ minimum: 500, maximum: 20000, default: 6000, description: "Maximum log/output characters to include." })),
|
|
202
206
|
});
|
|
@@ -686,8 +690,9 @@ async function findNewestFile(root: string, suffixes: string[]): Promise<string
|
|
|
686
690
|
let entries: Awaited<ReturnType<typeof readdir>>;
|
|
687
691
|
try {
|
|
688
692
|
entries = await readdir(root, { withFileTypes: true });
|
|
689
|
-
} catch {
|
|
690
|
-
return undefined;
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
695
|
+
throw error;
|
|
691
696
|
}
|
|
692
697
|
|
|
693
698
|
const files = await Promise.all(entries
|
|
@@ -697,7 +702,16 @@ async function findNewestFile(root: string, suffixes: string[]): Promise<string
|
|
|
697
702
|
const stats = await stat(fullPath);
|
|
698
703
|
return { fullPath, mtimeMs: stats.mtimeMs };
|
|
699
704
|
}));
|
|
700
|
-
return files.sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.fullPath;
|
|
705
|
+
return files.sort((left, right) => right.mtimeMs - left.mtimeMs || left.fullPath.localeCompare(right.fullPath))[0]?.fullPath;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function resolveLinkedArtifact(projectRoot: string, linkPath: string): Promise<string> {
|
|
709
|
+
const canonicalRoot = await realpath(projectRoot);
|
|
710
|
+
const candidate = resolve(projectRoot, linkPath);
|
|
711
|
+
const canonicalCandidate = await realpath(candidate);
|
|
712
|
+
const relativePath = relative(canonicalRoot, canonicalCandidate);
|
|
713
|
+
if (!relativePath || isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) throw new Error(`Linked artifact escapes the project root: ${linkPath}`);
|
|
714
|
+
return canonicalCandidate;
|
|
701
715
|
}
|
|
702
716
|
|
|
703
717
|
function resolveArtifactPath(cwd: string, projectRoot: string, value: string | undefined): string | undefined {
|
|
@@ -726,12 +740,14 @@ async function buildArtifactInspectionReport(
|
|
|
726
740
|
// An exact artifact request must not silently recruit unrelated latest evidence.
|
|
727
741
|
const useLatest = params.latestFromLogs !== false && ![params.testResultsPath, params.logFilePath, params.normalizedResultPath].some(value => value?.trim());
|
|
728
742
|
const logsRoot = join(candidate.projectRoot, "Logs");
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
const logFilePath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.logFilePath)
|
|
732
|
-
?? (useLatest ? await findNewestFile(logsRoot, [".log", ".txt"]) : undefined);
|
|
743
|
+
let testResultsPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.testResultsPath);
|
|
744
|
+
let logFilePath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.logFilePath);
|
|
733
745
|
const normalizedResultPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.normalizedResultPath)
|
|
734
746
|
?? (useLatest ? await findNewestFile(logsRoot, [".json"]) : undefined);
|
|
747
|
+
if (useLatest && !normalizedResultPath) {
|
|
748
|
+
testResultsPath = await findNewestFile(logsRoot, [".xml"]);
|
|
749
|
+
if (!testResultsPath) logFilePath = await findNewestFile(logsRoot, [".log", ".txt"]);
|
|
750
|
+
}
|
|
735
751
|
let normalized: NormalizedUnityTestResult | undefined;
|
|
736
752
|
const evidenceErrors: string[] = [];
|
|
737
753
|
const evidenceWarnings: string[] = [];
|
|
@@ -739,6 +755,11 @@ async function buildArtifactInspectionReport(
|
|
|
739
755
|
try {
|
|
740
756
|
if ((await stat(normalizedResultPath)).size > UNITY_TEST_MAX_ARTIFACT_BYTES) throw new Error("Normalized artifact exceeds its size limit.");
|
|
741
757
|
normalized = validateNormalizedUnityTestArtifact(JSON.parse(await readFile(normalizedResultPath, "utf8")));
|
|
758
|
+
// Stored backend links, not unrelated directory recency, are the only automatic companions.
|
|
759
|
+
if (useLatest) {
|
|
760
|
+
if (normalized.backendArtifacts?.nunit) testResultsPath = await resolveLinkedArtifact(candidate.projectRoot, normalized.backendArtifacts.nunit);
|
|
761
|
+
if (normalized.backendArtifacts?.log) logFilePath = await resolveLinkedArtifact(candidate.projectRoot, normalized.backendArtifacts.log);
|
|
762
|
+
}
|
|
742
763
|
} catch (error) {
|
|
743
764
|
evidenceErrors.push(`Normalized test result could not be loaded/validated: ${normalizedResultPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
744
765
|
}
|
|
@@ -769,7 +790,13 @@ async function buildArtifactInspectionReport(
|
|
|
769
790
|
}
|
|
770
791
|
if ((normalized.outcome === "passed" || normalized.outcome === "passed_with_flakes") && parsedTestResults.failedTests.length > 0) evidenceErrors.push("Conflicting normalized/XML evidence: XML contains failed tests.");
|
|
771
792
|
const linkedXml = normalized.backendArtifacts?.nunit;
|
|
772
|
-
if (linkedXml &&
|
|
793
|
+
if (linkedXml && testResultsPath) {
|
|
794
|
+
try {
|
|
795
|
+
if (await resolveLinkedArtifact(candidate.projectRoot, linkedXml) !== await realpath(testResultsPath)) evidenceErrors.push("Conflicting artifact identity: selected XML is not the normalized artifact's nunit path.");
|
|
796
|
+
} catch (error) {
|
|
797
|
+
evidenceErrors.push(`Normalized NUnit link could not be resolved: ${linkedXml}: ${error instanceof Error ? error.message : String(error)}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
773
800
|
if (!linkedXml) {
|
|
774
801
|
evidenceWarnings.push("Normalized JSON and XML have no shared run identity; matching counts alone do not correlate these files.");
|
|
775
802
|
testOutcome = "uncertain";
|
|
@@ -1230,7 +1257,25 @@ async function runUnifiedUnityTests(
|
|
|
1230
1257
|
}
|
|
1231
1258
|
const formats = request.reportFormats ?? defaultUnityTestReportFormats(route);
|
|
1232
1259
|
if (route === "connected") {
|
|
1233
|
-
|
|
1260
|
+
let result;
|
|
1261
|
+
try {
|
|
1262
|
+
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 }] }) });
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
if (!(error instanceof UnityPipelineTerminalTestEvidenceError)) throw error;
|
|
1265
|
+
const outcome = error.evidence.outcome;
|
|
1266
|
+
const normalized: NormalizedUnityTestResult = {
|
|
1267
|
+
schemaVersion: 1, source: "pipeline", platform: request.testPlatform,
|
|
1268
|
+
selection: { testFilters: request.testFilters, testCategories: request.testCategories },
|
|
1269
|
+
durationSeconds: error.evidence.elapsedSeconds, outcome, summary: {}, tests: error.evidence.testRecords,
|
|
1270
|
+
diagnostics: [error.evidence.reason, ...error.evidence.observations, ...error.evidence.warnings].slice(0, 8),
|
|
1271
|
+
};
|
|
1272
|
+
let artifactPath: string;
|
|
1273
|
+
try { artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized); } catch (persistenceError) {
|
|
1274
|
+
throw new Error(`${error.evidence.reason} Durable terminal evidence could not be persisted: ${persistenceError instanceof Error ? persistenceError.message : String(persistenceError)}`);
|
|
1275
|
+
}
|
|
1276
|
+
const text = `${compactUnityTestSummary(normalized)}\nRoute: connected Pipeline. Terminal evidence was incomplete or non-passing; no retry, fallback, or replay was performed. Normalized artifact: ${artifactPath}`;
|
|
1277
|
+
return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: "failed", testResult: { ...normalized, tests: [] }, artifactPath, route } };
|
|
1278
|
+
}
|
|
1234
1279
|
const counts = result.details.counts!;
|
|
1235
1280
|
const normalized: NormalizedUnityTestResult = {
|
|
1236
1281
|
schemaVersion: 1, source: "pipeline", platform: request.testPlatform,
|
|
@@ -1404,7 +1449,8 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1404
1449
|
const details = event.details as UnityToolDetails | undefined;
|
|
1405
1450
|
if ((event.toolName === "unity_pipeline_eval" && details?.mode === "pipeline_eval" && details.pipelineEval?.outcome === "rejected")
|
|
1406
1451
|
|| (event.toolName === "unity_pipeline_inspect" && details?.mode === "pipeline_inspection" && details.pipelineInspection?.outcome === "rejected")
|
|
1407
|
-
|| (event.toolName === "unity_pipeline_run_script" && details?.mode === "pipeline_run_script" && details.pipelineRunScript?.outcome === "rejected")
|
|
1452
|
+
|| (event.toolName === "unity_pipeline_run_script" && details?.mode === "pipeline_run_script" && details.pipelineRunScript?.outcome === "rejected")
|
|
1453
|
+
|| (event.toolName === "unity_run_tests" && details?.mode === "tests" && details.testResult?.outcome !== "passed" && details.testResult?.outcome !== "passed_with_flakes" && details.testResult?.outcome !== "empty_selection")) {
|
|
1408
1454
|
return { isError: true };
|
|
1409
1455
|
}
|
|
1410
1456
|
});
|
|
@@ -1667,12 +1713,13 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1667
1713
|
pi.registerTool({
|
|
1668
1714
|
name: "unity_pipeline_eval",
|
|
1669
1715
|
label: "Unity Pipeline Eval",
|
|
1670
|
-
description: "Execute one bounded C# snippet through advertised eval in an already-open exact Unity Pipeline Editor.",
|
|
1716
|
+
description: "Execute one bounded C# snippet through advertised eval in an already-open exact Unity Pipeline Editor. timeoutSeconds bounds pi-unity and Unity CLI waits; optional handlerTimeoutMilliseconds is forwarded only through a verified Pipeline raw-argv timeout contract and cannot cancel code already started on Unity's main thread.",
|
|
1671
1717
|
promptSnippet: "Query or operate on an already-open exact Unity project through Pipeline's Roslyn C# REPL.",
|
|
1672
1718
|
promptGuidelines: [
|
|
1673
1719
|
"Use unity_pipeline_eval for project-specific properties, APIs, and operations that advertised typed commands do not cover. It revalidates exact-copy identity and advertised eval immediately before dispatch.",
|
|
1674
1720
|
"Pipeline eval compiles arbitrary C# with Roslyn on the Editor main thread. Include an explicit return value for observable evidence; normal property reads and local-variable snippets are supported.",
|
|
1675
1721
|
"Eval is not statically read-only. Follow user intent and project guidance, and obtain explicit authorization before lifecycle, persistent-setting, destructive, asset, scene-save, package, build, or test mutations.",
|
|
1722
|
+
"timeoutSeconds bounds the host and Unity CLI wait. When supplied, handlerTimeoutMilliseconds bounds only the verified Pipeline dispatcher wait; a shorter host deadline may still win, and a server wait expiry cannot cancel code already started on Unity's main thread.",
|
|
1676
1723
|
"A rejected, malformed, failing, or timed-out eval is not success; do not silently retry it through another route.",
|
|
1677
1724
|
],
|
|
1678
1725
|
parameters: PIPELINE_EVAL_PARAMS,
|
|
@@ -1685,6 +1732,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1685
1732
|
unityVersion: await requireManualUnityVersion(candidate),
|
|
1686
1733
|
command: "eval",
|
|
1687
1734
|
evalSnippet: params.code,
|
|
1735
|
+
handlerTimeoutMilliseconds: params.handlerTimeoutMilliseconds,
|
|
1688
1736
|
}, {
|
|
1689
1737
|
execute: createPlanningUnityCliExecutor(pi),
|
|
1690
1738
|
signal,
|
|
@@ -1804,7 +1852,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1804
1852
|
"Use unity_inspect_artifacts after Unity failures when existing -testResults or -logFile artifacts need concise parsing without another Unity launch.",
|
|
1805
1853
|
"Prefer unity_inspect_artifacts over ad hoc bash parsing of Unity XML/log files when paths are known or Logs/ contains recent artifacts.",
|
|
1806
1854
|
"unity_inspect_artifacts does not launch Unity and is safe to use even when the Unity project is busy.",
|
|
1807
|
-
"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.",
|
|
1855
|
+
"Inspect details.testOutcome, not inspection status, for test success. Passing evidence needs consistent positive passing counts; missing explicit paths and conflicting artifacts fail inspection. With all paths omitted, latest selection uses one JSON and its contained declared links, otherwise XML alone then log context; explicit paths disable selection/link expansion. Latest files are not current-run identity.",
|
|
1808
1856
|
],
|
|
1809
1857
|
parameters: INSPECT_ARTIFACTS_PARAMS,
|
|
1810
1858
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aefree/pi-unity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./index.ts"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "tsx tests/unity-core.test.ts && tsx tests/unity-launch.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/unity-package-validation.test.ts",
|
|
21
|
+
"test": "tsx tests/unity-core.test.ts && tsx tests/unity-launch.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/release-registry-preflight.test.ts && tsx tests/unity-package-validation.test.ts",
|
|
22
22
|
"eval:guidance-skill": "tsx evals/auditing-unity-agent-guidance/run-eval.ts"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
@@ -67,4 +67,4 @@ Normally call the typed tools, not raw CLI commands. If a typed tool is unavaila
|
|
|
67
67
|
|
|
68
68
|
Use `unity_run_tests` with `execution: "isolated"` for a closed project, intentional isolation/CI, multiple selectors in one run, retries, sharding, coverage, or required NUnit/JUnit evidence. Multiple independent fixtures alone do not require closing a reachable Editor: use the serial recipe above. A single category is supported connected when Pipeline advertises the command; do not claim broader connected selector support. Do not use batchmode as an automatic fallback after an uncertain connected dispatch.
|
|
69
69
|
|
|
70
|
-
Use the typed compile/test tools when their polling and terminal evidence fit the task. For an explicitly requested existing C# builder file, `unity_pipeline_run_script` uses Pipeline 0.6 ephemeral in-memory compilation with bounded JSON arguments or compile-only dry run; it never enables hotpatch and remains arbitrary code execution requiring explicit mutation authorization. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows
|
|
70
|
+
Use the typed compile/test tools when their polling and terminal evidence fit the task. For an explicitly requested existing C# builder file, `unity_pipeline_run_script` uses Pipeline 0.6 ephemeral in-memory compilation with bounded JSON arguments or compile-only dry run; it never enables hotpatch and remains arbitrary code execution requiring explicit mutation authorization. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows. Its `timeoutSeconds` range is 1–86,400 seconds and bounds pi-unity/Unity CLI waits. Optional `handlerTimeoutMilliseconds` (1–86,400,000) is forwarded only when the exact reachable Pipeline advertises raw argv and the verified eval `code`/integer-`timeout` signature; it bounds Pipeline's dispatcher wait, while a shorter host wait can still win. A dispatcher expiry cannot cancel code already started on Unity's main thread, so the effect remains uncertain and no retry or fallback is allowed. It is an assistance surface, not a forbidden fallback or a substitute for the typed tools' completion protocol. Eval compiles arbitrary C# with Roslyn on the Editor main thread, so ordinary properties and local-variable snippets are valid; it is not expression-only or reliably statically read-only. Prefer typed tools for their stronger evidence, but let user intent and project guidance govern mutations. Lifecycle, persistent-setting, destructive, asset, scene-save, package, build, and test mutations require explicit authorization.
|
|
@@ -9,6 +9,16 @@ const strings = (value: unknown): value is string[] => Array.isArray(value) && v
|
|
|
9
9
|
const relativeId = (value: unknown): value is string => typeof value === "string" && !!value.trim()
|
|
10
10
|
&& !isAbsolute(value) && !/^(?:[A-Za-z]:|[\\/])/.test(value) && !value.split(/[\\/]/).includes("..") && !/\0/.test(value);
|
|
11
11
|
|
|
12
|
+
/** Shared count/retained-record invariant for durable validation and Pipeline pass acceptance. */
|
|
13
|
+
export function hasConsistentUnityTestCounts(summary: Record<string, unknown>, tests: Array<{ status: string }>): boolean {
|
|
14
|
+
const total = summary.total; const passed = summary.passed; const failed = summary.failed;
|
|
15
|
+
const skipped = summary.skipped; const inconclusive = summary.inconclusive;
|
|
16
|
+
if (![total, passed, failed, skipped, inconclusive].every(value => value === undefined || count(value))) return false;
|
|
17
|
+
if (count(total) && [passed, failed, skipped, inconclusive].reduce<number>((sum, value) => sum + (count(value) ? value : 0), 0) > total) return false;
|
|
18
|
+
if (count(total) && tests.length > total) return false;
|
|
19
|
+
return [["passed", passed], ["failed", failed], ["skipped", skipped], ["inconclusive", inconclusive]].every(([status, limit]) => !count(limit) || tests.filter(test => status === "passed" ? /^(?:passed|success)$/i.test(test.status) : test.status.toLowerCase() === status).length <= limit);
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
/** Read the durable schema, not a transport response. Missing optional counts remain unknown.
|
|
13
23
|
* Test records may be bounded or absent: never require tests.length === summary.total.
|
|
14
24
|
*/
|
|
@@ -25,10 +35,7 @@ export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedU
|
|
|
25
35
|
for (const key of ["total", "passed", "failed", "skipped", "inconclusive"]) {
|
|
26
36
|
if (summary[key] !== undefined && !count(summary[key])) invalid(`summary.${key} must be a non-negative integer`);
|
|
27
37
|
}
|
|
28
|
-
if (
|
|
29
|
-
const accounted = [summary.passed, summary.failed, summary.skipped, summary.inconclusive].reduce<number>((sum, item) => sum + (count(item) ? item : 0), 0);
|
|
30
|
-
if (accounted > summary.total) invalid("summary counts exceed total");
|
|
31
|
-
}
|
|
38
|
+
if (!hasConsistentUnityTestCounts(summary, [])) invalid("summary counts are inconsistent");
|
|
32
39
|
if (!Array.isArray(result.tests)) invalid("tests must be an array");
|
|
33
40
|
const tests = result.tests as unknown[];
|
|
34
41
|
for (const test of tests) {
|
|
@@ -38,14 +45,11 @@ export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedU
|
|
|
38
45
|
if (item.durationSeconds !== undefined && !nonnegative(item.durationSeconds)) invalid("test durationSeconds must be non-negative");
|
|
39
46
|
if (item.attempts !== undefined && (!count(item.attempts) || item.attempts < 1)) invalid("test attempts must be positive");
|
|
40
47
|
}
|
|
41
|
-
if (
|
|
48
|
+
if (!hasConsistentUnityTestCounts(summary, tests as Array<{ status: string }>)) invalid("test records conflict with summary counts");
|
|
42
49
|
const typed = result as unknown as NormalizedUnityTestResult;
|
|
43
|
-
for (const [status, key] of [["passed", "passed"], ["failed", "failed"], ["skipped", "skipped"], ["inconclusive", "inconclusive"]] as const) {
|
|
44
|
-
const observed = typed.tests.filter(test => test.status.toLowerCase() === status).length;
|
|
45
|
-
if (count(summary[key]) && observed > summary[key]) invalid(`test records conflict with summary.${key}`);
|
|
46
|
-
}
|
|
47
50
|
if (result.projectRelativeId !== undefined && !relativeId(result.projectRelativeId)) invalid("projectRelativeId must be project-relative");
|
|
48
51
|
if (result.backendArtifacts !== undefined && (!record(result.backendArtifacts) || !Object.values(result.backendArtifacts).every(relativeId))) invalid("backendArtifacts must contain project-relative paths");
|
|
52
|
+
if (result.diagnostics !== undefined && (!Array.isArray(result.diagnostics) || result.diagnostics.length > 8 || !result.diagnostics.every(item => typeof item === "string" && !!item.trim() && item.length <= 1_000))) invalid("diagnostics must contain at most eight bounded strings");
|
|
49
53
|
for (const key of ["startedAt", "completedAt"]) if (result[key] !== undefined && (typeof result[key] !== "string" || !Number.isFinite(Date.parse(result[key] as string)))) invalid(`${key} must be a timestamp`);
|
|
50
54
|
if (typed.startedAt && typed.completedAt && Date.parse(typed.completedAt) < Date.parse(typed.startedAt)) invalid("completion precedes start");
|
|
51
55
|
if (result.durationSeconds !== undefined && !nonnegative(result.durationSeconds)) invalid("durationSeconds must be non-negative");
|
package/src/unity-cli.ts
CHANGED
|
@@ -46,6 +46,13 @@ export type UnityCliPipelineInstance = {
|
|
|
46
46
|
|
|
47
47
|
export type UnityCliDiscoveryState = "not_attempted" | "available" | "absent" | "timeout" | "unavailable";
|
|
48
48
|
|
|
49
|
+
export type UnityCliCommandParameter = {
|
|
50
|
+
name: string;
|
|
51
|
+
type: string;
|
|
52
|
+
required: boolean;
|
|
53
|
+
defaultValue?: unknown;
|
|
54
|
+
};
|
|
55
|
+
|
|
49
56
|
export type UnityCliProjectCapabilities = {
|
|
50
57
|
cliAvailable: boolean;
|
|
51
58
|
cliVersion?: string;
|
|
@@ -56,6 +63,12 @@ export type UnityCliProjectCapabilities = {
|
|
|
56
63
|
advertisedCommands: string[];
|
|
57
64
|
advertisedCommandCount: number;
|
|
58
65
|
advertisedCommandsTruncated: boolean;
|
|
66
|
+
/** Display-oriented command descriptors. They are never capability evidence. */
|
|
67
|
+
advertisedCommandParameters?: Record<string, readonly UnityCliCommandParameter[]>;
|
|
68
|
+
/** Complete, bounded, unambiguous descriptors eligible for exact capability gates. */
|
|
69
|
+
verifiedCommandParameters?: Record<string, readonly UnityCliCommandParameter[]>;
|
|
70
|
+
/** True only when the exact live Pipeline descriptor advertises raw argv support. */
|
|
71
|
+
pipelineSupportsExecArgv?: boolean;
|
|
59
72
|
commandDiscoveryAttempted: boolean;
|
|
60
73
|
commandDiscoverySucceeded: boolean;
|
|
61
74
|
latestPipelineVersion?: string;
|
|
@@ -346,10 +359,16 @@ export function parseUnityCliPipelineListOutput(output: string, projectRoot: str
|
|
|
346
359
|
type UnityCliCommandCatalog = {
|
|
347
360
|
valid: boolean;
|
|
348
361
|
commands: string[];
|
|
362
|
+
parametersByCommand: Record<string, readonly UnityCliCommandParameter[]>;
|
|
363
|
+
verifiedParametersByCommand: Record<string, readonly UnityCliCommandParameter[]>;
|
|
349
364
|
total: number;
|
|
350
365
|
truncated: boolean;
|
|
351
366
|
};
|
|
352
367
|
|
|
368
|
+
function isBoundedDescriptorString(value: unknown): value is string {
|
|
369
|
+
return typeof value === "string" && value.length > 0 && value.length <= 120 && !/[\u0000-\u001f\u007f]/.test(value);
|
|
370
|
+
}
|
|
371
|
+
|
|
353
372
|
function parseUnityCliCommandCatalog(output: string): UnityCliCommandCatalog {
|
|
354
373
|
const payload = parseJsonObject(output);
|
|
355
374
|
const data = getRecord(payload?.data);
|
|
@@ -366,21 +385,53 @@ function parseUnityCliCommandCatalog(output: string): UnityCliCommandCatalog {
|
|
|
366
385
|
candidates.push(...value);
|
|
367
386
|
}
|
|
368
387
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
388
|
+
|
|
389
|
+
const parametersByCommand: Record<string, readonly UnityCliCommandParameter[]> = {};
|
|
390
|
+
const verifiedCandidates = new Map<string, Array<readonly UnityCliCommandParameter[] | undefined>>();
|
|
391
|
+
const names: string[] = [];
|
|
392
|
+
for (const entry of candidates) {
|
|
393
|
+
const record = getRecord(entry);
|
|
394
|
+
const rawName = typeof entry === "string" ? entry : optionalString(record?.name, record?.command, record?.id);
|
|
395
|
+
if (!isBoundedDescriptorString(rawName)) continue;
|
|
374
396
|
const name = rawName.trim();
|
|
375
|
-
if (!
|
|
376
|
-
|
|
377
|
-
|
|
397
|
+
if (!isBoundedDescriptorString(name)) continue;
|
|
398
|
+
names.push(name);
|
|
399
|
+
|
|
400
|
+
// Preserve a best-effort descriptor for status display, but retain authoritative
|
|
401
|
+
// evidence only when every declared parameter is valid and the array was not cut.
|
|
402
|
+
const rawParameters = record?.parameters;
|
|
403
|
+
let parsed: UnityCliCommandParameter[] | undefined;
|
|
404
|
+
if (Array.isArray(rawParameters) && rawParameters.length <= 32) {
|
|
405
|
+
parsed = [];
|
|
406
|
+
for (const item of rawParameters) {
|
|
407
|
+
const parameter = getRecord(item);
|
|
408
|
+
const parameterName = parameter?.name;
|
|
409
|
+
const type = parameter?.type;
|
|
410
|
+
if (!isBoundedDescriptorString(parameterName) || !isBoundedDescriptorString(type) || typeof parameter?.required !== "boolean") {
|
|
411
|
+
parsed = undefined;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
parsed.push({ name: parameterName, type, required: parameter.required, ...(Object.prototype.hasOwnProperty.call(parameter, "defaultValue") ? { defaultValue: parameter.defaultValue } : {}) });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!(name in parametersByCommand) && parsed) parametersByCommand[name] = parsed;
|
|
418
|
+
const entries = verifiedCandidates.get(name) ?? [];
|
|
419
|
+
entries.push(parsed);
|
|
420
|
+
verifiedCandidates.set(name, entries);
|
|
421
|
+
}
|
|
422
|
+
|
|
378
423
|
const unique = [...new Set(names)].sort((left, right) => left.localeCompare(right));
|
|
424
|
+
const commands = unique.slice(0, 256);
|
|
425
|
+
const verifiedParametersByCommand = Object.fromEntries([...verifiedCandidates].flatMap(([name, descriptors]) =>
|
|
426
|
+
descriptors.length === 1 && descriptors[0] ? [[name, descriptors[0]]] : [],
|
|
427
|
+
));
|
|
379
428
|
return {
|
|
380
429
|
valid: Boolean(payload?.success === true && valid),
|
|
381
|
-
commands
|
|
430
|
+
commands,
|
|
431
|
+
parametersByCommand: Object.fromEntries(commands.flatMap(name => parametersByCommand[name] ? [[name, parametersByCommand[name]]] : [])),
|
|
432
|
+
verifiedParametersByCommand,
|
|
382
433
|
total: unique.length,
|
|
383
|
-
truncated: unique.length > 256
|
|
434
|
+
truncated: unique.length > 256,
|
|
384
435
|
};
|
|
385
436
|
}
|
|
386
437
|
|
|
@@ -428,6 +479,14 @@ export async function readDeclaredUnityPipelineVersion(projectRoot: string): Pro
|
|
|
428
479
|
return optionalString(manifestDependencies?.["com.unity.pipeline"]);
|
|
429
480
|
}
|
|
430
481
|
|
|
482
|
+
/** Read only the public capability names; the descriptor's authentication token is never retained or surfaced. */
|
|
483
|
+
async function readPipelineDescriptorCapabilities(projectRoot: string): Promise<string[] | undefined> {
|
|
484
|
+
const descriptor = await readJsonFile(join(projectRoot, "Library", "Pipeline", ".unity-pipeline-port"));
|
|
485
|
+
if (!descriptor) return undefined;
|
|
486
|
+
const values = Array.isArray(descriptor.capabilities) ? descriptor.capabilities : [];
|
|
487
|
+
return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0 && value.length <= 120 && !/[\u0000-\u001f\u007f]/.test(value)))];
|
|
488
|
+
}
|
|
489
|
+
|
|
431
490
|
export function isUnityCliTimeout(result: Pick<UnityCliExecResult, "error">): boolean {
|
|
432
491
|
const error = result.error as (NodeJS.ErrnoException & { killed?: boolean }) | undefined;
|
|
433
492
|
return error?.code === "ETIMEDOUT" || error?.killed === true || error?.signal === "SIGTERM";
|
|
@@ -511,6 +570,8 @@ export async function inspectUnityCliProjectCapabilities(
|
|
|
511
570
|
result.pipelineDiscovery = "available";
|
|
512
571
|
const pipeline = parseUnityCliPipelineListOutput(pipelineResult.stdout, projectRoot);
|
|
513
572
|
result.matchingInstances = pipeline.instances;
|
|
573
|
+
const descriptorCapabilities = await readPipelineDescriptorCapabilities(projectRoot);
|
|
574
|
+
result.pipelineSupportsExecArgv = descriptorCapabilities?.includes("exec.argv") === true;
|
|
514
575
|
result.latestPipelineVersion = pipeline.latestVersion;
|
|
515
576
|
if (pipeline.instances.length === 0) {
|
|
516
577
|
result.pipelineDiscovery = "absent";
|
|
@@ -522,7 +583,9 @@ export async function inspectUnityCliProjectCapabilities(
|
|
|
522
583
|
}
|
|
523
584
|
|
|
524
585
|
result.commandDiscoveryAttempted = true;
|
|
525
|
-
|
|
586
|
+
// `unity list` normalizes parameter types/defaults, while `unity command` with no command
|
|
587
|
+
// returns the live Pipeline catalog descriptor needed for exact capability-gated forwarding.
|
|
588
|
+
const listResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot, "--detail", "full"], { timeout: discoveryTimeout, signal: options.signal });
|
|
526
589
|
const catalog = parseUnityCliCommandCatalog(listResult.stdout);
|
|
527
590
|
const listPayload = parseJsonObject(listResult.stdout);
|
|
528
591
|
const commandDiagnostics = cliEnvelopeDiagnostics(listPayload);
|
|
@@ -533,12 +596,16 @@ export async function inspectUnityCliProjectCapabilities(
|
|
|
533
596
|
// Commands in a warning-bearing catalog are informational only, not advertised
|
|
534
597
|
// capability evidence. Keep descriptors for status visibility without enabling dispatch.
|
|
535
598
|
result.advertisedCommands = catalog.commands;
|
|
599
|
+
result.advertisedCommandParameters = catalog.parametersByCommand;
|
|
600
|
+
result.verifiedCommandParameters = catalog.verifiedParametersByCommand;
|
|
536
601
|
result.advertisedCommandCount = catalog.total;
|
|
537
602
|
result.advertisedCommandsTruncated = catalog.truncated;
|
|
538
603
|
return result;
|
|
539
604
|
}
|
|
540
605
|
result.commandDiscovery = "available";
|
|
541
606
|
result.advertisedCommands = catalog.commands;
|
|
607
|
+
result.advertisedCommandParameters = catalog.parametersByCommand;
|
|
608
|
+
result.verifiedCommandParameters = catalog.verifiedParametersByCommand;
|
|
542
609
|
result.advertisedCommandCount = catalog.total;
|
|
543
610
|
result.advertisedCommandsTruncated = catalog.truncated;
|
|
544
611
|
result.commandDiscoverySucceeded = true;
|
|
@@ -569,6 +636,8 @@ export type UnityPlanningInspectionRequest = {
|
|
|
569
636
|
args?: string[];
|
|
570
637
|
/** A bounded C# snippet for advertised eval. Pipeline compiles it with Roslyn on the Editor main thread. */
|
|
571
638
|
evalSnippet?: string;
|
|
639
|
+
/** Verified Pipeline eval dispatcher wait in milliseconds; distinct from the CLI/host wait. */
|
|
640
|
+
handlerTimeoutMilliseconds?: number;
|
|
572
641
|
};
|
|
573
642
|
|
|
574
643
|
export type UnityPlanningInspectionResult =
|
|
@@ -624,6 +693,20 @@ function runScriptCommandFailure(output: string): "malformed" | "failure" | unde
|
|
|
624
693
|
return undefined;
|
|
625
694
|
}
|
|
626
695
|
|
|
696
|
+
function hasVerifiedEvalTimeoutContract(capabilities: UnityCliProjectCapabilities): boolean {
|
|
697
|
+
const parameters = capabilities.verifiedCommandParameters?.eval;
|
|
698
|
+
return capabilities.pipelineSupportsExecArgv === true
|
|
699
|
+
&& Array.isArray(parameters)
|
|
700
|
+
&& parameters.length === 2
|
|
701
|
+
&& parameters[0]?.name === "code"
|
|
702
|
+
&& parameters[0]?.type === "String"
|
|
703
|
+
&& parameters[0]?.required === true
|
|
704
|
+
&& parameters[1]?.name === "timeout"
|
|
705
|
+
&& parameters[1]?.type === "Int32"
|
|
706
|
+
&& parameters[1]?.required === false
|
|
707
|
+
&& parameters[1]?.defaultValue === 5000;
|
|
708
|
+
}
|
|
709
|
+
|
|
627
710
|
function connectedCommandFailure(output: string, isEval: boolean): "malformed" | "failure" | undefined {
|
|
628
711
|
const envelope = parseJsonObject(output);
|
|
629
712
|
if (!envelope) return "malformed";
|
|
@@ -691,6 +774,12 @@ export async function dispatchUnityPlanningInspection(
|
|
|
691
774
|
if (request.args?.length || !snippet || snippet.length > UNITY_PIPELINE_EVAL_MAX_CHARS || /[\u0000]/.test(snippet)) {
|
|
692
775
|
return { outcome: "rejected", code: "planning_eval_invalid", message: "Eval requires one non-empty bounded C# snippet and no separate arguments." };
|
|
693
776
|
}
|
|
777
|
+
if (request.handlerTimeoutMilliseconds !== undefined && (!Number.isInteger(request.handlerTimeoutMilliseconds) || request.handlerTimeoutMilliseconds < 1 || request.handlerTimeoutMilliseconds > 86_400_000)) {
|
|
778
|
+
return { outcome: "rejected", code: "planning_eval_invalid", message: "Eval handler timeout must be an integer from 1 to 86400000 milliseconds." };
|
|
779
|
+
}
|
|
780
|
+
if (request.handlerTimeoutMilliseconds !== undefined && !hasVerifiedEvalTimeoutContract(initial)) {
|
|
781
|
+
return { outcome: "rejected", code: "planning_eval_timeout_unavailable", message: "The exact Pipeline copy does not establish raw argv support and the documented eval timeout signature; eval was not dispatched." };
|
|
782
|
+
}
|
|
694
783
|
} else if (!UNITY_PLANNING_READ_COMMANDS.includes(request.command as typeof UNITY_PLANNING_READ_COMMANDS[number]) || (request.evalSnippet?.trim() ?? "") !== "") {
|
|
695
784
|
return { outcome: "rejected", code: "planning_command_invalid", message: "Only a package-owned purpose-built inspection command may be selected here." };
|
|
696
785
|
}
|
|
@@ -706,13 +795,16 @@ export async function dispatchUnityPlanningInspection(
|
|
|
706
795
|
if (!refreshed.advertisedCommands.includes(request.command)) {
|
|
707
796
|
return { outcome: "rejected", code: "planning_command_unadvertised", message: "The refreshed exact Pipeline copy did not advertise the requested command." };
|
|
708
797
|
}
|
|
798
|
+
if (isEval && request.handlerTimeoutMilliseconds !== undefined && !hasVerifiedEvalTimeoutContract(refreshed)) {
|
|
799
|
+
return { outcome: "rejected", code: "planning_eval_timeout_unavailable", message: "The exact Pipeline eval timeout capability changed before dispatch; eval was not dispatched." };
|
|
800
|
+
}
|
|
709
801
|
|
|
710
802
|
const command = resolveUnityCliCommand({ cliCommand: options.cliCommand });
|
|
711
803
|
const args = [
|
|
712
804
|
"--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot,
|
|
713
805
|
"--timeout", String(Math.max(1, Math.ceil((options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS) / 1000))),
|
|
714
806
|
request.command,
|
|
715
|
-
...(isEval ? [request.evalSnippet!.trim()] : request.args ?? []),
|
|
807
|
+
...(isEval ? [request.evalSnippet!.trim(), ...(request.handlerTimeoutMilliseconds === undefined ? [] : [String(request.handlerTimeoutMilliseconds)])] : request.args ?? []),
|
|
716
808
|
];
|
|
717
809
|
const execution = await options.execute(command, args, { timeout: options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS, signal: options.signal });
|
|
718
810
|
const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
|
package/src/unity-pipeline.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { projectPathsMatch } from "./unity-core";
|
|
3
|
+
import { hasConsistentUnityTestCounts } from "./unity-artifact-inspection";
|
|
3
4
|
import { redactUnityPlanningOutput, resolveUnityCliCommand, summarizeUnityCliText, unityCapabilityDiagnosticSuffix, type UnityCliExecResult, type UnityCliExecutor, type UnityCliProjectCapabilities } from "./unity-cli";
|
|
4
5
|
|
|
5
6
|
/** Public limits are deliberately small enough that connected work cannot create an unbounded agent wait loop. */
|
|
@@ -37,6 +38,11 @@ export type UnityPipelineOperationDetails = {
|
|
|
37
38
|
export type UnityPipelineTestRecord = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
|
|
38
39
|
/** testRecords are terminal evidence for the caller's durable artifact only; do not expose them in tool details. */
|
|
39
40
|
export type UnityPipelineOperationResult = { text: string; details: UnityPipelineOperationDetails; testRecords?: UnityPipelineTestRecord[] };
|
|
41
|
+
export type UnityPipelineTerminalTestEvidence = { state: "completed" | "failed" | "cancelled"; outcome: "uncertain" | "tests_failed" | "run_error" | "cancelled"; reason: string; elapsedSeconds: number; selection: { platform: "EditMode" | "PlayMode"; filter?: string }; correlation: Record<string, string>; observations: string[]; testRecords: UnityPipelineTestRecord[]; warnings: string[] };
|
|
42
|
+
/** Terminal Pipeline evidence can be durable and inspectable without being passing evidence. */
|
|
43
|
+
export class UnityPipelineTerminalTestEvidenceError extends Error {
|
|
44
|
+
constructor(readonly evidence: UnityPipelineTerminalTestEvidence) { super(evidence.reason); this.name = "UnityPipelineTerminalTestEvidenceError"; }
|
|
45
|
+
}
|
|
40
46
|
|
|
41
47
|
type RecordValue = Record<string, unknown>;
|
|
42
48
|
type ParsedEnvelope = { result: RecordValue; outerSuccess: boolean; malformed?: string };
|
|
@@ -46,6 +52,8 @@ type NormalizedTest = {
|
|
|
46
52
|
total?: number; passed?: number; failed?: number; inconclusive?: number; failures: string[];
|
|
47
53
|
correlation: Record<string, string>;
|
|
48
54
|
testRecords?: UnityPipelineTestRecord[];
|
|
55
|
+
testFailureEstablished: boolean;
|
|
56
|
+
runnerError: boolean;
|
|
49
57
|
};
|
|
50
58
|
|
|
51
59
|
type PipelineDependencies = {
|
|
@@ -199,7 +207,9 @@ export function normalizeUnityPipelineCompile(output: string): NormalizedCompile
|
|
|
199
207
|
const parsed = parseUnityPipelineEnvelope(output);
|
|
200
208
|
if (parsed.malformed) return { state: "uncertain", diagnostics: [], failed: false };
|
|
201
209
|
const compilerDiagnostics = diagnostics(parsed.result);
|
|
202
|
-
|
|
210
|
+
let compilationFailed = false;
|
|
211
|
+
walk(parsed.result, item => { if (field(item, "compilationfailed") === true) compilationFailed = true; });
|
|
212
|
+
const failed = !parsed.outerSuccess || hasSemanticFailure(parsed.result) || compilationFailed || compilerDiagnostics.length > 0;
|
|
203
213
|
const raw = statusOf(parsed.result);
|
|
204
214
|
const state = failed || raw === "failed" || raw === "error" ? "failed" : raw === "up_to_date" || raw === "uptodate" ? "up_to_date"
|
|
205
215
|
: raw === "triggered" ? "triggered" : raw === "compiling" || raw === "running" ? "compiling"
|
|
@@ -229,6 +239,9 @@ function testRecords(result: RecordValue): UnityPipelineTestRecord[] {
|
|
|
229
239
|
});
|
|
230
240
|
return values.slice(0, 2_000);
|
|
231
241
|
}
|
|
242
|
+
function isRecognizedFailedTestStatus(status: string): boolean {
|
|
243
|
+
return status.trim().toLowerCase() === "failed";
|
|
244
|
+
}
|
|
232
245
|
function testFailures(result: RecordValue): string[] {
|
|
233
246
|
const values: string[] = [];
|
|
234
247
|
walk(result, item => {
|
|
@@ -238,7 +251,7 @@ function testFailures(result: RecordValue): string[] {
|
|
|
238
251
|
for (const entry of entries.slice(0, 200)) {
|
|
239
252
|
const test = record(entry); if (!test) continue;
|
|
240
253
|
const outcome = string(field(test, "result", "status", "outcome"))?.toLowerCase();
|
|
241
|
-
if (!outcome ||
|
|
254
|
+
if (!outcome || /^(?:passed|success)$/i.test(outcome)) continue;
|
|
242
255
|
const name = string(field(test, "name", "fullname", "testname")) ?? "Unnamed test";
|
|
243
256
|
const message = string(field(test, "message", "error", "failuremessage"));
|
|
244
257
|
const stack = string(field(test, "stacktrace", "stack", "trace"));
|
|
@@ -268,19 +281,23 @@ function correlation(result: RecordValue): Record<string, string> {
|
|
|
268
281
|
}
|
|
269
282
|
export function normalizeUnityPipelineTest(output: string): NormalizedTest {
|
|
270
283
|
const parsed = parseUnityPipelineEnvelope(output);
|
|
271
|
-
if (parsed.malformed) return { state: "uncertain", failures: [], correlation: {} };
|
|
284
|
+
if (parsed.malformed) return { state: "uncertain", failures: [], correlation: {}, testFailureEstablished: false, runnerError: false };
|
|
272
285
|
const sum = summary(parsed.result);
|
|
273
286
|
const total = number(field(sum ?? parsed.result, "total"));
|
|
274
287
|
const passed = number(field(sum ?? parsed.result, "passed", "pass"));
|
|
275
288
|
const failedCount = number(field(sum ?? parsed.result, "failed", "fail"));
|
|
276
289
|
const inconclusive = number(field(sum ?? parsed.result, "inconclusive", "skipped"));
|
|
277
290
|
const raw = statusOf(parsed.result);
|
|
278
|
-
const
|
|
279
|
-
const
|
|
291
|
+
const records = testRecords(parsed.result);
|
|
292
|
+
const testFailureEstablished = (Number.isSafeInteger(failedCount) && (failedCount ?? 0) > 0) || records.some(test => isRecognizedFailedTestStatus(test.status));
|
|
293
|
+
const runnerError = !parsed.outerSuccess || raw === "failed" || raw === "error" || hasSemanticFailure(parsed.result);
|
|
294
|
+
// A reported active state remains active even when it carries partial records/counts.
|
|
295
|
+
const state = raw === "cancelled" || raw === "canceled" ? "cancelled"
|
|
280
296
|
: raw === "no_tests" || raw === "idle" || raw === "not_started" || raw === "not_running" ? "inactive"
|
|
281
297
|
: raw === "running" ? "running" : raw === "starting" || raw === "queued" ? "starting"
|
|
282
|
-
: raw === "completed" || raw === "complete" || raw === "success" ? "
|
|
283
|
-
|
|
298
|
+
: raw === "completed" || raw === "complete" || raw === "success" ? testFailureEstablished || runnerError ? "failed" : "completed"
|
|
299
|
+
: testFailureEstablished || runnerError ? "failed" : "uncertain";
|
|
300
|
+
return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result), testRecords: records, testFailureEstablished, runnerError };
|
|
284
301
|
}
|
|
285
302
|
|
|
286
303
|
function editorStopSucceeded(output: string): boolean {
|
|
@@ -465,10 +482,18 @@ function checkCorrelation(expected: Record<string, string>, actual: Record<strin
|
|
|
465
482
|
return Object.entries(expected).every(([key, value]) => !actual[key] || actual[key] === value);
|
|
466
483
|
}
|
|
467
484
|
function passingCounts(state: NormalizedTest): { total: number; passed: number; failed: number; inconclusive?: number } | undefined {
|
|
485
|
+
const summary = { total: state.total, passed: state.passed, failed: state.failed, inconclusive: state.inconclusive };
|
|
486
|
+
if (!hasConsistentUnityTestCounts(summary, state.testRecords ?? [])) return undefined;
|
|
487
|
+
if (state.testRecords?.some(test => !/^(?:passed|success)$/i.test(test.status))) return undefined;
|
|
468
488
|
if (state.total === undefined || state.total <= 0 || state.passed === undefined || state.failed !== 0 || (state.inconclusive ?? 0) > 0) return undefined;
|
|
469
489
|
if (state.passed + state.failed + (state.inconclusive ?? 0) !== state.total) return undefined;
|
|
470
490
|
return { total: state.total, passed: state.passed, failed: state.failed, inconclusive: state.inconclusive };
|
|
471
491
|
}
|
|
492
|
+
function terminalEvidence(state: NormalizedTest, reason: string, request: UnityPipelineTestRequest, elapsedSeconds: number, warnings: string[]): UnityPipelineTerminalTestEvidenceError {
|
|
493
|
+
const outcome = state.state === "cancelled" ? "cancelled" : state.testFailureEstablished ? "tests_failed" : state.runnerError ? "run_error" : "uncertain";
|
|
494
|
+
const observations = [`terminal state=${state.state}`, `terminal outcome=${outcome}`, ...["total", "passed", "failed", "inconclusive"].flatMap(key => state[key as keyof Pick<NormalizedTest, "total" | "passed" | "failed" | "inconclusive">] === undefined ? [] : [`reported ${key}=${String(state[key as keyof Pick<NormalizedTest, "total" | "passed" | "failed" | "inconclusive">])}`]), ...Object.entries(state.correlation).map(([key, value]) => `correlation ${key}=${value}`)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
|
|
495
|
+
return new UnityPipelineTerminalTestEvidenceError({ state: state.state as "completed" | "failed" | "cancelled", outcome, reason, elapsedSeconds, selection: { platform: request.testPlatform, ...(request.testFilter ?? request.testCategory ? { filter: request.testFilter ?? request.testCategory } : {}) }, correlation: state.correlation, observations, testRecords: state.testRecords ?? [], warnings });
|
|
496
|
+
}
|
|
472
497
|
function elapsed(start: number, now: () => number): number { return Math.max(0, (now() - start) / 1000); }
|
|
473
498
|
function timeoutMessage(operation: string): Error { return new Error(`Unity Pipeline ${operation} timed out; result is uncertain and may still be running. No cancellation, retry, or route switch was performed.`); }
|
|
474
499
|
function ensureBeforeDeadline(deadline: number, now: () => number, operation: string): void {
|
|
@@ -540,14 +565,14 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
540
565
|
const dispatchWarnings = pipelineEnvelopeWarnings(dispatched.stdout);
|
|
541
566
|
let state = normalizeUnityPipelineTest(dispatched.stdout);
|
|
542
567
|
if (state.state === "uncertain" || state.state === "inactive") throw new Error("Unity Pipeline test dispatch returned inactive, malformed, or uncertain evidence; test run may not have started.");
|
|
543
|
-
if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
|
|
544
568
|
const requestedCorrelation = { mode: request.testPlatform, ...(request.testFilter ? { filter: request.testFilter } : {}) };
|
|
545
569
|
if (!checkCorrelation(requestedCorrelation, state.correlation)) throw new Error("Unity Pipeline test dispatch reported a different mode or filter; operation state is uncertain.");
|
|
570
|
+
if (state.state === "failed" || state.state === "cancelled") throw terminalEvidence(state, `Unity ${request.testPlatform} tests ${state.state}: ${state.failures.join("; ") || state.state}.`, request, elapsed(start, now), dispatchWarnings);
|
|
546
571
|
const expected = { ...requestedCorrelation, ...state.correlation };
|
|
547
572
|
// Some Pipeline versions return a complete result directly from asynchronous dispatch.
|
|
548
573
|
if (state.state === "completed") {
|
|
549
574
|
const counts = passingCounts(state);
|
|
550
|
-
if (!counts) throw
|
|
575
|
+
if (!counts) throw terminalEvidence(state, "Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).", request, elapsed(start, now), dispatchWarnings);
|
|
551
576
|
return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}) }, testRecords: state.testRecords };
|
|
552
577
|
}
|
|
553
578
|
for (let poll = 0; now() < deadline; poll += 1) {
|
|
@@ -565,12 +590,12 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
565
590
|
retainWarnings(dispatchWarnings, response.stdout);
|
|
566
591
|
state = normalizeUnityPipelineTest(response.stdout);
|
|
567
592
|
if (!checkCorrelation(expected, state.correlation)) throw new Error("Unity Pipeline test status was displaced by a different run; operation state is uncertain.");
|
|
568
|
-
if (state.state === "failed" || state.state === "cancelled") throw
|
|
593
|
+
if (state.state === "failed" || state.state === "cancelled") throw terminalEvidence(state, `Unity ${request.testPlatform} tests ${state.state}: ${state.failures.join("; ") || state.state}.`, request, elapsed(start, now), dispatchWarnings);
|
|
569
594
|
if (state.state === "uncertain") throw new Error("Unity Pipeline test status is malformed or uncertain; operation may still be running.");
|
|
570
595
|
if (state.state === "inactive") throw new Error("Unity Pipeline test status became inactive before a terminal result; operation state is uncertain.");
|
|
571
596
|
if (state.state !== "completed") continue;
|
|
572
597
|
const counts = passingCounts(state);
|
|
573
|
-
if (!counts) throw
|
|
598
|
+
if (!counts) throw terminalEvidence(state, "Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).", request, elapsed(start, now), dispatchWarnings);
|
|
574
599
|
return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}) }, testRecords: state.testRecords };
|
|
575
600
|
}
|
|
576
601
|
throw timeoutMessage("tests");
|
package/src/unity-tests.ts
CHANGED
|
@@ -60,6 +60,8 @@ export type NormalizedUnityTestResult = {
|
|
|
60
60
|
tests: NormalizedUnityTest[];
|
|
61
61
|
flakyTests?: Array<{ name: string; attempts: number }>;
|
|
62
62
|
backendArtifacts?: Record<string, string>;
|
|
63
|
+
/** Bounded non-authoritative observations retained when terminal Pipeline evidence cannot establish a result. */
|
|
64
|
+
diagnostics?: string[];
|
|
63
65
|
};
|
|
64
66
|
|
|
65
67
|
export type UnityTestRouteRequirements = { requiresIsolation: boolean; reasons: string[] };
|
|
@@ -250,6 +252,7 @@ export function normalizeUnityTestResult(result: NormalizedUnityTestResult): Nor
|
|
|
250
252
|
summary: Object.fromEntries(Object.entries(result.summary).flatMap(([key, value]) => numberOrUndefined(value) === undefined ? [] : [[key, numberOrUndefined(value)!]])),
|
|
251
253
|
tests, ...(result.flakyTests ? { flakyTests: result.flakyTests.slice(0, UNITY_TEST_MAX_TESTS).map(item => ({ name: bound(item.name, 1_000) || "Unnamed test", attempts: Math.max(1, Math.floor(item.attempts)) })) } : {}),
|
|
252
254
|
...(Object.keys(artifacts).length ? { backendArtifacts: artifacts } : {}),
|
|
255
|
+
...(result.diagnostics ? { diagnostics: result.diagnostics.slice(0, 8).flatMap(value => typeof value === "string" ? [bound(value, 1_000) || ""] : []).filter(Boolean) } : {}),
|
|
253
256
|
};
|
|
254
257
|
}
|
|
255
258
|
|