|
| 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