diff --git a/internal/gateway/session_route.go b/internal/gateway/session_route.go index 4044c96f..ffd05cb7 100644 --- a/internal/gateway/session_route.go +++ b/internal/gateway/session_route.go @@ -129,7 +129,7 @@ func (t *trace) sessionLatest(ctx context.Context, session string, after int64, for i := len(t.routes) - 1; i >= 0; i-- { if r := t.routes[i]; r.Session == session { c := *r - c.Order = append([]Weighed(nil), r.Order...) + c.Order = append([]Weighed{}, r.Order...) // [] for none: the GUI reads it as a list c.Left = append([]Weighed(nil), r.Left...) c.Tries = append([]Try{}, r.Tries...) seq, found = r.Seq, &c diff --git a/internal/gateway/trace.go b/internal/gateway/trace.go index 98debba0..9e9bdc04 100644 --- a/internal/gateway/trace.go +++ b/internal/gateway/trace.go @@ -395,7 +395,7 @@ func (t *trace) update(r *Route, f func(r *Route)) { } if keepRoutes { c := *r - c.Order = append([]Weighed(nil), r.Order...) + c.Order = append([]Weighed{}, r.Order...) // [] for none: the GUI reads it as a list c.Left = append([]Weighed(nil), r.Left...) c.Tries = append([]Try{}, r.Tries...) c.Usage = append([]RouteUsage(nil), r.Usage...) @@ -421,7 +421,7 @@ func (s *Server) Trace(ctx context.Context, after int64, wait time.Duration) Tra for _, r := range t.routes { if r.Seq > after { c := *r - c.Order = append([]Weighed(nil), r.Order...) + c.Order = append([]Weighed{}, r.Order...) // [] for none: the GUI reads it as a list c.Left = append([]Weighed(nil), r.Left...) c.Tries = append([]Try{}, r.Tries...) c.Usage = append([]RouteUsage(nil), r.Usage...) diff --git a/internal/gateway/trace_test.go b/internal/gateway/trace_test.go index 1ace6c55..e8d6b09c 100644 --- a/internal/gateway/trace_test.go +++ b/internal/gateway/trace_test.go @@ -2,6 +2,7 @@ package gateway import ( "context" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -113,3 +114,26 @@ func TestRetryAfterRests(t *testing.T) { t.Fatalf("rest %+v", r) } } + +// A route weighed with no seats tells its order as [], not null: the +// Routing page reads it as a list, and a null one left a refused request +// that couldn't be opened (Discord, mythfish on v0.1.810). +func TestTraceOrderIsAList(t *testing.T) { + s := New() + r := s.trace.begin(Route{Model: "workbuddy-ai/deepseek-v4.1-flash"}) + s.trace.update(r, func(r *Route) { + r.Tries = append(r.Tries, Try{ID: "workbuddy-ai", Status: 400, Error: "WorkBuddy AI: Invalid request parameters"}) + r.Status, r.Error, r.Done = 400, "WorkBuddy AI: Invalid request parameters", true + }) + st := s.Trace(context.Background(), 0, 0) + b, err := json.Marshal(st.Routes) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"order":[]`) || strings.Contains(string(b), `"order":null`) { + t.Fatalf("trace routes = %s, want order []", b) + } + if _, c := s.trace.sessionLatest(context.Background(), "", 0, 0); c == nil || c.Order == nil { + t.Fatalf("session's latest route = %+v, want a non-nil order", c) + } +} diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 44d1f346..df897e4d 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -777,6 +777,14 @@ // tried is the seat a try went to const tried = (r, tr) => r.order.find((x) => seat(x) === seat(tr)) || r.order.find((x) => x.id === tr.id); const setOf = (r) => r.order.map(seat).sort().join("\n"); + // whole fills in the lists a route's JSON can leave null: a route the + // gateway weighed no seats for has "order": null, and the page reads + // r.order, r.tries and r.left as lists everywhere — one null threw in + // pick, and clicking such a request (a refused one) opened nothing + const whole = (r) => { + if (r) { r.order ||= []; r.tries ||= []; r.left ||= []; } + return r; + }; // staged: the routes the stage shows — a picked one alone; else those // playing, and each agent's latest while it lingers, a few agents at most @@ -1168,7 +1176,7 @@ const res = await fetch("/api/gateway/route?id=" + encodeURIComponent(id) + "&day=" + encodeURIComponent(time.slice(0, 10))); if (res.status === 404) throw new Error(t("Routing history for this request is no longer available.")); if (!res.ok) throw new Error(await res.text()); - r = await res.json(); + r = whole(await res.json()); noteAccounts(r); } day = routes.has(id) ? "" : r.time.slice(0, 10); @@ -1277,7 +1285,7 @@ const res = await (await fetch("/api/gateway/history?day=" + encodeURIComponent(d || ""))).json(); days = res.days || []; noteAccounts(res.routes); - if (d && d === day) { past = res.routes || []; pastCut = !!res.cut; } + if (d && d === day) { past = (res.routes || []).map(whole); pastCut = !!res.cut; } } catch {} renderHist(); // shown once there are days, though none are live } @@ -2096,7 +2104,7 @@ const fresh = []; for (const r of d.routes) { if (!routes.has(r.id) && !first) fresh.push(r.id); - routes.set(r.id, r); + routes.set(r.id, whole(r)); } for (const id of [...routes.keys()].sort((a, b) => a - b).slice(0, -60)) routes.delete(id); loaded = true; diff --git a/internal/gui/tests/routing-failed-open.test.cjs b/internal/gui/tests/routing-failed-open.test.cjs new file mode 100644 index 00000000..e4c17013 --- /dev/null +++ b/internal/gui/tests/routing-failed-open.test.cjs @@ -0,0 +1,100 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A request the gateway weighed no seats for comes with "order": null (Go's +// nil slice), and clicking it in the Routing page's list did nothing: pick +// read r.order as a list and threw before the story was drawn (Discord, +// mythfish on v0.1.810: "workbuddy-ai/deepseek-v4.1-flash • 400 · 失败 6.5 秒", +// WorkBuddy AI: Invalid request parameters). Live and in a kept day, the +// failed request opens and its story gives the vendor's words. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const now = new Date(); +const day = [now.getFullYear(), now.getMonth() + 1, now.getDate()].map((n) => String(n).padStart(2, "0")).join("-"); +const at = (i) => new Date(now.getTime() - (i + 1) * 60e3).toISOString(); +const vendor = "WorkBuddy AI: Invalid request parameters"; +const key = { id: "workbuddy-ai", provider: "workbuddy-ai", name: "WorkBuddy AI", kind: "provider", model: "deepseek-v4.1-flash" }; +const base = (i) => ({ id: 100 - i, seq: 100 - i, time: at(i), agent: "codex", model: "workbuddy-ai/deepseek-v4.1-flash", provider: "workbuddy-ai", done: true }); +// newest first: one that answered, then the 400 with no order, as the report's +const routes = [ + { ...base(0), order: [key], tries: [{ id: key.id, model: key.model, start: at(0), done: true, status: 200, ms: 900 }], status: 200, ms: 900 }, + { ...base(1), order: null, tries: [{ id: key.id, model: key.model, start: at(1), done: true, status: 400, ms: 6500, error: vendor }], status: 400, ms: 6500, error: vendor }, +]; + +function serve(lang, live) { + const state = { agents: [{ id: "codex", name: "Codex", path: "/test/config.toml", fields: [] }], profiles: [], settings: { lang, theme: "light" } }; + return async (route) => { + const url = new URL(route.request().url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname === "/api/gateway/trace") { + if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3)); + return json({ mine: true, now: now.toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: live ? structuredClone(routes) : [] }); + } + if (url.pathname === "/api/gateway/history") { + const d = url.searchParams.get("day"); + return json({ cut: false, days: live ? [] : [{ day, requests: routes.length }], routes: d && !live ? structuredClone(routes) : [] }); + } + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/providers") return json({ providers: [], excluded: [], presets: [], gateway: { running: true, window: true } }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +const want = { + en: { head: "How the request at", said: "It said: " + vendor }, + zh: { head: "的请求是怎么路由的", said: "原话:" + vendor }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + for (const live of [true, false]) { + test(`${engine} ${lang} ${live ? "live" : "kept day"}: a failed request with no order opens`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const context = await browser.newContext({ viewport: { width: 1100, height: 760 }, reducedMotion: "reduce" }); + const page = await context.newPage(); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, live)); + t.after(async () => { + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-${live ? "live" : "day"}-failed-open.png`), fullPage: true }); + } + await browser.close(); + }); + await page.goto("http://magpie.test/?view=routing"); + if (!live) await page.locator(".rt-day").nth(1).click(); + await page.locator(".rt-req").nth(routes.length - 1).waitFor(); + + const row = page.locator(".rt-req").nth(1); + assert.match(await row.textContent(), /400/); + const was = await row.evaluate((e) => e.getBoundingClientRect().top); + await row.click(); + await page.waitForTimeout(400); + assert(Math.abs((await row.evaluate((e) => e.getBoundingClientRect().top)) - was) <= 1, "picking the request moved the page"); + assert.equal(await row.getAttribute("aria-pressed"), "true"); + assert(!(await page.locator(".rt-log").evaluate((e) => e.hidden)), "the story is hidden"); + assert((await page.locator(".rt-log-head").textContent()).includes(want[lang].head)); + const got = await page.locator(".rt-steps li").evaluateAll((ls) => ls.map((l) => [l.className, l.textContent])); + assert.deepEqual(got.filter(([c]) => c === "aside said").map(([, s]) => s), [want[lang].said], JSON.stringify(got)); + + // and back to the one that answered + await page.locator(".rt-req").nth(0).click(); + await page.waitForTimeout(300); + assert.equal(await page.locator(".rt-req").nth(0).getAttribute("aria-pressed"), "true"); + assert.deepEqual(errors, []); + }); + } + } +}