diff --git a/internal/gateway/codex_accounts_test.go b/internal/gateway/codex_accounts_test.go index 1b2746ea..13e1e1ce 100644 --- a/internal/gateway/codex_accounts_test.go +++ b/internal/gateway/codex_accounts_test.go @@ -148,3 +148,104 @@ func TestResetsIn(t *testing.T) { } } } + +// A turn served by the pool of Codex's accounts goes on with what Codex +// says about it — a guardian review, the turn's metadata, Luna Reserve — +// as it would to Codex's own sign-in, signed by the pool's account and +// never by the client's; the Routing view and the usage log say it was a +// guardian review (碳碳双键: 为何 codex 桌面选的 5.6sol,但是请求记录…显示 +// 的全是 gpt-6-luna). +func TestCodexPoolForwardsCodexHeaders(t *testing.T) { + codexSignedIn(t, "spare@example.com") + var heads []http.Header + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.ReadAll(r.Body) + heads = append(heads, r.Header.Clone()) + if r.Header.Get("chatgpt-account-id") == "acct-1" { + w.WriteHeader(429) + io.WriteString(w, `{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","resets_in_seconds":7200}}`) + return + } + io.WriteString(w, sse( + `data: {"type":"response.created","response":{"id":"r1","model":"gpt-5.5"}}`, + `data: {"type":"response.output_text.delta","delta":"pong"}`, + `data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":7,"output_tokens":1}}}`)) + })) + t.Cleanup(up.Close) + was := provider.CodexBase + provider.CodexBase = up.URL + "/backend-api/codex" + t.Cleanup(func() { provider.CodexBase = was }) + + s := New() + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", CodexPath+"/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"ping"}`)) + req.Header.Set("Authorization", "Bearer chatgpt-token") + req.Header.Set("chatgpt-account-id", "acct-1") + req.Header.Set("User-Agent", "codex_cli_rs/0.155.1") + req.Header.Set("x-openai-subagent", "guardian") + req.Header.Set("x-codex-turn-metadata", `{"turn_id":"t1"}`) + req.Header.Set("x-openai-codex-luna-reserve", "true") + req.Header.Set("x-codex-installation-id", "inst-1") + req.Header.Set("x-codex-turn-state", "acct-1-state") + req.Header.Set("x-oai-attestation", "device-proof") + req.Header.Set("x-openai-actor-authorization", "Bearer someone") + s.Handler().ServeHTTP(rec, req) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "pong") { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + if len(heads) != 2 { + t.Fatalf("%d requests upstream", len(heads)) + } + for i, h := range heads { + for k, want := range map[string]string{ + "x-openai-subagent": "guardian", + "x-codex-turn-metadata": `{"turn_id":"t1"}`, + "x-codex-installation-id": "inst-1", + "chatgpt-account-id": "acct-" + string(rune('1'+i)), + } { + if got := h.Get(k); got != want { + t.Errorf("request %d: %s %q, want %q", i, k, got, want) + } + } + if h.Get("x-openai-codex-luna-reserve") != "" || h.Get("x-codex-turn-state") != "" { + t.Errorf("request %d carries what holds for Codex's own account: %v", i, h) + } + if h.Get("Authorization") == "Bearer chatgpt-token" || h.Get("x-oai-attestation") != "" || h.Get("x-openai-actor-authorization") != "" { + t.Errorf("request %d carries the client's sign-in: %v", i, h) + } + } + if r := s.trace.routes[len(s.trace.routes)-1]; r.Kind != "guardian" { + t.Errorf("route kind %q", r.Kind) + } + b, _ := os.ReadFile(filepath.Join(filepath.Dir(provider.Path()), "usage.jsonl")) + if !strings.Contains(string(b), `"kind":"guardian"`) { + t.Errorf("usage log: %s", b) + } +} + +func TestCallKind(t *testing.T) { + for want, h := range map[string]map[string]string{ + "": {}, + "guardian": {"x-openai-subagent": " guardian ", "x-openai-codex-luna-reserve": "true"}, + "thread_title": {"x-openai-subagent": "thread_title"}, + "memgen": {"x-openai-memgen-request": "1"}, + "luna_reserve": {"x-openai-codex-luna-reserve": "true"}, + } { + hh := http.Header{} + for k, v := range h { + hh.Set(k, v) + } + if got := callKind(hh); got != want { + t.Errorf("%v: %q, want %q", h, got, want) + } + } + for k, want := range map[string]bool{ + "x-openai-subagent": true, "X-Codex-Turn-Metadata": true, "X-Codex-Turn-State": false, "x-openai-codex-luna-reserve": false, "session_id": true, "Session_id": true, + "Authorization": false, "chatgpt-account-id": false, "x-oai-attestation": false, + "x-openai-actor-authorization": false, "User-Agent": false, "Cookie": false, + } { + if codexHeader(k) != want { + t.Errorf("codexHeader(%q) = %v", k, !want) + } + } +} diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index fbee8fae..8aa5d23a 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -224,7 +224,7 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri } model := modelOf(body) seat := Weighed{ID: "codex", Provider: "openai", Name: "OpenAI", Icon: "openai", Who: who, Kind: "account", Agent: "codex", Model: model} - tr = s.trace.begin(Route{Time: start, Agent: agentOf(r), Model: model, Provider: "openai", + tr = s.trace.begin(Route{Time: start, Agent: agentOf(r), Kind: callKind(r.Header), Model: model, Provider: "openai", Order: []Weighed{seat}, Tries: []Try{{ID: seat.ID, Model: model, Start: start}}}) end = func(status int, msg string, tokens, out int) { ms := time.Since(start).Milliseconds() @@ -321,7 +321,7 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri return } call := Call{Time: start, From: provider.Responses, To: provider.Responses, Model: modelOf(body), - Provider: "openai", Agent: agentOf(r), Status: res.StatusCode, + Provider: "openai", Agent: agentOf(r), Kind: callKind(r.Header), Status: res.StatusCode, Millis: time.Since(start).Milliseconds()} call.TTFT, call.FirstText = first.ms() var uu Usage @@ -334,7 +334,7 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri s.record(call) usage.Append(usage.Record{Time: start, Agent: call.Agent, Provider: call.Provider, Host: provider.HostOf(base), Model: call.Model, Input: uu.Input, Output: uu.Output, CacheRead: uu.CacheRead, CacheWrite: uu.CacheWrite, - Reasoning: uu.Reasoning, Millis: call.Millis, TTFT: call.TTFT, FirstText: call.FirstText, Status: call.Status, Session: sessionOf(r.Header)}) + Reasoning: uu.Reasoning, Millis: call.Millis, TTFT: call.TTFT, FirstText: call.FirstText, Status: call.Status, Session: sessionOf(r.Header), Kind: call.Kind}) } // unreadableItem is the item OpenAI's refusal names: sealed content it @@ -418,6 +418,48 @@ func hopHeader(k string) bool { return false } +// codexHeader is one of the headers Codex tells the ChatGPT backend about +// a request by, which go on with it when a pool account signs it as they +// do when Codex's own sign-in does (codexUpstream): x-openai-subagent (a +// guardian review, a thread's title, memories, a compaction…), the turn's +// x-codex-turn-metadata, its installation, window and parent thread, and +// the session. Never a sign-in: the account's own goes in its place, and +// Codex's device attestation is its own sign-in's. Nor what holds for +// Codex's own account alone: x-openai-codex-luna-reserve, which says its +// plan's allowance is used up (another account's may not be), and +// x-codex-turn-state, the backend's routing of the turn for that account. +func codexHeader(k string) bool { + k = strings.ToLower(k) + if strings.Contains(k, "authorization") || k == "x-oai-attestation" || + k == "x-openai-codex-luna-reserve" || k == "x-codex-turn-state" { + return false + } + switch k { + case "session_id", "conversation_id", "x-client-request-id", "version": + return true + } + return strings.HasPrefix(k, "x-openai-") || strings.HasPrefix(k, "x-codex-") +} + +// callKind is what an agent made a call for when it isn't a turn of the +// conversation, as Codex names it in x-openai-subagent: "guardian" (auto +// review of an approval), "review", "compact", "memory_consolidation", +// "thread_title", "collab_spawn"… A turn Codex sends on Luna Reserve, once +// the plan's own allowance is used up, is "luna_reserve". +func callKind(h http.Header) string { + v := strings.TrimSpace(h.Get("x-openai-subagent")) + if v == "" && h.Get("x-openai-memgen-request") != "" { + v = "memgen" + } + if v == "" && h.Get("x-openai-codex-luna-reserve") != "" { + v = "luna_reserve" + } + if len(v) > 40 { + v = v[:40] + } + return v +} + func copyHeaders(dst, src http.Header) { for k, vs := range src { if !hopHeader(k) && http.CanonicalHeaderKey(k) != "Host" { diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 0153215c..f96ddd29 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -153,8 +153,11 @@ func Serving() (running, window bool) { // Call is one request the gateway handled, for the status views. type Call struct { - Time time.Time `json:"time"` - Agent string `json:"agent"` // who called, from the client's User-Agent + Time time.Time `json:"time"` + Agent string `json:"agent"` // who called, from the client's User-Agent + // Kind: what the agent made the call for, when it isn't its turn — + // a Codex subagent's (callKind) — "" for a turn + Kind string `json:"kind,omitempty"` Model string `json:"model"` Provider string `json:"provider"` From provider.Protocol `json:"from"` @@ -643,7 +646,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro requestBody, requestTruncated := captureRequestBody(body) capture := &captureResponseWriter{ResponseWriter: w} w = capture - call := Call{Time: start, From: from, Model: unprefixed(modelOf(body)), Agent: agentOf(r), + call := Call{Time: start, From: from, Model: unprefixed(modelOf(body)), Agent: agentOf(r), Kind: callKind(r.Header), RequestBody: requestBody, RequestTruncated: requestTruncated} usage.Saw(call.Agent) finishCapture := func() { @@ -873,7 +876,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro if len(cands) == 1 { shown = nil // nobody else to stay away from } - tr := s.trace.begin(Route{Time: start, Agent: call.Agent, Model: call.Model, Effort: requestEffort(from, body), Provider: p.ID, Group: group, Rule: hit, Nested: nested, Affinity: shown, Order: pl.order, Left: pl.left}) + tr := s.trace.begin(Route{Time: start, Agent: call.Agent, Kind: call.Kind, Model: call.Model, Effort: requestEffort(from, body), Provider: p.ID, Group: group, Rule: hit, Nested: nested, Affinity: shown, Order: pl.order, Left: pl.left}) var skipped []string sent := "" // the reasoning the last try's model was asked for where := "" // the last try's provider.Where, for the usage @@ -1047,7 +1050,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro usage.Append(usage.Record{Time: start, Agent: call.Agent, Provider: call.Provider, Host: where, Model: model, Input: call.Usage.Input, Output: call.Usage.Output, CacheRead: call.Usage.CacheRead, CacheWrite: call.Usage.CacheWrite, Reasoning: call.Usage.Reasoning, Effort: sent, Millis: call.Millis, Status: call.Status, - TTFT: call.TTFT, FirstText: call.FirstText, Session: sessionOf(r.Header)}) + TTFT: call.TTFT, FirstText: call.FirstText, Session: sessionOf(r.Header), Kind: call.Kind}) } } @@ -1184,6 +1187,16 @@ func (s *Server) forwardOnce(ctx context.Context, p provider.Provider, to provid if p.IsOpenCode() { req.Header.Set("x-opencode-session", conversationID(in, body)) } + if p.Account != nil && p.Account.Agent == "codex" { + // what Codex says about the request goes on as codexUpstream + // relays it — a subagent's kind, the turn's metadata, a turn on + // Luna Reserve — the account signing it being the pool's + for k, vs := range in { + if codexHeader(k) { + req.Header[k] = slices.Clone(vs) + } + } + } if err := p.Sign(ctx, req, to, body); err != nil { return nil, err } diff --git a/internal/gateway/trace.go b/internal/gateway/trace.go index ad29bdbe..0c7179a7 100644 --- a/internal/gateway/trace.go +++ b/internal/gateway/trace.go @@ -26,6 +26,7 @@ type Route struct { ID int64 `json:"id"` Time time.Time `json:"time"` Agent string `json:"agent"` + Kind string `json:"kind,omitempty"` // what the call is for, as Call's Model string `json:"model"` // as the agent asked Effort string `json:"effort,omitempty"` // the reasoning the agent asked for; "" for none Provider string `json:"provider"` // the provider the model resolved to diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index f70bac1d..0311674b 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1066,6 +1066,15 @@ const I18N = { "the others go in their order.": "其余按顺序排。", "In order: with {rested} resting after a failure, {who} is the first that can answer.": "按顺序:{rested} 失败后正在休息,{who} 是第一个能答的。", "{agent} asked for {model}": "{agent} 请求 {model}", + "Approval check": "审批判定", + "Review": "审查", + "Compaction": "压缩", + "Memory": "记忆", + "Title": "标题", + "Subagent": "子代理", + "Luna Reserve": "Luna 储备", + "{agent} sent this turn on Luna Reserve, which it turns to once the plan's own allowance is used up; it picks the model itself.": "{agent} 用 Luna 储备发出了这一轮:套餐自己的额度用完后它会改用 Luna 储备,模型由它自己决定。", + "{agent} made this call itself ({kind}), not as a turn of the conversation, and picks its model itself.": "这是 {agent} 自己发起的调用({kind}),不是对话中的一轮,模型由它自己决定。", "Nothing was tried: {error}": "没有尝试任何账号:{error}", "Requests": "请求", "{agent} asked for {model}: {name} serves it, and the vendor is asked for {sent}": "{agent} 请求 {model}:由 {name} 提供,向厂商请求的模型是 {sent}", diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index fbbcdb1d..9b557f75 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -150,7 +150,7 @@ .rt-cols { display: grid; grid-template-columns: minmax(0, 1fr); column-gap: 16px; } @container (min-width: 860px) { .rt-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; } } .rt-col { min-width: 0; } -.rt-reqs { padding: 4px; max-height: 420px; overflow-y: auto; display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.4fr) auto; } +.rt-reqs { padding: 4px; max-height: 420px; overflow-y: auto; display: grid; grid-template-columns: auto fit-content(36%) minmax(0, 1fr) auto; } .rt-req { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; column-gap: 12px; padding: 6px 8px; font: inherit; font-size: 12px; text-align: left; color: var(--fg-2); background: none; border: 1px solid transparent; border-radius: 8px; cursor: pointer; } .rt-req:hover { background: var(--card-2); } .rt-req[aria-pressed="true"] { background: var(--card-2); border-color: var(--line); color: var(--fg); } @@ -168,6 +168,12 @@ .rt-req .to .ef { flex: none; overflow: visible; margin-left: -2px; color: var(--muted); } .rt-req .ef::before { content: "· "; } .rt-req .to .ef.picked { color: var(--fg-2); } +/* what a call was for (a Codex guardian review, a title…): a quiet grey tag by the model */ +.rt-req .asked .kind, .rp-req .kind, .rt-steps li .kind { flex: none; font-size: 10.5px; line-height: 16px; padding: 0 5px; border-radius: 5px; color: var(--muted); background: var(--card-2); border: 1px solid var(--line); white-space: nowrap; } +.rt-steps li .kind { display: inline-block; vertical-align: 1px; } +/* in a narrow row the model keeps its room first: the tag gives way, to an ellipsis */ +.rt-req .asked .kind { flex: 0 100 auto; min-width: 3.2em; max-width: 9em; overflow: hidden; text-overflow: ellipsis; } +.rt-req .asked.kinded .m { flex: none; max-width: calc(100% - 3.2em - 40px); } .rt-req .meta { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; } @container (max-width: 520px) { .rt-reqs { grid-template-columns: auto minmax(0, 1fr); } .rt-req { row-gap: 2px; } .rt-req .to { grid-column: 2; } .rt-req .meta { grid-column: 2; text-align: left; } } .rt-acts { padding: 4px 0; } diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index cc5e1325..f51c7b88 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -903,6 +903,7 @@ : main && main.model !== r.model ? t("{agent} asked for {model}: {name} serves it, and the vendor is asked for {sent}", { agent: agentName(r.agent), model: r.model, name: main.name, sent: main.model }) : t("{agent} asked for {model}", { agent: agentName(r.agent), model: r.model }) + " → " + (main?.name || r.provider), ""]); + if (r.kind) items.push([kindWhy(r), "aside kind"]); items.push([affWhy(r, true) || ruleWhy(r, true) || firstWhy(r), "why"]); for (const s of nestedWhy(r)) items.push([s, "why"]); for (const a of asides(r)) items.push([a, "aside"]); @@ -912,7 +913,11 @@ if (tr.done && tr.status >= 400 && tr.error) items.push([t("It said: {error}", { error: tr.error.length > 600 ? tr.error.slice(0, 600) + "…" : tr.error }), "aside said"]); }); if (r.done && !r.tries.length) items.push([t("Nothing was tried: {error}", { error: r.error || r.status }), "bad"]); - steps.replaceChildren(...items.map(([s, c]) => el("li", c, s))); + steps.replaceChildren(...items.map(([s, c]) => { + const li = el("li", c, s); + if (c === "aside kind") li.prepend(kindTag(r), " "); + return li; + })); } // pick sets the stage to a past request, or back to live with the newest. @@ -928,6 +933,30 @@ say(affWhy(r, true) || ruleWhy(r, true) || firstWhy(r)); } + // what a call was for when it isn't a turn of the conversation, as + // Codex names it (x-openai-subagent): its own guardian review of an + // approval, a thread's title, memories… — each on the model Codex picks + // for it, so a list of Luna calls under a Sol composer reads as it is + const KIND = { + guardian: "Approval check", auto_review: "Approval check", guardian_review: "Approval check", + review: "Review", compact: "Compaction", + memory_consolidation: "Memory", memgen: "Memory", memory: "Memory", + thread_title: "Title", title: "Title", + collab_spawn: "Subagent", thread_spawn: "Subagent", agent_job: "Subagent", + luna_reserve: "Luna Reserve", + }; + const kindName = (k) => KIND[k] ? t(KIND[k]) : k; + function kindTag(r) { + const k = el("span", "kind", kindName(r.kind)); + k.title = kindWhy(r); + return k; + } + function kindWhy(r) { + const agent = agentName(r.agent); + if (r.kind === "luna_reserve") return t("{agent} sent this turn on Luna Reserve, which it turns to once the plan's own allowance is used up; it picks the model itself.", { agent }); + return t("{agent} made this call itself ({kind}), not as a turn of the conversation, and picks its model itself.", { agent, kind: kindName(r.kind) }); + } + // who answered a request, or what its agent got function outcome(r) { if (!r.done) { @@ -1021,6 +1050,7 @@ const sw = el("i", "ag"); sw.style.setProperty("--agent", hueOf(r.agent)); asked.append(sw, icon(ag?.icon || "generic"), el("span", "m", r.model)); + if (r.kind) { asked.classList.add("kinded"); asked.append(kindTag(r)); } // the reasoning the model was sent at — the turn's pick, or the // agent's fitted to the model's levels; what the agent asked for is // in its title and the request's story @@ -1403,7 +1433,7 @@ sw.style.setProperty("--agent", hueOf(r.agent)); const [said] = outcome(r); row.append(el("span", "rp-n", t("request {i} of {n}", { i: g.n, n: p.plan.length })), sw, - el("b", "", agentName(r.agent)), el("code", "mdl", r.model), el("span", "rp-to", "→ " + said), + el("b", "", agentName(r.agent)), el("code", "mdl", r.model), ...(r.kind ? [kindTag(r)] : []), el("span", "rp-to", "→ " + said), el("span", "rp-at", t("sent {time}", { time: new Date(r.time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }) }))); row.onclick = () => { cur = r; renderLog(); }; return row; diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index fbd125ec..a72eaad3 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -23,10 +23,15 @@ was clicked where it is on every frame. scrolled: its fields hide, the head keeps the base URL and a copy button, the head stays where it was, and the fold is remembered across a reload. +`routing-kind.test.cjs` lists calls Codex makes for itself (a guardian review, +a title, memories, a turn on Luna Reserve, a kind it does not know yet): each +has a grey tag by its model in the Requests list, in English and Chinese, the +model keeping its room first, and the request's story says what it was. + With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/gateway-fold.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/routing-kind.test.cjs b/internal/gui/tests/routing-kind.test.cjs new file mode 100644 index 00000000..90b317e7 --- /dev/null +++ b/internal/gui/tests/routing-kind.test.cjs @@ -0,0 +1,101 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A call Codex makes for itself — its guardian review of an approval, a +// thread's title, memories — or a turn on Luna Reserve, carries a grey tag +// by the model in the Routing page's Requests list and in the request's +// story, so a list of Luna calls under a Sol composer reads as it is +// (碳碳双键: 为何 codex 桌面选的 5.6sol,但是请求记录…显示的全是 gpt-6-luna). +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 seat = { id: "codex", provider: "openai", name: "OpenAI", who: "Codex's own sign-in", kind: "account", agent: "codex", model: "gpt-6-luna" }; +const kinds = ["guardian", "thread_title", "memory_consolidation", "luna_reserve", "something_new", ""]; +const routes = kinds.map((kind, i) => ({ + id: 100 - i, seq: 100 - i, time: at(i), agent: "codex", model: "gpt-6-luna", provider: "openai", ...(kind ? { kind } : {}), + order: [seat], tries: [{ id: seat.id, model: "gpt-6-luna", start: at(i), done: true, status: 200, ms: 900 }], + done: true, status: 200, ms: 900, tokens: 1200, +})); + +function serve(lang) { + 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/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: [] }); + } + if (url.pathname === "/api/gateway/history") { + const d = url.searchParams.get("day"); + return json({ cut: false, days: [{ day, requests: routes.length }], routes: d ? routes : [] }); + } + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/providers") return json({ providers: [], 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: ["Approval check", "Title", "Memory", "Luna Reserve", "something_new"], + zh: ["审批判定", "标题", "记忆", "Luna 储备", "something_new"], +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: a call Codex makes for itself is tagged by its model`, 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)); + 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}-kind.png`), fullPage: true }); + } + await browser.close(); + }); + await page.goto("http://magpie.test/?view=routing"); + await page.locator(".rt-day").nth(1).click(); + await page.locator(".rt-req").nth(kinds.length - 1).waitFor(); + + const tags = await page.locator(".rt-req").evaluateAll((rows) => rows.map((r) => r.querySelector(".asked .kind")?.textContent || "")); + assert.deepEqual(tags, [...want[lang], ""]); + // beside the model, not in its place, the model keeping its room first + const look = await page.locator(".rt-req .asked .kind").first().evaluate((e) => { + const m = e.parentElement.querySelector(".m"); + return { after: m.getBoundingClientRect().right <= e.getBoundingClientRect().left + 0.5, shown: m.clientWidth >= m.scrollWidth, model: m.textContent }; + }); + assert(look.after && look.shown && look.model === "gpt-6-luna", JSON.stringify(look)); + + // the story says what it was, and the click leaves the row where it is + const row = page.locator(".rt-req").nth(0); + const was = await row.evaluate((e) => e.getBoundingClientRect().top); + await row.click(); + await page.waitForTimeout(600); + assert(Math.abs((await row.evaluate((e) => e.getBoundingClientRect().top)) - was) <= 1, "picking the request moved the page"); + const story = page.locator(".rt-steps li.kind"); + assert.equal(await story.locator(".kind").textContent(), want[lang][0]); + assert.match(await story.textContent(), lang === "zh" ? /不是对话中的一轮/ : /not as a turn of the conversation/); + // a turn has no tag in its story + await page.locator(".rt-req").nth(kinds.length - 1).click(); + await page.waitForTimeout(300); + assert.equal(await page.locator(".rt-steps li.kind").count(), 0); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/usage/usage.go b/internal/usage/usage.go index 64d53dc4..b48ac8bc 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -47,6 +47,9 @@ type Record struct { // it (X-Magpie-Session, or the session header Claude Code, Codex or // OpenCode sends): several sessions on one model told apart Session string `json:"session,omitempty"` + // Kind is what the agent made the call for when it isn't a turn of + // the conversation: a Codex subagent's (review, compact, guardian…) + Kind string `json:"kind,omitempty"` } // Path is the log file: ~/.config/magpie/usage.jsonl (XDG-aware).