- 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.
307 lines
13 KiB
Go
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)
|
|
}
|
|
}
|