@aefree/pi-unity 0.12.0 → 0.13.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 +26 -0
- package/README.md +17 -5
- package/index.ts +132 -23
- package/package.json +8 -8
- package/skills/unity-pipeline-workflows/SKILL.md +21 -3
- package/src/unity-artifact-inspection.ts +59 -0
- package/src/unity-batchmode.ts +44 -9
- package/src/unity-cli.ts +124 -25
- package/src/unity-core.ts +4 -4
- package/src/unity-pipeline.ts +58 -18
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project follows semantic versioning for public package releases.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.13.0 - 2026-09-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Added advertised `get_runtime_pipeline_settings` inspection and the bounded `unity_pipeline_run_script` tool for Pipeline 0.6 ephemeral in-memory compilation/execution of one existing project C# file. Hotpatch is intentionally out of scope.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Preserve bounded Unity CLI discovery warnings/info and treat warning-bearing discovery as uncertainty rather than a confirmed absent Pipeline or launch-safe signal. Force human CLI version formatting so inherited `UNITY_FORMAT` cannot corrupt version discovery.
|
|
19
|
+
- Accept Pipeline 0.6 compact response envelopes and warnings without treating warnings as compiler errors. Surface ambiguous busy responses rather than blindly redispatching commands that may be blocked by a modal dialog.
|
|
20
|
+
|
|
21
|
+
## 0.12.1 - 2026-09-10
|
|
22
|
+
|
|
23
|
+
### Security
|
|
24
|
+
|
|
25
|
+
- Update the development/test Pi baseline to 0.85.1 and refresh the lockfile to resolve patched `brace-expansion` 5.0.9 and `undici` 8.9.0. Consumer-managed Pi hosts must be updated separately; peer compatibility ranges are unchanged.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Compare existing Windows project aliases by canonical filesystem path, preventing connected project discovery from treating short paths or junction aliases as a different closed project.
|
|
30
|
+
- Preserve bounded, redacted CLI stdout/stderr on failed Pipeline inspections and evals, including actionable package-version errors; retain native failure, timeout uncertainty and single-dispatch behavior.
|
|
31
|
+
- Clarify connected single-selector arguments and pre-dispatch rejection recipes; document serial independent-fixture calls with stop-on-uncertainty boundaries, backed by offline registered-tool contract tests (not model-planning or live-Editor evidence).
|
|
32
|
+
- Validate normalized JSON test artifacts as standalone inspection evidence, separate inspection success from test outcome, and reject missing explicit paths or conflicting artifacts without substituting unrelated latest logs.
|
|
33
|
+
- Mark rejected Pipeline eval and inspection as native Pi tool failures while retaining structured diagnostics and uncertain post-dispatch effects, without retry or fallback.
|
|
34
|
+
- Reject contradictory combined XML counters and test-record evidence during artifact inspection, including self-closing failures and records beyond the display limit; keep skipped and inconclusive counters distinct and preserve valid partial records.
|
|
35
|
+
|
|
10
36
|
## 0.12.0 - 2026-08-27
|
|
11
37
|
|
|
12
38
|
### Changed
|
package/README.md
CHANGED
|
@@ -35,17 +35,20 @@ Use these tools with an already-open exact Unity project copy that has a reachab
|
|
|
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
37
|
- `unity_pipeline_eval` — execute bounded project-specific C# through Pipeline's Roslyn REPL. It accepts `timeoutSeconds` from 1–86,400 seconds; a timeout is uncertain and does not cancel or retry Editor work.
|
|
38
|
-
- `unity_pipeline_inspect` — dispatch supported package-owned inspection commands and return structured evidence.
|
|
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
|
+
- `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.
|
|
39
40
|
|
|
40
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.
|
|
41
42
|
|
|
42
|
-
A timeout is uncertain: work may still be running. The tools do not silently cancel, retry, launch another Editor, or switch to batchmode.
|
|
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
44
|
|
|
44
45
|
### Editor and batchmode
|
|
45
46
|
|
|
46
47
|
- `unity_open_editor` — open the Unity Editor GUI. Pass `automated: true` to add the Unity Editor `-automated` flag; this is distinct from the Unity CLI's own `--non-interactive` option.
|
|
47
48
|
- `unity_launch_batchmode` — run a bounded batchmode command through Unity CLI or the direct Editor executable.
|
|
48
|
-
- `unity_inspect_artifacts` —
|
|
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
|
+
|
|
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` discovers recent files but cannot establish current-run identity. Mixed JSON/XML evidence must agree; without an explicit native artifact link, matching counts alone leave correlation uncertain.
|
|
49
52
|
|
|
50
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.
|
|
51
54
|
|
|
@@ -82,6 +85,7 @@ Operation-specific recovery belongs to the operational skill. `unity-debugging`
|
|
|
82
85
|
| Existing failed-run artifacts | `unity_inspect_artifacts` |
|
|
83
86
|
| Project-specific C# query or operation | `unity_pipeline_eval` |
|
|
84
87
|
| Supported structured project inspection | `unity_pipeline_inspect` |
|
|
88
|
+
| Explicitly requested existing C# builder script | `unity_pipeline_run_script` |
|
|
85
89
|
| Open the GUI explicitly | `unity_open_editor` or `/unity-open` |
|
|
86
90
|
|
|
87
91
|
Pass an explicit project `path` when multiple copies may be discovered. Pipeline routing compares canonical paths so similarly named copies are not treated as interchangeable.
|
|
@@ -95,7 +99,7 @@ The connected compile and test tools:
|
|
|
95
99
|
- poll internally with fixed deadlines and bounded backoff;
|
|
96
100
|
- reject malformed or semantically failing nested results;
|
|
97
101
|
- require a known positive test count and zero failures before reporting a pass;
|
|
98
|
-
-
|
|
102
|
+
- keep routine tool output compact while preserving connected test records in durable normalized JSON evidence;
|
|
99
103
|
- detect pre-existing or clearly displaced test runs when available correlation fields permit it.
|
|
100
104
|
|
|
101
105
|
Another connected client is not a project lock. When Pipeline returns stable correlation fields, conflicting status is reported as displaced and uncertain. If Pipeline omits stable run identity, a competing same-mode, same-filter run may be indistinguishable from the requested run; the tool cannot prove exclusive ownership from shared Editor status alone.
|
|
@@ -111,6 +115,12 @@ Another connected client is not a project lock. When Pipeline returns stable cor
|
|
|
111
115
|
|
|
112
116
|
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.
|
|
113
117
|
|
|
118
|
+
### Pipeline run_script
|
|
119
|
+
|
|
120
|
+
`unity_pipeline_run_script` is arbitrary code execution, not a sandbox or read-only inspection. Use it only when the user explicitly asks to run that existing file; obtain explicit authorization for lifecycle, settings, asset, build, test, or destructive mutations. It validates the exact project copy and advertised command twice, accepts a single existing `.cs` file inside the project, uses only `mode: ephemeral`, and never launches, saves, cancels, retries, falls back, hotpatches, or exits Play Mode. `dryRun: true` compiles without loading or executing the assembly.
|
|
121
|
+
|
|
122
|
+
Rejected eval and inspection results are native Pi tool failures (`isError: true`) via the documented `tool_result` middleware, with structured rejection codes and bounded diagnostics retained. Pre-dispatch rejection does not execute the command; a timeout or dispatch failure can leave effects uncertain and never triggers retry or fallback.
|
|
123
|
+
|
|
114
124
|
## Launch and process safeguards
|
|
115
125
|
|
|
116
126
|
`unity_open_editor` and batchmode tools treat Unity CLI as the authoritative launcher: `unity open`, `unity run`, and `unity test` receive only the selected project and let Unity CLI read `ProjectVersion.txt`. The direct Editor executable is an exceptional compatibility fallback used only when Unity CLI is unavailable. Set `launcher` to `auto`, `unity-cli`, or `editor-executable` when explicit routing is needed.
|
|
@@ -226,7 +236,9 @@ The registry-clean `package-lock.json` is committed. Optional development packag
|
|
|
226
236
|
|
|
227
237
|
## Unity Pipeline project side effect
|
|
228
238
|
|
|
229
|
-
|
|
239
|
+
Legacy Pipeline releases (including `0.3.1-exp.1`) assigned `Application.runInBackground = true`, which Unity persisted as `PlayerSettings.runInBackground` in `ProjectSettings/ProjectSettings.asset`. Pipeline 0.6 restores the original value around server start/stop. Review tracked settings changes when installing older releases; Pipeline 0.6 reports non-automated Editor state as descriptor `info` rather than a console warning.
|
|
240
|
+
|
|
241
|
+
Pipeline 0.6 records local editor eval usage in `Library/Pipeline/eval-usage.jsonl`; raw source is not stored unless the Pipeline **Store Eval Source** setting is enabled. `UNITY_NO_CLI_INVOKED_TELEMETRY=1` opts out only of Unity CLI's per-invocation telemetry event; analytics and crash-reporting controls are separate and unchanged. Pipeline compact HTTP responses are accepted whether returned directly or inside the CLI's documented `data` wrapper; this does not assert that CLI wrapping has changed. For installed CLI/skill information, use read-only `unity skill show --list` or `unity skill show --path <path>`.
|
|
230
242
|
|
|
231
243
|
## License
|
|
232
244
|
|
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,
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type UnityParsedTestResults,
|
|
21
21
|
} from "./src/unity-batchmode";
|
|
22
22
|
import { formatPathForUser, hasUnityCommandLineFlag } from "./src/unity-core";
|
|
23
|
-
import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, createUnityCliTestCommand, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
|
|
23
|
+
import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, createUnityCliTestCommand, dispatchUnityPipelineRunScript, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
|
|
24
24
|
import { launchUnityCliOpenDetached } from "./src/unity-launch";
|
|
25
25
|
import { createUnityBatchmodeCommand, launchUnityEditorDetached, resolveUnityEditorPath } from "./src/unity-editor-fallback";
|
|
26
26
|
import { loadPiUnitySettings, type PiUnitySettings } from "./src/pi-unity-settings";
|
|
@@ -29,6 +29,8 @@ import { assertUnityProjectNotBusy, evaluateUnityLaunchSafety, getUnityNativeLoc
|
|
|
29
29
|
import { readUnityVersion, resolveUnityProjectCandidates, type UnityProjectCandidate } from "./src/unity-projects";
|
|
30
30
|
import { createUnityTestBatchPlan, type UnityTestBatchPlan, type UnityTestPlatform } from "./src/unity-test-batch";
|
|
31
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";
|
|
32
34
|
import { auditUnityGuidance, type UnityGuidanceAuditResult } from "./src/unity-guidance-audit";
|
|
33
35
|
import { runUnityPipelineRecompile, runUnityPipelineTests, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
|
|
34
36
|
import {
|
|
@@ -68,7 +70,7 @@ const GUI_WARNING = "This launches the full Unity Editor GUI and is not the same
|
|
|
68
70
|
const SINGLE_PROCESS_WARNING = "Unity allows only one process per project folder. GUI Editor and batchmode/headless both count as that one process.";
|
|
69
71
|
|
|
70
72
|
type UnityToolDetails = {
|
|
71
|
-
mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline" | "tests";
|
|
73
|
+
mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline_run_script" | "pipeline" | "tests";
|
|
72
74
|
projectRoot: string;
|
|
73
75
|
unityVersion: string;
|
|
74
76
|
editorPath: string;
|
|
@@ -83,6 +85,10 @@ type UnityToolDetails = {
|
|
|
83
85
|
invocation?: UnityBatchmodeInvocation;
|
|
84
86
|
artifacts?: UnityBatchmodeArtifacts;
|
|
85
87
|
parsedTestResults?: UnityParsedTestResults | null;
|
|
88
|
+
testOutcome?: NormalizedUnityTestResult["outcome"];
|
|
89
|
+
normalizedResultPath?: string;
|
|
90
|
+
normalizedResult?: Pick<NormalizedUnityTestResult, "source" | "platform" | "outcome" | "summary"> & { testRecordCount: number };
|
|
91
|
+
evidenceWarnings?: string[];
|
|
86
92
|
status?: "passed" | "failed" | "killed";
|
|
87
93
|
launcher?: "unity-cli" | "editor-executable";
|
|
88
94
|
cliArgs?: string[];
|
|
@@ -95,6 +101,7 @@ type UnityToolDetails = {
|
|
|
95
101
|
cliCapabilities?: UnityCliProjectCapabilities;
|
|
96
102
|
pipelineInspection?: { outcome: "dispatched"; command: string; output: string; truncated: boolean } | { outcome: "rejected"; code: string; message: string };
|
|
97
103
|
pipelineEval?: { outcome: "dispatched"; command: string; output: string; truncated: boolean } | { outcome: "rejected"; code: string; message: string };
|
|
104
|
+
pipelineRunScript?: { outcome: "dispatched"; command: string; output: string; truncated: boolean } | { outcome: "rejected"; code: string; message: string };
|
|
98
105
|
pipeline?: UnityPipelineOperationDetails;
|
|
99
106
|
};
|
|
100
107
|
|
|
@@ -118,11 +125,13 @@ const LAUNCH_BATCHMODE_PARAMS = Type.Object({
|
|
|
118
125
|
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
126
|
}, { additionalProperties: false });
|
|
120
127
|
|
|
128
|
+
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.';
|
|
129
|
+
|
|
121
130
|
const RUN_TESTS_PARAMS = Type.Object({
|
|
122
131
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
123
132
|
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 })),
|
|
133
|
+
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." })),
|
|
134
|
+
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
135
|
execution: Type.Optional(StringEnum(["auto", "connected", "isolated"] as const, { default: "auto" })),
|
|
127
136
|
isolatedLauncher: Type.Optional(StringEnum(["auto", "unity-cli", "editor-executable"] as const, { default: "auto" })),
|
|
128
137
|
retries: Type.Optional(Type.Integer({ minimum: 0, maximum: 20, default: 0 })),
|
|
@@ -159,6 +168,15 @@ const PIPELINE_EVAL_PARAMS = Type.Object({
|
|
|
159
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." })),
|
|
160
169
|
}, { additionalProperties: false });
|
|
161
170
|
|
|
171
|
+
const PIPELINE_RUN_SCRIPT_PARAMS = Type.Object({
|
|
172
|
+
path: Type.Optional(Type.String({ maxLength: 1000, description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
173
|
+
file: Type.String({ minLength: 1, maxLength: 1000, description: "Existing .cs file under the selected project root. The file is compiled in memory; it is not written or imported." }),
|
|
174
|
+
entry: Type.Optional(Type.String({ minLength: 1, maxLength: 500, description: "Named static entry point. Omit only when Pipeline can select Main or an unambiguous public static method." })),
|
|
175
|
+
args: Type.Optional(Type.Array(Type.Any(), { maxItems: 32, description: "Bounded JSON arguments coerced by Pipeline to the static entry-point parameter types." })),
|
|
176
|
+
dryRun: Type.Optional(Type.Boolean({ default: false, description: "Compile only; do not load the assembly or invoke the entry point." })),
|
|
177
|
+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400, default: 30, description: "Connected run_script deadline. A timeout is uncertain and does not cancel the script." })),
|
|
178
|
+
}, { additionalProperties: false });
|
|
179
|
+
|
|
162
180
|
const PIPELINE_INSPECTION_PARAMS = Type.Object({
|
|
163
181
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
164
182
|
command: StringEnum(UNITY_PLANNING_READ_COMMANDS, { description: "An advertised package-owned Pipeline inspection command." }),
|
|
@@ -178,7 +196,7 @@ const INSPECT_ARTIFACTS_PARAMS = Type.Object({
|
|
|
178
196
|
testResultsPath: Type.Optional(Type.String({ description: "Unity Test Framework XML results path. Relative paths are resolved against cwd and the Unity project root." })),
|
|
179
197
|
normalizedResultPath: Type.Optional(Type.String({ description: "pi-unity normalized JSON test artifact path." })),
|
|
180
198
|
logFilePath: Type.Optional(Type.String({ description: "Unity log file path. Relative paths are resolved against cwd and the Unity project root." })),
|
|
181
|
-
latestFromLogs: Type.Optional(Type.Boolean({ default: true, description: "
|
|
199
|
+
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." })),
|
|
182
200
|
maxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 60, description: "Maximum log/output lines to include." })),
|
|
183
201
|
maxChars: Type.Optional(Type.Integer({ minimum: 500, maximum: 20000, default: 6000, description: "Maximum log/output characters to include." })),
|
|
184
202
|
});
|
|
@@ -705,7 +723,8 @@ async function buildArtifactInspectionReport(
|
|
|
705
723
|
candidate: UnityProjectCandidate,
|
|
706
724
|
params: { testResultsPath?: string; normalizedResultPath?: string; logFilePath?: string; latestFromLogs?: boolean; maxLines?: number; maxChars?: number },
|
|
707
725
|
): Promise<{ text: string; details: UnityToolDetails }> {
|
|
708
|
-
|
|
726
|
+
// An exact artifact request must not silently recruit unrelated latest evidence.
|
|
727
|
+
const useLatest = params.latestFromLogs !== false && ![params.testResultsPath, params.logFilePath, params.normalizedResultPath].some(value => value?.trim());
|
|
709
728
|
const logsRoot = join(candidate.projectRoot, "Logs");
|
|
710
729
|
const testResultsPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.testResultsPath)
|
|
711
730
|
?? (useLatest ? await findNewestFile(logsRoot, [".xml"]) : undefined);
|
|
@@ -713,12 +732,16 @@ async function buildArtifactInspectionReport(
|
|
|
713
732
|
?? (useLatest ? await findNewestFile(logsRoot, [".log", ".txt"]) : undefined);
|
|
714
733
|
const normalizedResultPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.normalizedResultPath)
|
|
715
734
|
?? (useLatest ? await findNewestFile(logsRoot, [".json"]) : undefined);
|
|
716
|
-
let
|
|
735
|
+
let normalized: NormalizedUnityTestResult | undefined;
|
|
736
|
+
const evidenceErrors: string[] = [];
|
|
737
|
+
const evidenceWarnings: string[] = [];
|
|
717
738
|
if (normalizedResultPath) {
|
|
718
739
|
try {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
} catch
|
|
740
|
+
if ((await stat(normalizedResultPath)).size > UNITY_TEST_MAX_ARTIFACT_BYTES) throw new Error("Normalized artifact exceeds its size limit.");
|
|
741
|
+
normalized = validateNormalizedUnityTestArtifact(JSON.parse(await readFile(normalizedResultPath, "utf8")));
|
|
742
|
+
} catch (error) {
|
|
743
|
+
evidenceErrors.push(`Normalized test result could not be loaded/validated: ${normalizedResultPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
744
|
+
}
|
|
722
745
|
}
|
|
723
746
|
const invocation: UnityBatchmodeInvocation = {
|
|
724
747
|
isTestRun: Boolean(testResultsPath),
|
|
@@ -731,16 +754,51 @@ async function buildArtifactInspectionReport(
|
|
|
731
754
|
if (testResultsPath && artifacts.testResultsXml && !parsedTestResults) {
|
|
732
755
|
artifacts.warnings.push(`Unity test results XML could not be parsed: ${artifacts.testResultsPath ?? testResultsPath}`);
|
|
733
756
|
}
|
|
734
|
-
|
|
735
|
-
|
|
757
|
+
if (testResultsPath && !parsedTestResults) evidenceErrors.push(`Requested XML evidence is missing or malformed: ${testResultsPath}`);
|
|
758
|
+
if (parsedTestResults && hasConflictingUnityXmlTestEvidence(parsedTestResults)) {
|
|
759
|
+
evidenceErrors.push("Conflicting XML evidence: invalid or inconsistent counts/records.");
|
|
760
|
+
}
|
|
761
|
+
if (logFilePath && artifacts.logText === undefined) evidenceErrors.push(`Requested log evidence is missing: ${logFilePath}`);
|
|
762
|
+
const hasLoadedArtifacts = Boolean(normalized || parsedTestResults || artifacts.logText !== undefined);
|
|
763
|
+
if (!hasLoadedArtifacts) evidenceErrors.push("No valid Unity artifacts were loaded.");
|
|
764
|
+
let testOutcome = normalized?.outcome ?? (parsedTestResults ? determineUnityTestOutcome({ ...parsedTestResults, failed: parsedTestResults.failedTests.length > 0 ? Math.max(1, parsedTestResults.failed ?? 0) : parsedTestResults.failed }) : undefined);
|
|
765
|
+
if (normalized && parsedTestResults) {
|
|
766
|
+
if (hasConflictingUnityXmlTestEvidence(parsedTestResults, normalized.summary)) evidenceErrors.push("Conflicting normalized/XML evidence: combined counts/records disagree.");
|
|
767
|
+
for (const key of ["total", "passed", "failed", "skipped", "inconclusive"] as const) {
|
|
768
|
+
if (normalized.summary[key] !== undefined && parsedTestResults[key] !== undefined && normalized.summary[key] !== parsedTestResults[key]) evidenceErrors.push(`Conflicting normalized/XML evidence: ${key} differs.`);
|
|
769
|
+
}
|
|
770
|
+
if ((normalized.outcome === "passed" || normalized.outcome === "passed_with_flakes") && parsedTestResults.failedTests.length > 0) evidenceErrors.push("Conflicting normalized/XML evidence: XML contains failed tests.");
|
|
771
|
+
const linkedXml = normalized.backendArtifacts?.nunit;
|
|
772
|
+
if (linkedXml && resolve(candidate.projectRoot, linkedXml) !== resolve(testResultsPath!)) evidenceErrors.push("Conflicting artifact identity: selected XML is not the normalized artifact's nunit path.");
|
|
773
|
+
if (!linkedXml) {
|
|
774
|
+
evidenceWarnings.push("Normalized JSON and XML have no shared run identity; matching counts alone do not correlate these files.");
|
|
775
|
+
testOutcome = "uncertain";
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (parsedTestResults?.testRecordCounts?.other && (testOutcome === "passed" || testOutcome === "passed_with_flakes")) {
|
|
779
|
+
evidenceWarnings.push("XML contains test records with unknown outcomes; these are not passing evidence.");
|
|
780
|
+
testOutcome = "uncertain";
|
|
781
|
+
}
|
|
782
|
+
if (useLatest) evidenceWarnings.push("Latest artifact selection does not establish current-run identity; use exact paths for a particular run.");
|
|
783
|
+
if (evidenceErrors.length) testOutcome = "uncertain";
|
|
784
|
+
// Inspection succeeded even when valid evidence reports test failure or uncertainty.
|
|
785
|
+
const status = evidenceErrors.length ? "failed" : "passed";
|
|
736
786
|
const lines = [
|
|
737
787
|
`Unity artifacts inspected for ${formatPathForUser(ctx.cwd, candidate.projectRoot)}; Unity CLI selects the project's declared Editor version when launching.`,
|
|
738
788
|
testResultsPath ? `Requested test results: ${testResultsPath}` : "Requested test results: (none found)",
|
|
739
789
|
logFilePath ? `Requested log file: ${logFilePath}` : "Requested log file: (none found)",
|
|
740
790
|
normalizedResultPath ? `Requested normalized result: ${normalizedResultPath}` : "Requested normalized result: (none found)",
|
|
741
|
-
|
|
791
|
+
`Inspection: ${status}. Test outcome: ${testOutcome ?? "not established (log only)"}.`,
|
|
792
|
+
...(normalized ? [compactUnityTestSummary(normalized), `Normalized source: ${normalized.source}; selection (bounded): ${summarizeTextForAgent(JSON.stringify(normalized.selection), 1, 2000)}; ${normalized.tests.length} retained test record(s).`] : []),
|
|
793
|
+
...evidenceErrors,
|
|
794
|
+
...evidenceWarnings,
|
|
742
795
|
];
|
|
743
796
|
|
|
797
|
+
if (normalized) {
|
|
798
|
+
for (const test of normalized.tests.filter(test => !["passed", "success"].includes(test.status.toLowerCase())).slice(0, 8)) {
|
|
799
|
+
lines.push(`- ${test.name.slice(0, 1000)}: ${test.status.slice(0, 100)}${test.message ? ` — ${test.message.slice(0, 1000)}` : ""}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
744
802
|
if (parsedTestResults) {
|
|
745
803
|
lines.push(...formatParsedTestResultsForAgent(parsedTestResults));
|
|
746
804
|
}
|
|
@@ -767,6 +825,10 @@ async function buildArtifactInspectionReport(
|
|
|
767
825
|
artifacts: compactUnityArtifacts(artifacts),
|
|
768
826
|
parsedTestResults,
|
|
769
827
|
status,
|
|
828
|
+
testOutcome,
|
|
829
|
+
normalizedResultPath,
|
|
830
|
+
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,
|
|
831
|
+
evidenceWarnings,
|
|
770
832
|
},
|
|
771
833
|
};
|
|
772
834
|
}
|
|
@@ -1152,14 +1214,14 @@ async function runUnifiedUnityTests(
|
|
|
1152
1214
|
const busy = (await listBlockingUnityProcesses(candidate.projectRoot)).processes.length > 0 || capabilities.matchingInstances.length > 0;
|
|
1153
1215
|
let route: "connected" | "isolated";
|
|
1154
1216
|
if (request.execution === "connected") {
|
|
1155
|
-
if (requirements.requiresIsolation) throw new Error(`Connected execution cannot honor this request: ${requirements.reasons.join("; ")}.`);
|
|
1217
|
+
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.`);
|
|
1156
1218
|
if (!reachable) throw new Error("Connected execution requires an already-open exact-copy reachable Pipeline Editor; no Unity was launched.");
|
|
1157
1219
|
route = "connected";
|
|
1158
1220
|
} else if (request.execution === "isolated") {
|
|
1159
1221
|
if (reachable && !request.closeBlockingUnityProcess) throw new Error("Isolated execution will not close a reachable Pipeline Editor automatically. Close it first or use the explicitly guarded close option.");
|
|
1160
1222
|
route = "isolated";
|
|
1161
1223
|
} else if (reachable) {
|
|
1162
|
-
if (requirements.requiresIsolation) throw new Error(`This request requires isolated execution (${requirements.reasons.join("; ")}), but the exact project copy is open in reachable Pipeline. pi-unity will not close it automatically.`);
|
|
1224
|
+
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.`);
|
|
1163
1225
|
route = "connected";
|
|
1164
1226
|
} else {
|
|
1165
1227
|
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.");
|
|
@@ -1242,9 +1304,9 @@ function renderUnityPipelineResult(result: any, options: { expanded: boolean; is
|
|
|
1242
1304
|
const counts = pipeline.counts;
|
|
1243
1305
|
const passed = counts?.passed === undefined || counts?.total === undefined ? "tests completed" : `${counts.passed}/${counts.total} passed`;
|
|
1244
1306
|
text = `${icon} ${theme.fg("toolTitle", theme.bold(`Unity ${pipeline.testPlatform ?? ""} tests`.trim()))} ${theme.fg("accent", passed)}${theme.fg("muted", ` • ${pipeline.elapsedSeconds.toFixed(1)}s`)}`;
|
|
1245
|
-
} else if (details.mode === "pipeline_eval" || details.mode === "pipeline_inspection") {
|
|
1246
|
-
const output = details.mode === "pipeline_eval" ? details.pipelineEval : details.pipelineInspection;
|
|
1247
|
-
const label = details.mode === "pipeline_eval" ? "Unity Pipeline Eval" : "Unity Pipeline Inspection";
|
|
1307
|
+
} else if (details.mode === "pipeline_eval" || details.mode === "pipeline_inspection" || details.mode === "pipeline_run_script") {
|
|
1308
|
+
const output = details.mode === "pipeline_eval" ? details.pipelineEval : details.mode === "pipeline_run_script" ? details.pipelineRunScript : details.pipelineInspection;
|
|
1309
|
+
const label = details.mode === "pipeline_eval" ? "Unity Pipeline Eval" : details.mode === "pipeline_run_script" ? "Unity Pipeline Run Script" : "Unity Pipeline Inspection";
|
|
1248
1310
|
const summary = output?.outcome === "dispatched" ? output.output || "(no bounded output returned)" : output?.message || primaryText;
|
|
1249
1311
|
text = `${icon} ${theme.fg("toolTitle", theme.bold(label))}\n ${theme.fg("toolOutput", compactUnityRendererValue(summary, 240))}`;
|
|
1250
1312
|
} else {
|
|
@@ -1285,7 +1347,9 @@ function renderUnityToolResult(result: any, expanded: boolean, theme: any): Text
|
|
|
1285
1347
|
? "Unity Pipeline Inspection"
|
|
1286
1348
|
: details.mode === "pipeline_eval"
|
|
1287
1349
|
? "Unity Pipeline Eval"
|
|
1288
|
-
: details.mode === "
|
|
1350
|
+
: details.mode === "pipeline_run_script"
|
|
1351
|
+
? "Unity Pipeline Run Script"
|
|
1352
|
+
: details.mode === "pipeline"
|
|
1289
1353
|
? "Unity Pipeline"
|
|
1290
1354
|
: getBatchmodeVariantLabel(details.args);
|
|
1291
1355
|
const projectLabel = details.projectRoot ?? "(unknown project)";
|
|
@@ -1333,6 +1397,18 @@ function formatUnityGuidanceAudit(result: UnityGuidanceAuditResult): string {
|
|
|
1333
1397
|
}
|
|
1334
1398
|
|
|
1335
1399
|
export default function freeUnityPi(pi: ExtensionAPI) {
|
|
1400
|
+
// Pi's documented tool_result patch marks native failure without discarding the
|
|
1401
|
+
// structured rejection details (throwing from execute retains only error text).
|
|
1402
|
+
// A top-level isError property returned from execute is NOT a native error.
|
|
1403
|
+
pi.on("tool_result", (event) => {
|
|
1404
|
+
const details = event.details as UnityToolDetails | undefined;
|
|
1405
|
+
if ((event.toolName === "unity_pipeline_eval" && details?.mode === "pipeline_eval" && details.pipelineEval?.outcome === "rejected")
|
|
1406
|
+
|| (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")) {
|
|
1408
|
+
return { isError: true };
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1336
1412
|
type ScopeRegistrations = Readonly<{
|
|
1337
1413
|
artifactProfile?: Readonly<{ registry: ScopedRegistryV1; token: RegistrationToken }>;
|
|
1338
1414
|
fileDiscoveryFilter?: Readonly<{ registry: ScopedRegistryV1; token: RegistrationToken }>;
|
|
@@ -1546,6 +1622,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1546
1622
|
promptSnippet: "Run Unity EditMode or PlayMode tests through one safe routed workflow with durable normalized evidence.",
|
|
1547
1623
|
promptGuidelines: [
|
|
1548
1624
|
"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.",
|
|
1625
|
+
CONNECTED_TEST_SELECTOR_GUIDANCE,
|
|
1549
1626
|
"Do not use unity_launch_batchmode for ordinary tests; raw test flags there are an unsupported escape hatch.",
|
|
1550
1627
|
"A reachable Editor is never closed merely to obtain isolated-only options. Requests needing retries, sharding, reruns, coverage, multiple selectors, or XML reports are rejected before dispatch when it is open.",
|
|
1551
1628
|
"Timeout, malformed evidence, cancellation, or missing artifacts never cause a backend fallback or relaunch.",
|
|
@@ -1637,6 +1714,38 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1637
1714
|
},
|
|
1638
1715
|
});
|
|
1639
1716
|
|
|
1717
|
+
pi.registerTool({
|
|
1718
|
+
name: "unity_pipeline_run_script",
|
|
1719
|
+
label: "Unity Pipeline Run Script",
|
|
1720
|
+
description: "Compile one existing project C# file in memory and invoke its advertised static entry point through Pipeline 0.6 run_script.",
|
|
1721
|
+
promptSnippet: "Run one explicitly requested existing C# builder script through an already-open exact Unity Pipeline Editor.",
|
|
1722
|
+
promptGuidelines: [
|
|
1723
|
+
"unity_pipeline_run_script is arbitrary code execution, not read-only and not a sandbox. Use it only for explicit user intent; obtain explicit authorization for lifecycle, settings, asset, build, test, or destructive mutations.",
|
|
1724
|
+
"It uses Pipeline's ephemeral mode only: no hotpatch, no source upload, no asset import, no domain reload, no retry, fallback, cancellation, launch, save, or Play Mode exit.",
|
|
1725
|
+
"Use dryRun=true to compile without loading or executing the script. A failure, malformed result, or timeout never establishes success; a timeout may still have running script effects.",
|
|
1726
|
+
],
|
|
1727
|
+
parameters: PIPELINE_RUN_SCRIPT_PARAMS,
|
|
1728
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
1729
|
+
throwIfAborted(signal);
|
|
1730
|
+
const { candidate } = await resolveProjectCandidate(ctx, params.path);
|
|
1731
|
+
const file = isAbsolute(params.file) ? params.file : resolve(candidate.projectRoot, params.file);
|
|
1732
|
+
const result = await dispatchUnityPipelineRunScript({
|
|
1733
|
+
projectRoot: candidate.projectRoot,
|
|
1734
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1735
|
+
file,
|
|
1736
|
+
entry: params.entry,
|
|
1737
|
+
args: params.args,
|
|
1738
|
+
dryRun: params.dryRun,
|
|
1739
|
+
}, { execute: createPlanningUnityCliExecutor(pi), signal, timeout: (params.timeoutSeconds ?? 30) * 1000 });
|
|
1740
|
+
const text = result.outcome === "dispatched"
|
|
1741
|
+
? `Unity Pipeline run_script ${params.dryRun ? "compile-only completed" : "completed"}.\n${result.output || "(no bounded output returned)"}`
|
|
1742
|
+
: `Unity Pipeline run_script rejected: ${result.code}\n${result.message}`;
|
|
1743
|
+
return { content: [{ type: "text", text }], details: { mode: "pipeline_run_script", projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: result.outcome === "dispatched" ? "passed" : "failed", pipelineRunScript: result } satisfies UnityToolDetails };
|
|
1744
|
+
},
|
|
1745
|
+
renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_run_script", args, theme, context); },
|
|
1746
|
+
renderResult(result, options, theme, context) { return renderUnityPipelineResult(result, options, theme, context); },
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1640
1749
|
pi.registerTool({
|
|
1641
1750
|
name: "unity_pipeline_inspect",
|
|
1642
1751
|
label: "Unity Pipeline Inspect",
|
|
@@ -1689,13 +1798,13 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1689
1798
|
pi.registerTool({
|
|
1690
1799
|
name: "unity_inspect_artifacts",
|
|
1691
1800
|
label: "Unity Inspect Artifacts",
|
|
1692
|
-
description: "
|
|
1693
|
-
promptSnippet: "Inspect existing Unity
|
|
1801
|
+
description: "Inspect existing Unity normalized JSON, Test Framework XML and logs without launching Unity. Inspection success is separate from test outcome.",
|
|
1802
|
+
promptSnippet: "Inspect existing Unity normalized test evidence, XML or logs without launching Unity.",
|
|
1694
1803
|
promptGuidelines: [
|
|
1695
1804
|
"Use unity_inspect_artifacts after Unity failures when existing -testResults or -logFile artifacts need concise parsing without another Unity launch.",
|
|
1696
1805
|
"Prefer unity_inspect_artifacts over ad hoc bash parsing of Unity XML/log files when paths are known or Logs/ contains recent artifacts.",
|
|
1697
1806
|
"unity_inspect_artifacts does not launch Unity and is safe to use even when the Unity project is busy.",
|
|
1698
|
-
"
|
|
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.",
|
|
1699
1808
|
],
|
|
1700
1809
|
parameters: INSPECT_ARTIFACTS_PARAMS,
|
|
1701
1810
|
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.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./index.ts"
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
"typebox": "1.3.8"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
|
+
"@aefree/pi-file-discovery": "^0.1.0",
|
|
29
|
+
"@aefree/pi-project-artifacts": "^0.1.0",
|
|
28
30
|
"@earendil-works/pi-ai": "*",
|
|
29
31
|
"@earendil-works/pi-coding-agent": "*",
|
|
30
|
-
"@earendil-works/pi-tui": "*"
|
|
31
|
-
"@aefree/pi-project-artifacts": "^0.1.0",
|
|
32
|
-
"@aefree/pi-file-discovery": "^0.1.0"
|
|
32
|
+
"@earendil-works/pi-tui": "*"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
35
|
"node": ">=22.19.0"
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
},
|
|
49
49
|
"homepage": "https://github.com/aefreedman/pi-unity#readme",
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@earendil-works/pi-ai": "0.83.0",
|
|
52
|
-
"@earendil-works/pi-coding-agent": "0.83.0",
|
|
53
|
-
"@earendil-works/pi-tui": "0.83.0",
|
|
54
|
-
"@aefree/pi-project-artifacts": "^0.1.0",
|
|
55
51
|
"@aefree/pi-file-discovery": "^0.1.0",
|
|
52
|
+
"@aefree/pi-project-artifacts": "^0.1.0",
|
|
53
|
+
"@earendil-works/pi-ai": "0.85.1",
|
|
54
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
55
|
+
"@earendil-works/pi-tui": "0.85.1",
|
|
56
56
|
"tsx": "^4.23.5"
|
|
57
57
|
},
|
|
58
58
|
"peerDependenciesMeta": {
|
|
@@ -16,7 +16,7 @@ Use one typed tool call for each supported connected operation:
|
|
|
16
16
|
|
|
17
17
|
These tools resolve the exact copy, require advertised commands, inspect lifecycle state, dispatch once, validate identity, and poll internally with a fixed deadline. Do not recreate their wait loops with `bash`, `unity recompile_status`, or `unity test_status` calls.
|
|
18
18
|
|
|
19
|
-
A timeout or
|
|
19
|
+
A timeout, malformed response, or busy response is uncertain: the Unity operation may still be running or blocked by a modal dialog. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision. Pipeline 0.6 uses busy for modal blocks and no source-verified discriminator is available through this transport, so pi-unity does not blindly retry it.
|
|
20
20
|
|
|
21
21
|
## Preconditions and boundaries
|
|
22
22
|
|
|
@@ -41,12 +41,30 @@ Call `unity_run_tests` with:
|
|
|
41
41
|
|
|
42
42
|
The tool treats `no_tests`, idle, and not-started statuses as safe inactivity, detects a pre-existing active connected test before dispatch, and stops rather than claiming or replacing active work. It captures returned mode/filter/run identity fields when available and stops as uncertain if status is clearly displaced by another run.
|
|
43
43
|
|
|
44
|
+
### Two independent fixtures in an open Editor
|
|
45
|
+
|
|
46
|
+
Keep the Editor open. For an authorized request for two known non-overlapping fixtures, use the same explicit exact-copy `path` and platform, one selector per call. For example, replace `./SyntheticGame` with the selected project path and issue this `unity_run_tests` call:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{"path":"./SyntheticGame","testPlatform":"EditMode","execution":"connected","testFilters":["Synthetic.InventoryFixture"]}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Await the completed tool result and inspect its outcome and exact normalized artifact path. Only after a passing result, issue the second call (not in parallel or pre-queued in the same turn):
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{"path":"./SyntheticGame","testPlatform":"EditMode","execution":"connected","testFilters":["Synthetic.DialogueFixture"]}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Stop the remaining sequence on failure, timeout, cancellation, malformed evidence, displaced identity, or any uncertainty; report the first result and the remaining fixture as not run. Do not retry, close the Editor, switch to isolated execution, or broaden to a parent suite/category or empty selectors. Empty/omitted selectors select all tests. Keep each result's artifact separately; do not treat one fixture's evidence as covering the other.
|
|
59
|
+
|
|
60
|
+
For a single category use `testCategories: ["SyntheticCategory"]` and omit `testFilters`. Never combine the two families or join selectors with semicolons. Multiple-selector rejection means no test was dispatched: use the serial recipe only for independent, non-overlapping selections without other isolated-only requirements. If overlap or intended filter/category intersection semantics are unclear, stop for clarification rather than split, duplicate, or broaden tests. Required XML, retries, coverage, or other isolated-only options still need a deliberate isolation decision, not argument stripping.
|
|
61
|
+
|
|
44
62
|
## Bounded raw CLI troubleshooting only
|
|
45
63
|
|
|
46
64
|
Normally call the typed tools, not raw CLI commands. If a typed tool is unavailable in an older installed package and a user specifically authorizes troubleshooting, first use `unity_project_status` and require advertised `recompile_status` or `run_tests` and `test_status`. Use the documented asynchronous form with `--async_tests true`, one fixed deadline, and bounded backoff; parse object and stringified nested JSON. `Total: 0` with `result: running` is a valid nonterminal initiating response. Passing evidence reports successful completion, a known positive executed-test count, and zero failures; nested `success:false`, changed exact-copy identity, or polling timeout is non-passing uncertainty. Do not silently fall back to batchmode after uncertain connected dispatch. Connected work does not guarantee NUnit XML.
|
|
47
65
|
|
|
48
66
|
## When not to use connected tools
|
|
49
67
|
|
|
50
|
-
Use `unity_run_tests` with `execution: "isolated"` for a closed project, intentional isolation/CI, multiple selectors, retries, sharding, coverage, or required NUnit/JUnit evidence. 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.
|
|
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.
|
|
51
69
|
|
|
52
|
-
Use the typed compile/test tools when their polling and terminal evidence fit the task. 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 a timeout remains uncertain without cancellation or retry. 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.
|
|
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 a timeout remains uncertain without cancellation or retry. 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.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
import { determineUnityTestOutcome, type NormalizedUnityTestResult } from "./unity-tests";
|
|
3
|
+
|
|
4
|
+
const outcomes = ["passed", "passed_with_flakes", "tests_failed", "empty_selection", "run_error", "timed_out", "cancelled", "uncertain"];
|
|
5
|
+
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
const nonnegative = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
7
|
+
const count = (value: unknown): value is number => nonnegative(value) && Number.isSafeInteger(value);
|
|
8
|
+
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === "string" && !!item.trim() && !/[\0\r\n;]/.test(item));
|
|
9
|
+
const relativeId = (value: unknown): value is string => typeof value === "string" && !!value.trim()
|
|
10
|
+
&& !isAbsolute(value) && !/^(?:[A-Za-z]:|[\\/])/.test(value) && !value.split(/[\\/]/).includes("..") && !/\0/.test(value);
|
|
11
|
+
|
|
12
|
+
/** Read the durable schema, not a transport response. Missing optional counts remain unknown.
|
|
13
|
+
* Test records may be bounded or absent: never require tests.length === summary.total.
|
|
14
|
+
*/
|
|
15
|
+
export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedUnityTestResult {
|
|
16
|
+
const invalid = (reason: string): never => { throw new Error(`Invalid normalized Unity test artifact: ${reason}.`); };
|
|
17
|
+
if (!record(value) || value.schemaVersion !== 1) invalid("expected schemaVersion 1 object");
|
|
18
|
+
const result = value as Record<string, unknown>;
|
|
19
|
+
if (!["pipeline", "unity-cli", "editor-executable"].includes(result.source as string)) invalid("unsupported source");
|
|
20
|
+
if (!["EditMode", "PlayMode"].includes(result.platform as string)) invalid("unsupported platform");
|
|
21
|
+
if (!outcomes.includes(result.outcome as string)) invalid("unsupported outcome");
|
|
22
|
+
if (!record(result.selection) || !strings(result.selection.testFilters) || !strings(result.selection.testCategories)) invalid("selection must contain testFilters and testCategories arrays");
|
|
23
|
+
if (!record(result.summary)) invalid("summary must be an object");
|
|
24
|
+
const summary = result.summary as Record<string, unknown>;
|
|
25
|
+
for (const key of ["total", "passed", "failed", "skipped", "inconclusive"]) {
|
|
26
|
+
if (summary[key] !== undefined && !count(summary[key])) invalid(`summary.${key} must be a non-negative integer`);
|
|
27
|
+
}
|
|
28
|
+
if (count(summary.total)) {
|
|
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
|
+
}
|
|
32
|
+
if (!Array.isArray(result.tests)) invalid("tests must be an array");
|
|
33
|
+
const tests = result.tests as unknown[];
|
|
34
|
+
for (const test of tests) {
|
|
35
|
+
if (!record(test) || typeof test.name !== "string" || !test.name.trim() || typeof test.status !== "string" || !test.status.trim()) invalid("test records require name and status");
|
|
36
|
+
const item = test as Record<string, unknown>;
|
|
37
|
+
for (const key of ["message", "stackTrace"]) if (item[key] !== undefined && typeof item[key] !== "string") invalid(`test ${key} must be a string`);
|
|
38
|
+
if (item.durationSeconds !== undefined && !nonnegative(item.durationSeconds)) invalid("test durationSeconds must be non-negative");
|
|
39
|
+
if (item.attempts !== undefined && (!count(item.attempts) || item.attempts < 1)) invalid("test attempts must be positive");
|
|
40
|
+
}
|
|
41
|
+
if (count(summary.total) && tests.length > summary.total) invalid("test records exceed total");
|
|
42
|
+
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
|
+
if (result.projectRelativeId !== undefined && !relativeId(result.projectRelativeId)) invalid("projectRelativeId must be project-relative");
|
|
48
|
+
if (result.backendArtifacts !== undefined && (!record(result.backendArtifacts) || !Object.values(result.backendArtifacts).every(relativeId))) invalid("backendArtifacts must contain project-relative paths");
|
|
49
|
+
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
|
+
if (typed.startedAt && typed.completedAt && Date.parse(typed.completedAt) < Date.parse(typed.startedAt)) invalid("completion precedes start");
|
|
51
|
+
if (result.durationSeconds !== undefined && !nonnegative(result.durationSeconds)) invalid("durationSeconds must be non-negative");
|
|
52
|
+
if (result.flakyTests !== undefined && (!Array.isArray(result.flakyTests) || !result.flakyTests.every(item => record(item) && typeof item.name === "string" && !!item.name.trim() && count(item.attempts) && item.attempts > 0))) invalid("invalid flakyTests");
|
|
53
|
+
if (result.outcome === "passed" || result.outcome === "passed_with_flakes") {
|
|
54
|
+
if (determineUnityTestOutcome(typed.summary) !== "passed" || typed.tests.some(test => !["passed", "success"].includes(test.status.toLowerCase()))) invalid("passing outcome lacks consistent positive passing counts/records");
|
|
55
|
+
if (result.outcome === "passed_with_flakes" && !typed.flakyTests?.length) invalid("passed_with_flakes requires flakyTests evidence");
|
|
56
|
+
}
|
|
57
|
+
if (result.outcome === "empty_selection" && ((typed.summary.total ?? 0) > 0 || tests.length > 0)) invalid("empty selection conflicts with executed tests");
|
|
58
|
+
return typed;
|
|
59
|
+
}
|
package/src/unity-batchmode.ts
CHANGED
|
@@ -29,6 +29,8 @@ export type UnityParsedTestResults = {
|
|
|
29
29
|
failedTests: UnityFailedTest[];
|
|
30
30
|
/** Complete bounded per-test evidence for normalized artifacts, never routine tool output. */
|
|
31
31
|
tests: UnityParsedTestCase[];
|
|
32
|
+
/** Observed XML record lower bounds, counted before record output is truncated. */
|
|
33
|
+
testRecordCounts?: { total: number; passed: number; failed: number; skipped: number; inconclusive: number; other: number };
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
export type UnityBatchmodeArtifacts = {
|
|
@@ -82,10 +84,10 @@ function decodeXmlText(value: string | undefined): string | undefined {
|
|
|
82
84
|
|
|
83
85
|
function parseAttributes(tagSource: string): Record<string, string> {
|
|
84
86
|
const attributes: Record<string, string> = {};
|
|
85
|
-
const attributeRegex = /(\w[\w:-]*)\s*=\s*"([^"]*)"/g;
|
|
87
|
+
const attributeRegex = /(\w[\w:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
86
88
|
for (const match of tagSource.matchAll(attributeRegex)) {
|
|
87
89
|
const key = match[1];
|
|
88
|
-
const value = match[2] ?? "";
|
|
90
|
+
const value = match[2] ?? match[3] ?? "";
|
|
89
91
|
attributes[key] = value;
|
|
90
92
|
}
|
|
91
93
|
return attributes;
|
|
@@ -102,6 +104,12 @@ function parseOptionalNumber(value: string | undefined): number | undefined {
|
|
|
102
104
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
function parseTestCount(value: string | undefined): number | undefined {
|
|
108
|
+
// Only omission is unknown. Retain invalid supplied counters as NaN so the
|
|
109
|
+
// inspection validator rejects them instead of silently treating them as absent.
|
|
110
|
+
return value === undefined ? undefined : value.trim() ? Number(value) : Number.NaN;
|
|
111
|
+
}
|
|
112
|
+
|
|
105
113
|
export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults | null {
|
|
106
114
|
const testRunMatch = xml.match(/<test-run\b([^>]*)>/i);
|
|
107
115
|
const testRunCloseIndex = xml.search(/<\/test-run\s*>/i);
|
|
@@ -112,33 +120,43 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
|
|
|
112
120
|
const rootAttributes = parseAttributes(testRunMatch[1] ?? "");
|
|
113
121
|
const failedTests: UnityFailedTest[] = [];
|
|
114
122
|
const tests: UnityParsedTestCase[] = [];
|
|
123
|
+
const testRecordCounts = { total: 0, passed: 0, failed: 0, skipped: 0, inconclusive: 0, other: 0 };
|
|
115
124
|
|
|
116
|
-
|
|
125
|
+
// Match the self-closing alternative first so it cannot consume the body of
|
|
126
|
+
// the next paired record. Both forms carry authoritative failure evidence.
|
|
127
|
+
const testCaseRegex = /<test-case\b([^>]*?)(?:\/\s*>|>([\s\S]*?)<\/test-case\s*>)/gi;
|
|
117
128
|
for (const match of xml.matchAll(testCaseRegex)) {
|
|
118
129
|
const attributes = parseAttributes(match[1] ?? "");
|
|
119
130
|
const body = match[2] ?? "";
|
|
120
131
|
const result = String(attributes.result ?? attributes.label ?? "").toLowerCase();
|
|
121
132
|
const success = String(attributes.success ?? "").toLowerCase();
|
|
122
133
|
const isFailure = result === "failed" || success === "false";
|
|
134
|
+
const status = isFailure ? "Failed" : attributes.result ?? attributes.label ?? "Unknown";
|
|
135
|
+
const statusKey = isFailure ? "failed" : result === "passed" || result === "success" ? "passed" : result === "skipped" ? "skipped" : result === "inconclusive" ? "inconclusive" : "other";
|
|
136
|
+
testRecordCounts.total++;
|
|
137
|
+
testRecordCounts[statusKey]++;
|
|
123
138
|
const failureMessage = body.match(/<message[^>]*>([\s\S]*?)<\/message>/i);
|
|
124
139
|
const stackTrace = body.match(/<stack-trace[^>]*>([\s\S]*?)<\/stack-trace>/i);
|
|
125
140
|
const name = truncateEvidence(attributes.fullname ?? attributes.name ?? "(unknown test)", 1_000) ?? "(unknown test)";
|
|
126
|
-
if (tests.length < 2_000) tests.push({ name, status
|
|
141
|
+
if (tests.length < 2_000) tests.push({ name, status, ...(parseOptionalNumber(attributes.duration) === undefined ? {} : { durationSeconds: parseOptionalNumber(attributes.duration) }), ...(truncateEvidence(decodeXmlText(failureMessage?.[1]), 4_000) ? { message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 4_000) } : {}), ...(truncateEvidence(decodeXmlText(stackTrace?.[1]), 8_000) ? { stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 8_000) } : {}) });
|
|
127
142
|
if (!isFailure) continue;
|
|
128
143
|
if (failedTests.length < 50) failedTests.push({ name, message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 1_000), stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 4_000) });
|
|
129
144
|
}
|
|
130
145
|
|
|
131
|
-
|
|
146
|
+
// These are separate counters. Synthesizing skipped from inconclusive makes
|
|
147
|
+
// combined-count validation double-count one observed category.
|
|
148
|
+
const skipped = parseTestCount(rootAttributes.skipped);
|
|
132
149
|
|
|
133
150
|
const parsed: UnityParsedTestResults = {
|
|
134
|
-
total:
|
|
135
|
-
passed:
|
|
136
|
-
failed:
|
|
151
|
+
total: parseTestCount(rootAttributes.total) ?? parseTestCount(rootAttributes.testcasecount),
|
|
152
|
+
passed: parseTestCount(rootAttributes.passed),
|
|
153
|
+
failed: parseTestCount(rootAttributes.failed),
|
|
137
154
|
skipped,
|
|
138
|
-
inconclusive:
|
|
155
|
+
inconclusive: parseTestCount(rootAttributes.inconclusive),
|
|
139
156
|
durationSeconds: parseOptionalNumber(rootAttributes.duration),
|
|
140
157
|
failedTests,
|
|
141
158
|
tests,
|
|
159
|
+
testRecordCounts,
|
|
142
160
|
};
|
|
143
161
|
if (parsed.total === undefined && parsed.passed === undefined && parsed.failed === undefined && parsed.failedTests.length === 0) {
|
|
144
162
|
return null;
|
|
@@ -146,6 +164,23 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
|
|
|
146
164
|
return parsed;
|
|
147
165
|
}
|
|
148
166
|
|
|
167
|
+
/** Missing counters and omitted/bounded records are unknown, not zero executions.
|
|
168
|
+
* Supplied counters and observed record lower bounds must nevertheless agree.
|
|
169
|
+
* An optional linked summary can fill missing XML counters, not replace them.
|
|
170
|
+
*/
|
|
171
|
+
export function hasConflictingUnityXmlTestEvidence(
|
|
172
|
+
results: UnityParsedTestResults,
|
|
173
|
+
linkedSummary?: Pick<UnityParsedTestResults, "total" | "passed" | "failed" | "skipped" | "inconclusive">,
|
|
174
|
+
): boolean {
|
|
175
|
+
const keys = ["total", "passed", "failed", "skipped", "inconclusive"] as const;
|
|
176
|
+
const counts = Object.fromEntries(keys.map(key => [key, results[key] ?? linkedSummary?.[key]])) as Pick<UnityParsedTestResults, typeof keys[number]>;
|
|
177
|
+
if (keys.some(key => counts[key] !== undefined && (!Number.isSafeInteger(counts[key]) || counts[key]! < 0))) return true;
|
|
178
|
+
const observed = results.testRecordCounts;
|
|
179
|
+
if (keys.some(key => counts[key] !== undefined && (observed?.[key] ?? 0) > counts[key]!)) return true;
|
|
180
|
+
const accounted = keys.slice(1).reduce((sum, key) => sum + Math.max(counts[key] ?? 0, observed?.[key] ?? 0), 0);
|
|
181
|
+
return counts.total !== undefined && accounted > counts.total;
|
|
182
|
+
}
|
|
183
|
+
|
|
149
184
|
function buildArtifactCandidates(cwd: string, projectRoot: string, rawPath: string): string[] {
|
|
150
185
|
if (path.isAbsolute(rawPath)) {
|
|
151
186
|
return [path.normalize(rawPath)];
|
package/src/unity-cli.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { readFile, realpath } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
4
4
|
import { applyDefaultUnityBatchmodeArgs, buildUnityBatchmodeArgs, projectPathsMatch } from "./unity-core";
|
|
5
5
|
import type { RunningUnityProcess } from "./unity-processes";
|
|
6
6
|
|
|
@@ -265,10 +265,10 @@ export function parseUnityCliStatusOutput(output: string, projectRoot: string):
|
|
|
265
265
|
|
|
266
266
|
export async function listRunningUnityCliEditorsForProject(
|
|
267
267
|
projectRoot: string,
|
|
268
|
-
options: { cliCommand?: string; timeout?: number } = {},
|
|
268
|
+
options: { cliCommand?: string; timeout?: number; execute?: UnityCliExecutor } = {},
|
|
269
269
|
): Promise<{ processes: RunningUnityProcess[]; warning?: string }> {
|
|
270
270
|
const command = resolveUnityCliCommand(options);
|
|
271
|
-
const result = await execFileCollect(command, ["--format", "json", "--no-banner", "--non-interactive", "status", "--project", projectRoot], {
|
|
271
|
+
const result = await (options.execute ?? execFileCollect)(command, ["--format", "json", "--no-banner", "--non-interactive", "status", "--project", projectRoot], {
|
|
272
272
|
timeout: options.timeout ?? 5000,
|
|
273
273
|
});
|
|
274
274
|
|
|
@@ -277,11 +277,14 @@ export async function listRunningUnityCliEditorsForProject(
|
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
const processes = parseUnityCliStatusOutput(result.stdout, projectRoot);
|
|
280
|
-
|
|
281
|
-
|
|
280
|
+
const payload = parseJsonObject(result.stdout);
|
|
281
|
+
const statusWarnings = envelopeMessages(payload, "warnings");
|
|
282
|
+
if (statusWarnings.length > 0) {
|
|
283
|
+
return { processes, warning: `Unity CLI status response is incomplete; process absence is uncertain: ${statusWarnings.join("; ")}` };
|
|
282
284
|
}
|
|
285
|
+
if (processes.length > 0) return { processes };
|
|
286
|
+
|
|
283
287
|
|
|
284
|
-
const payload = parseJsonObject(result.stdout);
|
|
285
288
|
const errors = Array.isArray(payload?.errors) ? payload.errors : [];
|
|
286
289
|
const onlyNoInstances = errors.some((entry) => getRecord(entry)?.code === "STATUS_NO_INSTANCES");
|
|
287
290
|
if (result.error && !onlyNoInstances) {
|
|
@@ -430,14 +433,27 @@ export function isUnityCliTimeout(result: Pick<UnityCliExecResult, "error">): bo
|
|
|
430
433
|
return error?.code === "ETIMEDOUT" || error?.killed === true || error?.signal === "SIGTERM";
|
|
431
434
|
}
|
|
432
435
|
|
|
436
|
+
const UNITY_CLI_MAX_DIAGNOSTICS = 8;
|
|
437
|
+
function envelopeMessages(payload: Record<string, unknown> | null, fieldName: "errors" | "warnings" | "info"): string[] {
|
|
438
|
+
const entries = Array.isArray(payload?.[fieldName]) ? payload[fieldName] : [];
|
|
439
|
+
return entries.slice(0, UNITY_CLI_MAX_DIAGNOSTICS).flatMap((entry): string[] => {
|
|
440
|
+
const item = getRecord(entry);
|
|
441
|
+
const message = optionalString(item?.message, item?.detail, typeof entry === "string" ? entry : undefined);
|
|
442
|
+
return message ? [redactUnityPlanningOutput(summarizeUnityCliText(message, 1_000, 10))] : [];
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Warnings make discovery incomplete; informational descriptor/envelope notes do not. */
|
|
447
|
+
function cliEnvelopeDiagnostics(payload: Record<string, unknown> | null): string[] {
|
|
448
|
+
return envelopeMessages(payload, "warnings").map(message => `warning: ${message}`);
|
|
449
|
+
}
|
|
450
|
+
function cliEnvelopeInfo(payload: Record<string, unknown> | null): string[] {
|
|
451
|
+
return envelopeMessages(payload, "info").map(message => `info: ${message}`);
|
|
452
|
+
}
|
|
453
|
+
|
|
433
454
|
function cliFailureMessage(result: UnityCliExecResult): string | undefined {
|
|
434
455
|
const payload = parseJsonObject(result.stdout);
|
|
435
|
-
const
|
|
436
|
-
const messages = errors.flatMap((entry): string[] => {
|
|
437
|
-
const message = optionalString(getRecord(entry)?.message);
|
|
438
|
-
return message ? [message] : [];
|
|
439
|
-
});
|
|
440
|
-
const message = messages[0] ?? (result.stderr.trim() || result.error?.message);
|
|
456
|
+
const message = envelopeMessages(payload, "errors")[0] ?? (result.stderr.trim() || result.error?.message);
|
|
441
457
|
return message ? summarizeUnityCliText(message) : undefined;
|
|
442
458
|
}
|
|
443
459
|
|
|
@@ -467,7 +483,9 @@ export async function inspectUnityCliProjectCapabilities(
|
|
|
467
483
|
const command = resolveUnityCliCommand(options);
|
|
468
484
|
const versionTimeout = options.timeout ?? UNITY_CLI_VERSION_TIMEOUT_MS;
|
|
469
485
|
const discoveryTimeout = options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS;
|
|
470
|
-
|
|
486
|
+
// Explicitly select the supported human formatter: inherited UNITY_FORMAT must not turn
|
|
487
|
+
// --version into JSON (or another representation) and corrupt capability reporting.
|
|
488
|
+
const versionResult = await execute(command, ["--format", "human", "--version"], { timeout: versionTimeout, signal: options.signal });
|
|
471
489
|
if (versionResult.error && (versionResult.error as NodeJS.ErrnoException).code === "ENOENT") return result;
|
|
472
490
|
if (versionResult.error) {
|
|
473
491
|
result.warnings.push(`Unity CLI version probe ${isUnityCliTimeout(versionResult) ? "timed out" : "failed"}: ${cliFailureMessage(versionResult) ?? "unknown error"}`);
|
|
@@ -479,9 +497,15 @@ export async function inspectUnityCliProjectCapabilities(
|
|
|
479
497
|
const pipelineResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "pipeline", "list"], { timeout: discoveryTimeout, signal: options.signal });
|
|
480
498
|
const pipelinePayload = parseJsonObject(pipelineResult.stdout);
|
|
481
499
|
const pipelineData = getRecord(pipelinePayload?.data);
|
|
482
|
-
|
|
500
|
+
const pipelineDiagnostics = cliEnvelopeDiagnostics(pipelinePayload);
|
|
501
|
+
result.warnings.push(...cliEnvelopeInfo(pipelinePayload));
|
|
502
|
+
if (pipelineResult.error || pipelinePayload?.success !== true || !Array.isArray(pipelineData?.instances) || pipelineDiagnostics.length > 0) {
|
|
483
503
|
result.pipelineDiscovery = isUnityCliTimeout(pipelineResult) ? "timeout" : "unavailable";
|
|
484
|
-
|
|
504
|
+
const diagnostic = pipelineDiagnostics.join("; ") || cliFailureMessage(pipelineResult) || "malformed or unsupported JSON response";
|
|
505
|
+
result.warnings.push(`Unity Pipeline instance discovery ${result.pipelineDiscovery === "timeout" ? "timed out; Pipeline startup state is uncertain" : "is incomplete or failed; Pipeline startup state is uncertain"}: ${diagnostic}`);
|
|
506
|
+
// Retain any known positive exact-copy instance descriptors, but never use this
|
|
507
|
+
// incomplete response as a safe launch or connected-dispatch signal.
|
|
508
|
+
result.matchingInstances = parseUnityCliPipelineListOutput(pipelineResult.stdout, projectRoot).instances;
|
|
485
509
|
return result;
|
|
486
510
|
}
|
|
487
511
|
result.pipelineDiscovery = "available";
|
|
@@ -500,9 +524,17 @@ export async function inspectUnityCliProjectCapabilities(
|
|
|
500
524
|
result.commandDiscoveryAttempted = true;
|
|
501
525
|
const listResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "list", "--project-path", projectRoot], { timeout: discoveryTimeout, signal: options.signal });
|
|
502
526
|
const catalog = parseUnityCliCommandCatalog(listResult.stdout);
|
|
503
|
-
|
|
527
|
+
const listPayload = parseJsonObject(listResult.stdout);
|
|
528
|
+
const commandDiagnostics = cliEnvelopeDiagnostics(listPayload);
|
|
529
|
+
result.warnings.push(...cliEnvelopeInfo(listPayload));
|
|
530
|
+
if (listResult.error || !catalog.valid || commandDiagnostics.length > 0) {
|
|
504
531
|
result.commandDiscovery = isUnityCliTimeout(listResult) ? "timeout" : "unavailable";
|
|
505
|
-
result.warnings.push(`Unity Pipeline command discovery for the exact project copy ${result.commandDiscovery === "timeout" ? "timed out; command availability is uncertain" : "failed"}: ${cliFailureMessage(listResult)
|
|
532
|
+
result.warnings.push(`Unity Pipeline command discovery for the exact project copy ${result.commandDiscovery === "timeout" ? "timed out; command availability is uncertain" : "is incomplete or failed; command availability is uncertain"}: ${commandDiagnostics.join("; ") || cliFailureMessage(listResult) || "malformed or unsupported JSON response"}`);
|
|
533
|
+
// Commands in a warning-bearing catalog are informational only, not advertised
|
|
534
|
+
// capability evidence. Keep descriptors for status visibility without enabling dispatch.
|
|
535
|
+
result.advertisedCommands = catalog.commands;
|
|
536
|
+
result.advertisedCommandCount = catalog.total;
|
|
537
|
+
result.advertisedCommandsTruncated = catalog.truncated;
|
|
506
538
|
return result;
|
|
507
539
|
}
|
|
508
540
|
result.commandDiscovery = "available";
|
|
@@ -522,6 +554,7 @@ export const UNITY_PLANNING_READ_COMMANDS = Object.freeze([
|
|
|
522
554
|
"get_authoring_root",
|
|
523
555
|
"get_build_settings",
|
|
524
556
|
"get_player_settings",
|
|
557
|
+
"get_runtime_pipeline_settings",
|
|
525
558
|
"get_scene_hierarchy",
|
|
526
559
|
"editor_status",
|
|
527
560
|
"list_open_scenes",
|
|
@@ -542,6 +575,21 @@ export type UnityPlanningInspectionResult =
|
|
|
542
575
|
| { outcome: "dispatched"; command: string; output: string; truncated: boolean }
|
|
543
576
|
| { outcome: "rejected"; code: string; message: string };
|
|
544
577
|
|
|
578
|
+
export type UnityPipelineRunScriptRequest = {
|
|
579
|
+
projectRoot: string;
|
|
580
|
+
unityVersion: string;
|
|
581
|
+
file: string;
|
|
582
|
+
entry?: string;
|
|
583
|
+
args?: unknown[];
|
|
584
|
+
dryRun?: boolean;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
/** Attach bounded native discovery guidance without changing the readiness decision. */
|
|
588
|
+
export function unityCapabilityDiagnosticSuffix(capabilities: UnityCliProjectCapabilities): string {
|
|
589
|
+
const message = summarizeUnityCliText(redactUnityPlanningOutput(capabilities.warnings.slice(0, 8).join("; ")), 1_000, 10);
|
|
590
|
+
return message ? ` ${message}` : "";
|
|
591
|
+
}
|
|
592
|
+
|
|
545
593
|
function planningInspectionReadiness(capabilities: UnityCliProjectCapabilities): string | undefined {
|
|
546
594
|
if (!capabilities.cliAvailable) return "unity_cli_unavailable";
|
|
547
595
|
if (capabilities.pipelineDiscovery !== "available") return `pipeline_${capabilities.pipelineDiscovery}`;
|
|
@@ -556,12 +604,32 @@ function caseInsensitiveField(record: Record<string, unknown>, name: string): un
|
|
|
556
604
|
return entry?.[1];
|
|
557
605
|
}
|
|
558
606
|
|
|
607
|
+
function runScriptCommandFailure(output: string): "malformed" | "failure" | undefined {
|
|
608
|
+
const envelope = parseJsonObject(output);
|
|
609
|
+
if (!envelope || caseInsensitiveField(envelope, "success") !== true) return envelope ? "failure" : "malformed";
|
|
610
|
+
const wrapper = getRecord(caseInsensitiveField(envelope, "data")) ?? envelope;
|
|
611
|
+
if (caseInsensitiveField(wrapper, "success") === false) return "failure";
|
|
612
|
+
let response: unknown = caseInsensitiveField(wrapper, "result");
|
|
613
|
+
// Legacy wrapper puts the documented RunScriptResponse in data.result; compact
|
|
614
|
+
// transport puts it in result. A bare success envelope has no command evidence.
|
|
615
|
+
if (typeof response === "string") { try { response = JSON.parse(response); } catch { return "malformed"; } }
|
|
616
|
+
const result = getRecord(response);
|
|
617
|
+
if (!result || !Object.prototype.hasOwnProperty.call(result, "diagnostics") || !Array.isArray(caseInsensitiveField(result, "diagnostics"))) return "malformed";
|
|
618
|
+
if (caseInsensitiveField(result, "success") === false || caseInsensitiveField(result, "failed") === true) return "failure";
|
|
619
|
+
const diagnostics = caseInsensitiveField(result, "diagnostics") as unknown[];
|
|
620
|
+
if (diagnostics.some(item => {
|
|
621
|
+
const diagnostic = getRecord(item);
|
|
622
|
+
return String(caseInsensitiveField(diagnostic ?? {}, "severity") ?? "").toLowerCase() === "error";
|
|
623
|
+
})) return "failure";
|
|
624
|
+
return undefined;
|
|
625
|
+
}
|
|
626
|
+
|
|
559
627
|
function connectedCommandFailure(output: string, isEval: boolean): "malformed" | "failure" | undefined {
|
|
560
628
|
const envelope = parseJsonObject(output);
|
|
561
629
|
if (!envelope) return "malformed";
|
|
562
630
|
if (caseInsensitiveField(envelope, "success") !== true) return "failure";
|
|
563
|
-
|
|
564
|
-
|
|
631
|
+
// Pipeline 0.6 compact responses omit the CLI wrapper's data field.
|
|
632
|
+
const data = getRecord(caseInsensitiveField(envelope, "data")) ?? envelope;
|
|
565
633
|
if (caseInsensitiveField(data, "success") === false) return "failure";
|
|
566
634
|
if (!isEval) return undefined;
|
|
567
635
|
|
|
@@ -610,7 +678,7 @@ export async function dispatchUnityPlanningInspection(
|
|
|
610
678
|
}));
|
|
611
679
|
const initial = await inspect(projectRoot, request.unityVersion);
|
|
612
680
|
const initialFailure = planningInspectionReadiness(initial);
|
|
613
|
-
if (initialFailure) return { outcome: "rejected", code: initialFailure, message:
|
|
681
|
+
if (initialFailure) return { outcome: "rejected", code: initialFailure, message: `Exact-copy Pipeline planning inspection is not established.${unityCapabilityDiagnosticSuffix(initial)}` };
|
|
614
682
|
|
|
615
683
|
const isEval = request.command === "eval";
|
|
616
684
|
const hasBoundedArgs = (request.args?.length ?? 0) <= 12
|
|
@@ -633,7 +701,7 @@ export async function dispatchUnityPlanningInspection(
|
|
|
633
701
|
const refreshed = await inspect(projectRoot, request.unityVersion);
|
|
634
702
|
const refreshedFailure = planningInspectionReadiness(refreshed);
|
|
635
703
|
if (refreshedFailure || !haveSameKnownProcessIds(initial.matchingInstances, refreshed.matchingInstances)) {
|
|
636
|
-
return { outcome: "rejected", code: "unity_project_identity_changed", message:
|
|
704
|
+
return { outcome: "rejected", code: "unity_project_identity_changed", message: `Pipeline identity changed or disconnected immediately before planning dispatch.${unityCapabilityDiagnosticSuffix(refreshed)}` };
|
|
637
705
|
}
|
|
638
706
|
if (!refreshed.advertisedCommands.includes(request.command)) {
|
|
639
707
|
return { outcome: "rejected", code: "planning_command_unadvertised", message: "The refreshed exact Pipeline copy did not advertise the requested command." };
|
|
@@ -647,11 +715,11 @@ export async function dispatchUnityPlanningInspection(
|
|
|
647
715
|
...(isEval ? [request.evalSnippet!.trim()] : request.args ?? []),
|
|
648
716
|
];
|
|
649
717
|
const execution = await options.execute(command, args, { timeout: options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS, signal: options.signal });
|
|
718
|
+
const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
|
|
719
|
+
const output = summarizeUnityCliText(redactUnityPlanningOutput(raw), 4_000, 40);
|
|
650
720
|
if (execution.error) {
|
|
651
|
-
return { outcome: "rejected", code: isUnityCliTimeout(execution) ? "planning_command_timeout" : "planning_command_failed", message:
|
|
721
|
+
return { outcome: "rejected", code: isUnityCliTimeout(execution) ? "planning_command_timeout" : "planning_command_failed", message: `Connected command did not complete successfully; its effect may be uncertain.${output ? ` ${output}` : ""}` };
|
|
652
722
|
}
|
|
653
|
-
const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
|
|
654
|
-
const output = redactUnityPlanningOutput(summarizeUnityCliText(raw, 4_000, 40));
|
|
655
723
|
const reportedFailure = connectedCommandFailure(execution.stdout, isEval);
|
|
656
724
|
if (reportedFailure) {
|
|
657
725
|
return {
|
|
@@ -663,6 +731,37 @@ export async function dispatchUnityPlanningInspection(
|
|
|
663
731
|
return { outcome: "dispatched", command: request.command, output, truncated: output.length < raw.trim().length };
|
|
664
732
|
}
|
|
665
733
|
|
|
734
|
+
/** Dispatch the documented Pipeline 0.6 ephemeral run_script form; hotpatch is deliberately not exposed. */
|
|
735
|
+
export async function dispatchUnityPipelineRunScript(
|
|
736
|
+
request: UnityPipelineRunScriptRequest,
|
|
737
|
+
options: { cliCommand?: string; timeout?: number; signal?: AbortSignal; execute: UnityCliExecutor; inspect?: (projectRoot: string, unityVersion: string) => Promise<UnityCliProjectCapabilities> },
|
|
738
|
+
): Promise<UnityPlanningInspectionResult> {
|
|
739
|
+
let projectRoot: string; let file: string;
|
|
740
|
+
try { projectRoot = await realpath(request.projectRoot); file = await realpath(request.file); } catch {
|
|
741
|
+
return { outcome: "rejected", code: "run_script_path_unavailable", message: "The project root or existing script file could not be canonicalized." };
|
|
742
|
+
}
|
|
743
|
+
const relativeFile = relative(projectRoot, file);
|
|
744
|
+
let fileStats: Awaited<ReturnType<typeof stat>>;
|
|
745
|
+
try { fileStats = await stat(file); } catch { return { outcome: "rejected", code: "run_script_file_invalid", message: "run_script requires one readable existing C# file." }; }
|
|
746
|
+
if (!relativeFile || isAbsolute(relativeFile) || relativeFile === ".." || relativeFile.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || !fileStats.isFile() || !/\.cs$/i.test(relativeFile)) return { outcome: "rejected", code: "run_script_file_invalid", message: "run_script requires one existing .cs file inside the exact project root." };
|
|
747
|
+
let serializedArgs: string;
|
|
748
|
+
try { serializedArgs = JSON.stringify(request.args ?? []); } catch { return { outcome: "rejected", code: "run_script_args_invalid", message: "run_script arguments must be JSON-serializable." }; }
|
|
749
|
+
if (serializedArgs === undefined || serializedArgs.length > 4_000 || (request.entry?.length ?? 0) > 500 || /[\u0000-\u001f\u007f]/.test(request.entry ?? "")) return { outcome: "rejected", code: "run_script_args_invalid", message: "run_script entry or JSON arguments exceed bounded request limits." };
|
|
750
|
+
const inspect = options.inspect ?? ((root, version) => inspectUnityCliProjectCapabilities(root, version, { cliCommand: options.cliCommand, timeout: options.timeout, signal: options.signal, execute: options.execute }));
|
|
751
|
+
const initial = await inspect(projectRoot, request.unityVersion);
|
|
752
|
+
if (planningInspectionReadiness(initial) || !initial.advertisedCommands.includes("run_script")) return { outcome: "rejected", code: "run_script_unavailable", message: `The exact reachable Pipeline copy does not establish advertised run_script support.${unityCapabilityDiagnosticSuffix(initial)}` };
|
|
753
|
+
const refreshed = await inspect(projectRoot, request.unityVersion);
|
|
754
|
+
if (planningInspectionReadiness(refreshed) || !haveSameKnownProcessIds(initial.matchingInstances, refreshed.matchingInstances) || !refreshed.advertisedCommands.includes("run_script")) return { outcome: "rejected", code: "unity_project_identity_changed", message: `Pipeline identity or run_script availability changed immediately before dispatch.${unityCapabilityDiagnosticSuffix(refreshed)}` };
|
|
755
|
+
const timeout = Math.max(1, Math.min(options.timeout ?? 30_000, 86_400_000));
|
|
756
|
+
const args = ["--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot, "--timeout", String(Math.ceil(timeout / 1000)), "run_script", "--file", relativeFile, "--mode", "ephemeral", "--args", serializedArgs, "--timeout_ms", String(timeout), ...(request.entry?.trim() ? ["--entry", request.entry.trim()] : []), ...(request.dryRun ? ["--dry_run", "true"] : [])];
|
|
757
|
+
const execution = await options.execute(resolveUnityCliCommand({ cliCommand: options.cliCommand }), args, { timeout, signal: options.signal });
|
|
758
|
+
const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n"); const output = summarizeUnityCliText(redactUnityPlanningOutput(raw), 4_000, 40);
|
|
759
|
+
if (execution.error) return { outcome: "rejected", code: isUnityCliTimeout(execution) ? "run_script_timeout" : "run_script_failed", message: `run_script did not complete successfully; its effect may be uncertain.${output ? ` ${output}` : ""}` };
|
|
760
|
+
const failure = runScriptCommandFailure(execution.stdout);
|
|
761
|
+
if (failure) return { outcome: "rejected", code: failure === "malformed" ? "run_script_malformed" : "run_script_reported_failure", message: `${failure === "malformed" ? "run_script returned malformed JSON evidence" : "run_script reported failure"}.${output ? ` ${output}` : ""}` };
|
|
762
|
+
return { outcome: "dispatched", command: "run_script", output, truncated: output.length < raw.trim().length };
|
|
763
|
+
}
|
|
764
|
+
|
|
666
765
|
/** Keep connected inspection output useful without returning common credential forms verbatim. */
|
|
667
766
|
export function redactUnityPlanningOutput(value: string): string {
|
|
668
767
|
return value
|
package/src/unity-core.ts
CHANGED
|
@@ -104,8 +104,8 @@ export function normalizeForCommandSearch(value: string, platform: SupportedPlat
|
|
|
104
104
|
return platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
function
|
|
108
|
-
if (platform !== "darwin" || process.platform !==
|
|
107
|
+
function realpathMatchesOnNativePlatform(candidatePath: string, projectRoot: string, platform: SupportedPlatform): boolean | null {
|
|
108
|
+
if ((platform !== "darwin" && platform !== "win32") || process.platform !== platform) {
|
|
109
109
|
return null;
|
|
110
110
|
}
|
|
111
111
|
|
|
@@ -113,7 +113,7 @@ function realpathMatchesOnDarwin(candidatePath: string, projectRoot: string, pla
|
|
|
113
113
|
return realpathSync.native(candidatePath) === realpathSync.native(projectRoot);
|
|
114
114
|
} catch {
|
|
115
115
|
// Only use filesystem identity when both paths can be resolved. Falling back
|
|
116
|
-
// to the
|
|
116
|
+
// to the platform-specific textual comparison preserves existing behavior.
|
|
117
117
|
return null;
|
|
118
118
|
}
|
|
119
119
|
}
|
|
@@ -130,7 +130,7 @@ export function projectPathsMatch(candidatePath: string, projectRoot: string, pl
|
|
|
130
130
|
return false;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
const realpathMatch =
|
|
133
|
+
const realpathMatch = realpathMatchesOnNativePlatform(trimmedCandidatePath, trimmedProjectRoot, comparisonPlatform);
|
|
134
134
|
if (realpathMatch !== null) {
|
|
135
135
|
return realpathMatch;
|
|
136
136
|
}
|
package/src/unity-pipeline.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { projectPathsMatch } from "./unity-core";
|
|
3
|
-
import { resolveUnityCliCommand, type UnityCliExecResult, type UnityCliExecutor, type UnityCliProjectCapabilities } from "./unity-cli";
|
|
3
|
+
import { redactUnityPlanningOutput, resolveUnityCliCommand, summarizeUnityCliText, unityCapabilityDiagnosticSuffix, type UnityCliExecResult, type UnityCliExecutor, type UnityCliProjectCapabilities } from "./unity-cli";
|
|
4
4
|
|
|
5
5
|
/** Public limits are deliberately small enough that connected work cannot create an unbounded agent wait loop. */
|
|
6
6
|
export const UNITY_PIPELINE_COMPILE_TIMEOUT_SECONDS = 180;
|
|
@@ -31,6 +31,8 @@ export type UnityPipelineOperationDetails = {
|
|
|
31
31
|
testPlatform?: "EditMode" | "PlayMode";
|
|
32
32
|
testFilter?: string;
|
|
33
33
|
counts?: { total: number; passed?: number; failed: number; inconclusive?: number };
|
|
34
|
+
/** Bounded nonfatal Pipeline envelope guidance; never compiler-error evidence. */
|
|
35
|
+
warnings?: string[];
|
|
34
36
|
};
|
|
35
37
|
export type UnityPipelineTestRecord = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
|
|
36
38
|
/** testRecords are terminal evidence for the caller's durable artifact only; do not expose them in tool details. */
|
|
@@ -72,6 +74,39 @@ function bounded(value: string, limit = UNITY_PIPELINE_MAX_STACK_CHARS): string
|
|
|
72
74
|
const oneLine = value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
73
75
|
return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine;
|
|
74
76
|
}
|
|
77
|
+
function pipelineEnvelopeWarnings(output: string): string[] {
|
|
78
|
+
const outer = (() => { try { return record(JSON.parse(output)); } catch { return undefined; } })();
|
|
79
|
+
const data = record(outer?.data);
|
|
80
|
+
const entries = [outer?.warnings, data?.warnings].flatMap(value => Array.isArray(value) ? value.slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS) : []);
|
|
81
|
+
return entries.slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS).flatMap(entry => {
|
|
82
|
+
const item = record(entry); const message = string(field(item ?? {}, "message", "detail", "warning")) ?? string(entry);
|
|
83
|
+
return message ? [bounded(redactUnityPlanningOutput(message), 1_000)] : [];
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function retainWarnings(warnings: string[], output: string): void {
|
|
87
|
+
for (const warning of pipelineEnvelopeWarnings(output)) {
|
|
88
|
+
if (warnings.length >= UNITY_PIPELINE_MAX_DIAGNOSTICS) break;
|
|
89
|
+
if (!warnings.includes(warning)) warnings.push(warning);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function warningText(warnings: string[]): string {
|
|
93
|
+
return warnings.length ? `\nPipeline warnings: ${warnings.join("; ")}` : "";
|
|
94
|
+
}
|
|
95
|
+
function pipelineFailureDiagnostic(response: UnityCliExecResult): string {
|
|
96
|
+
const raw = [response.stdout, response.stderr, response.error?.message].filter(Boolean).join("\n");
|
|
97
|
+
let message: string | undefined;
|
|
98
|
+
try {
|
|
99
|
+
const outer = record(JSON.parse(response.stdout)); const data = record(outer?.data);
|
|
100
|
+
const errors = Array.isArray(outer?.errors) ? outer?.errors : [];
|
|
101
|
+
const first = record(errors[0]);
|
|
102
|
+
const messages = [
|
|
103
|
+
string(field(first ?? {}, "code")), string(field(first ?? {}, "message", "detail")),
|
|
104
|
+
...[data, outer].flatMap(item => ["error", "errordetails", "message"].map(key => string(field(item ?? {}, key)))),
|
|
105
|
+
].filter((value): value is string => Boolean(value));
|
|
106
|
+
message = [...new Set(messages)].join("; ") || undefined;
|
|
107
|
+
} catch { /* retain bounded native raw fallback */ }
|
|
108
|
+
return summarizeUnityCliText(redactUnityPlanningOutput(message ?? raw), 1_000, 10);
|
|
109
|
+
}
|
|
75
110
|
function throwIfAborted(signal?: AbortSignal): void {
|
|
76
111
|
if (signal?.aborted) throw new Error("Unity Pipeline operation aborted; its Editor operation may still be running.");
|
|
77
112
|
}
|
|
@@ -102,8 +137,10 @@ export function parseUnityPipelineEnvelope(output: string): ParsedEnvelope {
|
|
|
102
137
|
let outer: RecordValue | undefined;
|
|
103
138
|
try { outer = record(JSON.parse(output)); } catch { return { result: {}, outerSuccess: false, malformed: "Unity Pipeline returned malformed JSON." }; }
|
|
104
139
|
if (!outer) return { result: {}, outerSuccess: false, malformed: "Unity Pipeline returned a non-object JSON envelope." };
|
|
140
|
+
// Pipeline 0.6 may return the compact exec envelope { success, result, warnings }
|
|
141
|
+
// rather than the older CLI wrapper { success, data: { result } }.
|
|
105
142
|
const data = record(outer.data);
|
|
106
|
-
const rawResult = data?.result ?? data;
|
|
143
|
+
const rawResult = data?.result ?? outer.result ?? data;
|
|
107
144
|
if (typeof rawResult === "string") {
|
|
108
145
|
try {
|
|
109
146
|
const parsed = record(JSON.parse(rawResult));
|
|
@@ -148,15 +185,14 @@ function diagnostics(result: RecordValue): string[] {
|
|
|
148
185
|
});
|
|
149
186
|
return [...new Set(values)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
|
|
150
187
|
}
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
&& field(data ?? {}, "retryable") === true;
|
|
188
|
+
/**
|
|
189
|
+
* Do not retry generic busy envelopes. Pipeline 0.6 uses busy for modal dialogs as
|
|
190
|
+
* well as startup settling, and neither is distinguishable in the legacy CLI-shaped
|
|
191
|
+
* response. Retrying would blindly repeat a command blocked by a user-visible modal.
|
|
192
|
+
* A future retry must be wired only to a source-verified pre-dispatch discriminator.
|
|
193
|
+
*/
|
|
194
|
+
export function isUnityPipelineInitialSettlingBusy(_output: string): boolean {
|
|
195
|
+
return false;
|
|
160
196
|
}
|
|
161
197
|
|
|
162
198
|
export function normalizeUnityPipelineCompile(output: string): NormalizedCompile {
|
|
@@ -363,7 +399,7 @@ async function dispatchMainThreadCommand(deps: PipelineDependencies, projectRoot
|
|
|
363
399
|
|
|
364
400
|
async function requirePreflight(deps: PipelineDependencies, projectRoot: string, unityVersion: string, commands: string[], operation: "recompile" | "tests", signal: AbortSignal | undefined, deadline: number, now: () => number, allowAutonomousExitPlayMode = true): Promise<{ capabilities: UnityCliProjectCapabilities; exitedPlayMode: boolean; playModeHandling: UnityPipelinePlayModeHandling; scriptChangesWhilePlaying?: UnityScriptChangesWhilePlayingPolicy }> {
|
|
365
401
|
const capabilities = await inspectWithDeadline(deps, projectRoot, unityVersion, signal, deadline, now, "preflight");
|
|
366
|
-
const error = capabilityError(capabilities, commands); if (error) throw new Error(error);
|
|
402
|
+
const error = capabilityError(capabilities, commands); if (error) throw new Error(error + unityCapabilityDiagnosticSuffix(capabilities));
|
|
367
403
|
let editor = await executeCommand(deps, projectRoot, "editor_status", [], signal, deadline, now);
|
|
368
404
|
if (editor.error) throw new Error("Unity Pipeline editor_status failed; operation not started.");
|
|
369
405
|
let status = editorStatus(editor.stdout);
|
|
@@ -450,11 +486,12 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
|
|
|
450
486
|
ensureBeforeDeadline(deadline, now, "recompile before dispatch");
|
|
451
487
|
throwIfAborted(signal);
|
|
452
488
|
const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "recompile", [], "recompile", signal, deadline, now, sleep);
|
|
453
|
-
if (dispatched.error) throw new Error(
|
|
489
|
+
if (dispatched.error) throw new Error(`Unity Pipeline recompile dispatch failed; operation may not have started.${pipelineFailureDiagnostic(dispatched) ? ` ${pipelineFailureDiagnostic(dispatched)}` : ""}`);
|
|
490
|
+
const dispatchWarnings = pipelineEnvelopeWarnings(dispatched.stdout);
|
|
454
491
|
let state = normalizeUnityPipelineCompile(dispatched.stdout);
|
|
455
492
|
if (state.state === "uncertain") throw new Error("Unity Pipeline recompile dispatch returned malformed or uncertain evidence; operation may have started.");
|
|
456
493
|
if (state.state === "failed") throw new Error(`Unity recompile failed: ${state.diagnostics.join("; ") || "compiler failure reported"}`);
|
|
457
|
-
if (state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity scripts are up to date for ${projectRoot}; no compilation was triggered
|
|
494
|
+
if (state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity scripts are up to date for ${projectRoot}; no compilation was triggered.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "recompile", terminalState: "up_to_date", elapsedSeconds: elapsed(start, now), compilationTriggered: false, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}), ...playModeDetails(preflight) } };
|
|
458
495
|
for (let poll = 0; now() < deadline; poll += 1) {
|
|
459
496
|
options.onUpdate?.(`Unity recompile ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
|
|
460
497
|
const delay = Math.min(UNITY_PIPELINE_BACKOFF_SECONDS[Math.min(poll, UNITY_PIPELINE_BACKOFF_SECONDS.length - 1)]! * 1000, deadline - now());
|
|
@@ -467,9 +504,10 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
|
|
|
467
504
|
const response = await executeCommand(deps, projectRoot, "recompile_status", [], signal, deadline, now);
|
|
468
505
|
if (response.error) continue; // Domain reload can briefly disconnect the same exact copy.
|
|
469
506
|
if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
|
|
507
|
+
retainWarnings(dispatchWarnings, response.stdout);
|
|
470
508
|
state = normalizeUnityPipelineCompile(response.stdout);
|
|
471
509
|
if (state.state === "failed") throw new Error(`Unity recompile failed: ${state.diagnostics.join("; ") || "compiler failure reported"}`);
|
|
472
|
-
if (state.state === "completed" || state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity recompile completed for ${projectRoot} in ${elapsed(start, now).toFixed(1)}s; 0 compiler errors
|
|
510
|
+
if (state.state === "completed" || state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity recompile completed for ${projectRoot} in ${elapsed(start, now).toFixed(1)}s; 0 compiler errors.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "recompile", terminalState: state.state, elapsedSeconds: elapsed(start, now), compilationTriggered: true, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}), ...playModeDetails(preflight) } };
|
|
473
511
|
if (state.state === "uncertain") throw new Error("Unity Pipeline recompile status is malformed or uncertain; operation may still be running.");
|
|
474
512
|
}
|
|
475
513
|
throw timeoutMessage("recompile");
|
|
@@ -498,7 +536,8 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
498
536
|
const args = ["--mode", request.testPlatform === "EditMode" ? "editor" : "playmode", ...selectorArgs, "--async_tests", "true"];
|
|
499
537
|
ensureBeforeDeadline(deadline, now, "tests before dispatch"); throwIfAborted(signal);
|
|
500
538
|
const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "run_tests", args, "tests", signal, deadline, now, sleep);
|
|
501
|
-
if (dispatched.error) throw new Error(
|
|
539
|
+
if (dispatched.error) throw new Error(`Unity Pipeline test dispatch failed; test run may not have started.${pipelineFailureDiagnostic(dispatched) ? ` ${pipelineFailureDiagnostic(dispatched)}` : ""}`);
|
|
540
|
+
const dispatchWarnings = pipelineEnvelopeWarnings(dispatched.stdout);
|
|
502
541
|
let state = normalizeUnityPipelineTest(dispatched.stdout);
|
|
503
542
|
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.");
|
|
504
543
|
if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
|
|
@@ -509,7 +548,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
509
548
|
if (state.state === "completed") {
|
|
510
549
|
const counts = passingCounts(state);
|
|
511
550
|
if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
|
|
512
|
-
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
|
|
551
|
+
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 };
|
|
513
552
|
}
|
|
514
553
|
for (let poll = 0; now() < deadline; poll += 1) {
|
|
515
554
|
options.onUpdate?.(`Unity ${request.testPlatform} tests ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
|
|
@@ -523,6 +562,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
523
562
|
const response = await executeCommand(deps, projectRoot, "test_status", [], signal, deadline, now);
|
|
524
563
|
if (response.error) continue;
|
|
525
564
|
if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
|
|
565
|
+
retainWarnings(dispatchWarnings, response.stdout);
|
|
526
566
|
state = normalizeUnityPipelineTest(response.stdout);
|
|
527
567
|
if (!checkCorrelation(expected, state.correlation)) throw new Error("Unity Pipeline test status was displaced by a different run; operation state is uncertain.");
|
|
528
568
|
if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
|
|
@@ -531,7 +571,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
531
571
|
if (state.state !== "completed") continue;
|
|
532
572
|
const counts = passingCounts(state);
|
|
533
573
|
if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
|
|
534
|
-
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
|
|
574
|
+
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 };
|
|
535
575
|
}
|
|
536
576
|
throw timeoutMessage("tests");
|
|
537
577
|
}
|