| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "sync" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/config" |
| 13 | "reasonix/internal/control" |
| 14 | "reasonix/internal/event" |
| 15 | "reasonix/internal/provider" |
| 16 | ) |
| 17 | |
| 18 | // TestRuntimeRebuildsEmitRuntimeRebuiltForTab pins the chime-dedupe contract: |
| 19 | // model/effort rebuilds emit runtime:rebuilt; deprecated SetTokenMode does not. |
| 20 | func TestRuntimeRebuildsEmitRuntimeRebuiltForTab(t *testing.T) { |
| 21 | isolateDesktopUserDirs(t) |
| 22 | setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") |
| 23 | setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test") |
| 24 | |
| 25 | cfg := config.Default() |
| 26 | cfg.DefaultModel = "old/old-model" |
| 27 | cfg.Desktop.ProviderAccess = []string{"old", "new"} |
| 28 | cfg.Providers = []config.ProviderEntry{ |
| 29 | {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"}, |
| 30 | {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "deepseek-v4-pro", APIKeyEnv: "NEW_MODEL_KEY"}, |
| 31 | } |
| 32 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 33 | t.Fatalf("save config: %v", err) |
| 34 | } |
| 35 | |
| 36 | dir := config.SessionDir() |
| 37 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 38 | t.Fatalf("mkdir session dir: %v", err) |
| 39 | } |
| 40 | sess := agent.NewSession("sys") |
| 41 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) |
| 42 | exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) |
| 43 | path := filepath.Join(dir, "rebuild-events.jsonl") |
| 44 | ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard}) |
| 45 | |
| 46 | app := NewApp() |
| 47 | app.ctx = context.Background() |
| 48 | // emitReady calls the Wails runtime directly; the ready hook keeps the |
| 49 | // workspace-reconcile path (which SetEffortForTab can take) off the real |
| 50 | // event bridge, which log.Fatals on a plain Background context. |
| 51 | app.readyHook = func() {} |
| 52 | |
| 53 | var mu sync.Mutex |
| 54 | var rebuilt []string |
| 55 | // The App-level queue must stay silent: ordering against the tab's agent |
| 56 | // events only holds when the notice rides the tab sink's own queue, so a |
| 57 | // notice showing up here means the routing regressed to the fallback. |
| 58 | app.runtimeEvents.emit = func(_ context.Context, name string, _ ...any) { |
| 59 | if name == "runtime:rebuilt" { |
| 60 | mu.Lock() |
| 61 | rebuilt = append(rebuilt, "VIA-APP-QUEUE") |
| 62 | mu.Unlock() |
| 63 | } |
| 64 | } |
| 65 | sinkEmit := func(_ context.Context, name string, payload ...any) { |
| 66 | if name != "runtime:rebuilt" { |
| 67 | return |
| 68 | } |
| 69 | tabID := "" |
| 70 | if len(payload) > 0 { |
| 71 | tabID, _ = payload[0].(string) |
| 72 | } |
| 73 | mu.Lock() |
| 74 | rebuilt = append(rebuilt, tabID) |
| 75 | mu.Unlock() |
| 76 | } |
| 77 | |
| 78 | tab := &WorkspaceTab{ |
| 79 | ID: "tab_rebuild_events", |
| 80 | Scope: "global", |
| 81 | WorkspaceRoot: globalTabWorkspaceRoot(), |
| 82 | Ready: true, |
| 83 | model: "old/old-model", |
| 84 | Ctrl: ctrl, |
| 85 | sink: &tabEventSink{tabID: "tab_rebuild_events", app: app, ctx: context.Background()}, |
| 86 | disabledMCP: map[string]ServerView{}, |
| 87 | } |
| 88 | tab.sink.runtimeEvents.emit = sinkEmit |
| 89 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 90 | app.tabOrder = []string{tab.ID} |
| 91 | app.activeTabID = tab.ID |
| 92 | t.Cleanup(func() { |
| 93 | if tab.Ctrl != nil { |
| 94 | tab.Ctrl.Close() |
| 95 | } |
| 96 | }) |
| 97 | |
| 98 | waitCount := func(want int, step string) { |
| 99 | t.Helper() |
| 100 | deadline := time.Now().Add(5 * time.Second) |
| 101 | for time.Now().Before(deadline) { |
| 102 | mu.Lock() |
| 103 | n := len(rebuilt) |
| 104 | mu.Unlock() |
| 105 | if n >= want { |
| 106 | return |
| 107 | } |
| 108 | time.Sleep(10 * time.Millisecond) |
| 109 | } |
| 110 | mu.Lock() |
| 111 | defer mu.Unlock() |
| 112 | t.Fatalf("after %s: runtime:rebuilt events = %v, want %d", step, rebuilt, want) |
| 113 | } |
| 114 | |
| 115 | if err := app.SetModelForTab(tab.ID, "new/deepseek-v4-pro"); err != nil { |
| 116 | t.Fatalf("SetModelForTab: %v", err) |
| 117 | } |
| 118 | waitCount(1, "model switch") |
| 119 | |
| 120 | if err := app.SetEffortForTab(tab.ID, "high"); err != nil { |
| 121 | t.Fatalf("SetEffortForTab: %v", err) |
| 122 | } |
| 123 | waitCount(2, "effort switch") |
| 124 | |
| 125 | if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil { |
| 126 | t.Fatalf("SetTokenModeForTab: %v", err) |
| 127 | } |
| 128 | // Give a real rebuild event time to arrive if the no-op regresses. |
| 129 | time.Sleep(50 * time.Millisecond) |
| 130 | mu.Lock() |
| 131 | if len(rebuilt) != 2 { |
| 132 | t.Fatalf("after SetTokenModeForTab: runtime:rebuilt events = %v, want 2 (no rebuild)", rebuilt) |
| 133 | } |
| 134 | for i, id := range rebuilt { |
| 135 | if id == "VIA-APP-QUEUE" { |
| 136 | t.Fatalf("event %d took the App-level fallback queue; it must ride the tab sink queue so it orders before the rebuilt controller's agent events (full: %v)", i, rebuilt) |
| 137 | } |
| 138 | if id != tab.ID { |
| 139 | t.Fatalf("event %d carried tab id %q, want %q (full: %v)", i, id, tab.ID, rebuilt) |
| 140 | } |
| 141 | } |
| 142 | mu.Unlock() |
| 143 | } |
| 144 | |
| 145 | // TestRuntimeReattachFencesPendingAskBeforeReplay pins the detached-runtime |
| 146 | // handoff order. A transferred controller keeps its pending ask, but the |
| 147 | // frontend must learn the transferred epoch before that ask reaches it. |
| 148 | func TestRuntimeReattachFencesPendingAskBeforeReplay(t *testing.T) { |
| 149 | type emittedEvent struct { |
| 150 | name string |
| 151 | payload []any |
| 152 | } |
| 153 | emitted := make(chan emittedEvent, 4) |
| 154 | sink := &tabEventSink{ |
| 155 | tabID: "tab-reattach", |
| 156 | ctx: context.Background(), |
| 157 | runtimeEpoch: "runtime-new", |
| 158 | } |
| 159 | sink.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) { |
| 160 | emitted <- emittedEvent{name: name, payload: payload} |
| 161 | } |
| 162 | |
| 163 | ctrl := control.New(control.Options{Sink: sink}) |
| 164 | ctx, cancel := context.WithCancel(t.Context()) |
| 165 | done := make(chan struct{}) |
| 166 | go func() { |
| 167 | defer close(done) |
| 168 | _, _ = ctrl.Ask(ctx, []event.AskQuestion{{ID: "choice", Prompt: "Pick one"}}) |
| 169 | }() |
| 170 | t.Cleanup(func() { |
| 171 | cancel() |
| 172 | <-done |
| 173 | ctrl.Close() |
| 174 | }) |
| 175 | |
| 176 | select { |
| 177 | case initial := <-emitted: |
| 178 | if initial.name != eventChannel { |
| 179 | t.Fatalf("initial event = %q, want %q", initial.name, eventChannel) |
| 180 | } |
| 181 | case <-time.After(2 * time.Second): |
| 182 | t.Fatal("timed out waiting for initial ask") |
| 183 | } |
| 184 | |
| 185 | app := NewApp() |
| 186 | tab := &WorkspaceTab{ID: "tab-reattach", Ctrl: ctrl, sink: sink, Ready: true} |
| 187 | app.replayPendingPromptsAfterRuntimeAttach(tab.ID, sink, ctrl, "runtime-new") |
| 188 | |
| 189 | var got []emittedEvent |
| 190 | for len(got) < 2 { |
| 191 | select { |
| 192 | case next := <-emitted: |
| 193 | got = append(got, next) |
| 194 | case <-time.After(2 * time.Second): |
| 195 | t.Fatalf("timed out waiting for reattach events; got %+v", got) |
| 196 | } |
| 197 | } |
| 198 | if got[0].name != "runtime:rebuilt" || got[1].name != eventChannel { |
| 199 | t.Fatalf("reattach event order = [%s, %s], want [runtime:rebuilt, %s]", got[0].name, got[1].name, eventChannel) |
| 200 | } |
| 201 | if len(got[0].payload) < 2 || got[0].payload[0] != tab.ID || got[0].payload[1] != "runtime-new" { |
| 202 | t.Fatalf("runtime:rebuilt payload = %#v", got[0].payload) |
| 203 | } |
| 204 | } |
| 205 |