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