package backthink import ( "context" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) func TestParseProactive_WithMarkers(t *testing.T) { t.Parallel() conclusion := "[我决定:push]\n[渠道:kook]\n我想提醒你明天有体检" msg, hint, push := parseProactive(conclusion) if !push { t.Fatalf("push = false, want true (decision said push)") } if hint != "kook" { t.Fatalf("channel hint = %q, want kook", hint) } if msg != "我想提醒你明天有体检" { t.Fatalf("message = %q, want the clean body", msg) } } func TestParseProactive_NoPushMarker(t *testing.T) { t.Parallel() conclusion := "[我决定:no_push]\n[渠道:telegram]\n暂时没有想说的" msg, hint, push := parseProactive(conclusion) if push { t.Fatalf("push = true, want false (decision said no_push)") } if hint != "telegram" { t.Fatalf("channel hint = %q, want telegram", hint) } if msg != "暂时没有想说的" { t.Fatalf("message = %q, want the clean body", msg) } } func TestParseProactive_ChannelColonWidth(t *testing.T) { t.Parallel() // Tolerate a full-width colon and trailing spaces, and uppercase channel value. conclusion := "[我决定:push] [渠道:WEB] 晚上早点睡" msg, hint, push := parseProactive(conclusion) if !push { t.Fatalf("push = false, want true") } if hint != "web" { t.Fatalf("channel hint = %q, want web (lowercased)", hint) } if msg != "晚上早点睡" { t.Fatalf("message = %q, want the clean body", msg) } } func TestParseProactive_NothingVariants(t *testing.T) { t.Parallel() for _, c := range []string{ "无", "无。", "nothing to report", "Nothing to report.", "nothing", "暂无", } { if _, _, push := parseProactive(c); push { t.Fatalf("parseProactive(%q) push = true, want false", c) } } } func TestParseProactive_NoMarkerDefaultPush(t *testing.T) { t.Parallel() conclusion := "洗衣机洗好了,衣服可以收了" msg, hint, push := parseProactive(conclusion) if !push { t.Fatalf("push = false, want true (no marker, not a nothing phrase)") } if hint != "" { t.Fatalf("channel hint = %q, want empty", hint) } if msg != conclusion { t.Fatalf("message = %q, want the whole conclusion", msg) } } func TestParseProactive_MalformedFailsSafe(t *testing.T) { t.Parallel() cases := []string{ "[我决定:maybe]", // decision value outside allow-list "[我决定]", // bracket without a value "[我决定:push", // unterminated bracket "[渠道:web]", // channel marker alone, no decision → but no decision marker "[我决定:push", // deliberate ambiguity } for _, c := range cases { if _, _, push := parseProactive(c); push { t.Fatalf("parseProactive(%q) push = true, want false (fail safe on ambiguity)", c) } } } func TestParseProactive_MarkerWithoutBody(t *testing.T) { t.Parallel() // A lone push marker with no body must not push an empty message (fail safe). if _, _, push := parseProactive("[我决定:push]"); push { t.Fatalf("push = true for a marker with no body, want false") } // A push marker whose body says "nothing" is contradictory → fail safe. if _, _, push := parseProactive("[我决定:push]\n无"); push { t.Fatalf("push = true for a push marker with nothing body, want false") } } func TestThink_ParsesStructuredDecision_EndToEnd(t *testing.T) { t.Parallel() conclusion := "[我决定:push]\n[渠道:kook]\n我想提醒你明天有体检" thinker := &mockThinker{text: conclusion} active := &mockActive{n: 1} im := &mockIMAdapter{} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{}}, Active: active, IM: im, Gate: &mockGate{allow: true}, Now: func() time.Time { return dayAt() }, }) b.think(context.Background(), heartbeatTrigger()) // The channel hint "kook" routes to the IM bridge (a specific platform), not the // active client. if active.calls != 0 { t.Fatalf("active calls = %d, want 0 (specific platform routes to IM)", active.calls) } if im.count() != 1 { t.Fatalf("im calls = %d, want 1", im.count()) } if im.last.Content != "我想提醒你明天有体检" { t.Fatalf("im message content = %q, want the clean body", im.last.Content) } if im.last.Platform != "kook" { t.Fatalf("im message platform = %q, want kook", im.last.Platform) } recs, err := b.List(context.Background(), 10) if err != nil { t.Fatalf("List: %v", err) } if len(recs) == 0 { t.Fatal("no record persisted") } if !recs[0].ShouldPush { t.Fatalf("ShouldPush = false, want true") } if recs[0].Conclusion != "我想提醒你明天有体检" { t.Fatalf("record Conclusion = %q, want the clean body", recs[0].Conclusion) } if recs[0].PushResult.Route != RouteIMBridge { t.Fatalf("record Route = %q, want %q", recs[0].PushResult.Route, RouteIMBridge) } if !strings.Contains(recs[0].PushResult.Detail, "channel_hint=kook") { t.Fatalf("record Detail = %q, want it to include the kook hint", recs[0].PushResult.Detail) } if !recs[0].PushResult.Pushed { t.Fatalf("record Pushed = false, want true") } } func TestThink_NoMarkerDefaultRoute_IM(t *testing.T) { t.Parallel() // No channel marker → auto → a single IM channel (the bridge picks her choice / // her recent platform / the whitelist default), NOT the active web client. conclusion := "[我决定:push]\n洗衣机洗好了,衣服可以收了" thinker := &mockThinker{text: conclusion} active := &mockActive{n: 2} im := &mockIMAdapter{} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{}}, Active: active, IM: im, Gate: &mockGate{allow: true}, Now: func() time.Time { return dayAt() }, }) b.think(context.Background(), heartbeatTrigger()) if active.calls != 0 { t.Fatalf("active calls = %d, want 0 (auto targets the IM bridge, not the web client)", active.calls) } if im.count() != 1 { t.Fatalf("im calls = %d, want 1 (single IM target)", im.count()) } recs, err := b.List(context.Background(), 10) if err != nil { t.Fatalf("List: %v", err) } if recs[0].PushResult.Route != RouteIMBridge { t.Fatalf("record Route = %q, want %q", recs[0].PushResult.Route, RouteIMBridge) } if recs[0].PushResult.Pushed != true { t.Fatalf("record Pushed = %v, want true", recs[0].PushResult.Pushed) } } func TestThink_ConcludedNoPush_NotPushed(t *testing.T) { t.Parallel() conclusion := "[我决定:no_push]\n今晚就不打扰她了" thinker := &mockThinker{text: conclusion} active := &mockActive{n: 1} im := &mockIMAdapter{} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{}}, Active: active, IM: im, Gate: &mockGate{allow: true}, Now: func() time.Time { return dayAt() }, }) b.think(context.Background(), heartbeatTrigger()) if active.calls != 0 || im.count() != 0 { t.Fatalf("push attempted despite no_push: active=%d im=%d", active.calls, im.count()) } recs, err := b.List(context.Background(), 10) if err != nil { t.Fatalf("List: %v", err) } if recs[0].ShouldPush { t.Fatalf("ShouldPush = true, want false") } if recs[0].PushResult.Route != RouteNone { t.Fatalf("record Route = %q, want %q", recs[0].PushResult.Route, RouteNone) } } func TestThink_DoNotDisturbBlocksPush(t *testing.T) { t.Parallel() // She wants to push, but the user is asleep → the hard safety gate refuses, // regardless of her decision. conclusion := "[我决定:push]\n想跟你说晚安" thinker := &mockThinker{text: conclusion} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{Sleeping: true}}, Pusher: &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}, Now: func() time.Time { return nightAt() }, }) b.think(context.Background(), heartbeatTrigger()) recs, err := b.List(context.Background(), 10) if err != nil { t.Fatalf("List: %v", err) } if recs[0].ShouldPush { t.Fatalf("ShouldPush = true, want false (asleep blocks push)") } if recs[0].PushResult.Route != RouteNone { t.Fatalf("record Route = %q, want %q", recs[0].PushResult.Route, RouteNone) } } func TestBuildProactiveMessage_DefaultPlatform(t *testing.T) { t.Parallel() b := &Backthink{now: dayAt} msg := b.buildProactiveMessage("晚安", "") if msg.Platform != defaultProactivePlatform { t.Fatalf("Platform = %q, want default %q", msg.Platform, defaultProactivePlatform) } if msg.Content != "晚安" { t.Fatalf("Content = %q, want 晚安", msg.Content) } if msg.ChannelType != adapter.ChannelDirect || msg.ContentType != adapter.ContentText { t.Fatalf("message kind = %q/%q, want direct/text", msg.ChannelType, msg.ContentType) } } func TestBuildProactiveMessage_SpecificPlatform(t *testing.T) { t.Parallel() b := &Backthink{now: dayAt} msg := b.buildProactiveMessage("晚安", "telegram") if msg.Platform != "telegram" { t.Fatalf("Platform = %q, want telegram", msg.Platform) } } func TestPusher_SpecificPlatform_NoIM_FallsBackToActive(t *testing.T) { t.Parallel() // When a specific platform is requested but no IM sink is wired, the pusher // falls back to the default route so the owner still receives the message, and // records the requested hint. active := &mockActive{n: 1} p := newPusher(active, nil, &mockGate{allow: true}, discardLog()) res := p.Push(context.Background(), PushRequest{ Message: adapter.UnifiedMessage{}, ChannelHint: "kook", }) if !res.Pushed || res.Route != RouteActiveClient { t.Fatalf("push = %+v, want pushed active_client (fallback)", res) } if !strings.Contains(res.Detail, "channel_hint=kook") { t.Fatalf("Detail = %q, want it to include the kook hint", res.Detail) } } // TestStripInternalMarkers_CleanBody verifies the internal decision/channel // markers are removed from a proactive body in every shape a model may emit // (bracketed, full-width, bare), so a marker can never reach the user-facing text. func TestStripInternalMarkers_CleanBody(t *testing.T) { t.Parallel() cases := []struct { name, in, want string }{ {name: "bracketed own lines", in: "[我决定:push]\n[渠道:kook]\n我想提醒你明天有体检", want: "我想提醒你明天有体检"}, {name: "inline bracketed", in: "[我决定:push] [渠道:WEB] 晚上早点睡", want: "晚上早点睡"}, {name: "bare markers", in: "我决定:push\n渠道:kook\n记得喝水哦", want: "记得喝水哦"}, {name: "full-width brackets", in: "【我决定:push】【渠道:telegram】早点休息", want: "早点休息"}, {name: "marker trailing body", in: "今天风很大[我决定:push][渠道:kook]", want: "今天风很大"}, {name: "no marker", in: "洗衣机洗好了,衣服可以收了", want: "洗衣机洗好了,衣服可以收了"}, {name: "empty", in: "", want: ""}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := stripInternalMarkers(tc.in); got != tc.want { t.Fatalf("stripInternalMarkers(%q) = %q, want %q", tc.in, got, tc.want) } }) } } // TestThink_BareMarkersStrippedFromBody is the regression for the KOOK leak: a // conclusion carrying bare (bracket-less) "我决定:push"/"渠道:..." markers must // still deliver a clean warm body, never the structured decision text. func TestThink_BareMarkersStrippedFromBody(t *testing.T) { t.Parallel() conclusion := "我决定:push\n渠道:kook\n记得喝水哦" thinker := &mockThinker{text: conclusion} im := &mockIMAdapter{} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{}}, IM: im, Gate: &mockGate{allow: true}, Now: func() time.Time { return dayAt() }, }) b.think(context.Background(), heartbeatTrigger()) if im.count() != 1 { t.Fatalf("im calls = %d, want 1", im.count()) } if im.last.Content != "记得喝水哦" { t.Fatalf("im content = %q, want the clean body", im.last.Content) } if strings.Contains(im.last.Content, "我决定") || strings.Contains(im.last.Content, "渠道") { t.Fatalf("internal marker leaked into the message body: %q", im.last.Content) } }