From 3a72b74371856527ceb36a1760f8674fa50111a7 Mon Sep 17 00:00:00 2001 From: canvas-bot Date: Mon, 15 Jun 2026 19:27:37 +0400 Subject: [PATCH] fix(chat): tolerate 500 on empty inbox edges; fix(agent): clear LLM-unavailable msg - chatApi.listThreads: catch on the edges query so a fresh empty inbox doesn't bubble a 500 to the UI. New users see 'No conversations yet' instead of the error banner. - agentTools.llmFallback: when the LLM gateway returns 503/500, show a clear 'plain-language replies temporarily unavailable' message naming the reason, listing the deterministic capabilities that still work. No more silent null returns. --- qa/_studio_e2e.mjs | 77 +++++++++++++++++++++++++++++++++++++++++++ src/lib/agentTools.ts | 13 +++++++- src/lib/chatApi.ts | 8 ++++- 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 qa/_studio_e2e.mjs diff --git a/qa/_studio_e2e.mjs b/qa/_studio_e2e.mjs new file mode 100644 index 0000000..76678d6 --- /dev/null +++ b/qa/_studio_e2e.mjs @@ -0,0 +1,77 @@ +// End-to-end Studio flow against LIVE. +import { chromium } from "playwright"; + +const URL = "https://canvas.flow-master.ai/"; +const calls = []; +const consoleErrs = []; + +const b = await chromium.launch({ headless: true }); +const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } }); +const p = await ctx.newPage(); +p.on("response", (r) => { + const u = r.url(); + if (u.includes("/api/")) calls.push({ status: r.status(), method: r.request().method(), url: u.replace("https://canvas.flow-master.ai", "") }); +}); +p.on("console", (m) => { if (m.type() === "error") consoleErrs.push(m.text().slice(0, 200)); }); +await ctx.addInitScript(() => localStorage.setItem("fm.canvas.tour.seen", String(Date.now()))); + +const log = (s) => console.log(`[${(Date.now() - t0) / 1000}s] ${s}`); +const t0 = Date.now(); + +log("login"); +await p.goto(URL, { waitUntil: "domcontentloaded" }); +await p.waitForTimeout(2500); +await p.locator(".persona-chip").filter({ hasText: /Mariana/ }).click(); +await p.waitForSelector(".topbar", { timeout: 30000 }); +log("logged in, on " + (await p.evaluate(() => document.querySelector("[class*='shell-']")?.className))); + +log("nav to studio"); +const studioTab = p.locator(".tab").filter({ hasText: /Studio/i }).first(); +await studioTab.click({ timeout: 10000 }); +await p.waitForTimeout(2500); +log("studio scene: " + (await p.evaluate(() => document.querySelector(".scene")?.innerText?.slice(0, 150)))); + +log("fill intake"); +const desc = p.locator("textarea").first(); +await desc.fill("E2E test: when a store manager requests a laptop, run procurement approval."); +await p.waitForTimeout(500); + +log("click Start drafting"); +await p.locator("button.btn-primary").filter({ hasText: /draft/i }).first().click({ timeout: 8000 }); +await p.waitForTimeout(7000); + +log("step now: " + (await p.evaluate(() => { + const t = document.querySelector(".scene")?.innerText || ""; + return t.split("\n").slice(0, 3).join(" | "); +}))); + +log("click Confirm structure"); +const confirmBtn = p.locator("button.btn-primary").filter({ hasText: /Confirm structure/i }).first(); +if (await confirmBtn.count()) { + await confirmBtn.click({ timeout: 8000 }); + log("waiting for toast or step change…"); + await p.waitForTimeout(10000); + const toasts = await p.locator(".toaster, .toast, .toast-msg").allTextContents(); + log("toasts: " + JSON.stringify(toasts.slice(-3))); + const step = await p.evaluate(() => { + const t = document.querySelector(".scene")?.innerText || ""; + return t.split("\n").slice(0, 3).join(" | "); + }); + log("now: " + step); +} + +await p.screenshot({ path: "/tmp/canvas-evidence/studio-e2e.png" }); + +await b.close(); +console.log("\n=== Network ==="); +const byStatus = {}; +for (const c of calls) byStatus[c.status] = (byStatus[c.status] || 0) + 1; +console.log("by status:", JSON.stringify(byStatus)); +const fails = calls.filter(c => c.status >= 400); +console.log("\nFails:"); +for (const f of fails) console.log(` ${f.status} ${f.method} ${f.url.slice(0, 90)}`); +const applyBatchCalls = calls.filter(c => c.url.includes("apply-batch")); +console.log("\napply-batch calls:"); +for (const c of applyBatchCalls) console.log(` ${c.status} ${c.method} ${c.url}`); +console.log("\nConsole errors:"); +for (const e of consoleErrs.slice(-10)) console.log(" " + e); diff --git a/src/lib/agentTools.ts b/src/lib/agentTools.ts index e2b9e1d..200a169 100644 --- a/src/lib/agentTools.ts +++ b/src/lib/agentTools.ts @@ -194,7 +194,18 @@ async function llmFallback(trimmed: string, ctx: ToolContext, hints: Memory[]): const user: LlmMessage = { role: "user", content: trimmed }; const reply = await llmClient.chat({ messages: [system, user], max_tokens: 320 }); if (reply.ok) return { ok: true, display: reply.content }; - return null; + if (!reply.configured) { + return { + ok: false, + display: + `Plain-language replies are temporarily unavailable (${reply.reason}). ` + + `I can still: navigate, list processes, start a process, message a teammate, remember / recall notes.`, + }; + } + return { + ok: false, + display: `LLM provider error: ${reply.error}. I can still navigate, list/start processes, message a teammate, or remember notes.`, + }; } export async function routeAgentInput(text: string, ctx: ToolContext): Promise { diff --git a/src/lib/chatApi.ts b/src/lib/chatApi.ts index 8fabe94..ab9a656 100644 --- a/src/lib/chatApi.ts +++ b/src/lib/chatApi.ts @@ -111,7 +111,13 @@ export const chatApi = { if (!inbox) { throw new Error("Chat inbox setup failed (EA2 backend not reachable). Try refreshing."); } - const edges = await jsonFetch(`/api/ea2/edges/defines?from=flow/${inboxKey}&limit=200`, { signal }); + let edges: { items?: unknown[] } = { items: [] }; + try { + edges = await jsonFetch(`/api/ea2/edges/defines?from=flow/${inboxKey}&limit=200`, { signal }); + } catch { + // No edges yet for a fresh inbox; treat as empty thread list. + edges = { items: [] }; + } const threadKeys = ((edges?.items || []) as any[]) .filter((e) => e?.role === "presentation") .map((e) => (e._to || "").split("/").pop())