Skip to content

Commit 5c65e5f

Browse files
committed
chore(scripts): add headless dashboard-agent asker
Drives a real magic-link login and the dashboard-agent resource route over HTTP, polls until the turn settles, and prints the transcript. Lets UAT scenarios be exercised without a browser.
1 parent ca97486 commit 5c65e5f

1 file changed

Lines changed: 375 additions & 0 deletions

File tree

scripts/ask-dashboard-agent.ts

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
#!/usr/bin/env tsx
2+
3+
/**
4+
* Asks the in-dashboard agent a question, headlessly, and prints the finished transcript.
5+
* Companion script for UAT scenarios that need to drive the agent without a browser.
6+
*
7+
* AUTH: drives the real local magic-link login over HTTP instead of minting a session
8+
* cookie by hand. In development `sendMagicLinkEmail` (apps/webapp/app/services/email.server.ts)
9+
* throws a redirect straight to the magic link instead of sending an email - the same
10+
* shortcut the chrome-devtools login flow documented in apps/webapp/CLAUDE.md relies on. The
11+
* strategy's magic-link token is self-contained (email + issue time, AES-encrypted with
12+
* MAGIC_LINK_SECRET - see remix-auth-email-link's `validateMagicLink`) and, since this repo
13+
* never sets `validateSessionMagicLink`, verifying it does not require the session cookie
14+
* that carried it in a browser. So the two POST/GET calls below don't need any secret this
15+
* script would otherwise have to read out of the webapp's env - just the two HTTP hops a
16+
* browser makes, which is more robust than replicating `sessionStorage.server.ts`'s cookie
17+
* signing here.
18+
*
19+
* ASK: replicates the calls `DashboardAgentPanel`/`DashboardAgentChat` make against
20+
* `resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts`:
21+
* `intent=create` (or `intent=start` to resume a chat with `--chat`) starts the turn. Locally
22+
* ANTHROPIC_API_KEY is set, so `create` head-starts the run server-side and dispatches the
23+
* first message itself - no need to also drive the `.in` AI-SDK proxy the browser's streaming
24+
* transport uses. Settlement is read back exactly the way `settled-transcript.ts` decides a
25+
* turn is still open: `transcriptLooksUnfinished` (an in-flight `tool-*` part on the last
26+
* assistant message, or an investigation block whose outcome is still `in_progress`).
27+
*
28+
* USAGE:
29+
* pnpm exec tsx scripts/ask-dashboard-agent.ts \
30+
* --org references-0eb0 --project hello-world-jpz1 --env dev \
31+
* --message "What failed in the last hour?" \
32+
* [--user katia+test@trigger.dev] [--chat chat_xxx] [--base-url http://localhost:3030] \
33+
* [--timeout 120]
34+
*
35+
* FLAGS:
36+
* --org, --project, --env slugs, same as the dashboard URL
37+
* --message the question to ask
38+
* --user who's asking (default: katia+test@trigger.dev)
39+
* --chat resume an existing chat instead of starting a new one
40+
* --base-url webapp origin (default: http://localhost:3030)
41+
* --timeout seconds to wait for the turn to settle (default: 120)
42+
*
43+
* The dashboard agent must be enabled for the org (`hasDashboardAgentAccess` feature flag,
44+
* or `DASHBOARD_AGENT_ADMIN_PREVIEW=1` with an admin user) or `create`/`start` 501 with
45+
* "The dashboard agent is not configured."
46+
*/
47+
48+
type Part = { type?: string; state?: string; text?: string; output?: unknown };
49+
type UIMessage = { id: string; role: string; parts?: Part[] };
50+
51+
type Args = {
52+
org: string;
53+
project: string;
54+
env: string;
55+
message: string;
56+
user: string;
57+
chat?: string;
58+
baseUrl: string;
59+
timeoutSeconds: number;
60+
};
61+
62+
function parseArgs(argv: string[]): Args {
63+
const get = (flag: string) => {
64+
const index = argv.indexOf(flag);
65+
return index === -1 ? undefined : argv[index + 1];
66+
};
67+
68+
const org = get("--org");
69+
const project = get("--project");
70+
const env = get("--env");
71+
const message = get("--message");
72+
if (!org || !project || !env || !message) {
73+
console.error(
74+
"Usage: pnpm exec tsx scripts/ask-dashboard-agent.ts --org <slug> --project <slug> --env <slug> --message <text> [--user <email>] [--chat <id>] [--base-url <url>] [--timeout <seconds>]"
75+
);
76+
process.exit(1);
77+
}
78+
79+
return {
80+
org,
81+
project,
82+
env,
83+
message,
84+
user: get("--user") ?? "katia+test@trigger.dev",
85+
chat: get("--chat"),
86+
baseUrl: get("--base-url") ?? "http://localhost:3030",
87+
timeoutSeconds: Number(get("--timeout") ?? "120"),
88+
};
89+
}
90+
91+
// ---------------------------------------------------------------------------
92+
// Cookie jar - just enough to carry the session cookie across the login hops
93+
// and every subsequent call. `fetch`'s automatic cookie handling only spans a
94+
// single call, so requests here are all `redirect: "manual"` and forwarded by hand.
95+
// ---------------------------------------------------------------------------
96+
97+
class CookieJar {
98+
private cookies = new Map<string, string>();
99+
100+
absorb(res: Response) {
101+
for (const raw of res.headers.getSetCookie?.() ?? []) {
102+
const [pair] = raw.split(";");
103+
const eq = pair.indexOf("=");
104+
if (eq === -1) continue;
105+
this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
106+
}
107+
}
108+
109+
header(): string {
110+
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
111+
}
112+
}
113+
114+
async function loginViaMagicLink(baseUrl: string, email: string): Promise<CookieJar> {
115+
const jar = new CookieJar();
116+
117+
// Step 1: request the link. Dev mode short-circuits email delivery into a 302
118+
// whose Location is the magic link itself.
119+
const sendBody = new URLSearchParams({ action: "send", email });
120+
const sendRes = await fetch(`${baseUrl}/login/magic`, {
121+
method: "POST",
122+
body: sendBody,
123+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
124+
redirect: "manual",
125+
});
126+
jar.absorb(sendRes);
127+
const magicLink = sendRes.headers.get("location");
128+
if (sendRes.status !== 302 || !magicLink) {
129+
throw new Error(
130+
`Magic link request didn't redirect (status ${sendRes.status}). Is NODE_ENV=development on the webapp?`
131+
);
132+
}
133+
134+
// Step 2: "click" the link. The callback verifies the token, sets the authenticated
135+
// session cookie, and redirects home.
136+
const magicRes = await fetch(magicLink, {
137+
redirect: "manual",
138+
headers: { Cookie: jar.header() },
139+
});
140+
jar.absorb(magicRes);
141+
if (magicRes.status !== 302) {
142+
throw new Error(`Magic link verify didn't redirect (status ${magicRes.status}).`);
143+
}
144+
if (!jar.header()) {
145+
throw new Error("Magic link verify produced no session cookie.");
146+
}
147+
148+
return jar;
149+
}
150+
151+
// ---------------------------------------------------------------------------
152+
// Dashboard-agent resource route calls
153+
// ---------------------------------------------------------------------------
154+
155+
function actionPath(baseUrl: string, org: string, project: string, env: string): string {
156+
return `${baseUrl}/resources/orgs/${org}/projects/${project}/env/${env}/dashboard-agent`;
157+
}
158+
159+
async function postForm(
160+
url: string,
161+
jar: CookieJar,
162+
fields: Record<string, string>
163+
): Promise<{ status: number; body: any }> {
164+
const body = new URLSearchParams(fields);
165+
const res = await fetch(url, {
166+
method: "POST",
167+
body,
168+
headers: { "Content-Type": "application/x-www-form-urlencoded", Cookie: jar.header() },
169+
});
170+
jar.absorb(res);
171+
const body_ = await res.json().catch(() => ({}));
172+
return { status: res.status, body: body_ };
173+
}
174+
175+
async function getJson(url: string, jar: CookieJar): Promise<any> {
176+
const res = await fetch(url, { headers: { Cookie: jar.header() } });
177+
jar.absorb(res);
178+
return res.json().catch(() => ({}));
179+
}
180+
181+
/** Same criteria `settled-transcript.ts` uses client-side, after its stream closes. */
182+
function transcriptLooksUnfinished(messages: UIMessage[]): boolean {
183+
// An investigation block (from a `tool-render_view` output) whose latest revision is
184+
// still `in_progress`.
185+
const latest = new Map<string, { revision: number; outcome?: string }>();
186+
for (const message of messages) {
187+
for (const part of message.parts ?? []) {
188+
if (part.type !== "tool-render_view") continue;
189+
const blocks = (part.output as { blocks?: unknown[] } | undefined)?.blocks;
190+
if (!Array.isArray(blocks)) continue;
191+
for (const block of blocks as Array<{
192+
type?: string;
193+
id?: string;
194+
revision?: number;
195+
investigation?: { outcome?: string };
196+
}>) {
197+
if (block?.type !== "investigation" || typeof block.id !== "string") continue;
198+
const revision = typeof block.revision === "number" ? block.revision : 0;
199+
const current = latest.get(block.id);
200+
if (!current || revision >= current.revision) {
201+
latest.set(block.id, { revision, outcome: block.investigation?.outcome });
202+
}
203+
}
204+
}
205+
}
206+
if ([...latest.values()].some((block) => block.outcome === "in_progress")) return true;
207+
208+
// An in-flight tool part on the last message, if it's an assistant turn.
209+
const last = messages[messages.length - 1];
210+
if (last?.role !== "assistant") return false;
211+
const inFlightStates = new Set(["input-streaming", "input-available"]);
212+
return (last.parts ?? []).some(
213+
(part) =>
214+
typeof part.type === "string" &&
215+
part.type.startsWith("tool-") &&
216+
inFlightStates.has(part.state ?? "")
217+
);
218+
}
219+
220+
function toolCallsInOrder(messages: UIMessage[]): string[] {
221+
const names: string[] = [];
222+
for (const message of messages) {
223+
for (const part of message.parts ?? []) {
224+
if (typeof part.type === "string" && part.type.startsWith("tool-")) {
225+
names.push(part.type.slice("tool-".length));
226+
}
227+
}
228+
}
229+
return names;
230+
}
231+
232+
function investigationCards(
233+
messages: UIMessage[]
234+
): Array<{ id: string; revision: number; outcome?: string; severity?: string }> {
235+
const latest = new Map<
236+
string,
237+
{ id: string; revision: number; outcome?: string; severity?: string }
238+
>();
239+
for (const message of messages) {
240+
for (const part of message.parts ?? []) {
241+
if (part.type !== "tool-render_view") continue;
242+
const blocks = (part.output as { blocks?: unknown[] } | undefined)?.blocks;
243+
if (!Array.isArray(blocks)) continue;
244+
for (const block of blocks as Array<{
245+
type?: string;
246+
id?: string;
247+
revision?: number;
248+
investigation?: { outcome?: string; severity?: string };
249+
}>) {
250+
if (block?.type !== "investigation" || typeof block.id !== "string") continue;
251+
const revision = typeof block.revision === "number" ? block.revision : 0;
252+
const current = latest.get(block.id);
253+
if (!current || revision >= current.revision) {
254+
latest.set(block.id, {
255+
id: block.id,
256+
revision,
257+
outcome: block.investigation?.outcome,
258+
severity: block.investigation?.severity,
259+
});
260+
}
261+
}
262+
}
263+
}
264+
return [...latest.values()];
265+
}
266+
267+
function finalAssistantText(messages: UIMessage[]): string {
268+
for (let i = messages.length - 1; i >= 0; i--) {
269+
const message = messages[i];
270+
if (message.role !== "assistant") continue;
271+
return (message.parts ?? [])
272+
.filter((part) => part.type === "text" && typeof part.text === "string")
273+
.map((part) => part.text)
274+
.join("");
275+
}
276+
return "";
277+
}
278+
279+
async function main() {
280+
const args = parseArgs(process.argv.slice(2));
281+
const start = Date.now();
282+
283+
console.log(`Logging in as ${args.user}...`);
284+
const jar = await loginViaMagicLink(args.baseUrl, args.user);
285+
286+
const path = actionPath(args.baseUrl, args.org, args.project, args.env);
287+
let chatId = args.chat;
288+
289+
if (!chatId) {
290+
console.log("Creating chat...");
291+
const firstMessage: UIMessage = {
292+
id: `msg_${Math.random().toString(36).slice(2)}`,
293+
role: "user",
294+
parts: [{ type: "text", text: args.message }],
295+
};
296+
const { status, body } = await postForm(path, jar, {
297+
intent: "create",
298+
message: JSON.stringify(firstMessage),
299+
});
300+
if (status !== 200 || !body.chatId) {
301+
console.error(`create failed (status ${status}):`, body);
302+
process.exit(1);
303+
}
304+
chatId = body.chatId;
305+
console.log(`Chat ${chatId} started (headStarted=${body.headStarted})`);
306+
} else {
307+
console.log(`Resuming chat ${chatId}...`);
308+
// `start` only resumes an existing session; sending a follow-up message on an
309+
// already-running chat isn't exposed by this route without the `.in` AI-SDK proxy the
310+
// browser's streaming transport uses, so `--chat` is for polling a chat already in flight.
311+
const { status, body } = await postForm(path, jar, { intent: "start", chatId });
312+
if (status !== 200) {
313+
console.error(`start failed (status ${status}):`, body);
314+
process.exit(1);
315+
}
316+
}
317+
318+
console.log(`Waiting for the turn to settle (up to ${args.timeoutSeconds}s)...`);
319+
const deadline = Date.now() + args.timeoutSeconds * 1000;
320+
let messages: UIMessage[] = [];
321+
let settled = false;
322+
while (Date.now() < deadline) {
323+
const data = await getJson(`${path}?chatId=${encodeURIComponent(chatId)}`, jar);
324+
if (Array.isArray(data.messages)) {
325+
messages = data.messages;
326+
if (messages.length > 0 && !transcriptLooksUnfinished(messages)) {
327+
settled = true;
328+
break;
329+
}
330+
}
331+
await new Promise((resolve) => setTimeout(resolve, 1500));
332+
}
333+
334+
const elapsedMs = Date.now() - start;
335+
const quota = await getJson(`${path}?quota=1`, jar);
336+
337+
console.log("\n=== Transcript ===");
338+
for (const message of messages) {
339+
console.log(`[${message.role}] ${message.id}`);
340+
}
341+
342+
console.log("\n=== Tool calls (in order) ===");
343+
console.log(toolCallsInOrder(messages).join(", ") || "(none)");
344+
345+
const cards = investigationCards(messages);
346+
if (cards.length > 0) {
347+
console.log("\n=== Investigation cards ===");
348+
for (const card of cards) {
349+
console.log(
350+
`${card.id} rev=${card.revision} outcome=${card.outcome} severity=${card.severity}`
351+
);
352+
}
353+
}
354+
355+
console.log("\n=== Final assistant message ===");
356+
console.log(finalAssistantText(messages) || "(no text)");
357+
358+
console.log(`\n=== Timing ===`);
359+
console.log(`chatId=${chatId} elapsed=${elapsedMs}ms settled=${settled}`);
360+
if (typeof quota.used === "number") {
361+
console.log(
362+
`quota used=${quota.used}${quota.limit != null ? ` limit=${quota.limit}` : " (unlimited)"}`
363+
);
364+
}
365+
366+
if (!settled) {
367+
console.error(`\nTimed out after ${args.timeoutSeconds}s waiting for the turn to settle.`);
368+
process.exit(1);
369+
}
370+
}
371+
372+
main().catch((error) => {
373+
console.error("Fatal error:", error);
374+
process.exit(1);
375+
});

0 commit comments

Comments
 (0)