mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
a92c859d07
## What Adds `argent lens` — open Argent Lens bound to a fresh `claude` session. ## Why The Lens preview window and the agent communicate through a shared, process-wide tool-server that carries **no per-agent identity** (only a coarse telemetry header). So when the user requests changes in the window, there's no reliable way to route them to the *specific* agent that opened it. `argent lens` sidesteps the problem by inverting ownership: the human launches Lens, and Lens spawns (and therefore owns) exactly one `claude` terminal. The binding is 1:1 by construction — no identity plumbing required. ## How it works 1. `argent lens` ensures a tool-server and **begins a CLI Lens session**, which opens the preview window up front (no `await_user_selection` needed) and keeps it open across rounds. 2. It spawns a tracked `claude` terminal in the current directory — a TypeScript port of the macOS applet's `osascript` spawn/track mechanism — seeded to propose variants without blocking. 3. A watcher polls `GET /preview/outcome`; on each submitted round it types a one-line feedback summary into that terminal, queuing it to the agent as its next prompt. Requesting changes therefore "talks back" to the bound agent instead of closing the window. macOS only (drives Terminal/iTerm via `osascript`); off-darwin it exits with a clear message. ## Changes - **CLI** `lens-terminal.ts` — `osascript` spawn/track + a new write-into-running-session path (the "outputting" channel; injects input into the running TUI, which a tty write cannot do). - **CLI** `lens.ts` — the command + feedback formatter. - **tool-server** — a `cliSession` flag in the variant store (opens the window, suppresses auto-close on submit); `POST /preview/cli-session` and `GET /preview/outcome` routes. - **UI** — relabels the submit action to "Request changes" while a CLI session is active. Behind the existing `argent-lens` feature flag. ## Verification - Build + typecheck across the workspace; ESLint (`--max-warnings 0`) + Prettier clean. - 34 new unit tests; full `@argent/cli` suite **118 passing**, tool-server variant/preview suites **46 passing**. - **E2E (observed on this machine):** - **tool-server HTTP** — `cli-session` toggles `cliSession` true/false; submitted feedback round-trips through `/preview/outcome`. - **terminal injection** — spawned a window running `cat`, confirmed `writeToSession` lands text in the running program's **stdin** (both iTerm and Terminal), with embedded newlines flattened. - **full relay** — the real `watchAndRelay` loop against a real tool-server typed two distinct submission rounds' feedback into a stand-in agent terminal (dedup on `completedAt` verified). - `argent lens --help` and the feature-flag gate (exit 1 with guidance, no spawn). Note: a real end-to-end with a live `claude` agent was not run (it would drive a paid interactive session); the agent-facing channel is proven by injecting into a stand-in process's stdin via the exact same code path. --------- Co-authored-by: Filip131311 <contact@filipkaminski.com>
129 lines
5.2 KiB
TypeScript
129 lines
5.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import type { Registry } from "@argent/registry";
|
|
|
|
// The lens-only routes (/boot etc.) are gated behind the argent-lens flag. Mock
|
|
// it ON by default so this suite tests the route logic deterministically
|
|
// regardless of the machine's real ~/.argent/flags.json; a dedicated test below
|
|
// flips it OFF to assert the gate.
|
|
vi.mock("@argent/configuration-core", () => ({ isFlagEnabled: vi.fn(() => true) }));
|
|
|
|
import { createPreviewRouter } from "../src/preview";
|
|
import { variantProposalStore } from "../src/utils/variant-proposals";
|
|
import { isFlagEnabled } from "@argent/configuration-core";
|
|
|
|
const mockFlag = vi.mocked(isFlagEnabled);
|
|
|
|
const IOS_UDID = "00000000-0000-0000-0000-000000000000";
|
|
const IOS_UDID_2 = "11111111-1111-1111-1111-111111111111";
|
|
const ANDROID_SERIAL = "emulator-5554";
|
|
|
|
// Build an app whose registry answers list-devices with `devices` and records
|
|
// every boot-device invocation so the test can assert what was (not) booted.
|
|
function harness(devices: unknown[]) {
|
|
const bootCalls: unknown[] = [];
|
|
const invokeTool = vi.fn(async (id: string, params?: unknown) => {
|
|
if (id === "boot-device") {
|
|
bootCalls.push(params);
|
|
return { platform: "ios", udid: (params as { udid: string }).udid, booted: true };
|
|
}
|
|
return { devices };
|
|
});
|
|
const registry = { invokeTool } as unknown as Registry;
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use(createPreviewRouter(registry));
|
|
return { app, invokeTool, bootCalls };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
// The store is a process singleton; clear any ownership a prior test left.
|
|
variantProposalStore.takeOwnedDevices();
|
|
mockFlag.mockReturnValue(true);
|
|
});
|
|
|
|
describe("POST /preview/boot", () => {
|
|
it("boots a stopped iOS sim headless and marks it Lens-owned", async () => {
|
|
const { app, bootCalls } = harness([{ platform: "ios", udid: IOS_UDID, state: "Shutdown" }]);
|
|
|
|
const res = await request(app).post("/boot").send({ udid: IOS_UDID });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({ booted: true, alreadyRunning: false, owned: true });
|
|
// Booted headless (no Simulator.app window).
|
|
expect(bootCalls).toEqual([{ udid: IOS_UDID, headless: true }]);
|
|
expect(variantProposalStore.isDeviceOwned(IOS_UDID)).toBe(true);
|
|
});
|
|
|
|
it("does NOT boot or own a sim that is already running", async () => {
|
|
const { app, bootCalls } = harness([{ platform: "ios", udid: IOS_UDID, state: "Booted" }]);
|
|
|
|
const res = await request(app).post("/boot").send({ udid: IOS_UDID });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({ alreadyRunning: true, owned: false });
|
|
expect(bootCalls).toEqual([]); // never re-booted
|
|
expect(variantProposalStore.isDeviceOwned(IOS_UDID)).toBe(false);
|
|
});
|
|
|
|
it.each(["Booting", "Shutting Down", "Creating"])(
|
|
"does NOT boot or own a sim in the transient %s state (only Shutdown is ownable)",
|
|
async (state) => {
|
|
// A device the user is mid-booting externally must not be re-booted and
|
|
// marked Lens-owned, or session-end would shut down the user's own sim.
|
|
const { app, bootCalls } = harness([{ platform: "ios", udid: IOS_UDID, state }]);
|
|
|
|
const res = await request(app).post("/boot").send({ udid: IOS_UDID });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({ alreadyRunning: true, owned: false });
|
|
expect(bootCalls).toEqual([]);
|
|
expect(variantProposalStore.isDeviceOwned(IOS_UDID)).toBe(false);
|
|
}
|
|
);
|
|
|
|
it("rejects an unknown udid without booting (auth-exempt spawn guard)", async () => {
|
|
const { app, bootCalls } = harness([{ platform: "ios", udid: IOS_UDID, state: "Shutdown" }]);
|
|
|
|
const res = await request(app).post("/boot").send({ udid: IOS_UDID_2 });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("Unknown device");
|
|
expect(bootCalls).toEqual([]);
|
|
expect(variantProposalStore.isDeviceOwned(IOS_UDID_2)).toBe(false);
|
|
});
|
|
|
|
it("rejects a non-iOS device (Android emulators aren't bootable from the preview)", async () => {
|
|
const { app, bootCalls } = harness([
|
|
{ platform: "android", serial: ANDROID_SERIAL, state: "offline" },
|
|
]);
|
|
|
|
const res = await request(app).post("/boot").send({ udid: ANDROID_SERIAL });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("iOS simulators");
|
|
expect(bootCalls).toEqual([]);
|
|
});
|
|
|
|
it("400s on a missing udid", async () => {
|
|
const { app } = harness([]);
|
|
const res = await request(app).post("/boot").send({});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("Missing");
|
|
});
|
|
|
|
it("404s (route hidden) and never spawns when the argent-lens flag is off", async () => {
|
|
mockFlag.mockReturnValue(false);
|
|
const { app, invokeTool } = harness([{ platform: "ios", udid: IOS_UDID, state: "Shutdown" }]);
|
|
|
|
const res = await request(app).post("/boot").send({ udid: IOS_UDID });
|
|
|
|
// Gated like a hidden tool: a user who never enabled Lens gets no new
|
|
// unauthenticated boot surface, and no list-devices/boot-device is spawned.
|
|
expect(res.status).toBe(404);
|
|
expect(invokeTool).not.toHaveBeenCalled();
|
|
expect(variantProposalStore.isDeviceOwned(IOS_UDID)).toBe(false);
|
|
});
|
|
});
|