Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/nushell/__tests__/contract.test.ts

Raw
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import {
	type ExtensionAPI,
	initTheme,
	type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

type ToolRenderContext = Parameters<
	NonNullable<ToolDefinition["renderCall"]>
>[2];

import { afterEach, describe, expect, it, vi } from "vitest";
import {
	createPiSession,
	type PiSession,
} from "../../../test/fixture/create-pi-session.ts";
import { findNu } from "../runtime.ts";

const extensionPath = fileURLToPath(new URL("../index.ts", import.meta.url));

describe("nushell contract", () => {
	let session: PiSession | undefined;
	let root: string | undefined;
	afterEach(async () => {
		await session?.dispose();
		session = undefined;
		if (root) rmSync(root, { recursive: true, force: true });
		root = undefined;
		vi.restoreAllMocks();
	});

	it("registers inert tools and toggles without changing unrelated tools", async () => {
		session = await createPiSession({ extensionPaths: [extensionPath] });
		const runner = session.session.extensionRunner;
		const command = runner.getCommand("nushell");
		const ctx = runner.createCommandContext();
		expect(session.extensions[0]?.tools.has("nu")).toBe(true);
		expect(session.extensions[0]?.tools.has("nu_session")).toBe(false);
		expect(
			session.extensions[0]?.tools.get("nu")?.definition.annotations
				?.readOnlyHint,
		).toBe(false);
		expect(session.session.getActiveToolNames()).not.toContain("nu");
		expect(command?.getArgumentCompletions?.("o")).toEqual([
			{ value: "on", label: "on" },
			{ value: "off", label: "off" },
		]);
		expect(command?.getArgumentCompletions?.("re")).toBeNull();
		const initial = session.session.getActiveToolNames();
		expect(
			session.session.getAllTools().find((tool) => tool.name === "nu")
				?.exposure,
		).toBe("direct");
		expect(
			session.session.getAllTools().map((tool) => tool.name),
		).not.toContain("nu_session");
		await command?.handler("on", ctx);
		const enabled = session.session.getActiveToolNames();
		if (!findNu()) {
			expect(enabled).toEqual(initial);
			return;
		}
		expect(enabled).toContain("nu");
		expect(enabled).not.toContain("nu_session");
		expect(enabled).not.toContain("bash");
		expect(enabled).not.toContain("powershell");
		for (const name of initial.filter(
			(name) => name !== "bash" && name !== "powershell",
		))
			expect(enabled).toContain(name);
		await command?.handler("off", ctx);
		expect([...session.session.getActiveToolNames()].sort()).toEqual(
			[...initial].sort(),
		);
	});

	it("keeps shell tools active and warns when PATH has no executable Nu", async () => {
		session = await createPiSession({ extensionPaths: [extensionPath] });
		root = mkdtempSync(path.join(tmpdir(), "pi-ext-nushell-path-"));
		mkdirSync(path.join(root, process.platform === "win32" ? "nu.exe" : "nu"));
		session.env["PATH"] = root;
		process.env["PATH"] = root;
		const runner = session.session.extensionRunner;
		const notify = vi.spyOn(runner.createCommandContext().ui, "notify");
		const initial = session.session.getActiveToolNames();
		runner.setFlagValue("nushell", true);
		await runner.emit({ type: "session_start", reason: "startup" });
		expect(session.session.getActiveToolNames()).toEqual(initial);
		expect(notify.mock.calls.at(-1)).toEqual([
			"Nushell executable not found on PATH; tools unchanged",
			"warning",
		]);
		await runner
			.getCommand("nushell")
			?.handler("on", runner.createCommandContext());
		expect(session.session.getActiveToolNames()).toEqual(initial);
		expect(notify.mock.calls.at(-1)).toEqual([
			"Nushell executable not found on PATH; tools unchanged",
			"warning",
		]);
	});

	it("registers --nushell to enable fresh Nu only", async () => {
		session = await createPiSession({ extensionPaths: [extensionPath] });
		const runner = session.session.extensionRunner;
		expect(runner.getFlags().has("nushell")).toBe(true);
		runner.setFlagValue("nushell", true);
		await runner.emit({ type: "session_start", reason: "startup" });
		const active = session.session.getActiveToolNames();
		if (!findNu()) {
			expect(active).not.toContain("nu");
			return;
		}
		expect(active).toContain("nu");
		expect(active).not.toContain("nu_session");
		expect(active).not.toContain("bash");
		expect(active).not.toContain("powershell");
	});

	it.runIf(Boolean(findNu()))(
		"does not claim activation when direct Nu is excluded",
		async () => {
			session = await createPiSession({ extensionPaths: [extensionPath] });
			const runner = session.session.extensionRunner;
			const notify = vi.spyOn(runner.createCommandContext().ui, "notify");
			const initial = session.session.getActiveToolNames();
			const tools = session.session.getAllTools();
			vi.spyOn(session.session, "getAllTools").mockReturnValue(
				tools.filter((tool) => tool.name !== "nu"),
			);
			runner.setFlagValue("nushell", true);
			await runner.emit({ type: "session_start", reason: "startup" });
			expect(session.session.getActiveToolNames()).toEqual(initial);
			expect(notify.mock.calls.at(-1)).toEqual([
				expect.stringContaining("nu"),
				"warning",
			]);
		},
	);

	it.runIf(Boolean(findNu()))(
		"renders fresh Nu with syntax highlighting and shell-style results",
		async () => {
			// The syntax highlighter spawns `nu` in Pi's own cwd (the repo root),
			// where a mise shim would refuse the untrusted mise.toml under the
			// sandbox HOME. Trusting the repo config restores the real behavior and
			// is inert when `nu` is a plain executable.
			session = await createPiSession({
				extensionPaths: [extensionPath],
				env: { MISE_TRUSTED_CONFIG_PATHS: process.cwd() },
			});
			initTheme("default", false);
			const tools = session.extensions[0]?.tools;
			const theme = {
				fg: (color: string, value: string) =>
					value ? `<${color}>${value}</${color}>` : "",
				bold: (value: string) => value,
			} as never;
			const tool = tools?.get("nu")?.definition;
			const icon = "";
			expect(tool?.label).toBe(`${icon} nu`);
			const context = {
				state: {},
				executionStarted: false,
				argsComplete: false,
				invalidate: vi.fn(),
			} as unknown as ToolRenderContext;

			const partial = tool?.renderCall?.(
				{ command: "let value" },
				theme,
				context,
			);
			expect(partial?.render(1000).join("\n")).not.toContain(
				"<syntaxVariable>",
			);
			context.argsComplete = true;
			context.executionStarted = true;
			const command = 'let value = "safe"';
			const call = tool?.renderCall?.({ command }, theme, context);
			const lines = call?.render(1000).join("\n") ?? "";
			expect(lines.replace(/<\/?[a-zA-Z]+>/g, "")).toContain(command);
			expect(lines).not.toContain("<syntaxVariable>");
			await vi.waitFor(() =>
				expect(context.invalidate).toHaveBeenCalledTimes(1),
			);
			const highlighted =
				tool
					?.renderCall?.({ command }, theme, context)
					.render(1000)
					.join("\n") ?? "";
			expect(highlighted).toContain("<syntaxVariable>value</syntaxVariable>");
			expect(highlighted).toContain('<syntaxString>"safe"</syntaxString>');
			expect(highlighted).not.toMatch(/\x1b\[/);
			const paletteCommand =
				'if true { let value = "safe"; $value | str uppercase; 2 > 1 }';
			tool?.renderCall?.({ command: paletteCommand }, theme, context);
			await vi.waitFor(() =>
				expect(context.invalidate).toHaveBeenCalledTimes(2),
			);
			const palette =
				tool
					?.renderCall?.({ command: paletteCommand }, theme, context)
					.render(2000)
					.join("\n") ?? "";
			for (const token of [
				"<syntaxFunction>if</syntaxFunction>",
				"<syntaxKeyword>true</syntaxKeyword>",
				"<syntaxPunctuation>{</syntaxPunctuation>",
				"<syntaxVariable>$value</syntaxVariable>",
				"<syntaxPunctuation>|</syntaxPunctuation>",
				"<syntaxOperator>></syntaxOperator>",
				'<syntaxString>"safe"</syntaxString>',
			])
				expect(palette).toContain(token);
			expect(lines).toContain(`${icon} nu>`);
			const result = tool?.renderResult?.(
				{
					content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }],
					details: undefined,
				},
				{ expanded: false, isPartial: false },
				theme,
				{ ...context, showImages: false, isError: false },
			);
			expect(result?.render(120).join("\n")).toContain("to expand");
		},
	);

	it.runIf(Boolean(findNu()))(
		"keeps fresh Nu callable through a real nested Pi tool call",
		async () => {
			const calls: Array<{
				toolName: string;
				parentToolCallId: string | undefined;
			}> = [];
			const nestedProbe = (pi: ExtensionAPI) => {
				pi.on("tool_call", (event) => {
					calls.push({
						toolName: event.toolName,
						parentToolCallId: event.parentToolCallId,
					});
				});
				pi.registerTool({
					name: "nu_nested_probe",
					label: "Nu nested probe",
					description: "Run a fresh Nushell command.",
					parameters: Type.Object({ command: Type.String() }),
					async execute(_id, { command }, signal, _onUpdate, ctx) {
						const nested = await ctx.executeTool(
							"nu",
							{ command },
							{
								...(signal ? { signal } : {}),
							},
						);
						return nested.result;
					},
				});
			};
			session = await createPiSession({
				extensionPaths: [extensionPath],
				extensionFactories: [nestedProbe],
				responses: [
					fauxAssistantMessage(
						fauxToolCall(
							"nu_nested_probe",
							{ command: "1 + 1" },
							{ id: "probe-1" },
						),
						{ stopReason: "toolUse" },
					),
					fauxAssistantMessage("done"),
				],
			});
			const runner = session.session.extensionRunner;
			await runner
				.getCommand("nushell")
				?.handler("on", runner.createCommandContext());
			await session.session.prompt("run fresh Nu");
			expect(session.errors).toEqual([]);
			expect(calls).toContainEqual(
				expect.objectContaining({
					toolName: "nu",
					parentToolCallId: "probe-1",
				}),
			);
		},
	);

	it.runIf(Boolean(findNu()))(
		"isolates variables, functions, environment, and cwd across fresh calls",
		async () => {
			root = mkdtempSync(path.join(tmpdir(), "pi-ext-nushell-isolation-"));
			const subdirectory = path.join(root, "subdirectory");
			mkdirSync(subdirectory);
			session = await createPiSession({
				cwd: root,
				extensionPaths: [extensionPath],
			});
			const runner = session.session.extensionRunner;
			const tool = session.extensions[0]?.tools.get("nu")?.definition;
			const run = (command: string) =>
				tool?.execute(
					"call",
					{ command },
					undefined,
					undefined,
					runner.createToolContext("call", undefined),
				);
			const first = await run(
				'let value = 41; def private_test [] { 42 }; $env.PI_NUSHELL_TEST = "set"; cd subdirectory; pwd',
			);
			expect(first?.content[0]).toMatchObject({
				type: "text",
				text: expect.stringContaining(subdirectory),
			});
			const second = await run(
				"[(scope variables | where name == '$value' | length), (scope commands | where name == 'private_test' | length), $env.PI_NUSHELL_TEST?, $env.PWD] | to json --raw",
			);
			const secondText = second?.content[0];
			const text = secondText?.type === "text" ? secondText.text : "";
			expect(JSON.parse(text)).toEqual([0, 0, null, root]);
		},
	);
});