From d0c7465130d9967c13b9b47e9703118f1f9faf87 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 06:24:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20/api/ui-inject=20=E7=9A=84=20sta?= =?UTF-8?q?ges=20=E6=BC=8F=E6=8E=89=20chain=5Fstep=20+=20=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=E4=B8=B4=E6=97=B6=E6=9E=84=E5=BB=BA=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部署到线上时用隔离实例(独立端口 18099 + 独立 config/runtime/adapter 目录) 发真实请求验证,抓到的第三个 bug。 ## bug:discovery 文档漏掉新 stage handlePluginUI 的响应里 stages 是**字面写死的三个**。加 chain_step 时只改了 lua.AllStages,没改这里,于是插件作者读 GET /api/ui-inject 会看到 ["request_start","routed","request_end"],**合理地得出结论:没有 chain_step 这个 stage**。stage 本身是注册好的、也确实在触发,只是没被声明。 改成从 lua.AllStages 派生——AllStages 是唯一定义顺序的地方,让它保持唯一。 ★ 而我原来的测试断言 `len(view.Stages) != 3`,**断言本身是 bug 的保护伞**: 它把"三个"固化成了期望值,于是新增第四个 stage 时测试全绿、bug 上线。 现在断言改为「与 AllStages 等长且逐项相同」,并显式要求 chain_step 在其中。 新增 stage 而忘了声明,这类问题会立刻红。 ## 顺带:.gitignore 补上临时构建目录 .probe/ 和 .build-work/ 是我调试时当 GOTMPDIR 和临时二进制用的,之前每轮 手工删,这轮差点提交进去 9.5MB 的二进制。 ## 部署验证留档 隔离实例跑真实 chat(158 prompt / 13 completion / 128 cache_hit),计费插件 算出 0.000688,与手算 (158-128)*1e-5 + 128*1e-5*0.1 + 13*2e-5 **逐位吻合**。 ★ 第一次手算我按全价算成 0.00184,一度以为插件算错了——查审计记录才看到 cache_hit_tokens。**算钱不对时先查输入再怀疑实现**,而我忘的恰是刚修的折扣。 ## 验证 351 个测试全绿;变异(stages 改回硬编码三个)被 TestUIInjectServesPluginUI 抓住,3 条断言同时红。 --- .gitignore | 4 ++++ internal/gateway/plugins_api.go | 23 +++++++++++++++++------ internal/gateway/plugins_api_test.go | 25 +++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 131c027..0bfbbea 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ cmd/gui/dist/ # local design/working notes (not part of the shipped repo) /plan.md + +# scratch build/probe dirs used while debugging (GOTMPDIR, throwaway binaries) +.build-work/ +.probe/ diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index 6b6953b..e7f4dc1 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -51,16 +51,27 @@ func (g *Gateway) handlePluginUI(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"ui": lua.UIExtension{}}) return } + // AllStages, not a hand-written list: an earlier version enumerated the + // three stages literally here, so when chain_step was added it was silently + // missing from this response — a plugin author reading the discovery + // document would have believed the stage did not exist. Deriving it from + // the one place that defines the order is the whole point of having it. writeJSON(w, http.StatusOK, map[string]interface{}{ - "ui": ps.UI(), - "stages": []string{ - string(lua.StageRequestStart), - string(lua.StageRouted), - string(lua.StageRequestEnd), - }, + "ui": ps.UI(), + "stages": pipelineStageNames(), }) } +// stageNames returns the pipeline stage names in firing order, for the +// discovery payload and for tests. +func pipelineStageNames() []string { + out := make([]string, 0, len(lua.AllStages)) + for _, s := range lua.AllStages { + out = append(out, string(s)) + } + return out +} + func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { ps := g.core.Plugins() if ps == nil { diff --git a/internal/gateway/plugins_api_test.go b/internal/gateway/plugins_api_test.go index fd56b7b..b9fbffc 100644 --- a/internal/gateway/plugins_api_test.go +++ b/internal/gateway/plugins_api_test.go @@ -97,8 +97,19 @@ func TestUIInjectServesPluginUI(t *testing.T) { t.Errorf("element target = %q, want \"status\"", e.Target) } } - if len(view.Stages) != 3 { - t.Errorf("stages = %v, want the three pipeline stages", view.Stages) + // Derived from AllStages, not hardcoded: the previous assertion of "3" + // is exactly what let chain_step go missing from this payload unnoticed. + if len(view.Stages) != len(lua.AllStages) { + t.Errorf("stages = %v, want %d (one per AllStages entry)", view.Stages, len(lua.AllStages)) + } + for i, st := range lua.AllStages { + if i >= len(view.Stages) || view.Stages[i] != string(st) { + t.Errorf("stages[%d] = %v, want %q", i, view.Stages, string(st)) + } + } + if !containsStr(view.Stages, string(lua.StageChainStep)) { + t.Error("the discovery payload does not advertise chain_step; a plugin " + + "author would conclude the stage does not exist") } } @@ -116,6 +127,16 @@ func TestUIInjectIsEmptyWithoutPlugins(t *testing.T) { } } +// containsStr reports whether list has s. +func containsStr(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + // newRecorderFor pushes a request through the full handler chain. func newRecorderFor(t *testing.T, g *Gateway, req *http.Request) *httptest.ResponseRecorder { t.Helper()