@aefree/pi-unity 0.13.0 → 0.14.0

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