| 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 frontend contract the |
| 19 | // prompt-chime dedupe depends on: every in-place controller replacement must |
| 20 | // announce itself. Model, effort, and token-mode switches rebuild the tab's |
| 21 | // controller WITHOUT an agent:ready — the rebuilt controller restarts its |
| 22 | // approval/ask id counter at "1", so without runtime:rebuilt the frontend's |
| 23 | // id-keyed chime dedupe mutes the first prompt after a switch. |
| 24 | func TestRuntimeRebuildsEmitRuntimeRebuiltForTab(t *testing.T) { |
| 25 | isolateDesktopUserDirs(t) |
| 26 | setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") |
| 27 | setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test") |
| 28 | |
| 29 | cfg := config.Default() |
| 30 | cfg.DefaultModel = "old/old-model" |
| 31 | cfg.Desktop.ProviderAccess = []string{"old", "new"} |
| 32 | cfg.Providers = []config.ProviderEntry{ |
| 33 | {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"}, |
| 34 | {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "deepseek-v4-pro", APIKeyEnv: "NEW_MODEL_KEY"}, |
| 35 | } |
| 36 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 37 | t.Fatalf("save config: %v", err) |
| 38 | } |
| 39 | |
| 40 | dir := config.SessionDir() |
| 41 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 42 | t.Fatalf("mkdir session dir: %v", err) |
| 43 | } |
| 44 | sess := agent.NewSession("sys") |
| 45 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) |
| 46 | exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) |
| 47 | path := filepath.Join(dir, "rebuild-events.jsonl") |
| 48 | ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard}) |
| 49 | |
| 50 | app := NewApp() |
| 51 | app.ctx = context.Background() |
| 52 | // emitReady calls the Wails runtime directly; the ready hook keeps the |
| 53 | // workspace-reconcile path (which SetEffortForTab can take) off the real |
| 54 | // event bridge, which log.Fatals on a plain Background context. |
| 55 | app.readyHook = func() {} |
| 56 | |
| 57 | var mu sync.Mutex |
| 58 | var rebuilt []string |
| 59 | // The App-level queue must stay silent: ordering against the tab's agent |
| 60 | // events only holds when the notice rides the tab sink's own queue, so a |
| 61 | // notice showing up here means the routing regressed to the fallback. |
| 62 | app.runtimeEvents.emit = func(_ context.Context, name string, _ ...interface{}) { |
| 63 | if name == "runtime:rebuilt" { |
| 64 | mu.Lock() |
| 65 | rebuilt = append(rebuilt, "VIA-APP-QUEUE") |
| 66 | mu.Unlock() |
| 67 | } |
| 68 | } |
| 69 | sinkEmit := func(_ context.Context, name string, payload ...interface{}) { |
| 70 | if name != "runtime:rebuilt" { |
| 71 | return |
| 72 | } |
| 73 | tabID := "" |
| 74 | if len(payload) > 0 { |
| 75 | tabID, _ = payload[0].(string) |
| 76 | } |
| 77 | mu.Lock() |
| 78 | rebuilt = append(rebuilt, tabID) |
| 79 | mu.Unlock() |
| 80 | } |
| 81 | |
| 82 | tab := &WorkspaceTab{ |
| 83 | ID: "tab_rebuild_events", |
| 84 | Scope: "global", |
| 85 | WorkspaceRoot: globalTabWorkspaceRoot(), |
| 86 | Ready: true, |
| 87 | model: "old/old-model", |
| 88 | Ctrl: ctrl, |
| 89 | sink: &tabEventSink{tabID: "tab_rebuild_events", app: app, ctx: context.Background()}, |
| 90 | disabledMCP: map[string]ServerView{}, |
| 91 | } |
| 92 | tab.sink.runtimeEvents.emit = sinkEmit |
| 93 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 94 | app.tabOrder = []string{tab.ID} |
| 95 | app.activeTabID = tab.ID |
| 96 | t.Cleanup(func() { |
| 97 | if tab.Ctrl != nil { |
| 98 | tab.Ctrl.Close() |
| 99 | } |
| 100 | }) |
| 101 | |
| 102 | waitCount := func(want int, step string) { |
| 103 | t.Helper() |
| 104 | deadline := time.Now().Add(5 * time.Second) |
| 105 | for time.Now().Before(deadline) { |
| 106 | mu.Lock() |
| 107 | n := len(rebuilt) |
| 108 | mu.Unlock() |
| 109 | if n >= want { |
| 110 | return |
| 111 | } |
| 112 | time.Sleep(10 * time.Millisecond) |
| 113 | } |
| 114 | mu.Lock() |
| 115 | defer mu.Unlock() |
| 116 | t.Fatalf("after %s: runtime:rebuilt events = %v, want %d", step, rebuilt, want) |
| 117 | } |
| 118 | |
| 119 | if err := app.SetModelForTab(tab.ID, "new/deepseek-v4-pro"); err != nil { |
| 120 | t.Fatalf("SetModelForTab: %v", err) |
| 121 | } |
| 122 | waitCount(1, "model switch") |
| 123 | |
| 124 | if err := app.SetEffortForTab(tab.ID, "high"); err != nil { |
| 125 | t.Fatalf("SetEffortForTab: %v", err) |
| 126 | } |
| 127 | waitCount(2, "effort switch") |
| 128 | |
| 129 | if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil { |
| 130 | t.Fatalf("SetTokenModeForTab: %v", err) |
| 131 | } |
| 132 | waitCount(3, "token-mode switch") |
| 133 | |
| 134 | mu.Lock() |
| 135 | defer mu.Unlock() |
| 136 | for i, id := range rebuilt { |
| 137 | if id == "VIA-APP-QUEUE" { |
| 138 | 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) |
| 139 | } |
| 140 | if id != tab.ID { |
| 141 | t.Fatalf("event %d carried tab id %q, want %q (full: %v)", i, id, tab.ID, rebuilt) |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 |