Files
software-mansion__argent/packages/argent-cli/test/lens-feedback.test.ts
Ignacy Łątka a92c859d07 feat(argent-lens): argent lens — bind a claude session to the preview window (#416)
## 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>
2026-07-01 19:57:56 +02:00

144 lines
4.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { formatLensFeedback, buildSeedPrompt } from "../src/lens.js";
// Minimal completed-outcome factory.
function outcome(over: Partial<Parameters<typeof formatLensFeedback>[0]> = {}) {
return {
status: "completed" as const,
round: 2,
selections: [],
unselected: [],
annotations: [],
completedAt: 123,
...over,
};
}
describe("formatLensFeedback", () => {
it("is always a single line (no embedded newlines)", () => {
const out = formatLensFeedback(outcome({ globalComment: "first line\nsecond line" }));
expect(out).not.toContain("\n");
});
it("is prefixed so the agent recognises it, and names the round", () => {
expect(formatLensFeedback(outcome())).toContain("[Argent Lens]");
expect(formatLensFeedback(outcome({ round: 7 }))).toContain("round 7");
});
it("lists chosen variants with element, selector, variant, summary, source, and note", () => {
const out = formatLensFeedback(
outcome({
selections: [
{
element: "Login button",
match: { by: "text", value: "Login" },
chosenVariant: {
name: "Bold primary",
summary: "filled CTA",
filePath: "src/Login.tsx",
},
comment: "make it bigger",
},
],
})
);
expect(out).toContain('"Login button" [text=Login] → "Bold primary"');
expect(out).toContain("(filled CTA)");
expect(out).toContain("[src: src/Login.tsx]");
expect(out).toContain("make it bigger");
});
it("surfaces proposed-but-untouched elements as left-as-is (excluding chosen/noted)", () => {
const out = formatLensFeedback(
outcome({
selections: [
{ element: "CTA", match: { by: "text", value: "Go" }, chosenVariant: { name: "A" } },
],
unselected: [{ element: "Footer" }, { element: "CTA" }],
})
);
expect(out).toContain('Left as-is (reviewed, no change) — "Footer"');
// CTA was chosen, so it must not be reported as left-as-is.
expect(out).not.toMatch(/Left as-is[^.]*CTA/);
});
it("separates element notes (comment but no pick) from chosen variants, with selector", () => {
const out = formatLensFeedback(
outcome({
selections: [
{
element: "Header",
match: { by: "text", value: "Header" },
chosenVariant: null,
comment: "darker",
},
],
})
);
expect(out).toContain("Element notes");
expect(out).toContain('"Header" [text=Header]: darker');
expect(out).not.toContain("Chosen variants");
});
it("includes inspector annotations with their matcher", () => {
const out = formatLensFeedback(
outcome({
annotations: [
{
target: "Submit",
match: { by: "identifier", value: "submit-btn" },
comment: "align right",
},
],
})
);
expect(out).toContain('"Submit" [identifier=submit-btn]: align right');
});
it("includes the global comment", () => {
expect(formatLensFeedback(outcome({ globalComment: "tighten spacing" }))).toContain(
"Overall direction — tighten spacing"
);
});
it("falls back to a neutral message when nothing concrete was submitted", () => {
expect(formatLensFeedback(outcome())).toContain("No specific picks");
});
it("offers propose_variant only when there's open-ended direction, and ends the turn", () => {
const out = formatLensFeedback(outcome({ globalComment: "make the header feel bolder" }));
expect(out).toContain("propose_variant");
expect(out).toContain("end your turn");
// The await tool is hidden in a CLI session, so feedback never names it.
expect(out).not.toContain("await_user_selection");
});
it("does not push new variants when the feedback is a plain approval", () => {
const out = formatLensFeedback(
outcome({
selections: [
{ element: "CTA", match: { by: "text", value: "Go" }, chosenVariant: { name: "A" } },
],
globalComment: "",
})
);
// A pick with no attached direction is an approval — stop, don't re-propose.
expect(out).not.toContain("propose_variant");
expect(out).toContain("end your turn");
});
});
describe("buildSeedPrompt", () => {
it("tells the agent it's a CLI Lens session and not to block", () => {
const seed = buildSeedPrompt();
expect(seed).toContain("Argent Lens CLI session");
expect(seed).toContain("propose_variant");
expect(seed).toContain("end your turn");
// Don't name a tool the agent can't see in this session.
expect(seed).not.toContain("await_user_selection");
});
it("is a single line so it survives the spawn command unquoted", () => {
expect(buildSeedPrompt()).not.toContain("\n");
});
});