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>
218 lines
7.5 KiB
TypeScript
218 lines
7.5 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import http from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import express from "express";
|
|
import type { Registry } from "@argent/registry";
|
|
import { createPreviewRouter } from "../src/preview";
|
|
import { variantProposalStore } from "../src/utils/variant-proposals";
|
|
|
|
// Minimal SSE frame parse for the test (mirrors the CLI's parseSseBuffer).
|
|
function parseFrames(buf: string): Array<{ event: string; data: string }> {
|
|
const out: Array<{ event: string; data: string }> = [];
|
|
for (const frame of buf.split("\n\n")) {
|
|
let event = "message";
|
|
const data: string[] = [];
|
|
for (const line of frame.split("\n")) {
|
|
if (line.startsWith(":") || line === "") continue;
|
|
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
|
|
}
|
|
if (data.length) out.push({ event, data: data.join("\n") });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
let server: http.Server | null = null;
|
|
|
|
afterEach(async () => {
|
|
// Reset shared store state so the next test starts clean.
|
|
variantProposalStore.setCliSession(false);
|
|
if (server) {
|
|
await new Promise<void>((r) => server!.close(() => r()));
|
|
server = null;
|
|
}
|
|
});
|
|
|
|
async function startServer(): Promise<string> {
|
|
const registry = { invokeTool: async () => ({ devices: [] }) } as unknown as Registry;
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/preview", createPreviewRouter(registry));
|
|
server = http.createServer(app);
|
|
await new Promise<void>((r) => server!.listen(0, "127.0.0.1", r));
|
|
const { port } = server!.address() as AddressInfo;
|
|
return `http://127.0.0.1:${port}`;
|
|
}
|
|
|
|
/**
|
|
* Connect to the SSE stream and collect frames until `predicate` is satisfied
|
|
* or the timeout elapses, then abort. Runs `onConnected` once the stream is
|
|
* open so the caller can trigger store events that should be pushed.
|
|
*/
|
|
async function collectFrames(
|
|
base: string,
|
|
onConnected: () => void,
|
|
predicate: (frames: Array<{ event: string; data: string }>) => boolean,
|
|
timeoutMs = 2_000
|
|
): Promise<Array<{ event: string; data: string }>> {
|
|
const ac = new AbortController();
|
|
const res = await fetch(`${base}/preview/lens-stream`, { signal: ac.signal });
|
|
const reader = res.body!.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buf = "";
|
|
let connectedFired = false;
|
|
const deadline = Date.now() + timeoutMs;
|
|
try {
|
|
// Fire the trigger on the next tick so the subscription is fully wired.
|
|
setTimeout(() => {
|
|
connectedFired = true;
|
|
onConnected();
|
|
}, 20);
|
|
while (Date.now() < deadline) {
|
|
const readPromise = reader.read();
|
|
const timed = await Promise.race([
|
|
readPromise,
|
|
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 200)),
|
|
]);
|
|
if (timed === "timeout") {
|
|
if (connectedFired && predicate(parseFrames(buf))) break;
|
|
continue;
|
|
}
|
|
const { value, done } = timed;
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
if (connectedFired && predicate(parseFrames(buf))) break;
|
|
}
|
|
} finally {
|
|
ac.abort();
|
|
try {
|
|
reader.releaseLock();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
return parseFrames(buf);
|
|
}
|
|
|
|
describe("GET /preview/lens-stream (SSE push)", () => {
|
|
it("pushes an agent-choice frame when the human picks", async () => {
|
|
const base = await startServer();
|
|
variantProposalStore.setCliSession(true, [{ id: "claude", name: "Claude Code" }]);
|
|
|
|
const frames = await collectFrames(
|
|
base,
|
|
() => variantProposalStore.setLensAgentChoice("claude"),
|
|
(f) => f.some((x) => x.event === "agent-choice")
|
|
);
|
|
|
|
const choice = frames.find((f) => f.event === "agent-choice");
|
|
expect(choice).toBeDefined();
|
|
expect(JSON.parse(choice!.data)).toEqual({ id: "claude", remember: false });
|
|
});
|
|
|
|
it("carries the remember flag when the human ticks 'Remember this choice'", async () => {
|
|
const base = await startServer();
|
|
variantProposalStore.setCliSession(true, [{ id: "claude", name: "Claude Code" }]);
|
|
|
|
const frames = await collectFrames(
|
|
base,
|
|
() => variantProposalStore.setLensAgentChoice("claude", true),
|
|
(f) => f.some((x) => x.event === "agent-choice")
|
|
);
|
|
|
|
const choice = frames.find((f) => f.event === "agent-choice");
|
|
expect(JSON.parse(choice!.data)).toEqual({ id: "claude", remember: true });
|
|
});
|
|
|
|
it("pushes an outcome frame when a round is submitted", async () => {
|
|
const base = await startServer();
|
|
variantProposalStore.setCliSession(true);
|
|
variantProposalStore.proposeVariant({
|
|
element: "Foo",
|
|
variant: { name: "Bold", summary: "bold" },
|
|
});
|
|
|
|
const frames = await collectFrames(
|
|
base,
|
|
() =>
|
|
variantProposalStore.submitSelection({
|
|
selections: [],
|
|
annotations: [
|
|
{ target: "Foo", match: { by: "text", value: "Foo" }, comment: "make it pop" },
|
|
],
|
|
}),
|
|
(f) => f.some((x) => x.event === "outcome")
|
|
);
|
|
|
|
const outcome = frames.find((f) => f.event === "outcome");
|
|
expect(outcome).toBeDefined();
|
|
const parsed = JSON.parse(outcome!.data);
|
|
expect(parsed.status).toBe("completed");
|
|
expect(parsed.annotations[0].comment).toBe("make it pop");
|
|
expect(typeof parsed.completedAt).toBe("number");
|
|
});
|
|
|
|
it("replays the existing agent choice to a late subscriber", async () => {
|
|
const base = await startServer();
|
|
variantProposalStore.setCliSession(true, [{ id: "codex", name: "Codex CLI" }]);
|
|
variantProposalStore.setLensAgentChoice("codex"); // picked BEFORE we connect
|
|
|
|
const frames = await collectFrames(
|
|
base,
|
|
() => {
|
|
/* nothing — the choice already happened */
|
|
},
|
|
(f) => f.some((x) => x.event === "agent-choice")
|
|
);
|
|
|
|
expect(JSON.parse(frames.find((f) => f.event === "agent-choice")!.data)).toEqual({
|
|
id: "codex",
|
|
remember: false,
|
|
});
|
|
});
|
|
|
|
it("replays the last submitted outcome to a late subscriber (reconnect-gap safety)", async () => {
|
|
const base = await startServer();
|
|
variantProposalStore.setCliSession(true);
|
|
variantProposalStore.proposeVariant({
|
|
element: "Foo",
|
|
variant: { name: "Bold", summary: "bold" },
|
|
});
|
|
// Submit BEFORE connecting — mimics a round the human completes while the
|
|
// CLI relay's SSE is briefly disconnected. On reconnect the client must
|
|
// still receive this outcome (the CLI re-reads /outcome only once, at
|
|
// startup), or the feedback is lost forever.
|
|
variantProposalStore.submitSelection({
|
|
selections: [],
|
|
annotations: [{ target: "Foo", match: { by: "text", value: "Foo" }, comment: "tighten" }],
|
|
});
|
|
|
|
const frames = await collectFrames(
|
|
base,
|
|
() => {
|
|
/* nothing — the submit already happened before we connected */
|
|
},
|
|
(f) => f.some((x) => x.event === "outcome")
|
|
);
|
|
|
|
const outcome = frames.find((f) => f.event === "outcome");
|
|
expect(outcome).toBeDefined();
|
|
const parsed = JSON.parse(outcome!.data);
|
|
expect(parsed.status).toBe("completed");
|
|
expect(parsed.annotations[0].comment).toBe("tighten");
|
|
});
|
|
|
|
it("pushes session-end when the CLI session closes", async () => {
|
|
const base = await startServer();
|
|
variantProposalStore.setCliSession(true);
|
|
|
|
const frames = await collectFrames(
|
|
base,
|
|
() => variantProposalStore.setCliSession(false),
|
|
(f) => f.some((x) => x.event === "session-end")
|
|
);
|
|
|
|
expect(frames.some((f) => f.event === "session-end")).toBe(true);
|
|
});
|
|
});
|