| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/config" |
| 10 | "reasonix/internal/control" |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/serve" |
| 13 | "reasonix/internal/servecontract" |
| 14 | "reasonix/internal/sessioninbox" |
| 15 | ) |
| 16 | |
| 17 | func TestRemoteInboxQueueCapabilitiesAndIdentity(t *testing.T) { |
| 18 | isolateDesktopUserDirs(t) |
| 19 | dir := t.TempDir() |
| 20 | path := filepath.Join(dir, "session.jsonl") |
| 21 | ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Sink: event.Discard}) |
| 22 | defer ctrl.Close() |
| 23 | if err := ctrl.SetInboxPaused(true); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | receipt, err := ctrl.EnqueueInbox(control.InboxRequest{Submit: "remote full text", Idempotency: "remote"}) |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | server := httptest.NewServer(operatorServeHandler(serve.New(ctrl, nil, config.ServeConfig{}))) |
| 31 | defer server.Close() |
| 32 | a, tab := remoteRuntimeTestApp(server.Client()) |
| 33 | tab.base, tab.routing.currentPath, tab.session.path = server.URL, path, path |
| 34 | target, err := a.CaptureInboxTarget(tab.id, path) |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | request := control.InboxQueueRequest{Kind: "read", ItemID: receipt.ItemID} |
| 39 | unsupported, err := a.InboxQueueForTarget(target, request) |
| 40 | if err != nil || unsupported.Reason != "unsupported" { |
| 41 | t.Fatalf("old remote: %+v %v", unsupported, err) |
| 42 | } |
| 43 | tab.capabilities[servecontract.InboxMutationsV1] = true |
| 44 | read, err := a.InboxQueueForTarget(target, request) |
| 45 | if err != nil || read.Edit == nil || read.Edit.Text != "remote full text" { |
| 46 | t.Fatalf("read: %+v %v", read, err) |
| 47 | } |
| 48 | request = control.InboxQueueRequest{Kind: "edit", ItemID: receipt.ItemID, ContentVersion: read.Edit.ContentVersion, Text: "edited remote"} |
| 49 | saved, err := a.InboxQueueForTarget(target, request) |
| 50 | if err != nil || saved.Outcome != "applied" || !saved.Snapshot.Paused { |
| 51 | t.Fatalf("save: %+v %v", saved, err) |
| 52 | } |
| 53 | tab.selectionRevision++ |
| 54 | stale, err := a.InboxQueueForTarget(target, request) |
| 55 | if err != nil || stale.Reason != "session_changed" { |
| 56 | t.Fatalf("stale target: %+v %v", stale, err) |
| 57 | } |
| 58 | _, env, _ := ctrl.ReadInboxItem(receipt.ItemID) |
| 59 | if env.SubmitText != "edited remote" { |
| 60 | t.Fatal("remote queue changed unexpectedly") |
| 61 | } |
| 62 | tab.selectionRevision-- |
| 63 | ctrl.SetSessionPath(filepath.Join(dir, "other.jsonl")) |
| 64 | stale, err = a.InboxQueueForTarget(target, control.InboxQueueRequest{Kind: "pause", Paused: true}) |
| 65 | if err != nil || stale.Reason != "session_changed" { |
| 66 | t.Fatalf("remote foreground switch: %+v %v", stale, err) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func TestTargetGuidanceQueuesEndedTurnAndRejectsReplacementSession(t *testing.T) { |
| 71 | isolateDesktopUserDirs(t) |
| 72 | dir := t.TempDir() |
| 73 | path := filepath.Join(dir, "session.jsonl") |
| 74 | ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Sink: event.Discard}) |
| 75 | cleanupExactTurnController(t, ctrl) |
| 76 | if err := ctrl.SetInboxPaused(true); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | tab := &WorkspaceTab{ID: "tab", Ctrl: ctrl, SessionPath: path, SessionGeneration: 1, Ready: true} |
| 80 | a := &App{tabs: map[string]*WorkspaceTab{"tab": tab}} |
| 81 | target, err := a.CaptureInboxTarget("tab", path) |
| 82 | if err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | request := control.InboxQueueRequest{Kind: "enqueue_steer", TurnID: "ended-turn", Text: "preserve guidance", Display: "preserve guidance", IdempotencyKey: "one-guidance"} |
| 86 | result, err := a.InboxQueueForTarget(target, request) |
| 87 | if err != nil || result.Receipt == nil || result.Receipt.Disposition != sessioninbox.DispositionQueuedFollowup { |
| 88 | t.Fatalf("ended turn: %+v %v", result, err) |
| 89 | } |
| 90 | duplicate, err := a.InboxQueueForTarget(target, request) |
| 91 | if err != nil || duplicate.Receipt == nil || duplicate.Receipt.ItemID != result.Receipt.ItemID || len(ctrl.InboxSnapshot().Items) != 1 { |
| 92 | t.Fatalf("duplicate: %+v %v", duplicate, err) |
| 93 | } |
| 94 | confirmed, err := a.LookupInboxFollowupForTarget(target, request.IdempotencyKey) |
| 95 | if err != nil || confirmed.ItemID != result.Receipt.ItemID { |
| 96 | t.Fatalf("receipt recovery: %+v %v", confirmed, err) |
| 97 | } |
| 98 | tab.SessionGeneration++ |
| 99 | request.IdempotencyKey = "stale-input" |
| 100 | stale, err := a.InboxQueueForTarget(target, request) |
| 101 | if err != nil || stale.Reason != "session_changed" || len(ctrl.InboxSnapshot().Items) != 1 { |
| 102 | t.Fatalf("replacement fence: %+v %v", stale, err) |
| 103 | } |
| 104 | // Legacy tab-only calls cannot prove session ownership and keep their guard. |
| 105 | if _, err := a.EnqueueInboxSteerForTurn("tab", "ended-turn", "unsafe", "unsafe", "unsafe"); err == nil { |
| 106 | t.Fatal("legacy request bypassed the session fence") |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // operatorServeHandler stands in for the remote client, which holds the |
| 111 | // launch token a serve requires for mutations. |
| 112 | func operatorServeHandler(s *serve.Server) http.Handler { |
| 113 | h := s.Handler() |
| 114 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 115 | r.Header.Set("Authorization", "Bearer "+s.AuthToken()) |
| 116 | h.ServeHTTP(w, r) |
| 117 | }) |
| 118 | } |
| 119 |