返回 DeepSeek-Reasonix
replace_test.go
根目录 / internal / extension / replace_test.go
1 package extension
2
3 import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8 )
9
10 // TestParseSlot pins the accepted slot forms: the eight named slots, tool:
11 // with a bare tool name, and provider: with a name/model ref — including the
12 // extension-hosted plugin/<plugin>/<name>/<model> form (stage 7). Everything
13 // else is rejected so a typo can never open a slot nothing reads.
14 func TestParseSlot(t *testing.T) {
15 valid := []string{
16 "system_prompt", "context", "provider_request", "provider_response",
17 "compaction", "session_policy", "permission", "frontend_events",
18 "tool:bash", "tool:read_file", "provider:openai/gpt-5", "provider:deepseek/deepseek-chat",
19 "provider:plugin/demo/fake/x",
20 }
21 for _, s := range valid {
22 if _, err := ParseSlot(s); err != nil {
23 t.Errorf("ParseSlot(%q) = %v, want ok", s, err)
24 }
25 }
26 invalid := []string{
27 "", "bogus", "SYSTEM_PROMPT", "tool:", "tool:a b", "provider:", "provider:openai",
28 "provider:openai/x/y", "tool", "provider",
29 "provider:plugin/demo/fake", "provider:plugin//fake/x", "provider:plugin/ /fake/x",
30 }
31 for _, s := range invalid {
32 if _, err := ParseSlot(s); err == nil {
33 t.Errorf("ParseSlot(%q) succeeded, want error", s)
34 }
35 }
36 // Constructors produce exactly the forms ParseSlot accepts.
37 if got := SlotTool("bash"); got != Slot("tool:bash") {
38 t.Fatalf("SlotTool(bash) = %q", got)
39 }
40 if _, err := ParseSlot(string(SlotTool("bash"))); err != nil {
41 t.Fatalf("SlotTool output rejected by ParseSlot: %v", err)
42 }
43 if got := SlotProviderRef("openai/x"); got != Slot("provider:openai/x") {
44 t.Fatalf("SlotProviderRef(openai/x) = %q", got)
45 }
46 if _, err := ParseSlot(string(SlotProviderRef("openai/x"))); err != nil {
47 t.Fatalf("SlotProviderRef output rejected by ParseSlot: %v", err)
48 }
49 if _, err := ParseSlot(string(SlotProviderRef("plugin/demo/fake/x"))); err != nil {
50 t.Fatalf("SlotProviderRef plugin output rejected by ParseSlot: %v", err)
51 }
52 }
53
54 // TestReplaceClaims: single ownership per slot; the second claimant gets a
55 // SlotConflictError naming both owners and ownership stays with the first.
56 func TestReplaceClaims(t *testing.T) {
57 claims := NewReplaceClaims()
58 ownerA := src(ScopePlugin, "pa", "plugin")
59 ownerB := src(ScopePlugin, "pb", "plugin")
60
61 if err := claims.Claim(SlotSystemPrompt, ownerA); err != nil {
62 t.Fatalf("first claim failed: %v", err)
63 }
64 if err := claims.Claim(SlotTool("bash"), ownerA); err != nil {
65 t.Fatalf("tool slot claim failed: %v", err)
66 }
67 if err := claims.Claim(SlotProviderRef("openai/x"), ownerB); err != nil {
68 t.Fatalf("provider slot claim failed: %v", err)
69 }
70
71 err := claims.Claim(SlotSystemPrompt, ownerB)
72 var conflict *SlotConflictError
73 if !errors.As(err, &conflict) {
74 t.Fatalf("second claim error = %v, want *SlotConflictError", err)
75 }
76 if conflict.Slot != SlotSystemPrompt {
77 t.Fatalf("conflict slot = %q, want system_prompt", conflict.Slot)
78 }
79 if len(conflict.Owners) != 2 || conflict.Owners[0].PluginID != "pa" || conflict.Owners[1].PluginID != "pb" {
80 t.Fatalf("conflict owners = %+v, want pa then pb", conflict.Owners)
81 }
82 if !strings.Contains(err.Error(), "pa") || !strings.Contains(err.Error(), "pb") {
83 t.Fatalf("conflict message must name both owners: %v", err)
84 }
85
86 // The failed claim must not have taken over the slot.
87 if owner, ok := claims.Owner(SlotSystemPrompt); !ok || owner.PluginID != "pa" {
88 t.Fatalf("owner after conflict = %+v, want pa", owner)
89 }
90 // Claiming an invalid slot string is an error, not a silent new slot.
91 if err := claims.Claim(Slot("not-a-slot"), ownerA); err == nil {
92 t.Fatal("claiming an invalid slot succeeded")
93 }
94 // Claims() returns a copy.
95 cp := claims.Claims()
96 cp[SlotSystemPrompt] = ownerB
97 if owner, _ := claims.Owner(SlotSystemPrompt); owner.PluginID != "pa" {
98 t.Fatal("mutating Claims() result changed the claim table")
99 }
100 }
101
102 // claimPayload is a contribution payload that claims replacement slots.
103 type claimPayload struct {
104 body string
105 slots []Slot
106 }
107
108 func (p claimPayload) ReplacementSlots() []Slot { return p.slots }
109
110 // TestBuildSlotConflict: slot claims from two contributions collide at
111 // resolve time even though the contributions themselves do not.
112 func TestBuildSlotConflict(t *testing.T) {
113 b := NewBuilder()
114 b.AddContributor(
115 staticContributor("a", Contribution{
116 Kind: KindStrategy, ID: "strat-a",
117 Source: src(ScopePlugin, "pa", "plugin"),
118 Payload: claimPayload{body: "a", slots: []Slot{SlotSystemPrompt}},
119 }),
120 staticContributor("b", Contribution{
121 Kind: KindStrategy, ID: "strat-b",
122 Source: src(ScopePlugin, "pb", "plugin"),
123 Payload: claimPayload{body: "b", slots: []Slot{SlotSystemPrompt}},
124 }),
125 )
126 _, _, err := b.Build(context.Background())
127 var conflict *SlotConflictError
128 if !errors.As(err, &conflict) {
129 t.Fatalf("Build error = %v, want *SlotConflictError", err)
130 }
131 if conflict.Slot != SlotSystemPrompt {
132 t.Fatalf("conflict slot = %q", conflict.Slot)
133 }
134 }
135
136 // TestBuildSlotWinner: a single claim lands in the snapshot's Replacements.
137 func TestBuildSlotWinner(t *testing.T) {
138 b := NewBuilder()
139 b.AddContributor(staticContributor("a", Contribution{
140 Kind: KindStrategy, ID: "strat-a",
141 Source: src(ScopePlugin, "pa", "plugin"),
142 Payload: claimPayload{body: "a", slots: []Slot{SlotSystemPrompt, SlotTool("bash")}},
143 }))
144 snap, _, err := b.Build(context.Background())
145 if err != nil {
146 t.Fatalf("Build failed: %v", err)
147 }
148 repl := snap.Replacements()
149 if len(repl) != 2 {
150 t.Fatalf("Replacements = %v, want 2 entries", repl)
151 }
152 if repl[SlotSystemPrompt].PluginID != "pa" || repl[SlotTool("bash")].PluginID != "pa" {
153 t.Fatalf("slot owners wrong: %v", repl)
154 }
155 }
156
156 lines GO