routing: a failed request whose route has no order opens when clicked (Discord, mythfish on v0.1.810: on the Routing page's 实时 list, clicking "11:28:08 AM • workbuddy-ai/deepseek-v4.1-flash • 400 · 失败 6.5 秒" did nothing; the request was refused with WorkBuddy AI 400 {"message":"WorkBuddy AI: Invalid request parameters","type":"invalid_request_error"}) — Route.Order has no omitempty and the trace's snapshots (Trace, the saved copy in update, sessionLatest) copied it with append([]Weighed(nil), r.Order...), so a route begun with no seats weighed reached the page as "order": null, while its tries were there. routing.js reads r.order as a list throughout: a row's click runs pick → sync → rebuild, whose setOf(r) is r.order.map(seat), and renderActs runs r.order.forEach, so pick threw (webkit: "null is not an object (evaluating 'r.order.map')") before the story was drawn and the click looked dead. Reproduced with a live trace fixture: a 400 with order null fails to open; the same 400 with order [] or a one-seat order, every failure kind, and an order whose seats don't match the try all open, so it is the null and not the 400 or the WorkBuddy error. The page now fills in a route's lists where routes come in (whole: order, tries and left default to []), for the live trace, a kept day's history and openRoute's fetched route (the tray panel's and Sessions' links), and the gateway writes Order as [] when it is empty in all three snapshots, so an older page reading a newer gateway is fine too. Tests: routing-failed-open.test.cjs (webkit, en and zh, live and a kept day: a request that answered and the report's 400 with "order": null; clicking the 400 presses its row, shows the story "How the request at … was routed" / "… 的请求是怎么路由的" with "It said: WorkBuddy AI: Invalid request parameters" / "原话:…", doesn't move the page, and the answered one opens again after; no page errors) fails all four on the old routing.js and passes; TestTraceOrderIsAList (a route begun with no order and refused with a 400: Trace's JSON has "order":[], sessionLatest's copy a non-nil Order) fails on the old trace.go with "order":null and passes. The routing-*.test.cjs and click-scroll GUI tests pass in webkit (routing-time's zh case failed once in a 4-way parallel run and passes on its own). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not found: which gateway path began the reporter's route with no order — the plain chat path weighs at least the provider it was asked for — so the fix is to the shape, not to that path; not tried with the reporter's WorkBuddy AI account. The 400 itself (WorkBuddy refusing the request's parameters) is a separate question this doesn't touch.

This commit is contained in:
yetone committed 2026-10-04 12:23:43 +08:00
1 parent 8c74f935fd
commit d6096e2fb1
5 files changed
+138 -6

No files matched your search

+1 -1
View File
@@ -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
+2 -2
View File
@@ -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...)
+24
View File
@@ -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)
}
}
+11 -3
View File
@@ -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;
@@ -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, []);
});
}
}
}