@aefree/pi-unity 0.9.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.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Minimal adapter for the published capability-registry global protocol.
3
+ *
4
+ * Optional Pi packages have independent Node module roots, so provider packages
5
+ * must rendezvous through the protocol's documented `globalThis[Symbol.for()]`
6
+ * root rather than importing their contract from pi-unity's module root. This
7
+ * adapter only manages pi-unity's known-valid records; schema validation remains
8
+ * owned by each consuming package's contract.
9
+ */
10
+ const ROOT_PROTOCOL = "@aefree/pi-capability-registry/root";
11
+ const ROOT_PROTOCOL_VERSION = 1;
12
+
13
+ export type OptionalRegistrationToken = Readonly<{
14
+ registryKey: string;
15
+ contractVersion: 1;
16
+ scope: object;
17
+ ownerKey: string;
18
+ id: string;
19
+ nonce: number;
20
+ }>;
21
+
22
+ type StoredRecord = Readonly<{ nonce: number; record: Readonly<Record<string, unknown>> }>;
23
+ type ScopedState = { sequence: number; records: Map<string, StoredRecord> };
24
+ type VersionState = { version: 1; scopes: WeakMap<object, ScopedState> };
25
+ type RegistryRoot = { protocol: string; protocolVersion: number; registryKey: string; versions: Map<unknown, unknown> };
26
+
27
+ export type OptionalIntegrationRegistryV1 = Readonly<{
28
+ register: (scope: object, record: Readonly<Record<string, unknown>>) => OptionalRegistrationToken;
29
+ unregister: (token: OptionalRegistrationToken) => boolean;
30
+ }>;
31
+
32
+ export function isOptionalIntegrationActive(pi: { getActiveTools?: () => string[] }, toolName: string): boolean {
33
+ return pi.getActiveTools?.().includes(toolName) ?? false;
34
+ }
35
+
36
+ /**
37
+ * Returns no registry when the owning integration is absent. If its advertised
38
+ * tool is present, create or validate that integration's actual global contract
39
+ * root. A malformed root is an installed/broken contract and throws visibly.
40
+ */
41
+ export function createOptionalIntegrationRegistryV1(
42
+ registryKey: string,
43
+ integrationName: string,
44
+ ): OptionalIntegrationRegistryV1 {
45
+ const globalRecord = globalThis as typeof globalThis & Record<symbol, unknown>;
46
+ const symbol = Symbol.for(registryKey);
47
+ let candidate = globalRecord[symbol];
48
+ if (candidate === undefined) {
49
+ candidate = {
50
+ protocol: ROOT_PROTOCOL,
51
+ protocolVersion: ROOT_PROTOCOL_VERSION,
52
+ registryKey,
53
+ versions: new Map(),
54
+ } satisfies RegistryRoot;
55
+ globalRecord[symbol] = candidate;
56
+ }
57
+ const root = assertRegistryRoot(candidate, registryKey, integrationName);
58
+ let stateCandidate = root.versions.get(1);
59
+ if (stateCandidate === undefined) {
60
+ stateCandidate = { version: 1, scopes: new WeakMap<object, ScopedState>() } satisfies VersionState;
61
+ root.versions.set(1, stateCandidate);
62
+ }
63
+ const version = assertVersionState(stateCandidate, registryKey, integrationName);
64
+
65
+ return Object.freeze({
66
+ register(scope, record) {
67
+ if ((typeof scope !== "object" && typeof scope !== "function") || scope === null) {
68
+ throw new TypeError(`${integrationName} contract requires a session scope object.`);
69
+ }
70
+ const owner = record.owner as Record<string, unknown> | undefined;
71
+ if (typeof record.id !== "string" || typeof owner?.packageName !== "string" || typeof owner.packageRoot !== "string") {
72
+ throw new TypeError(`pi-unity attempted an invalid ${integrationName} contract registration.`);
73
+ }
74
+ let scoped = version.scopes.get(scope);
75
+ if (scoped === undefined) {
76
+ scoped = { sequence: 0, records: new Map() };
77
+ version.scopes.set(scope, scoped);
78
+ }
79
+ const ownerKey = `${owner.packageName}\0${owner.packageRoot}\0${record.id}`;
80
+ for (const current of scoped.records.values()) {
81
+ if (current.record.id === record.id && current.record.owner !== owner && ownerKeyFor(current.record) !== ownerKey) {
82
+ throw new TypeError(`Provider id '${record.id}' conflicts in ${integrationName} contract '${registryKey}'.`);
83
+ }
84
+ }
85
+ const nonce = scoped.sequence + 1;
86
+ scoped.records.set(ownerKey, { nonce, record });
87
+ scoped.sequence = nonce;
88
+ return Object.freeze({ registryKey, contractVersion: 1, scope, ownerKey, id: record.id, nonce });
89
+ },
90
+ unregister(token) {
91
+ if (!token || token.registryKey !== registryKey || token.contractVersion !== 1) return false;
92
+ const scoped = version.scopes.get(token.scope);
93
+ const current = scoped?.records.get(token.ownerKey);
94
+ if (current === undefined || current.nonce !== token.nonce || current.record.id !== token.id) return false;
95
+ scoped!.records.delete(token.ownerKey);
96
+ return true;
97
+ },
98
+ });
99
+ }
100
+
101
+ function ownerKeyFor(record: Readonly<Record<string, unknown>>): string {
102
+ const owner = record.owner as Record<string, unknown>;
103
+ return `${String(owner.packageName)}\0${String(owner.packageRoot)}\0${String(record.id)}`;
104
+ }
105
+
106
+ function assertRegistryRoot(value: unknown, registryKey: string, integrationName: string): RegistryRoot {
107
+ if (value === null || typeof value !== "object") throw brokenContract(integrationName, registryKey);
108
+ const root = value as Partial<RegistryRoot>;
109
+ if (root.protocol !== ROOT_PROTOCOL || root.protocolVersion !== ROOT_PROTOCOL_VERSION || root.registryKey !== registryKey || !(root.versions instanceof Map)) {
110
+ throw brokenContract(integrationName, registryKey);
111
+ }
112
+ return root as RegistryRoot;
113
+ }
114
+
115
+ function assertVersionState(value: unknown, registryKey: string, integrationName: string): VersionState {
116
+ if (value === null || typeof value !== "object") throw brokenContract(integrationName, registryKey);
117
+ const state = value as Partial<VersionState>;
118
+ if (state.version !== 1 || !(state.scopes instanceof WeakMap)) throw brokenContract(integrationName, registryKey);
119
+ return state as VersionState;
120
+ }
121
+
122
+ function brokenContract(integrationName: string, registryKey: string): TypeError {
123
+ return new TypeError(`${integrationName} advertises an incompatible capability-registry contract for '${registryKey}'.`);
124
+ }
@@ -0,0 +1,88 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export type PiUnitySettings = {
6
+ /** Allows pi-unity to close a running Unity process that targets the resolved project before a batchmode launch. */
7
+ allowCloseRunningUnityProcess: boolean;
8
+ /** Keeps automatic process closing constrained to Unity Test Framework runs unless explicitly disabled. */
9
+ closeRunningUnityProcessOnlyForTests: boolean;
10
+ /** Maximum time to wait for the closed Unity process to exit before failing the launch. */
11
+ closeRunningUnityProcessTimeoutMs: number;
12
+ };
13
+
14
+ export type PiUnitySettingsContext = {
15
+ cwd: string;
16
+ isProjectTrusted?: () => boolean;
17
+ };
18
+
19
+ export type LoadPiUnitySettingsOptions = {
20
+ globalSettingsPath?: string;
21
+ projectSettingsPath?: string;
22
+ env?: NodeJS.ProcessEnv;
23
+ };
24
+
25
+ export const DEFAULT_PI_UNITY_SETTINGS: PiUnitySettings = {
26
+ allowCloseRunningUnityProcess: false,
27
+ closeRunningUnityProcessOnlyForTests: true,
28
+ closeRunningUnityProcessTimeoutMs: 30_000,
29
+ };
30
+
31
+ function isRecord(value: unknown): value is Record<string, unknown> {
32
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
33
+ }
34
+
35
+ async function readJsonFile(filePath: string): Promise<Record<string, unknown>> {
36
+ try {
37
+ const raw = await readFile(filePath, "utf8");
38
+ const parsed: unknown = JSON.parse(raw);
39
+ return isRecord(parsed) ? parsed : {};
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+
45
+ function getPiUnitySettingsRecord(settings: Record<string, unknown>): Record<string, unknown> {
46
+ return isRecord(settings.piUnity) ? settings.piUnity : {};
47
+ }
48
+
49
+ function getGlobalSettingsPath(env: NodeJS.ProcessEnv = process.env): string {
50
+ const configuredAgentDir = env.PI_CODING_AGENT_DIR?.trim();
51
+ const agentDir = configuredAgentDir && configuredAgentDir.length > 0
52
+ ? configuredAgentDir
53
+ : join(homedir(), ".pi", "agent");
54
+ return join(agentDir, "settings.json");
55
+ }
56
+
57
+ function getProjectSettingsPath(cwd: string): string {
58
+ return join(cwd, ".pi", "settings.json");
59
+ }
60
+
61
+ export function normalizePiUnitySettings(raw: Record<string, unknown>): PiUnitySettings {
62
+ const timeout = typeof raw.closeRunningUnityProcessTimeoutMs === "number" && Number.isFinite(raw.closeRunningUnityProcessTimeoutMs)
63
+ ? Math.max(1_000, Math.min(120_000, Math.trunc(raw.closeRunningUnityProcessTimeoutMs)))
64
+ : DEFAULT_PI_UNITY_SETTINGS.closeRunningUnityProcessTimeoutMs;
65
+
66
+ return {
67
+ allowCloseRunningUnityProcess: raw.allowCloseRunningUnityProcess === true,
68
+ closeRunningUnityProcessOnlyForTests: raw.closeRunningUnityProcessOnlyForTests !== false,
69
+ closeRunningUnityProcessTimeoutMs: timeout,
70
+ };
71
+ }
72
+
73
+ export async function loadPiUnitySettings(
74
+ ctx: PiUnitySettingsContext,
75
+ options: LoadPiUnitySettingsOptions = {},
76
+ ): Promise<PiUnitySettings> {
77
+ const globalPath = options.globalSettingsPath ?? getGlobalSettingsPath(options.env);
78
+ const globalSettings = getPiUnitySettingsRecord(await readJsonFile(globalPath));
79
+ let mergedSettings: Record<string, unknown> = { ...globalSettings };
80
+
81
+ if (ctx.isProjectTrusted?.() === true) {
82
+ const projectPath = options.projectSettingsPath ?? getProjectSettingsPath(ctx.cwd);
83
+ const projectSettings = getPiUnitySettingsRecord(await readJsonFile(projectPath));
84
+ mergedSettings = { ...mergedSettings, ...projectSettings };
85
+ }
86
+
87
+ return normalizePiUnitySettings(mergedSettings);
88
+ }
@@ -0,0 +1,110 @@
1
+ import { access } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import * as path from "node:path";
4
+ import type {
5
+ ArtifactCandidateV1,
6
+ ArtifactProfileV1,
7
+ ArtifactValidationResultV1,
8
+ } from "@aefree/pi-project-artifacts/contracts/v1";
9
+
10
+ export const UNITY_ARTIFACT_PROFILE_ID_V1 = "unity.artifacts.v1" as const;
11
+ export const UNITY_RENDER_PIPELINES = ["builtin", "urp", "hdrp", "custom", "agnostic"] as const;
12
+
13
+ const OWNER = Object.freeze({
14
+ packageName: "@aefree/pi-unity",
15
+ packageVersion: "0.8.3",
16
+ packageRoot: path.resolve(fileURLToPath(new URL("..", import.meta.url))),
17
+ registeredBy: "index.ts",
18
+ });
19
+
20
+ /**
21
+ * Optional project-artifacts enrichment. These fields describe and validate
22
+ * metadata but never authorize raw discovery/filtering. Artifact paths already
23
+ * distinguish solutions from memories, while generic tags/module/component
24
+ * metadata remains project-owned and schema-open.
25
+ */
26
+ export function createUnityArtifactProfileV1(): ArtifactProfileV1 {
27
+ return Object.freeze({
28
+ contractVersion: 1,
29
+ id: UNITY_ARTIFACT_PROFILE_ID_V1,
30
+ kind: "artifact-profile",
31
+ owner: OWNER,
32
+ artifactKinds: Object.freeze(["solution", "memory"]),
33
+ fields: Object.freeze([
34
+ { name: "engine", type: "string", indexed: true, filterable: true, enumValues: Object.freeze(["unity"]) },
35
+ { name: "unity_version", type: "string", indexed: true, filterable: true },
36
+ { name: "unity_packages", type: "string_list", indexed: true, filterable: true },
37
+ { name: "render_pipeline", type: "string", indexed: true, filterable: true, enumValues: UNITY_RENDER_PIPELINES },
38
+ { name: "platforms", type: "string_list", indexed: true, filterable: true },
39
+ ]),
40
+ validators: Object.freeze([{
41
+ id: "unity.artifact-metadata.v1",
42
+ async validate(_context, request) {
43
+ if (request.signal.aborted) return { outcome: "unavailable", code: "aborted", retryable: true };
44
+ return validateUnityArtifactMetadata(request.artifact);
45
+ },
46
+ }]),
47
+ async appliesTo(_context, request) {
48
+ if (request.signal.aborted) return false;
49
+ // A conventional docs path is not Unity authority. Require direct project
50
+ // evidence before contributing definitions or validation confidence.
51
+ try {
52
+ await access(path.join(request.workspaceRoot, "ProjectSettings", "ProjectVersion.txt"));
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ },
58
+ });
59
+ }
60
+
61
+ /** Validate only declared Unity fields that are present; all fields are optional. */
62
+ export function validateUnityArtifactMetadata(artifact: ArtifactCandidateV1): ArtifactValidationResultV1 {
63
+ const frontmatter = artifact.frontmatter;
64
+ const issues: { code: string; field: string; summary: string }[] = [];
65
+
66
+ validateEnumString(frontmatter, "engine", ["unity"], issues);
67
+ validateNonEmptyString(frontmatter, "unity_version", issues);
68
+ validateStringList(frontmatter, "unity_packages", issues);
69
+ validateEnumString(frontmatter, "render_pipeline", UNITY_RENDER_PIPELINES, issues);
70
+ validateStringList(frontmatter, "platforms", issues);
71
+
72
+ return issues.length === 0 ? { outcome: "valid" } : { outcome: "invalid", issues };
73
+ }
74
+
75
+ function validateNonEmptyString(
76
+ frontmatter: Readonly<Record<string, unknown>>,
77
+ field: string,
78
+ issues: { code: string; field: string; summary: string }[],
79
+ ): void {
80
+ const value = frontmatter[field];
81
+ if (value === undefined) return;
82
+ if (typeof value !== "string" || value.trim() === "") {
83
+ issues.push({ code: `unity_${field}_invalid`, field, summary: `Unity ${field} must be a non-empty string when present.` });
84
+ }
85
+ }
86
+
87
+ function validateEnumString(
88
+ frontmatter: Readonly<Record<string, unknown>>,
89
+ field: string,
90
+ values: readonly string[],
91
+ issues: { code: string; field: string; summary: string }[],
92
+ ): void {
93
+ const value = frontmatter[field];
94
+ if (value === undefined) return;
95
+ if (typeof value !== "string" || !values.includes(value)) {
96
+ issues.push({ code: `unity_${field}_invalid`, field, summary: `Unity ${field} must be one of: ${values.join(", ")}.` });
97
+ }
98
+ }
99
+
100
+ function validateStringList(
101
+ frontmatter: Readonly<Record<string, unknown>>,
102
+ field: string,
103
+ issues: { code: string; field: string; summary: string }[],
104
+ ): void {
105
+ const value = frontmatter[field];
106
+ if (value === undefined) return;
107
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || item.trim() === "")) {
108
+ issues.push({ code: `unity_${field}_invalid`, field, summary: `Unity ${field} must be a non-empty list of non-empty strings when present.` });
109
+ }
110
+ }
@@ -0,0 +1,355 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { hasUnityCommandLineFlag } from "./unity-core";
4
+
5
+ export type UnityBatchmodeInvocation = {
6
+ isTestRun: boolean;
7
+ usesNoGraphics: boolean;
8
+ testPlatform?: string;
9
+ testFilter?: string;
10
+ testCategory?: string;
11
+ testResultsPath?: string;
12
+ logFilePath?: string;
13
+ };
14
+
15
+ export type UnityFailedTest = {
16
+ name: string;
17
+ message?: string;
18
+ stackTrace?: string;
19
+ };
20
+
21
+ export type UnityParsedTestResults = {
22
+ total?: number;
23
+ passed?: number;
24
+ failed?: number;
25
+ skipped?: number;
26
+ inconclusive?: number;
27
+ durationSeconds?: number;
28
+ failedTests: UnityFailedTest[];
29
+ };
30
+
31
+ export type UnityBatchmodeArtifacts = {
32
+ testResultsPath?: string;
33
+ logFilePath?: string;
34
+ testResultsXml?: string;
35
+ logText?: string;
36
+ testResultsBytes?: number;
37
+ logBytes?: number;
38
+ logExcerpt?: string;
39
+ warnings: string[];
40
+ };
41
+
42
+ export function parseUnityBatchmodeInvocation(args: string[]): UnityBatchmodeInvocation {
43
+ const getValue = (flag: string): string | undefined => {
44
+ for (let index = 0; index < args.length; index += 1) {
45
+ const value = args[index];
46
+ if (value === flag) {
47
+ return args[index + 1];
48
+ }
49
+ if (value.startsWith(`${flag}=`)) {
50
+ return value.slice(flag.length + 1);
51
+ }
52
+ }
53
+ return undefined;
54
+ };
55
+
56
+ return {
57
+ isTestRun: hasUnityCommandLineFlag(args, "-runTests"),
58
+ usesNoGraphics: hasUnityCommandLineFlag(args, "-nographics"),
59
+ testPlatform: getValue("-testPlatform"),
60
+ testFilter: getValue("-testFilter"),
61
+ testCategory: getValue("-testCategory"),
62
+ testResultsPath: getValue("-testResults"),
63
+ logFilePath: getValue("-logFile"),
64
+ };
65
+ }
66
+
67
+ function decodeXmlText(value: string | undefined): string | undefined {
68
+ if (!value) return undefined;
69
+ const trimmed = value.trim();
70
+ const withoutCdata = trimmed.replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/u, "$1");
71
+ return withoutCdata
72
+ .replace(/&lt;/g, "<")
73
+ .replace(/&gt;/g, ">")
74
+ .replace(/&quot;/g, '"')
75
+ .replace(/&apos;/g, "'")
76
+ .replace(/&amp;/g, "&")
77
+ .trim();
78
+ }
79
+
80
+ function parseAttributes(tagSource: string): Record<string, string> {
81
+ const attributes: Record<string, string> = {};
82
+ const attributeRegex = /(\w[\w:-]*)\s*=\s*"([^"]*)"/g;
83
+ for (const match of tagSource.matchAll(attributeRegex)) {
84
+ const key = match[1];
85
+ const value = match[2] ?? "";
86
+ attributes[key] = value;
87
+ }
88
+ return attributes;
89
+ }
90
+
91
+ function truncateEvidence(value: string | undefined, maxChars: number): string | undefined {
92
+ if (!value) return undefined;
93
+ return value.length <= maxChars ? value : `${value.slice(0, Math.max(0, maxChars - 1))}…`;
94
+ }
95
+
96
+ function parseOptionalNumber(value: string | undefined): number | undefined {
97
+ if (!value) return undefined;
98
+ const parsed = Number(value);
99
+ return Number.isFinite(parsed) ? parsed : undefined;
100
+ }
101
+
102
+ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults | null {
103
+ const testRunMatch = xml.match(/<test-run\b([^>]*)>/i);
104
+ const testRunCloseIndex = xml.search(/<\/test-run\s*>/i);
105
+ if (!testRunMatch || testRunCloseIndex < (testRunMatch.index ?? 0) + testRunMatch[0].length) {
106
+ return null;
107
+ }
108
+
109
+ const rootAttributes = parseAttributes(testRunMatch[1] ?? "");
110
+ const failedTests: UnityFailedTest[] = [];
111
+
112
+ const testCaseRegex = /<test-case\b([^>]*)>([\s\S]*?)<\/test-case>/gi;
113
+ for (const match of xml.matchAll(testCaseRegex)) {
114
+ const attributes = parseAttributes(match[1] ?? "");
115
+ const body = match[2] ?? "";
116
+ const result = String(attributes.result ?? attributes.label ?? "").toLowerCase();
117
+ const success = String(attributes.success ?? "").toLowerCase();
118
+ const isFailure = result === "failed" || success === "false";
119
+ if (!isFailure) continue;
120
+
121
+ const failureMessage = body.match(/<message[^>]*>([\s\S]*?)<\/message>/i);
122
+ const stackTrace = body.match(/<stack-trace[^>]*>([\s\S]*?)<\/stack-trace>/i);
123
+ if (failedTests.length < 50) {
124
+ failedTests.push({
125
+ name: truncateEvidence(attributes.fullname ?? attributes.name ?? "(unknown test)", 500) ?? "(unknown test)",
126
+ message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 1_000),
127
+ stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 4_000),
128
+ });
129
+ }
130
+ }
131
+
132
+ const skipped = parseOptionalNumber(rootAttributes.skipped) ?? parseOptionalNumber(rootAttributes.inconclusive);
133
+
134
+ const parsed: UnityParsedTestResults = {
135
+ total: parseOptionalNumber(rootAttributes.total) ?? parseOptionalNumber(rootAttributes.testcasecount),
136
+ passed: parseOptionalNumber(rootAttributes.passed),
137
+ failed: parseOptionalNumber(rootAttributes.failed),
138
+ skipped,
139
+ inconclusive: parseOptionalNumber(rootAttributes.inconclusive),
140
+ durationSeconds: parseOptionalNumber(rootAttributes.duration),
141
+ failedTests,
142
+ };
143
+ if (parsed.total === undefined && parsed.passed === undefined && parsed.failed === undefined && parsed.failedTests.length === 0) {
144
+ return null;
145
+ }
146
+ return parsed;
147
+ }
148
+
149
+ function buildArtifactCandidates(cwd: string, projectRoot: string, rawPath: string): string[] {
150
+ if (path.isAbsolute(rawPath)) {
151
+ return [path.normalize(rawPath)];
152
+ }
153
+
154
+ const candidates = [
155
+ path.resolve(cwd, rawPath),
156
+ path.resolve(projectRoot, rawPath),
157
+ ].map((value) => path.normalize(value));
158
+
159
+ return Array.from(new Set(candidates));
160
+ }
161
+
162
+ async function readFirstExistingText(pathsToTry: string[]): Promise<{ path?: string; text?: string }> {
163
+ for (const candidate of pathsToTry) {
164
+ try {
165
+ const text = await fs.readFile(candidate, "utf8");
166
+ return { path: candidate, text };
167
+ } catch {
168
+ // Try next candidate.
169
+ }
170
+ }
171
+ return {};
172
+ }
173
+
174
+ export async function loadUnityBatchmodeArtifacts(
175
+ cwd: string,
176
+ projectRoot: string,
177
+ invocation: UnityBatchmodeInvocation,
178
+ ): Promise<UnityBatchmodeArtifacts> {
179
+ const warnings: string[] = [];
180
+ const artifacts: UnityBatchmodeArtifacts = { warnings };
181
+
182
+ if (invocation.testResultsPath) {
183
+ const result = await readFirstExistingText(buildArtifactCandidates(cwd, projectRoot, invocation.testResultsPath));
184
+ if (result.path && result.text !== undefined) {
185
+ artifacts.testResultsPath = result.path;
186
+ artifacts.testResultsXml = result.text;
187
+ } else {
188
+ warnings.push(`Unity test results file was not found: ${invocation.testResultsPath}`);
189
+ }
190
+ }
191
+
192
+ if (invocation.logFilePath && invocation.logFilePath !== "-") {
193
+ const result = await readFirstExistingText(buildArtifactCandidates(cwd, projectRoot, invocation.logFilePath));
194
+ if (result.path && result.text !== undefined) {
195
+ artifacts.logFilePath = result.path;
196
+ artifacts.logText = result.text;
197
+ } else {
198
+ warnings.push(`Unity log file was not found: ${invocation.logFilePath}`);
199
+ }
200
+ }
201
+
202
+ return artifacts;
203
+ }
204
+
205
+ export function summarizeTextForAgent(value: string | undefined, maxLines = 40, maxChars = 4000): string | undefined {
206
+ if (!value) return undefined;
207
+ const trimmed = value.trim();
208
+ if (!trimmed) return undefined;
209
+
210
+ const lines = trimmed.split(/\r?\n/);
211
+ const selected = lines.length > maxLines ? lines.slice(-maxLines) : lines;
212
+ let text = selected.join("\n");
213
+ if (text.length > maxChars) {
214
+ text = text.slice(text.length - maxChars);
215
+ }
216
+
217
+ const omittedLines = lines.length - selected.length;
218
+ const prefix = omittedLines > 0 ? `[showing last ${selected.length} of ${lines.length} lines]\n` : "";
219
+ return `${prefix}${text}`;
220
+ }
221
+
222
+ export function formatParsedTestResultsForAgent(results: UnityParsedTestResults): string[] {
223
+ const counts: string[] = [];
224
+ if (results.total !== undefined) counts.push(`total=${results.total}`);
225
+ if (results.passed !== undefined) counts.push(`passed=${results.passed}`);
226
+ if (results.failed !== undefined) counts.push(`failed=${results.failed}`);
227
+ if (results.skipped !== undefined) counts.push(`skipped=${results.skipped}`);
228
+ if (results.inconclusive !== undefined) counts.push(`inconclusive=${results.inconclusive}`);
229
+ if (results.durationSeconds !== undefined) counts.push(`duration=${results.durationSeconds}s`);
230
+
231
+ const lines = counts.length > 0 ? [`Results: ${counts.join(", ")}`] : [];
232
+ if (results.failedTests.length > 0) {
233
+ lines.push("Failed tests:");
234
+ for (const failed of results.failedTests.slice(0, 8)) {
235
+ lines.push(`- ${failed.name}`);
236
+ if (failed.message) {
237
+ lines.push(` ${failed.message.split(/\r?\n/)[0]}`);
238
+ }
239
+ }
240
+ if (results.failedTests.length > 8) {
241
+ lines.push(`- ... ${results.failedTests.length - 8} more failed tests`);
242
+ }
243
+ }
244
+ return lines;
245
+ }
246
+
247
+ export type UnityBatchmodeAgentTextInput = {
248
+ displayProjectPath: string;
249
+ unityVersion: string;
250
+ editorPath: string;
251
+ exitCode: number;
252
+ killed: boolean;
253
+ invocation: UnityBatchmodeInvocation;
254
+ artifacts: UnityBatchmodeArtifacts;
255
+ parsedTestResults?: UnityParsedTestResults | null;
256
+ stdout?: string;
257
+ stderr?: string;
258
+ warning?: string;
259
+ singleProcessWarning: string;
260
+ };
261
+
262
+ export function hasKnownPositiveExecutedTestCount(
263
+ parsedTestResults?: UnityParsedTestResults | null,
264
+ ): boolean {
265
+ return parsedTestResults?.total !== undefined
266
+ && Number.isFinite(parsedTestResults.total)
267
+ && parsedTestResults.total > 0;
268
+ }
269
+
270
+ export function isPassingUnityTestEvidence(
271
+ parsedTestResults?: UnityParsedTestResults | null,
272
+ ): boolean {
273
+ return hasKnownPositiveExecutedTestCount(parsedTestResults)
274
+ && (parsedTestResults?.failed ?? 0) === 0
275
+ && (parsedTestResults?.failedTests.length ?? 0) === 0;
276
+ }
277
+
278
+ export function deriveUnityArtifactInspectionStatus(
279
+ hasLoadedArtifacts: boolean,
280
+ invocation: UnityBatchmodeInvocation,
281
+ parsedTestResults?: UnityParsedTestResults | null,
282
+ ): "passed" | "failed" {
283
+ if (!hasLoadedArtifacts) return "failed";
284
+ if (invocation.isTestRun && !isPassingUnityTestEvidence(parsedTestResults)) return "failed";
285
+ return "passed";
286
+ }
287
+
288
+ export function deriveUnityBatchmodeStatus(
289
+ exitCode: number,
290
+ killed: boolean,
291
+ invocation: UnityBatchmodeInvocation,
292
+ parsedTestResults?: UnityParsedTestResults | null,
293
+ ): "passed" | "failed" | "killed" {
294
+ if (killed) return "killed";
295
+ if (invocation.isTestRun && !isPassingUnityTestEvidence(parsedTestResults)) return "failed";
296
+ if (parsedTestResults && ((parsedTestResults.failed ?? 0) > 0 || parsedTestResults.failedTests.length > 0)) {
297
+ return "failed";
298
+ }
299
+ return exitCode === 0 ? "passed" : "failed";
300
+ }
301
+
302
+ function getOutcomeLabel(input: UnityBatchmodeAgentTextInput): "passed" | "failed" | "killed" {
303
+ return deriveUnityBatchmodeStatus(input.exitCode, input.killed, input.invocation, input.parsedTestResults);
304
+ }
305
+
306
+ function getBatchmodeVariantLabel(invocation: UnityBatchmodeInvocation): "Unity (headless)" | "Unity (graphics)" {
307
+ return invocation.usesNoGraphics ? "Unity (headless)" : "Unity (graphics)";
308
+ }
309
+
310
+ export function buildUnityBatchmodeAgentText(input: UnityBatchmodeAgentTextInput): string {
311
+ const outcome = getOutcomeLabel(input);
312
+ const batchmodeVariant = getBatchmodeVariantLabel(input.invocation);
313
+ const lines = [
314
+ `${batchmodeVariant} ${outcome} for ${input.displayProjectPath} using Unity ${input.unityVersion}.`,
315
+ `Editor: ${input.editorPath}`,
316
+ `Exit code: ${input.exitCode}`,
317
+ `Mode: ${batchmodeVariant}`,
318
+ input.singleProcessWarning,
319
+ ];
320
+
321
+ if (input.invocation.isTestRun) {
322
+ lines.push("Run type: Unity Test Framework");
323
+ if (input.invocation.testPlatform) lines.push(`Test platform: ${input.invocation.testPlatform}`);
324
+ if (input.invocation.testFilter) lines.push(`Test filter: ${input.invocation.testFilter}`);
325
+ if (input.invocation.testCategory) lines.push(`Test category: ${input.invocation.testCategory}`);
326
+ }
327
+
328
+ if (input.parsedTestResults) {
329
+ lines.push(...formatParsedTestResultsForAgent(input.parsedTestResults));
330
+ }
331
+ if (input.invocation.isTestRun && !hasKnownPositiveExecutedTestCount(input.parsedTestResults)) {
332
+ lines.push(input.parsedTestResults?.total === 0
333
+ ? "Unity reported zero executed tests; this batch is not passing evidence."
334
+ : "Unity did not report a known positive executed-test count; this batch is not passing evidence.");
335
+ }
336
+
337
+ if (input.artifacts.testResultsPath) lines.push(`Test results: ${input.artifacts.testResultsPath}`);
338
+ if (input.artifacts.logFilePath) lines.push(`Log file: ${input.artifacts.logFilePath}`);
339
+ for (const artifactWarning of input.artifacts.warnings) lines.push(artifactWarning);
340
+ if (input.invocation.testResultsPath && input.artifacts.testResultsXml && !input.parsedTestResults) {
341
+ lines.push(`Unity test results XML could not be parsed: ${input.artifacts.testResultsPath ?? input.invocation.testResultsPath}`);
342
+ }
343
+ if (input.warning) lines.push(input.warning);
344
+
345
+ const preferredOutput = input.parsedTestResults
346
+ ? undefined
347
+ : summarizeTextForAgent(input.stderr) ?? summarizeTextForAgent(input.stdout) ?? summarizeTextForAgent(input.artifacts.logText);
348
+
349
+ if (preferredOutput) {
350
+ lines.push("Relevant output:");
351
+ lines.push(preferredOutput);
352
+ }
353
+
354
+ return lines.join("\n");
355
+ }