| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "testing" |
| 6 | "time" |
| 7 | |
| 8 | "reasonix/internal/control" |
| 9 | "reasonix/internal/event" |
| 10 | ) |
| 11 | |
| 12 | func TestDetachedAskPublishesGlobalPendingInteraction(t *testing.T) { |
| 13 | isolateDesktopUserDirs(t) |
| 14 | dir := t.TempDir() |
| 15 | path := writeTopicSessionWithPrompt(t, dir, "a.jsonl", "topic-a", "Conversation A", "", "fixture", time.Now()) |
| 16 | a := NewApp() |
| 17 | a.ctx = context.Background() |
| 18 | pending := make(chan RuntimeSessionState, 1) |
| 19 | a.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) { |
| 20 | if name != "runtime-state:changed" { |
| 21 | return |
| 22 | } |
| 23 | for _, session := range payload[0].(RuntimeStateProjection).Sessions { |
| 24 | if !session.Open && len(session.State.Interactions) > 0 { |
| 25 | select { |
| 26 | case pending <- session: |
| 27 | default: |
| 28 | } |
| 29 | } |
| 30 | } |
| 31 | } |
| 32 | sink := &tabEventSink{tabID: "A", app: a, ctx: a.ctx} |
| 33 | runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})} |
| 34 | ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Sink: sink}) |
| 35 | defer ctrl.Close() |
| 36 | tab := &WorkspaceTab{ID: "A", Scope: "global", TopicID: "topic-a", TopicTitle: "Conversation A", SessionPath: path, Ctrl: ctrl, sink: sink, Ready: true} |
| 37 | a.tabs[tab.ID] = tab |
| 38 | a.activeTabID = tab.ID |
| 39 | ctrl.Submit("fixture turn") |
| 40 | <-runner.started |
| 41 | if !a.detachRuntimeForReplacement(tab) { |
| 42 | t.Fatal("could not detach running A") |
| 43 | } |
| 44 | a.mu.Lock() |
| 45 | delete(a.tabs, "A") |
| 46 | a.tabs["B"] = &WorkspaceTab{ID: "B"} |
| 47 | a.activeTabID = "B" |
| 48 | a.mu.Unlock() |
| 49 | if sink.context() != nil { |
| 50 | t.Fatal("detached transcript must stay disconnected") |
| 51 | } |
| 52 | ctx, cancel := context.WithCancel(t.Context()) |
| 53 | done := make(chan struct{}) |
| 54 | go func() { |
| 55 | defer close(done) |
| 56 | _, _ = ctrl.Ask(ctx, []event.AskQuestion{{ID: "choice", Prompt: "Choose"}}) |
| 57 | }() |
| 58 | defer func() { |
| 59 | cancel() |
| 60 | <-done |
| 61 | close(runner.release) |
| 62 | waitNotRunning(t, ctrl) |
| 63 | a.mu.RLock() |
| 64 | detached := a.detachedSessions[sessionRuntimeKey(path)] |
| 65 | a.mu.RUnlock() |
| 66 | a.quiesceTabAutosave(detached) |
| 67 | }() |
| 68 | select { |
| 69 | case session := <-pending: |
| 70 | prompt := session.State.Interactions[0] |
| 71 | if session.TabID == "B" || session.SessionPath != path || prompt.Kind != "ask" || prompt.RequestID == "" || prompt.TurnID == "" { |
| 72 | t.Fatalf("wrong background prompt identity: %+v", session) |
| 73 | } |
| 74 | case <-time.After(3 * time.Second): |
| 75 | t.Fatal("background Ask was not pushed without reopening A") |
| 76 | } |
| 77 | a.mu.RLock() |
| 78 | active := a.activeTabID |
| 79 | a.mu.RUnlock() |
| 80 | if active != "B" { |
| 81 | t.Fatalf("background Ask changed active tab to %q", active) |
| 82 | } |
| 83 | } |
| 84 |