interactive_model_refresh_test.go RawHistoryFolder
1package modes
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8 "time"
9)
10
11func awaitModelRefresh(t *testing.T, i *Interactive) modelRefreshResult {
12 t.Helper()
13 select {
14 case result := <-i.modelRefresh:
15 return result
16 case <-time.After(5 * time.Second):
17 t.Fatal("refresh result never delivered")
18 }
19 return modelRefreshResult{}
20}
21
22func TestModelPickerRefreshesCustomDiscoveryWithoutLlama(t *testing.T) {
23 calls := make(chan struct{}, 2)
24 i := &Interactive{
25 cfg: InteractiveConfig{
26 CustomDiscoveryConfigured: func() bool { return true },
27 RefreshCustomProviderModels: func(ctx context.Context) error {
28 if _, ok := ctx.Deadline(); !ok {
29 t.Error("custom refresh has no deadline")
30 }
31 calls <- struct{}{}
32 return nil
33 },
34 RefreshLlamaCPPModels: func(context.Context) error { t.Error("llama refreshed while unconfigured"); return nil },
35 },
36 modelRefresh: make(chan modelRefreshResult, 1),
37 modelDialog: newModelDialog(),
38 }
39
40 i.runSlash(context.Background(), "/model")
41 if i.modelDialog.Active() || i.statusOK != "Refreshing models" || !i.modelRefreshing {
42 t.Fatalf("picker opened early or status wrong: active=%v status=%q", i.modelDialog.Active(), i.statusOK)
43 }
44 result := awaitModelRefresh(t, i)
45 <-calls
46 i.openModelPickerAfterRefresh(result.err)
47 if !i.modelDialog.Active() {
48 t.Fatal("picker did not open after refresh")
49 }
50}
51
52func TestModelPickerCustomDiscoveryOptOutOpensImmediately(t *testing.T) {
53 i := &Interactive{
54 cfg: InteractiveConfig{
55 CustomDiscoveryConfigured: func() bool { return false },
56 RefreshCustomProviderModels: func(context.Context) error { t.Error("unexpected refresh"); return nil },
57 },
58 modelRefresh: make(chan modelRefreshResult, 1),
59 modelDialog: newModelDialog(),
60 }
61 i.runSlash(context.Background(), "/model")
62 if !i.modelDialog.Active() || i.modelRefreshing {
63 t.Fatal("picker did not open immediately without opt-in")
64 }
65}
66
67func TestModelPickerRunsLlamaAndCustomIndependentlyAndJoinsErrors(t *testing.T) {
68 var order []string
69 llamaErr, customErr := errors.New("llama down"), errors.New("custom down")
70 i := &Interactive{
71 llamaConfigured: true,
72 cfg: InteractiveConfig{
73 RefreshLlamaCPPModels: func(ctx context.Context) error {
74 order = append(order, "llama")
75 return llamaErr
76 },
77 CustomDiscoveryConfigured: func() bool { return true },
78 RefreshCustomProviderModels: func(ctx context.Context) error {
79 // A failed sibling must not consume this refresh's own context.
80 if ctx.Err() != nil {
81 t.Errorf("custom refresh context already done: %v", ctx.Err())
82 }
83 order = append(order, "custom")
84 return customErr
85 },
86 },
87 modelRefresh: make(chan modelRefreshResult, 1),
88 modelDialog: newModelDialog(),
89 }
90 i.runSlash(context.Background(), "/model")
91 result := awaitModelRefresh(t, i)
92 if strings.Join(order, ",") != "llama,custom" {
93 t.Fatalf("refresh order = %v", order)
94 }
95 if !errors.Is(result.err, llamaErr) || !errors.Is(result.err, customErr) {
96 t.Fatalf("errors not joined: %v", result.err)
97 }
98 i.openModelPickerAfterRefresh(result.err)
99 if !i.modelDialog.Active() {
100 t.Fatal("picker unusable after refresh errors")
101 }
102 if !strings.HasPrefix(i.statusErr, "model refresh: ") || !strings.Contains(i.statusErr, "llama down") || !strings.Contains(i.statusErr, "custom down") {
103 t.Fatalf("status = %q", i.statusErr)
104 }
105}
106
107func TestModelPickerRefreshDoesNotLeakWhenContextCancelled(t *testing.T) {
108 ctx, cancel := context.WithCancel(context.Background())
109 done := make(chan struct{})
110 i := &Interactive{
111 cfg: InteractiveConfig{
112 CustomDiscoveryConfigured: func() bool { return true },
113 RefreshCustomProviderModels: func(ctx context.Context) error {
114 cancel()
115 close(done)
116 return nil
117 },
118 },
119 // Unbuffered and never read: delivery must give up on ctx.Done.
120 modelRefresh: make(chan modelRefreshResult),
121 modelDialog: newModelDialog(),
122 }
123 i.runSlash(ctx, "/model")
124 select {
125 case <-done:
126 case <-time.After(5 * time.Second):
127 t.Fatal("refresh not invoked")
128 }
129 // The goroutine must exit without a receiver; a leaked sender would keep
130 // this send from ever being received by a fresh reader.
131 select {
132 case r := <-i.modelRefresh:
133 t.Fatalf("result delivered after cancellation: %+v", r)
134 case <-time.After(100 * time.Millisecond):
135 }
136}