package ctxmgr import ( "context" "errors" "strings" "testing" "time" ) func TestSemanticSummary_FlagOffUnchanged(t *testing.T) { t.Cleanup(ResetSummaryFlagsForTest) SetSummaryFlagsForTest(SummaryFeatureFlags{Shadow: false, Replace: false}) mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) mgr.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "SHOULD_NOT_APPEAR"}) ctx := context.Background() sessID := "sess_flag_off" batch := []ChatMessageRecord{ {ID: 1, Role: "user", Content: "我们准备研发高频超声手术刀系统,请教立项建议", CreatedAt: time.Now()}, {ID: 2, Role: "assistant", Content: "需关注电气安全。", CreatedAt: time.Now()}, } sum, err := mgr.GenerateRollingSummary(ctx, sessID, nil, batch) if err != nil { t.Fatal(err) } if sum.Shadow != nil { t.Fatalf("flag off must not attach shadow: %+v", sum.Shadow) } if sum.FreshnessNote != "" { t.Fatalf("flag off must not set freshness: %q", sum.FreshnessNote) } if strings.Contains(sum.Narrative, "SHOULD_NOT_APPEAR") { t.Fatalf("stub narrative leaked into served narrative: %s", sum.Narrative) } if sum.Source != "deterministic" { t.Fatalf("source=%q want deterministic", sum.Source) } } func TestSemanticSummary_ShadowOKKeepsDeterministic(t *testing.T) { t.Cleanup(ResetSummaryFlagsForTest) SetSummaryFlagsForTest(SummaryFeatureFlags{Shadow: true, Replace: true}) // REPLACE env true but unwired mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) mgr.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"}) ctx := context.Background() batch := []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()}, } sum, err := mgr.GenerateRollingSummary(ctx, "sess_shadow_ok", nil, batch) if err != nil { t.Fatal(err) } if sum.Shadow == nil { t.Fatal("expected advisory shadow attachment") } if strings.Contains(sum.Narrative, "语义影子叙事") { t.Fatalf("REPLACE must stay unwired; narrative replaced: %s", sum.Narrative) } if sum.Facts["device_type"] != "超声手术设备" { t.Fatalf("deterministic facts lost: %+v", sum.Facts) } // Production path is async: instant response is pending; drain / wait for OK. if sum.Shadow.Status == ShadowStatusPending { _ = mgr.DrainSemanticShadowTasks(ctx) if !mgr.WaitShadowIdle(2 * time.Second) { t.Fatal("shadow worker did not become idle") } sum, err = mgr.GetChatSummary(ctx, "sess_shadow_ok") if err != nil || sum == nil { t.Fatalf("get after drain: %v %+v", err, sum) } } if sum.Shadow == nil || sum.Shadow.Status != ShadowStatusOK { t.Fatalf("expected shadow ok after drain, got %+v", sum.Shadow) } if sum.Shadow.Narrative != "语义影子叙事:超声刀立项" { t.Fatalf("shadow narrative mismatch: %q", sum.Shadow.Narrative) } if sum.Shadow.ToolPermissions != SemanticToolPermissions { t.Fatalf("tool permissions=%q want none", sum.Shadow.ToolPermissions) } if sum.Shadow.InputDigest == "" { t.Fatal("missing input digest") } if strings.Contains(sum.Narrative, "语义影子叙事") { t.Fatalf("REPLACE must stay unwired after async attach: %s", sum.Narrative) } if sum.FreshnessNote != "" { t.Fatalf("ok path should clear freshness note: %q", sum.FreshnessNote) } } func TestSemanticSummary_ShadowFailureKeepsDeterministicWithFreshness(t *testing.T) { t.Cleanup(ResetSummaryFlagsForTest) SetSummaryFlagsForTest(SummaryFeatureFlags{Shadow: true}) mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) mgr.SetSemanticSummarizer(&StubSemanticSummarizer{Err: errors.New("model timeout")}) ctx := context.Background() batch := []ChatMessageRecord{ {ID: 1, Role: "user", Content: "我们准备研发高频超声手术刀系统,请教立项建议", CreatedAt: time.Now()}, } sum, err := mgr.GenerateRollingSummary(ctx, "sess_shadow_fail", nil, batch) if err != nil { t.Fatal(err) } if sum.Shadow == nil { t.Fatal("expected advisory shadow attachment") } if sum.Narrative == "" { t.Fatal("deterministic narrative must be retained") } if strings.Contains(sum.Narrative, "model timeout") { t.Fatal("error text must not pollute narrative") } if sum.Shadow.Status == ShadowStatusPending { _ = mgr.DrainSemanticShadowTasks(ctx) if !mgr.WaitShadowIdle(2 * time.Second) { t.Fatal("shadow worker did not become idle") } sum, err = mgr.GetChatSummary(ctx, "sess_shadow_fail") if err != nil || sum == nil { t.Fatalf("get after drain: %v %+v", err, sum) } } if sum.Shadow == nil || sum.Shadow.Status != ShadowStatusFallback { t.Fatalf("expected fallback shadow after drain, got %+v", sum.Shadow) } if sum.FreshnessNote == "" || !strings.Contains(sum.FreshnessNote, "deterministic retained") { t.Fatalf("expected freshness note, got %q", sum.FreshnessNote) } } func TestSemanticSummary_MinimizeDropsMetadataAndCaps(t *testing.T) { msgs := make([]ChatMessageRecord, 0, 50) for i := 1; i <= 50; i++ { msgs = append(msgs, ChatMessageRecord{ ID: int64(i), Role: "user", Content: strings.Repeat("字", 400) + " secret-meta", StageKey: "V1.0_quoting", Metadata: map[string]any{"stage_key": "V1.0_quoting", "token": "SECRET"}, }) } min := MinimizeAuthorizedMessages(msgs) if len(min) != semanticMaxMessages { t.Fatalf("len=%d want %d", len(min), semanticMaxMessages) } if min[0].ID != 11 { t.Fatalf("expected tail window start id=11, got %d", min[0].ID) } for _, m := range min { if utf8Count(m.Content) > semanticMaxContentRunes+3 { // truncateRunes appends "..." t.Fatalf("content not truncated: %d runes", utf8Count(m.Content)) } } digest1 := DigestAuthorizedInput(min) digest2 := DigestAuthorizedInput(min) if digest1 == "" || digest1 != digest2 { t.Fatalf("digest unstable: %q vs %q", digest1, digest2) } } func utf8Count(s string) int { return len([]rune(s)) } func TestDigestSemanticShadow_FlagOff(t *testing.T) { t.Cleanup(ResetSummaryFlagsForTest) SetSummaryFlagsForTest(SummaryFeatureFlags{Shadow: false}) mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) mgr.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "shadow-headline"}) ctx := context.Background() day := time.Now().Format("2006-01-02") msgs := []ChatMessageRecord{ {ID: 1, Role: "user", Content: "讨论立项", DayKey: day, CreatedAt: time.Now()}, } d, err := mgr.GenerateDailyDigestFromMessages(ctx, "sess_d", day, msgs) if err != nil { t.Fatal(err) } if d.Shadow != nil || d.FreshnessNote != "" { t.Fatalf("flag off digest must omit shadow: %+v note=%q", d.Shadow, d.FreshnessNote) } if d.Source != "rule" { t.Fatalf("source=%q", d.Source) } } func TestDigestSemanticShadow_Fallback(t *testing.T) { t.Cleanup(ResetSummaryFlagsForTest) SetSummaryFlagsForTest(SummaryFeatureFlags{Shadow: true}) mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) mgr.SetSemanticSummarizer(&StubSemanticSummarizer{Err: errors.New("boom")}) ctx := context.Background() day := time.Now().Format("2006-01-02") msgs := []ChatMessageRecord{ {ID: 1, Role: "user", Content: "讨论立项进度", DayKey: day, CreatedAt: time.Now()}, } d, err := mgr.GenerateDailyDigestFromMessages(ctx, "sess_d2", day, msgs) if err != nil { t.Fatal(err) } headline := d.Headline if d.Shadow == nil || d.Shadow.Status != ShadowStatusFallback { t.Fatalf("want fallback shadow: %+v", d.Shadow) } if d.FreshnessNote == "" { t.Fatal("want freshness note") } if d.Headline != headline || d.Headline == "" { t.Fatal("deterministic headline must remain") } }