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) } }