diff --git a/ai-code-backends-infra.el b/ai-code-backends-infra.el index 0a71e0e9..c963c283 100644 --- a/ai-code-backends-infra.el +++ b/ai-code-backends-infra.el @@ -496,18 +496,20 @@ MULTILINE-INPUT-SEQUENCE configures `S-' and `C-' when non-nil." (puthash key session-buffer ai-code-backends-infra--file-session-map) (remhash key ai-code-backends-infra--file-session-map)))) -(defun ai-code-backends-infra--attached-file-session (prefix source-buffer working-dir) - "Return attached session state for PREFIX, SOURCE-BUFFER and WORKING-DIR. +(defun ai-code-backends-infra--attached-file-session (prefix source-buffer _working-dir) + "Return attached session state for PREFIX and SOURCE-BUFFER. +The working-directory argument is accepted for interface compatibility but +ignored here because +an explicit file attachment should win as long as the attached buffer is live. Return a cons of (BUFFER . MISSING-P)." (let ((key (ai-code-backends-infra--file-session-map-key prefix source-buffer))) (if (null key) (cons nil nil) (let* ((attached (gethash key ai-code-backends-infra--file-session-map)) (valid (and (buffer-live-p attached) - (memq attached - (ai-code-backends-infra--find-session-buffers - prefix - working-dir))))) + (ai-code-backends-infra--parse-session-buffer-name + (buffer-name attached) + prefix)))) (cond (valid (cons attached nil)) @@ -523,25 +525,16 @@ Return a cons of (BUFFER . MISSING-P)." MISSING-MESSAGE is used when no target session exists. When PREFIX and WORKING-DIR are present, prefer the attached session for SOURCE-BUFFER unless FORCE-PROMPT is non-nil." - (let* ((file-key (and prefix - source-buffer - (ai-code-backends-infra--file-session-map-key - prefix - source-buffer))) - (attached-state (and prefix working-dir + (let* ((attached-state (and prefix working-dir (ai-code-backends-infra--attached-file-session prefix source-buffer working-dir))) (attached-buffer (car-safe attached-state)) (attached-missing (cdr-safe attached-state)) - (needs-initial-file-selection (and (null buffer-name) - file-key - (null attached-buffer) - (not attached-missing))) - (effective-force-prompt (or force-prompt - attached-missing - needs-initial-file-selection)) + (effective-force-prompt + (or force-prompt + attached-missing)) (buffer (or (and buffer-name (get-buffer buffer-name)) (and attached-buffer (not force-prompt) attached-buffer) (and prefix working-dir diff --git a/test/test_ai-code-backends-infra.el b/test/test_ai-code-backends-infra.el index b5442f40..70bb54ba 100644 --- a/test/test_ai-code-backends-infra.el +++ b/test/test_ai-code-backends-infra.el @@ -361,13 +361,14 @@ (when (buffer-live-p buf) (kill-buffer buf)))))) -(ert-deftest test-ai-code-backends-infra-send-line-unassociated-file-forces-selection () - "Unassociated file should force session selection even if a session is remembered." +(ert-deftest test-ai-code-backends-infra-send-line-unassociated-file-reuses-remembered-session () + "Unassociated file should reuse the remembered repo session." (let* ((prefix "codex") (working-dir "/tmp/ai-code-file-new-association/") (source (generate-new-buffer " *ai-code-source-new-association*")) (session-a (get-buffer-create "*codex[file-new-association:a]*")) (session-b (get-buffer-create "*codex[file-new-association:b]*")) + (selection-count 0) (force-prompts nil) (send-targets nil)) (unwind-protect @@ -383,15 +384,14 @@ (setq-local ai-code-backends-infra--session-directory working-dir)) (with-current-buffer session-b (setq-local ai-code-backends-infra--session-directory working-dir)) - ;; Simulate repo-level remembered session (the previous behavior picked this directly). + ;; Simulate the current repo-level active/remembered session. (ai-code-backends-infra--remember-session-buffer prefix working-dir session-b) (cl-letf (((symbol-function 'ai-code-backends-infra--select-session-buffer) (lambda (_prefix _dir &optional force-prompt) + (setq selection-count (1+ selection-count)) (push force-prompt force-prompts) - (if (= (length force-prompts) 1) - session-a - (ert-fail "Should not prompt again once file is associated.")))) + session-b)) ((symbol-function 'ai-code-backends-infra--terminal-send-string) (lambda (&rest _args) (push (buffer-name (current-buffer)) send-targets))) @@ -405,14 +405,15 @@ (ai-code-backends-infra--send-line-to-session nil "missing" "line-2" prefix working-dir))) - (should (equal (nreverse force-prompts) (list t))) + (should (= selection-count 1)) + (should (equal (nreverse force-prompts) (list nil))) (should (equal (nreverse send-targets) - (list "*codex[file-new-association:a]*" - "*codex[file-new-association:a]*"))) + (list "*codex[file-new-association:b]*" + "*codex[file-new-association:b]*"))) (should (eq (gethash (ai-code-backends-infra--file-session-map-key prefix source) ai-code-backends-infra--file-session-map) - session-a))) + session-b))) (dolist (buf (list source session-a session-b)) (when (buffer-live-p buf) (kill-buffer buf)))))) @@ -467,7 +468,7 @@ (ai-code-backends-infra--send-line-to-session nil "missing" "line-2" prefix working-dir))) - (should (equal (nreverse force-prompts) (list t t))) + (should (equal (nreverse force-prompts) (list nil t))) (should (equal (nreverse send-targets) (list "*codex[file-rebind:a]*" "*codex[file-rebind:b]*"))) @@ -477,6 +478,60 @@ (when (buffer-live-p buf) (kill-buffer buf)))))) +(ert-deftest test-ai-code-backends-infra-switch-new-file-prompts-when-multiple-sessions-active () + "A newly opened file should prompt when multiple repo sessions are active." + (let* ((prefix "codex") + (working-dir "/tmp/ai-code-file-multi-active/") + (source (generate-new-buffer " *ai-code-source-multi-active*")) + (session-a (get-buffer-create "*codex[file-multi-active:a]*")) + (session-b (get-buffer-create "*codex[file-multi-active:b]*")) + (captured-collection nil) + (captured-default nil)) + (unwind-protect + (progn + (clrhash ai-code-backends-infra--directory-buffer-map) + (when (boundp 'ai-code-backends-infra--file-session-map) + (clrhash ai-code-backends-infra--file-session-map)) + + (with-current-buffer source + (setq buffer-file-name "/tmp/ai-code-file-multi-active/main.el") + (setq default-directory working-dir)) + (with-current-buffer session-a + (setq-local ai-code-backends-infra--session-directory working-dir)) + (with-current-buffer session-b + (setq-local ai-code-backends-infra--session-directory working-dir)) + + (cl-letf (((symbol-function 'ai-code-backends-infra--find-session-buffers) + (lambda (_prefix _dir) + (list session-a session-b))) + ((symbol-function 'completing-read) + (lambda (_prompt collection _predicate _require-match + &optional _initial-input _hist def &rest _) + (setq captured-collection collection) + (setq captured-default def) + "b")) + ((symbol-function 'get-buffer-window) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--display-buffer-in-side-window) + (lambda (_buffer) nil))) + (with-current-buffer source + (ai-code-backends-infra--switch-to-session-buffer + nil + "missing" + prefix + working-dir + nil))) + + (should (equal captured-collection '("a" "b"))) + (should (equal captured-default "a")) + (should (eq (gethash + (ai-code-backends-infra--file-session-map-key prefix source) + ai-code-backends-infra--file-session-map) + session-b))) + (dolist (buf (list source session-a session-b)) + (when (buffer-live-p buf) + (kill-buffer buf)))))) + (ert-deftest test-ai-code-backends-infra-switch-force-prompt-prioritizes-attached-session () "Force prompt should place attached file session at the top and as default." (let* ((prefix "codex") @@ -633,7 +688,7 @@ (ai-code-backends-infra--send-line-to-session nil "missing" "line-2" prefix working-dir))) - (should (equal (nreverse force-prompts) (list t t))) + (should (equal (nreverse force-prompts) (list nil t))) (should (equal (nreverse send-targets) (list "*codex[file-missing:a]*" "*codex[file-missing:b]*"))) @@ -645,6 +700,55 @@ (when (buffer-live-p buf) (kill-buffer buf)))))) +(ert-deftest test-ai-code-backends-infra-switch-reuses-live-attached-session-despite-working-dir-mismatch () + "Reuse a live attached session even when WORKING-DIR no longer matches it." + (let* ((prefix "codex") + (session-dir "/tmp/ai-code-file-attached-root/") + (working-dir "/tmp/ai-code-file-attached-root/subdir/") + (source (generate-new-buffer " *ai-code-source-attached-live*")) + (attached (get-buffer-create "*codex[file-attached-root:attached]*")) + (displayed nil)) + (unwind-protect + (progn + (clrhash ai-code-backends-infra--directory-buffer-map) + (when (boundp 'ai-code-backends-infra--file-session-map) + (clrhash ai-code-backends-infra--file-session-map)) + + (with-current-buffer source + (setq buffer-file-name "/tmp/ai-code-file-attached-root/main.el") + (setq default-directory working-dir)) + (with-current-buffer attached + (setq-local ai-code-backends-infra--session-directory session-dir)) + (ai-code-backends-infra--remember-file-session-buffer prefix source attached) + + (cl-letf (((symbol-function 'ai-code-backends-infra--find-session-buffers) + (lambda (_prefix _dir) nil)) + ((symbol-function 'ai-code-backends-infra--select-session-buffer) + (lambda (&rest _args) + (ert-fail "Should reuse the live attached session without prompting."))) + ((symbol-function 'get-buffer-window) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--display-buffer-in-side-window) + (lambda (buffer) + (setq displayed buffer) + nil))) + (with-current-buffer source + (ai-code-backends-infra--switch-to-session-buffer + nil + "missing" + prefix + working-dir + nil))) + + (should (eq displayed attached)) + (should (eq (gethash + (ai-code-backends-infra--file-session-map-key prefix source) + ai-code-backends-infra--file-session-map) + attached))) + (dolist (buf (list source attached)) + (when (buffer-live-p buf) + (kill-buffer buf)))))) + (ert-deftest test-ai-code-backends-infra-toggle-or-create-session-passes-env-vars () "ENV-VARS are forwarded to `ai-code-backends-infra--create-terminal-session'." (let* ((ai-code-backends-infra--processes (make-hash-table :test 'equal))