From d6096e2fb1710ba123c0d4f59ed8e7e22165de07 Mon Sep 17 00:00:00 2001 From: yetone Date: Sun, 4 Oct 2026 12:22:45 +0800 Subject: [PATCH] =?UTF-8?q?routing:=20a=20failed=20request=20whose=20route?= =?UTF-8?q?=20has=20no=20order=20opens=20when=20clicked=20(Discord,=20myth?= =?UTF-8?q?fish=20on=20v0.1.810:=20on=20the=20Routing=20page's=20=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=20list,=20clicking=20"11:28:08=20AM=20=E2=80=A2=20wor?= =?UTF-8?q?kbuddy-ai/deepseek-v4.1-flash=20=E2=80=A2=20400=20=C2=B7=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=206.5=20=E7=A7=92"=20did=20nothing;=20the=20?= =?UTF-8?q?request=20was=20refused=20with=20WorkBuddy=20AI=20400=20{"messa?= =?UTF-8?q?ge":"WorkBuddy=20AI:=20Invalid=20request=20parameters","type":"?= =?UTF-8?q?invalid=5Frequest=5Ferror"})=20=E2=80=94=20Route.Order=20has=20?= =?UTF-8?q?no=20omitempty=20and=20the=20trace's=20snapshots=20(Trace,=20th?= =?UTF-8?q?e=20saved=20copy=20in=20update,=20sessionLatest)=20copied=20it?= =?UTF-8?q?=20with=20append([]Weighed(nil),=20r.Order...),=20so=20a=20rout?= =?UTF-8?q?e=20begun=20with=20no=20seats=20weighed=20reached=20the=20page?= =?UTF-8?q?=20as=20"order":=20null,=20while=20its=20tries=20were=20there.?= =?UTF-8?q?=20routing.js=20reads=20r.order=20as=20a=20list=20throughout:?= =?UTF-8?q?=20a=20row's=20click=20runs=20pick=20=E2=86=92=20sync=20?= =?UTF-8?q?=E2=86=92=20rebuild,=20whose=20setOf(r)=20is=20r.order.map(seat?= =?UTF-8?q?),=20and=20renderActs=20runs=20r.order.forEach,=20so=20pick=20t?= =?UTF-8?q?hrew=20(webkit:=20"null=20is=20not=20an=20object=20(evaluating?= =?UTF-8?q?=20'r.order.map')")=20before=20the=20story=20was=20drawn=20and?= =?UTF-8?q?=20the=20click=20looked=20dead.=20Reproduced=20with=20a=20live?= =?UTF-8?q?=20trace=20fixture:=20a=20400=20with=20order=20null=20fails=20t?= =?UTF-8?q?o=20open;=20the=20same=20400=20with=20order=20[]=20or=20a=20one?= =?UTF-8?q?-seat=20order,=20every=20failure=20kind,=20and=20an=20order=20w?= =?UTF-8?q?hose=20seats=20don't=20match=20the=20try=20all=20open,=20so=20i?= =?UTF-8?q?t=20is=20the=20null=20and=20not=20the=20400=20or=20the=20WorkBu?= =?UTF-8?q?ddy=20error.=20The=20page=20now=20fills=20in=20a=20route's=20li?= =?UTF-8?q?sts=20where=20routes=20come=20in=20(whole:=20order,=20tries=20a?= =?UTF-8?q?nd=20left=20default=20to=20[]),=20for=20the=20live=20trace,=20a?= =?UTF-8?q?=20kept=20day's=20history=20and=20openRoute's=20fetched=20route?= =?UTF-8?q?=20(the=20tray=20panel's=20and=20Sessions'=20links),=20and=20th?= =?UTF-8?q?e=20gateway=20writes=20Order=20as=20[]=20when=20it=20is=20empty?= =?UTF-8?q?=20in=20all=20three=20snapshots,=20so=20an=20older=20page=20rea?= =?UTF-8?q?ding=20a=20newer=20gateway=20is=20fine=20too.=20Tests:=20routin?= =?UTF-8?q?g-failed-open.test.cjs=20(webkit,=20en=20and=20zh,=20live=20and?= =?UTF-8?q?=20a=20kept=20day:=20a=20request=20that=20answered=20and=20the?= =?UTF-8?q?=20report's=20400=20with=20"order":=20null;=20clicking=20the=20?= =?UTF-8?q?400=20presses=20its=20row,=20shows=20the=20story=20"How=20the?= =?UTF-8?q?=20request=20at=20=E2=80=A6=20was=20routed"=20/=20"=E2=80=A6=20?= =?UTF-8?q?=E7=9A=84=E8=AF=B7=E6=B1=82=E6=98=AF=E6=80=8E=E4=B9=88=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E7=9A=84"=20with=20"It=20said:=20WorkBuddy=20AI:=20In?= =?UTF-8?q?valid=20request=20parameters"=20/=20"=E5=8E=9F=E8=AF=9D?= =?UTF-8?q?=EF=BC=9A=E2=80=A6",=20doesn't=20move=20the=20page,=20and=20the?= =?UTF-8?q?=20answered=20one=20opens=20again=20after;=20no=20page=20errors?= =?UTF-8?q?)=20fails=20all=20four=20on=20the=20old=20routing.js=20and=20pa?= =?UTF-8?q?sses;=20TestTraceOrderIsAList=20(a=20route=20begun=20with=20no?= =?UTF-8?q?=20order=20and=20refused=20with=20a=20400:=20Trace's=20JSON=20h?= =?UTF-8?q?as=20"order":[],=20sessionLatest's=20copy=20a=20non-nil=20Order?= =?UTF-8?q?)=20fails=20on=20the=20old=20trace.go=20with=20"order":null=20a?= =?UTF-8?q?nd=20passes.=20The=20routing-*.test.cjs=20and=20click-scroll=20?= =?UTF-8?q?GUI=20tests=20pass=20in=20webkit=20(routing-time's=20zh=20case?= =?UTF-8?q?=20failed=20once=20in=20a=204-way=20parallel=20run=20and=20pass?= =?UTF-8?q?es=20on=20its=20own).=20go=20vet,=20builds=20(darwin,=20GOOS=3D?= =?UTF-8?q?linux,=20GOOS=3Dwindows)=20and=20go=20test=20-tags=20nogui=20-c?= =?UTF-8?q?ount=3D1=20./...=20under=20a=20temp=20HOME=20pass.=20Not=20foun?= =?UTF-8?q?d:=20which=20gateway=20path=20began=20the=20reporter's=20route?= =?UTF-8?q?=20with=20no=20order=20=E2=80=94=20the=20plain=20chat=20path=20?= =?UTF-8?q?weighs=20at=20least=20the=20provider=20it=20was=20asked=20for?= =?UTF-8?q?=20=E2=80=94=20so=20the=20fix=20is=20to=20the=20shape,=20not=20?= =?UTF-8?q?to=20that=20path;=20not=20tried=20with=20the=20reporter's=20Wor?= =?UTF-8?q?kBuddy=20AI=20account.=20The=20400=20itself=20(WorkBuddy=20refu?= =?UTF-8?q?sing=20the=20request's=20parameters)=20is=20a=20separate=20ques?= =?UTF-8?q?tion=20this=20doesn't=20touch.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/session_route.go | 2 +- internal/gateway/trace.go | 4 +- internal/gateway/trace_test.go | 24 +++++ internal/gui/assets/routing.js | 14 ++- .../gui/tests/routing-failed-open.test.cjs | 100 ++++++++++++++++++ 5 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 internal/gui/tests/routing-failed-open.test.cjs 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, []); + }); + } + } +}