Files
weichen e9c0281404 feat(ctxmgr): wildcard session allowlist for semantic replace rollout
- SEMANTIC_SUMMARY_REPLACE_SESSIONS supports * (all sessions, present and
  future), mirroring INTENT_PLAN_PROJECT_ALLOWLIST; empty allowlist still
  replaces nothing (fail-closed defense in depth unchanged)
- declare SEMANTIC_SUMMARY_REPLACE(_SESSIONS) in dev/test/main compose app
  env (stale Wave2 "REPLACE unwired" comment fixed); extend A-4 declaration
  test; .env.example key docs updated
- sysadmin feature catalog: SESSIONS description + REPLACE reason note *

Prepares dev degraded-flags enable (task degraded-flags-enable-2026-09-26-r4):
user-authorized all-session replace, plan tier confirm, shadow summary, PI
change-report narrative; env ops and runtime verification follow.
2026-09-26 02:29:29 +00:00

307 lines
13 KiB
Go

package ctxmgr
import (
"context"
"strings"
"testing"
"time"
)
func replaceBatch() []ChatMessageRecord {
return []ChatMessageRecord{
{ID: 1, Role: "user", Content: "我们准备研发高频超声手术刀系统,请教立项建议", CreatedAt: time.Now()},
{ID: 2, Role: "assistant", Content: "超声手术设备属于二类或三类器械。", CreatedAt: time.Now()},
{ID: 3, Role: "user", Content: "目标市场是国内及欧洲CE,项目预算大约在200万左右", CreatedAt: time.Now()},
}
}
func drainShadow(t *testing.T, m *ContextManager, sessionID string) *ChatSummary {
t.Helper()
ctx := context.Background()
_ = m.DrainSemanticShadowTasks(ctx)
if !m.WaitShadowIdle(2 * time.Second) {
t.Fatal("shadow worker did not become idle")
}
sum, err := m.GetChatSummary(ctx, sessionID)
if err != nil || sum == nil {
t.Fatalf("get summary: %v %+v", err, sum)
}
return sum
}
// Master flag on but empty allowlist: nothing is replaced (defense in depth).
func TestSemanticReplace_EmptyAllowlistKeepsDeterministic(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{Shadow: true, Replace: true})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"})
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_empty", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_empty")
if sum.Source != "deterministic" || strings.Contains(sum.Narrative, "语义影子叙事") {
t.Fatalf("empty allowlist must keep deterministic, got source=%s narrative=%.60s", sum.Source, sum.Narrative)
}
}
// Allowlisted session + OK report: served narrative swaps with provenance.
func TestSemanticReplace_AllowlistedSessionSwapsNarrative(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: true, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_r_hit": {}},
})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"})
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_hit", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_hit")
if sum.Source != "semantic_replace" {
t.Fatalf("expected semantic_replace source, got %s", sum.Source)
}
if sum.Narrative != "语义影子叙事:超声刀立项" {
t.Fatalf("narrative not replaced: %.80s", sum.Narrative)
}
if !strings.Contains(sum.FreshnessNote, "semantic_replace") {
t.Fatalf("freshness note missing replace provenance: %s", sum.FreshnessNote)
}
// Facts/counts stay deterministic under the declared scope.
if sum.Facts["device_type"] != "超声手术设备" {
t.Fatalf("deterministic facts lost: %+v", sum.Facts)
}
}
// Wildcard allowlist (*) covers sessions never enumerated explicitly,
// including sessions created after the flag was set.
func TestSemanticReplace_WildcardAllowlistSwapsNarrative(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: true, Replace: true, ReplaceAllSessions: true,
})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"})
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_wild_new", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_wild_new")
if sum.Source != "semantic_replace" || sum.Narrative != "语义影子叙事:超声刀立项" {
t.Fatalf("wildcard session must swap narrative, got source=%s narrative=%.60s", sum.Source, sum.Narrative)
}
if sum.Facts["device_type"] != "超声手术设备" {
t.Fatalf("deterministic facts lost: %+v", sum.Facts)
}
}
// Non-allowlisted session on the same config: deterministic retained.
func TestSemanticReplace_NonAllowlistedKeepsDeterministic(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: true, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_other": {}},
})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"})
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_miss", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_miss")
if sum.Source != "deterministic" || strings.Contains(sum.Narrative, "语义影子叙事") {
t.Fatalf("non-allowlisted session must keep deterministic, got %s", sum.Source)
}
}
// Failing summarizer on an allowlisted session: fail-closed keeps deterministic.
func TestSemanticReplace_ModelErrorFailsClosed(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: true, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_r_err": {}},
})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
m.SetSemanticSummarizer(&StubSemanticSummarizer{Err: context.DeadlineExceeded})
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_err", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_err")
if sum.Source != "deterministic" || sum.Shadow == nil || sum.Shadow.Status != ShadowStatusFallback {
t.Fatalf("expected fallback with deterministic retained, got source=%s shadow=%+v", sum.Source, sum.Shadow)
}
}
// Oversized narrative: treated as anomaly, deterministic retained.
func TestSemanticReplace_OversizedNarrativeFailsClosed(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: true, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_r_big": {}},
})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: strings.Repeat("超", semanticReplaceMaxRunes+10)})
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_big", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_big")
if sum.Source != "deterministic" || strings.Contains(sum.Narrative, "超超超") {
t.Fatalf("oversized narrative must fail closed, got source=%s", sum.Source)
}
if !strings.Contains(sum.FreshnessNote, "narrative_out_of_bounds") {
t.Fatalf("missing out-of-bounds note: %s", sum.FreshnessNote)
}
}
// Stale job (summary advanced past the job's revision): watermark guard
// rejects persistence; no swap even if the session is eligible.
func TestSemanticReplace_StaleWatermarkKeepsDeterministic(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: true, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_r_stale": {}},
})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
ctx := context.Background()
sum1, err := m.GenerateRollingSummary(ctx, "sess_r_stale", nil, replaceBatch())
if err != nil {
t.Fatal(err)
}
// Turn shadow scheduling off so the advanced summary enqueues no new job;
// replacement flags stay armed to prove only the watermark guard rejects.
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: false, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_r_stale": {}},
})
// Advance the served summary to a later revision.
advanced := append(replaceBatch(),
ChatMessageRecord{ID: 4, Role: "user", Content: "补充:预算调整为300万,型检周期多久", CreatedAt: time.Now()})
if _, err := m.GenerateRollingSummary(ctx, "sess_r_stale", nil, advanced); err != nil {
t.Fatal(err)
}
// Replay the stale (rev1) OK report directly: watermark must reject it.
staleJob := SemanticShadowJob{
SessionID: "sess_r_stale", SummaryRevision: sum1.Revision,
InputDigest: "digest-rev1", VisibilityDigest: sum1.VisibilityDigest,
WatermarkToMessageID: sum1.ToMessageID,
}
okReport := &SemanticShadowReport{
Status: ShadowStatusOK, Narrative: "旧版语义叙事", InputDigest: "digest-rev1",
Model: "stub-semantic-v1", Protocol: semanticProtocolV1, GeneratedAt: time.Now(),
}
if err := m.persistShadowProvenance(ctx, staleJob, okReport, nil); err == nil {
t.Fatal("stale job must be rejected by watermark guard")
}
sum := drainShadow(t, m, "sess_r_stale")
if sum.Source == "semantic_replace" || strings.Contains(sum.Narrative, "旧版语义叙事") {
t.Fatalf("stale report must not replace, got source=%s narrative=%.60s", sum.Source, sum.Narrative)
}
}
// Flag parse: allowlist parsing and fail-closed accessor.
func TestSemanticReplace_FlagParsing(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
got, all := parseReplaceSessions(" a , b ,, ")
if all {
t.Fatal("id list must not widen to all sessions")
}
if len(got) != 2 {
t.Fatalf("expected 2 sessions, got %d", len(got))
}
if _, ok := got["a"]; !ok {
t.Fatal("session a missing")
}
if sessions, all := parseReplaceSessions(""); all || len(sessions) != 0 {
t.Fatal("empty allowlist must stay empty and non-wildcard")
}
for _, raw := range []string{"*", " a , * "} {
if sessions, all := parseReplaceSessions(raw); !all || len(sessions) != 0 {
t.Fatalf("%q must parse to the all-sessions wildcard", raw)
}
}
if SemanticReplaceEnabledFor("") {
t.Fatal("empty session must never be eligible")
}
SetSummaryFlagsForTest(SummaryFeatureFlags{Replace: true, ReplaceSessions: map[string]struct{}{"s1": {}}})
if !SemanticReplaceEnabledFor("s1") {
t.Fatal("s1 should be eligible")
}
if SemanticReplaceEnabledFor("s2") {
t.Fatal("s2 must not be eligible")
}
SetSummaryFlagsForTest(SummaryFeatureFlags{Replace: true})
if SemanticReplaceEnabledFor("s1") {
t.Fatal("master on + empty allowlist must replace nothing")
}
SetSummaryFlagsForTest(SummaryFeatureFlags{Replace: true, ReplaceAllSessions: true})
if !SemanticReplaceEnabledFor("any-future-session") {
t.Fatal("wildcard allowlist must cover every nonempty session id")
}
SetSummaryFlagsForTest(SummaryFeatureFlags{ReplaceAllSessions: true})
if SemanticReplaceEnabledFor("s1") {
t.Fatal("wildcard + master off must replace nothing")
}
}
// Wildcard allowlist (*) + master off: fail-closed keeps deterministic.
func TestSemanticReplace_WildcardMasterOffKeepsDeterministic(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{Shadow: true, ReplaceAllSessions: true})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"})
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_wild_off", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_wild_off")
if sum.Source != "deterministic" || strings.Contains(sum.Narrative, "语义影子叙事") {
t.Fatalf("wildcard + master off must keep deterministic, got source=%s", sum.Source)
}
}
// Rollback residue: after a replace, turning flags off must regenerate a
// narrative free of semantic text (displaced deterministic narrative merges).
func TestSemanticReplace_RollbackLeavesNoResidue(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: true, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_r_roll": {}},
})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
ctx := context.Background()
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"})
if _, err := m.GenerateRollingSummary(ctx, "sess_r_roll", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_roll")
if sum.Source != "semantic_replace" {
t.Fatalf("setup: expected replace, got %s", sum.Source)
}
if sum.Shadow == nil || !strings.Contains(sum.Shadow.DisplacedNarrative, "超声") == false && sum.Shadow.DisplacedNarrative == "" {
t.Fatalf("displaced deterministic narrative must be kept: %+v", sum.Shadow)
}
// Rollback: flags off (shadow off so no new job), advance the summary.
SetSummaryFlagsForTest(SummaryFeatureFlags{})
advanced := append(replaceBatch(),
ChatMessageRecord{ID: 5, Role: "user", Content: "补充:预算调整为400万", CreatedAt: time.Now()})
if _, err := m.GenerateRollingSummary(ctx, "sess_r_roll", sum, advanced); err != nil {
t.Fatal(err)
}
after, err := m.GetChatSummary(ctx, "sess_r_roll")
if err != nil || after == nil {
t.Fatal(err)
}
if strings.Contains(after.Narrative, "语义影子叙事") {
t.Fatalf("semantic residue after rollback: %s", after.Narrative)
}
if after.Source != "deterministic" {
t.Fatalf("expected deterministic after rollback, got %s", after.Source)
}
}