diff --git a/cmd/release-readiness/main.go b/cmd/release-readiness/main.go index c3c906b81..06bdf5905 100644 --- a/cmd/release-readiness/main.go +++ b/cmd/release-readiness/main.go @@ -28,6 +28,7 @@ func main() { flag.StringVar(&opts.webuiBuildResult, "webui-build-result", "unknown", "WebUI build gate result: pass, fail, unknown") flag.StringVar(&opts.liveResult, "live-result", "skip", "live gate result: pass, fail, skip, unknown") flag.StringVar(&opts.liveSkipReason, "live-skip-reason", "not required unless high-risk live path changes", "Reason when live gate is skipped") + flag.StringVar(&opts.offlineCurrentInputSmokeResult, "offline-current-input-smoke-result", "skip", "offline current-input smoke result: pass, fail, skip, unknown") flag.StringVar(&opts.historyAnalyzerJSON, "history-analyzer-json", "", "Future input: History Analyzer JSON report") flag.StringVar(&opts.parserShadowJSON, "parser-shadow-json", "", "Future input: parser shadow JSON report") flag.StringVar(&opts.contextShadowJSON, "context-shadow-json", "", "Future input: context shadow JSON report") @@ -42,23 +43,24 @@ func main() { } type cliOptions struct { - version string - branch string - scope string - owner string - outMarkdown string - outJSON string - lintResult string - refactorResult string - unitResult string - webuiBuildResult string - liveResult string - liveSkipReason string - historyAnalyzerJSON string - parserShadowJSON string - contextShadowJSON string - autoContinueJSON string - capabilityRouterJSON string + version string + branch string + scope string + owner string + outMarkdown string + outJSON string + lintResult string + refactorResult string + unitResult string + webuiBuildResult string + liveResult string + liveSkipReason string + offlineCurrentInputSmokeResult string + historyAnalyzerJSON string + parserShadowJSON string + contextShadowJSON string + autoContinueJSON string + capabilityRouterJSON string } func run(opts cliOptions) error { @@ -118,6 +120,10 @@ func buildGates(opts cliOptions) ([]readiness.GateResult, error) { if err != nil { return nil, fmt.Errorf("live-result: %w", err) } + offlineCurrentInputSmoke, err := parseOptionalGateResult(opts.offlineCurrentInputSmokeResult) + if err != nil { + return nil, fmt.Errorf("offline-current-input-smoke-result: %w", err) + } return []readiness.GateResult{ {Name: "lint", Result: lint, Evidence: "./scripts/lint.sh"}, @@ -125,6 +131,7 @@ func buildGates(opts cliOptions) ([]readiness.GateResult, error) { {Name: "unit all", Result: unit, Evidence: "./tests/scripts/run-unit-all.sh"}, {Name: "webui build", Result: webuiBuild, Evidence: "npm run build --prefix webui"}, {Name: "live", Result: live, Evidence: liveEvidence(live, opts.liveSkipReason)}, + {Name: "offline current-input smoke", Result: offlineCurrentInputSmoke, Evidence: "./tests/scripts/run-offline-current-input-smoke.sh"}, }, nil } @@ -164,6 +171,10 @@ func parseRequiredGateResult(value string) (readiness.GateResultValue, error) { } func parseLiveGateResult(value string) (readiness.GateResultValue, error) { + return parseOptionalGateResult(value) +} + +func parseOptionalGateResult(value string) (readiness.GateResultValue, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "skip": return readiness.GateSkip, nil diff --git a/cmd/release-readiness/main_test.go b/cmd/release-readiness/main_test.go index 30cfb160e..7b1b9ee7f 100644 --- a/cmd/release-readiness/main_test.go +++ b/cmd/release-readiness/main_test.go @@ -23,6 +23,33 @@ func TestParseGateResultAllowsLiveSkip(t *testing.T) { } } +func TestBuildGatesIncludesOfflineCurrentInputSmoke(t *testing.T) { + gates, err := buildGates(cliOptions{ + lintResult: "pass", + refactorResult: "pass", + unitResult: "pass", + webuiBuildResult: "pass", + liveResult: "skip", + liveSkipReason: "no credentials", + offlineCurrentInputSmokeResult: "pass", + }) + if err != nil { + t.Fatal(err) + } + for _, gate := range gates { + if gate.Name == "offline current-input smoke" { + if gate.Result != readiness.GatePass { + t.Fatalf("offline current-input smoke result = %q, want %q", gate.Result, readiness.GatePass) + } + if gate.Evidence != "./tests/scripts/run-offline-current-input-smoke.sh" { + t.Fatalf("offline current-input smoke evidence = %q", gate.Evidence) + } + return + } + } + t.Fatalf("missing offline current-input smoke gate: %#v", gates) +} + func TestResolveBranchUsesExplicitValue(t *testing.T) { got, err := resolveBranch("release/test") if err != nil { diff --git a/docs/TESTING.md b/docs/TESTING.md index 18a644e0e..be3ff561b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -13,6 +13,7 @@ DS2API 提供两个层级的测试: | 单元测试(Go) | `./tests/scripts/run-unit-go.sh` | 不需要真实账号 | | 单元测试(Node) | `./tests/scripts/run-unit-node.sh` | 不需要真实账号 | | 单元测试(全部) | `./tests/scripts/run-unit-all.sh` | 不需要真实账号 | +| 离线 current-input smoke | `./tests/scripts/run-offline-current-input-smoke.sh` | 不需要真实账号;覆盖协议入口、上下文文件化、工具引用和 completion runtime 的关键离线路径 | | Release 目标交叉编译 | `./tests/scripts/check-cross-build.sh` | 覆盖发布包支持的 GOOS/GOARCH | | 端到端测试 | `./tests/scripts/run-live.sh` | 使用真实账号执行全链路测试 | @@ -37,6 +38,7 @@ npm run build --prefix webui - `./scripts/lint.sh` 会运行 Go 格式化检查和 `golangci-lint`;修改 Go 文件后仍建议先执行 `gofmt -w `。 - `run-unit-all.sh` 串行调用 Go 与 Node 单元测试入口。 - CI 还会额外在 macOS/Windows 跑 Go 单测,并执行 release 目标交叉编译检查。 +- `run-offline-current-input-smoke.sh` 是无账号替代验证层,只验证本地协议归一、context rendering、filename policy、tool reference 和 runtime 装配,不证明 DeepSeek 账号、PoW、真实上游 completion 或网络路径可用。 - `run-live.sh` 是真实账号端到端测试,适合作为发布或高风险改动后的补充验证,不属于每次 PR 的固定本地门禁。 --- @@ -62,6 +64,35 @@ npm run build --prefix webui ./tests/scripts/check-cross-build.sh ``` +### 无账号替代验证 | Offline Stand-in Smoke + +当没有可用 DeepSeek 账号或真实上游环境时,不能把 live gate 记为通过。此时建议先执行: + +```bash +./tests/scripts/run-offline-current-input-smoke.sh +``` + +该脚本用于快速覆盖最近高风险的纯本地路径: + +- OpenAI Chat / Responses 的 current input file 应用。 +- Claude / Gemini 入口归一化后的 current input file 应用。 +- `hybrid_recent`、`neutral_random`、tool reference 分离和 prompt-visible 术语约束。 +- completion runtime 在非流式和流式重试路径中的 current input 文件重传。 + +它不能覆盖: + +- 真实 DeepSeek 账号登录、token 刷新和封禁状态。 +- 真实 PoW 请求和上游 completion 语义。 +- 真实网络超时、429、账号切换和文件上传服务状态。 + +Release Readiness 中应把 live gate 标记为 `SKIP`,并写明原因,例如: + +```bash +go run ./cmd/release-readiness \ + --live-result skip \ + --live-skip-reason "no credentials; offline current-input smoke passed" +``` + 说明:`plans/stage6-manual-smoke.md` 是阶段 6 手工烟测记录;`./tests/scripts/check-stage6-manual-smoke.sh` 只应在完成 live smoke 后通过,当前不属于常规 CI 单元门禁。 ### 端到端测试 | End-to-End Tests diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 529ea341a..e59989a4d 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -20,6 +20,7 @@ Release Readiness 不是替代 PR Gate,而是回答: |---|---| | PR Gate | `lint.sh`、`check-refactor-line-gate.sh`、`run-unit-all.sh`、WebUI build | | Live Gate | 高风险改动运行 `run-live.sh`,产物脱敏 | +| Offline Stand-in Smoke | 无真实账号时运行 `run-offline-current-input-smoke.sh`,作为本地路径补充证据 | | History Analyzer | 输出异常统计和高风险样本 | | Parser Shadow Report | diff 率、confidence 分布、marker leak | | Context Shadow Report | warnings、trimmed、tool pair、budget | @@ -45,6 +46,7 @@ M4.0 只建立 release readiness baseline,不改变任何主请求链路行为 - 不把 parser、context、auto continue 或 capability router 推到 `enforce`。 - 不读取或输出未脱敏 prompt、token、账号凭证和完整真实请求。 - 不把 live test 失败或缺凭证隐藏成通过结果。 +- 无账号时可以记录 offline stand-in smoke 结果,但不能把它等同于 live gate 通过。 ## 4. 报告模板 @@ -65,6 +67,7 @@ Decision owner: | unit all | PASS/FAIL/UNKNOWN | link or local log | | | webui build | PASS/FAIL/UNKNOWN | link or local log | | | live | PASS/FAIL/SKIP/UNKNOWN | link or reason | | +| offline current-input smoke | PASS/FAIL/SKIP/UNKNOWN | link or local log | | ## Feature Flag Readiness @@ -120,6 +123,7 @@ Required follow-ups: - 有 critical analyzer finding,不允许 release,除非明确不影响本次变更范围。 - Auto Continue 没有 live smoke,不允许 stream enforce。 - Parser / Context diff 无人工审阅,不允许默认开启。 +- live gate 因无账号 `SKIP` 时,必须写明是否执行了 offline stand-in smoke;该证据只能说明本地协议和上下文路径未回归。 ### 6.1 Feature Flag 晋级矩阵 @@ -140,6 +144,23 @@ Required follow-ups: `PENDING` 和 `UNKNOWN` 不能支持 feature flag 晋级到 `enforce`。 +### 6.3 无账号验证口径 + +当没有真实账号或上游环境时: + +- `live` gate 使用 `SKIP`,evidence 写明 `no credentials`,不要写成 `PASS`。 +- 运行 `./tests/scripts/run-offline-current-input-smoke.sh`,把结果作为 `offline current-input smoke` 证据。 +- 报告结论优先使用 `GO-WITH-FLAGS-OFF`,除非本次变更完全不影响请求链路。 +- 后续拿到账号后补跑 `./tests/scripts/run-live.sh`,并更新 release readiness 或追加 live smoke 记录。 + +无账号替代验证重点覆盖: + +- current input inline-first 和上传触发边界。 +- `hybrid_recent` 渲染、`neutral_random` 文件名策略和 prompt-visible 术语约束。 +- tool reference 与 conversation context 分离。 +- OpenAI / Claude / Gemini 协议入口归一后的一致性。 +- completion runtime 的账号切换重传和 token accounting 本地逻辑。 + ## 7. Phase Closure Review 每个 M4.0 Phase 完成后,必须对照以下问题做偏差检查: @@ -176,7 +197,8 @@ go run ./cmd/release-readiness \ --unit-result pass \ --webui-build-result pass \ --live-result skip \ - --live-skip-reason "no credentials" + --live-skip-reason "no credentials" \ + --offline-current-input-smoke-result pass ``` 也可以使用脚本封装: diff --git a/internal/readiness/baseline.go b/internal/readiness/baseline.go index 65494273d..6fbf824ff 100644 --- a/internal/readiness/baseline.go +++ b/internal/readiness/baseline.go @@ -66,6 +66,7 @@ func DefaultGateResults() []GateResult { {Name: "unit all", Result: GateUnknown, Evidence: "./tests/scripts/run-unit-all.sh"}, {Name: "webui build", Result: GateUnknown, Evidence: "npm run build --prefix webui"}, {Name: "live", Result: GateSkip, Evidence: "not required unless high-risk live path changes"}, + {Name: "offline current-input smoke", Result: GateSkip, Evidence: "./tests/scripts/run-offline-current-input-smoke.sh"}, } } diff --git a/internal/readiness/markdown_test.go b/internal/readiness/markdown_test.go index dc28eebf0..e0f3c2170 100644 --- a/internal/readiness/markdown_test.go +++ b/internal/readiness/markdown_test.go @@ -45,6 +45,7 @@ func TestRenderMarkdown(t *testing.T) { Gates: []GateResult{ {Name: "lint", Result: GatePass, Evidence: "./scripts/lint.sh"}, {Name: "live", Result: GateSkip, Evidence: "no credentials"}, + {Name: "offline current-input smoke", Result: GatePass, Evidence: "./tests/scripts/run-offline-current-input-smoke.sh"}, }, Features: []FeatureReadiness{ { @@ -76,6 +77,7 @@ func TestRenderMarkdown(t *testing.T) { "# Release Readiness Report", "Generated at: 2026-05-12T00:00:00Z", "| lint | pass | ./scripts/lint.sh |", + "| offline current-input smoke | pass | ./tests/scripts/run-offline-current-input-smoke.sh |", "| parser_v2 | off | shadow | hold | waiting for shadow data | parser shadow report; manual diff review |", "| tool | 0 | 1 | 2 | HA_TOOL_MARKER_LEAK |", "| history analyzer | pending | 0 | M4.1 pending |", diff --git a/tests/scripts/run-offline-current-input-smoke.sh b/tests/scripts/run-offline-current-input-smoke.sh new file mode 100755 index 000000000..c146d0087 --- /dev/null +++ b/tests/scripts/run-offline-current-input-smoke.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT_DIR" + +go test \ + ./internal/promptcompat \ + ./internal/httpapi/openai \ + ./internal/httpapi/openai/chat \ + ./internal/httpapi/openai/history \ + ./internal/httpapi/openai/responses \ + ./internal/httpapi/claude \ + ./internal/httpapi/gemini \ + ./internal/completionruntime