;;; test-ellama.el --- Ellama tests -*- lexical-binding: t; package-lint-main-file: "../ellama.el"; -*-

;; Copyright (C) 2023-2026  Free Software Foundation, Inc.

;; Author: Sergey Kostyaev <sskostyaev@gmail.com>

;; This file is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation; either version 3, or (at your option)
;; any later version.

;; This file is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs.  If not, see <http://www.gnu.org/licenses/>.

;;; Commentary:
;;
;; Ellama tests.
;;

;;; Code:

(add-to-list 'load-path default-directory)

(require 'cl-lib)
(require 'ellama)
(require 'ellama-transient)
(require 'ert)
(require 'llm-fake)
(require 'llm-openai)

(defconst ellama-test--root
  (expand-file-name
   ".."
   (file-name-directory (or load-file-name buffer-file-name default-directory)))
  "Project root directory for `test-ellama.el'.")

(defun ellama-test--fake-stream-partials (response style)
  "Return streaming partial strings for RESPONSE using STYLE."
  (let ((prev "")
        (chunks (pcase style
                  ('line (string-lines response))
                  ((or 'word-leading 'word-trailing)
                   (string-split response " "))
                  (_ (error "Unknown style %S" style)))))
    (mapcar
     (lambda (chunk)
       (setq prev
             (pcase style
               ('line (concat prev chunk))
               ('word-leading (concat prev " " chunk))
               ('word-trailing (concat prev chunk " "))))
       prev)
     chunks)))

(defun ellama-test--run-with-fake-streaming (response prompt-regexp fn
                                                      &optional style)
  "Run FN with fake streaming RESPONSE and assert PROMPT-REGEXP.
STYLE controls partial message shape.  Default value is `word-leading'."
  (let* ((provider
          (make-llm-fake
           :chat-action-func (lambda () response)))
         (ellama-provider provider)
         (ellama-coding-provider provider)
         (ellama-response-process-method 'streaming)
         (ellama-spinner-enabled nil)
         (partial-style (or style 'word-leading)))
    (cl-letf (((symbol-function 'llm-chat-streaming)
               (lambda (stream-provider prompt partial-callback
                                        response-callback _error-callback _multi-output)
                 (should (string-match-p prompt-regexp
                                         (llm-chat-prompt-to-text prompt)))
                 (let ((response-plist (llm-chat stream-provider prompt t)))
                   (dolist (partial
                            (ellama-test--fake-stream-partials
                             (plist-get response-plist :text)
                             partial-style))
                     (funcall partial-callback `(:text ,partial)))
                   (funcall response-callback response-plist)))))
      (funcall fn))))

(defun ellama-test--assert-replace-region-stream-args (call-fn)
  "Assert CALL-FN passes replace-region arguments to `ellama-stream'."
  (with-temp-buffer
    (insert "one\ntwo\n")
    (let (captured-args)
      (cl-letf (((symbol-function 'ellama-stream)
                 (lambda (&rest args)
                   (setq captured-args (cdr args))
                   (should (equal (buffer-string) "one\ntwo\n")))))
        (funcall call-fn))
      (should (equal (plist-get captured-args :replace-beg)
                     (point-min)))
      (should (equal (plist-get captured-args :replace-end)
                     (point-max))))))

(ert-deftest test-ellama-request-mode-binds-c-g-to-cancel ()
  (with-temp-buffer
    (ellama-request-mode +1)
    (should (eq (key-binding (kbd "C-g") t)
                #'ellama--cancel-current-request-and-quit))))

(ert-deftest test-ellama-request-mode-c-g-cancels-current-request ()
  (let ((cancelled-request nil)
        (ellama-spinner-enabled nil))
    (with-temp-buffer
      (setq-local ellama--current-request 'request)
      (ellama-request-mode +1)
      (cl-letf (((symbol-function 'llm-cancel-request)
                 (lambda (request)
                   (setq cancelled-request request))))
        (let ((cancel-command (key-binding (kbd "C-g") t)))
          (should (eq cancel-command
                      #'ellama--cancel-current-request-and-quit))
          (condition-case nil
              (call-interactively cancel-command)
            (quit nil))))
      (should (eq cancelled-request 'request))
      (should (null ellama--current-request))
      (should-not ellama-request-mode))))

(ert-deftest test-ellama-request-mode-c-g-appends-user-header-in-chat ()
  (let ((cancelled-request nil)
        (ellama-spinner-enabled nil)
        (ellama-session-auto-save nil)
        (ellama-user-nick "User")
        (ellama-assistant-nick "Ellama"))
    (with-temp-buffer
      (org-mode)
      (setq-local ellama--current-session
                  (make-ellama-session :id "cancel-chat"))
      (insert "** User:\nStart\n\n** Ellama:\nPartial output")
      (ellama--set-current-request 'request (list (current-buffer)))
      (cl-letf (((symbol-function 'llm-cancel-request)
                 (lambda (request)
                   (setq cancelled-request request))))
        (condition-case nil
            (call-interactively #'ellama--cancel-current-request-and-quit)
          (quit nil)))
      (should (eq cancelled-request 'request))
      (should (null ellama--current-request))
      (should-not ellama-request-mode)
      (should (string-suffix-p "\n\n** User:\n" (buffer-string))))))

(ert-deftest test-ellama-setup-agentic-coding-keeps-confirmations-without-srt ()
  (let* ((agent-tool (llm-make-tool :name "read_file" :function #'ignore))
         (ellama-tools-available (list agent-tool))
         (ellama-tools-enabled nil)
         (ellama-auto-scroll nil)
         (ellama-display-session-buffer-on-generation nil)
         (ellama-session-auto-compact-enabled nil)
         (ellama-session-auto-compact-threshold-percent 80)
         (ellama-session-auto-compact-keep-last-turns 3)
         (ellama-session-auto-compact-allow-fewer-kept-turns nil)
         (ellama-tools-allow-all nil)
         (ellama-tools-use-srt nil)
         (ellama-tools-dlp-enabled nil)
         (ellama-tools-dlp-mode 'monitor)
         (ellama-tools-dlp-input-fail-open t)
         (ellama-tools-dlp-output-fail-open t)
         (ellama-tools-dlp-scan-env-exact-secrets nil)
         (ellama-tools-dlp-input-default-action 'allow)
         (ellama-tools-dlp-output-default-action 'redact)
         (ellama-tools-dlp-output-warn-behavior 'allow)
         (ellama-tools-irreversible-enabled nil)
         (ellama-tools-irreversible-default-action 'block)
         (ellama-tools-irreversible-require-typed-confirm nil)
         (ellama-tools-dlp-log-targets '(message file))
         (ellama-tools-agent-default-max-steps 40)
         (ellama-tools-subagent-default-max-steps 30))
    (ellama-setup-agentic-coding)
    (should ellama-auto-scroll)
    (should ellama-display-session-buffer-on-generation)
    (should ellama-session-auto-compact-enabled)
    (should (= ellama-session-auto-compact-threshold-percent 75))
    (should (= ellama-session-auto-compact-keep-last-turns 2))
    (should ellama-session-auto-compact-allow-fewer-kept-turns)
    (should-not ellama-tools-allow-all)
    (should-not ellama-tools-use-srt)
    (should ellama-tools-dlp-enabled)
    (should (eq ellama-tools-dlp-mode 'enforce))
    (should-not ellama-tools-dlp-input-fail-open)
    (should-not ellama-tools-dlp-output-fail-open)
    (should ellama-tools-dlp-scan-env-exact-secrets)
    (should (eq ellama-tools-dlp-input-default-action 'warn))
    (should (eq ellama-tools-dlp-output-default-action 'warn))
    (should (eq ellama-tools-dlp-output-warn-behavior 'confirm))
    (should ellama-tools-irreversible-enabled)
    (should (eq ellama-tools-irreversible-default-action 'block))
    (should ellama-tools-irreversible-require-typed-confirm)
    (should (equal ellama-tools-dlp-log-targets '(memory)))
    (should (equal ellama-tools-enabled ellama-tools-available))
    (should (= ellama-tools-agent-default-max-steps 100))
    (should (= ellama-tools-subagent-default-max-steps 60))))

(ert-deftest test-ellama-setup-agentic-coding-enables-allow-all-with-srt ()
  (let ((ellama-tools-allow-all nil)
        (ellama-tools-use-srt nil)
        (ellama-tools-srt-args nil)
        (ellama-tools-dlp-input-default-action 'warn)
        (ellama-tools-dlp-output-default-action 'warn)
        (ellama-tools-dlp-output-warn-behavior 'confirm)
        (ellama-tools-irreversible-default-action 'warn))
    (ellama-setup-agentic-coding "/tmp/ellama-srt.json")
    (should ellama-tools-use-srt)
    (should ellama-tools-allow-all)
    (should (equal ellama-tools-srt-args
                   '("--settings" "/tmp/ellama-srt.json")))
    (should (eq ellama-tools-dlp-input-default-action 'allow))
    (should (eq ellama-tools-dlp-output-default-action 'redact))
    (should (eq ellama-tools-dlp-output-warn-behavior 'confirm))
    (should (eq ellama-tools-irreversible-default-action 'block))))

(ert-deftest test-ellama-setup-agentic-coding-secure-blocks-with-srt ()
  (let ((ellama-tools-allow-all nil)
        (ellama-tools-use-srt nil)
        (ellama-tools-srt-args nil)
        (ellama-tools-dlp-input-default-action 'warn)
        (ellama-tools-dlp-output-default-action 'warn)
        (ellama-tools-dlp-output-warn-behavior 'confirm)
        (ellama-tools-irreversible-default-action 'warn))
    (ellama-setup-agentic-coding "/tmp/ellama-srt.json" t)
    (should ellama-tools-use-srt)
    (should ellama-tools-allow-all)
    (should (equal ellama-tools-srt-args
                   '("--settings" "/tmp/ellama-srt.json")))
    (should (eq ellama-tools-dlp-input-default-action 'block))
    (should (eq ellama-tools-dlp-output-default-action 'block))
    (should (eq ellama-tools-dlp-output-warn-behavior 'block))
    (should (eq ellama-tools-irreversible-default-action 'block))))

(ert-deftest test-ellama-request-mode-c-g-cancels-from-reasoning-buffer ()
  (let ((cancelled-request nil)
        (ellama-spinner-enabled nil)
        (reasoning-buffer (generate-new-buffer " *ellama-reasoning-test*")))
    (unwind-protect
        (with-temp-buffer
          (let ((main-buffer (current-buffer)))
            (ellama--set-current-request
             'request
             (list main-buffer reasoning-buffer))
            (cl-letf (((symbol-function 'llm-cancel-request)
                       (lambda (request)
                         (setq cancelled-request request))))
              (with-current-buffer reasoning-buffer
                (let ((cancel-command (key-binding (kbd "C-g") t)))
                  (should (eq cancel-command
                              #'ellama--cancel-current-request-and-quit))
                  (condition-case nil
                      (call-interactively cancel-command)
                    (quit nil)))))
            (should (eq cancelled-request 'request))
            (should-not ellama-request-mode)
            (with-current-buffer reasoning-buffer
              (should-not ellama-request-mode))))
      (when (buffer-live-p reasoning-buffer)
        (kill-buffer reasoning-buffer)))))

(ert-deftest test-ellama-cancel-does-not-affect-other-request-buffers ()
  (let* ((cancelled-request nil)
         (ellama-spinner-enabled nil)
         (main-a (generate-new-buffer " *ellama-main-a*"))
         (reasoning-a (generate-new-buffer " *ellama-reasoning-a*"))
         (main-b (generate-new-buffer " *ellama-main-b*"))
         (reasoning-b (generate-new-buffer " *ellama-reasoning-b*"))
         request-context-a
         request-context-b)
    (unwind-protect
        (progn
          (setq request-context-a
                (with-current-buffer main-a
                  (ellama--set-current-request
                   'request-a
                   (list main-a reasoning-a))))
          (setq request-context-b
                (with-current-buffer main-b
                  (ellama--set-current-request
                   'request-b
                   (list main-b reasoning-b))))
          (cl-letf (((symbol-function 'llm-cancel-request)
                     (lambda (request)
                       (setq cancelled-request request))))
            (with-current-buffer main-a
              (let ((cancel-command (key-binding (kbd "C-g") t)))
                (condition-case nil
                    (call-interactively cancel-command)
                  (quit nil)))))
          (should (eq cancelled-request 'request-a))
          (with-current-buffer main-a
            (should-not ellama-request-mode))
          (with-current-buffer reasoning-a
            (should-not ellama-request-mode))
          (with-current-buffer main-b
            (should ellama-request-mode)
            (should (eq ellama--current-request 'request-b)))
          (with-current-buffer reasoning-b
            (should ellama-request-mode)
            (should (eq ellama--current-request 'request-b))))
      (when (buffer-live-p main-a)
        (with-current-buffer main-a
          (ellama--deactivate-current-request request-context-a)))
      (when (buffer-live-p main-b)
        (with-current-buffer main-b
          (ellama--deactivate-current-request request-context-b)))
      (when (buffer-live-p reasoning-a)
        (kill-buffer reasoning-a))
      (when (buffer-live-p reasoning-b)
        (kill-buffer reasoning-b))
      (when (buffer-live-p main-a)
        (kill-buffer main-a))
      (when (buffer-live-p main-b)
        (kill-buffer main-b)))))

(ert-deftest test-ellama-response-handler-refresh-request-on-tool-call ()
  (let* ((main-buffer (generate-new-buffer " *ellama-main-test*"))
         (reasoning-buffer (generate-new-buffer " *ellama-reasoning-test*"))
         (ellama-response-process-method 'streaming)
         (ellama-spinner-enabled nil)
         request-context)
    (unwind-protect
        (progn
          (with-current-buffer main-buffer
            (setq request-context
                  (ellama--set-current-request
                   'request-1
                   (list main-buffer reasoning-buffer)))
            (cl-letf (((symbol-function 'ellama--insert)
                       (lambda (&rest _args) #'ignore))
                      ((symbol-function 'ellama--handle-partial)
                       (lambda (&rest _args) #'ignore))
                      ((symbol-function 'llm-chat-streaming)
                       (lambda (&rest _args) 'request-2)))
              (funcall
               (ellama--response-handler
                #'ignore
                reasoning-buffer
                main-buffer
                #'ignore
                #'ignore
                'provider
                'prompt
                nil
                #'identity)
               '(:tool-results "tool"))))
          (with-current-buffer main-buffer
            (should (eq ellama--current-request 'request-2)))
          (with-current-buffer reasoning-buffer
            (should (eq ellama--current-request 'request-2))))
      (when (buffer-live-p main-buffer)
        (with-current-buffer main-buffer
          (ellama--deactivate-current-request request-context))
        (kill-buffer main-buffer))
      (when (buffer-live-p reasoning-buffer)
        (kill-buffer reasoning-buffer)))))

(ert-deftest test-ellama-response-handler-clears-request-before-done ()
  (let ((request-active-in-done t)
        (ellama-spinner-enabled nil)
        request-context)
    (with-temp-buffer
      (setq-local ellama--change-group (prepare-change-group))
      (activate-change-group ellama--change-group)
      (setq request-context
            (ellama--set-current-request 'request (list (current-buffer))))
      (funcall
       (ellama--response-handler
        #'ignore
        nil
        (current-buffer)
        (lambda (_text)
          (setq request-active-in-done ellama--current-request))
        #'ignore
        'provider
        'prompt
        t
        #'identity
        request-context)
       '(:text "answer"))
      (should-not request-active-in-done)
      (should-not ellama--current-request))))

(ert-deftest test-ellama-format-tool-results-readable-alist ()
  (should
   (equal
    (ellama--format-tool-results
     '((grep_in_file . "90:first line\n97:second line")))
    "grep_in_file\n  90:first line\n  97:second line")))

(ert-deftest test-ellama-format-tool-results-readable-string-name ()
  (let ((entry (cons "grep" "\"./router.el:1:(defun route (status)\""))
        (expected "grep\n  ./router.el:1:(defun route (status)"))
    (should
     (equal (ellama--format-tool-results (list entry))
            expected))
    (should
     (equal (ellama--format-tool-results entry)
            expected))))

(ert-deftest test-ellama-format-tool-results-decodes-json-string ()
  (should
   (equal
    (ellama--format-tool-results
     '((read_file . "\"one\\ntwo\"")))
    "read_file\n  one\n  two")))

(ert-deftest test-ellama-handle-partial-scrolls-tool-results ()
  (let ((buffer (generate-new-buffer " *ellama-tool-results-test*"))
        (reasoning-buffer (generate-new-buffer " *ellama-tool-reasoning-test*"))
        (ellama-auto-scroll t)
        (scroll-call nil))
    (unwind-protect
        (progn
          (cl-letf (((symbol-function 'ellama--scroll)
                     (lambda (&optional scroll-buffer point)
                       (setq scroll-call (list scroll-buffer point)))))
            (let ((insert-text (ellama--insert buffer nil #'identity)))
              (funcall
               (ellama--handle-partial insert-text #'ignore reasoning-buffer)
               '(:tool-results ((read_file . "\"one\\ntwo\""))))))
          (should (eq (car scroll-call) buffer))
          (should (markerp (cadr scroll-call)))
          (with-current-buffer buffer
            (should (string-match-p "read_file" (buffer-string)))
            (should (string-match-p "one" (buffer-string)))))
      (when (buffer-live-p buffer)
        (kill-buffer buffer))
      (when (buffer-live-p reasoning-buffer)
        (kill-buffer reasoning-buffer)))))

(ert-deftest test-ellama-default-stream-filter-translates-org-think ()
  (with-temp-buffer
    (org-mode)
    (let ((result
           (funcall
            (ellama--default-stream-filter (current-buffer))
            "<think>\ngrep\n  ./router.el:1:match\n</think>\n")))
      (should (string-match-p "#\\+BEGIN_QUOTE" result))
      (should (string-match-p "#\\+END_QUOTE" result))
      (should-not (string-match-p "<think>" result)))))

(ert-deftest test-ellama-ask-about-add-selection-ephemeral ()
  (let (captured-ephemeral)
    (with-temp-buffer
      (cl-letf (((symbol-function 'read-string)
                 (lambda (&rest _args) "question"))
                ((symbol-function 'region-active-p)
                 (lambda () t))
                ((symbol-function 'ellama-context-add-selection)
                 (lambda (&optional ephemeral)
                   (setq captured-ephemeral ephemeral)))
                ((symbol-function 'ellama-context-add-buffer)
                 (lambda (&rest _args)
                   (ert-fail "Unexpected call to ellama-context-add-buffer")))
                ((symbol-function 'ellama-chat)
                 (lambda (&rest _args) nil)))
        (ellama-ask-about)))
    (should (eq captured-ephemeral t))))

(ert-deftest test-ellama-ask-about-add-buffer-ephemeral ()
  (let (captured-ephemeral captured-buffer)
    (with-temp-buffer
      (cl-letf (((symbol-function 'read-string)
                 (lambda (&rest _args) "question"))
                ((symbol-function 'region-active-p)
                 (lambda () nil))
                ((symbol-function 'ellama-context-add-selection)
                 (lambda (&rest _args)
                   (ert-fail "Unexpected call to ellama-context-add-selection")))
                ((symbol-function 'ellama-context-add-buffer)
                 (lambda (buf &optional ephemeral)
                   (setq captured-buffer buf)
                   (setq captured-ephemeral ephemeral)))
                ((symbol-function 'ellama-chat)
                 (lambda (&rest _args) nil)))
        (ellama-ask-about)
        (should (equal captured-buffer (buffer-name (current-buffer))))))
    (should (eq captured-ephemeral t))))

(ert-deftest test-ellama-code-review-add-selection-ephemeral ()
  (let (captured-ephemeral)
    (with-temp-buffer
      (cl-letf (((symbol-function 'region-active-p)
                 (lambda () t))
                ((symbol-function 'ellama-context-add-selection)
                 (lambda (&optional ephemeral)
                   (setq captured-ephemeral ephemeral)))
                ((symbol-function 'ellama-context-add-buffer)
                 (lambda (&rest _args)
                   (ert-fail "Unexpected call to ellama-context-add-buffer")))
                ((symbol-function 'ellama-chat)
                 (lambda (&rest _args) nil)))
        (ellama-code-review)))
    (should (eq captured-ephemeral t))))

(ert-deftest test-ellama-code-review-add-buffer-ephemeral ()
  (let (captured-ephemeral captured-buffer)
    (with-temp-buffer
      (cl-letf (((symbol-function 'region-active-p)
                 (lambda () nil))
                ((symbol-function 'ellama-context-add-selection)
                 (lambda (&rest _args)
                   (ert-fail "Unexpected call to ellama-context-add-selection")))
                ((symbol-function 'ellama-context-add-buffer)
                 (lambda (buf &optional ephemeral)
                   (setq captured-buffer buf)
                   (setq captured-ephemeral ephemeral)))
                ((symbol-function 'ellama-chat)
                 (lambda (&rest _args) nil)))
        (ellama-code-review)
        (should (equal captured-buffer (current-buffer)))))
    (should (eq captured-ephemeral t))))

(ert-deftest test-ellama-write-add-selection-ephemeral ()
  (let (captured-ephemeral)
    (with-temp-buffer
      (cl-letf (((symbol-function 'region-active-p)
                 (lambda () t))
                ((symbol-function 'ellama-context-add-selection)
                 (lambda (&optional ephemeral)
                   (setq captured-ephemeral ephemeral)))
                ((symbol-function 'ellama-stream)
                 (lambda (&rest _args) nil)))
        (ellama-write "instruction")))
    (should (eq captured-ephemeral t))))

(ert-deftest test-ellama-code-add-add-selection-ephemeral ()
  (let (captured-ephemeral)
    (with-temp-buffer
      (cl-letf (((symbol-function 'region-active-p)
                 (lambda () t))
                ((symbol-function 'ellama-context-add-selection)
                 (lambda (&optional ephemeral)
                   (setq captured-ephemeral ephemeral)))
                ((symbol-function 'ellama-stream)
                 (lambda (&rest _args) nil)))
        (ellama-code-add "description")))
    (should (eq captured-ephemeral t))))

(ert-deftest test-ellama-change-passes-replace-region-to-stream ()
  (ellama-test--assert-replace-region-stream-args
   (lambda ()
     (ellama-change "fix text" 1))))

(ert-deftest test-ellama-code-edit-passes-replace-region-to-stream ()
  (ellama-test--assert-replace-region-stream-args
   (lambda ()
     (ellama-code-edit "improve code"))))

(ert-deftest test-ellama-code-improve-passes-replace-region-to-stream ()
  (ellama-test--assert-replace-region-stream-args
   (lambda ()
     (ellama-code-improve))))

(ert-deftest test-ellama-make-format-passes-replace-region-to-stream ()
  (ellama-test--assert-replace-region-stream-args
   (lambda ()
     (ellama-make-format "as list"))))

(ert-deftest test-ellama-change-restores-buffer-on-stream-error ()
  (let ((ellama-provider (make-llm-fake))
        (ellama-spinner-enabled nil)
        (ellama-response-process-method 'streaming)
        (ellama-undo-on-error t)
        (original "Hello world.\n")
        streaming-called)
    (with-temp-buffer
      (insert original)
      (cl-letf (((symbol-function 'llm-chat-streaming)
                 (lambda (_provider _prompt partial-callback _response-callback
                                    error-callback _multi-output)
                   (setq streaming-called t)
                   (funcall partial-callback '(:text "Partial response"))
                   (funcall error-callback 'error "network failed")
                   'request)))
        (should-error
         (ellama-change "fix grammar")
         :type 'error))
      (should streaming-called)
      (should (equal (buffer-string) original)))))

(ert-deftest test-ellama-change-keeps-partial-response-on-stream-error ()
  (let ((ellama-provider (make-llm-fake))
        (ellama-spinner-enabled nil)
        (ellama-response-process-method 'streaming)
        (ellama-undo-on-error nil)
        streaming-called)
    (with-temp-buffer
      (insert "Hello world.\n")
      (cl-letf (((symbol-function 'llm-chat-streaming)
                 (lambda (_provider _prompt partial-callback _response-callback
                                    error-callback _multi-output)
                   (setq streaming-called t)
                   (funcall partial-callback '(:text "Partial response"))
                   (funcall error-callback 'error "network failed")
                   'request)))
        (should-error
         (ellama-change "fix grammar")
         :type 'error))
      (should streaming-called)
      (should (equal (buffer-string) "Partial response")))))

(ert-deftest test-ellama--code-filter ()
  (should (equal "" (ellama--code-filter "")))
  (should (equal "(hello)" (ellama--code-filter "(hello)")))
  (should (equal "(hello)\n" (ellama--code-filter "```lisp\n(hello)\n```"))))

(ert-deftest test-ellama-code-improve ()
  (let ((original "(hello)\n")
        (improved "```lisp\n(hello)\n```"))
    (with-temp-buffer
      (insert original)
      (ellama-test--run-with-fake-streaming
       improved
       original
       (lambda ()
         (ellama-code-improve))
       'line)
      (should (equal original (buffer-string))))))

(ert-deftest test-ellama-lorem-ipsum ()
  (let ((fill-column 70)
        (raw "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.")
        (expected "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
culpa qui officia deserunt mollit anim id est laborum. Sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
ea commodo consequat. Duis aute irure dolor in reprehenderit in
voluptate velit esse cillum dolore eu fugiat nulla pariatur.")
        )
    (with-temp-buffer
      (org-mode)
      (ellama-test--run-with-fake-streaming
       raw
       "test"
       (lambda ()
         (ellama-write "test"))
       'word-leading)
      (should (equal expected (buffer-string))))))

(ert-deftest test-ellama-sieve-of-eratosthenes ()
  (let* ((fill-column 80)
         (raw "Sure! Let's go through the **Sieve of Eratosthenes** step by step — it's an ancient and elegant algorithm used to find all **prime numbers** up to a given limit.

---

### 🔍 What is the Sieve of Eratosthenes?

The **Sieve of Eratosthenes** is a method developed by the ancient Greek mathematician Eratosthenes (circa 240 BCE) to efficiently find all **prime numbers** less than or equal to a given number (say, N).

A **prime number** is a natural number greater than 1 that has no positive divisors other than 1 and itself.

---

### 🎯 Goal

Given a number N, list all **prime numbers** from 2 to N.

---

### ✅ How Does It Work? (Step-by-Step)

Let’s say we want to find all primes up to **30**.

#### Step 1: List all numbers from 2 to N
Write down all integers from 2 to 30:

```
2  3  4  5  6  7  5  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
```

We’ll go through them one by one.

---

#### Step 2: Start with the first prime — 2

- 2 is the first prime number.
- Eliminate all multiples of 2 (except 2 itself):
  → 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30

Now our list looks like this (only numbers not crossed out):

```
2  3  5  7  9 11 13 15 17 19 21 23 25 27 29
```

(We removed all multiples of 2)

---

#### Step 3: Move to the next uneliminated number — 3

- 3 is the next prime.
- Eliminate all multiples of 3 (except 3 itself):
  → 9, 15, 21, 27

Now remove those from the list:

```
2  3  5  7  11 13 17 19 23 25 29
```

(Now 9, 15, 21, 27 are gone)

---

#### Step 4: Next uneliminated number — 5

- 5 is next prime.
- Eliminate multiples of 5:
  → 25 (since 5×5=25)

Remove 25:

```
2  3  5  7  11 13 17 19 23 29
```

---

#### Step 5: Next uneliminated number — 7

- Check if 7 has any multiples left that haven’t been removed.
- Multiples of 7: 14, 21, 28
- But 14, 21, 28 were already eliminated earlier (by 2 and 3), so no need to remove them again.

So we **stop** here.

Why? Because the next number after 7 is 11 — but **11 is greater than √30**.

---

### 🔍 Key Insight

> You only need to check numbers up to √N.

Why?
- If a number greater than √N is composite (not prime), it must have a factor less than or equal to √N.
- So if we’ve already eliminated all multiples of numbers ≤ √N, then all remaining numbers are prime.

For N = 30, √30 ≈ 5.48 → we only need to check primes up to 5.

So we only need to go through 2, 3, 5 — which we did.

---

### ✅ Final Result

All the remaining numbers are **prime**:

> **2, 3, 5, 7, 11, 13, 17, 19, 23, 29**

✅ These are all the prime numbers ≤ 30.

---

### 📝 Summary of the Algorithm

1. Create a list of consecutive integers from 2 to N.
2. Start with the first number (2).
3. Mark all multiples of that number (except the number itself) as composite (not prime).
4. Move to the next unmarked number and repeat.
5. Stop when the square of the current number exceeds N (i.e., when current number > √N).

---

### 💡 Example in Code (Python)

```python
def sieve_of_eratosthenes(n):
    if n < 2:
        return []

    # Create a boolean array \"is_prime[0..n]\" and initialize all entries as True
    is_prime = [True] * (n + 1)
    is_prime[0] = is_prime[1] = False  # 0 and 1 are not prime

    for i in range(2, int(n**0.5) + 1):
        if is_prime[i]:
            # Mark all multiples of i (starting from i*i) as not prime
            for j in range(i * i, n + 1, i):
                is_prime[j] = False

    # Collect all prime numbers
    primes = [i for i in range(2, n + 1) if is_prime[i]]
    return primes

# Example: Get primes up to 30
print(sieve_of_eratosthenes(30))
# Output: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
```

---

### 🎉 Why Is It Important?

- Very efficient for finding all primes up to a limit.
- Used in number theory, cryptography, and computer science.
- Simple and intuitive — great for learning algorithms.

---

### 🚀 Fun Fact

Eratosthenes was not only a mathematician but also a geographer, astronomer, and poet. He used this sieve to find primes — and even calculated Earth’s circumference with remarkable accuracy!

---

Let me know if you'd like to see a visual version, or try it with a different number! 😊
")
         (expected "Sure! Let's go through the *Sieve of Eratosthenes* step by step — it's an
ancient and elegant algorithm used to find all *prime numbers* up to a given
limit.

---

*** 🔍 What is the Sieve of Eratosthenes?

The *Sieve of Eratosthenes* is a method developed by the ancient Greek
mathematician Eratosthenes (circa 240 BCE) to efficiently find all *prime
numbers* less than or equal to a given number (say, N).

A *prime number* is a natural number greater than 1 that has no positive
divisors other than 1 and itself.

---

*** 🎯 Goal

Given a number N, list all *prime numbers* from 2 to N.

---

*** ✅ How Does It Work? (Step-by-Step)

Let’s say we want to find all primes up to *30*.

**** Step 1: List all numbers from 2 to N
Write down all integers from 2 to 30:
#+BEGIN_SRC
2  3  4  5  6  7  5  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
28 29 30
#+END_SRC

We’ll go through them one by one.

---

**** Step 2: Start with the first prime — 2

- 2 is the first prime number.
- Eliminate all multiples of 2 (except 2 itself):
  → 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30

Now our list looks like this (only numbers not crossed out):
#+BEGIN_SRC
2  3  5  7  9 11 13 15 17 19 21 23 25 27 29
#+END_SRC

(We removed all multiples of 2)

---

**** Step 3: Move to the next uneliminated number — 3

- 3 is the next prime.
- Eliminate all multiples of 3 (except 3 itself):
  → 9, 15, 21, 27

Now remove those from the list:
#+BEGIN_SRC
2  3  5  7  11 13 17 19 23 25 29
#+END_SRC

(Now 9, 15, 21, 27 are gone)

---

**** Step 4: Next uneliminated number — 5

- 5 is next prime.
- Eliminate multiples of 5:
  → 25 (since 5×5=25)

Remove 25:
#+BEGIN_SRC
2  3  5  7  11 13 17 19 23 29
#+END_SRC

---

**** Step 5: Next uneliminated number — 7

- Check if 7 has any multiples left that haven’t been removed.
- Multiples of 7: 14, 21, 28
- But 14, 21, 28 were already eliminated earlier (by 2 and 3), so no need to
  remove them again.

So we *stop* here.

Why? Because the next number after 7 is 11 — but *11 is greater than √30*.

---

*** 🔍 Key Insight

> You only need to check numbers up to √N.

Why?
- If a number greater than √N is composite (not prime), it must have a factor
  less than or equal to √N.
- So if we’ve already eliminated all multiples of numbers ≤ √N, then all
  remaining numbers are prime.

For N = 30, √30 ≈ 5.48 → we only need to check primes up to 5.

So we only need to go through 2, 3, 5 — which we did.

---

*** ✅ Final Result

All the remaining numbers are *prime*:

> *2, 3, 5, 7, 11, 13, 17, 19, 23, 29*

✅ These are all the prime numbers ≤ 30.

---

*** 📝 Summary of the Algorithm

1. Create a list of consecutive integers from 2 to N.
2. Start with the first number (2).
3. Mark all multiples of that number (except the number itself) as composite
(not prime).
4. Move to the next unmarked number and repeat.
5. Stop when the square of the current number exceeds N (i.e., when current
number > √N).

---

*** 💡 Example in Code (Python)
#+BEGIN_SRC python
def sieve_of_eratosthenes(n):
    if n < 2:
        return []

    # Create a boolean array \"is_prime[0..n]\" and initialize all entries as True
    is_prime = [True] * (n + 1)
    is_prime[0] = is_prime[1] = False  # 0 and 1 are not prime

    for i in range(2, int(n**0.5) + 1):
        if is_prime[i]:
            # Mark all multiples of i (starting from i*i) as not prime
            for j in range(i * i, n + 1, i):
                is_prime[j] = False

    # Collect all prime numbers
    primes = [i for i in range(2, n + 1) if is_prime[i]]
    return primes

# Example: Get primes up to 30
print(sieve_of_eratosthenes(30))
# Output: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
#+END_SRC

---

*** 🎉 Why Is It Important?

- Very efficient for finding all primes up to a limit.
- Used in number theory, cryptography, and computer science.
- Simple and intuitive — great for learning algorithms.

---

*** 🚀 Fun Fact

Eratosthenes was not only a mathematician but also a geographer, astronomer, and
poet. He used this sieve to find primes — and even calculated Earth’s
circumference with remarkable accuracy!

---

Let me know if you'd like to see a visual version, or try it with a different
number! 😊")
         )
    (with-temp-buffer
      (org-mode)
      (ellama-test--run-with-fake-streaming
       raw
       "test"
       (lambda ()
         (ellama-write "test"))
       'word-leading)
      (should (equal expected
                     (buffer-substring-no-properties (point-min)
                                                     (point-max)))))))

(ert-deftest test-ellama-duplicate-strings ()
  (let ((fill-column 80)
        (raw "Great question! Whether you should start with **\"Natural Language Processing with Transformers\"** (O’Reilly) or wait for **\"Build a Large Language Model (From Scratch)\"** depends on your **goals, background, and learning style**. Here’s a detailed comparison to help
you decide:

---

")
        (expected "Great question! Whether you should start with *\"Natural Language Processing with
Transformers\"* (O’Reilly) or wait for *\"Build a Large Language Model (From
Scratch)\"* depends on your *goals, background, and learning style*. Here’s a
detailed comparison to help you decide:

---")
        )
    (with-temp-buffer
      (org-mode)
      (ellama-test--run-with-fake-streaming
       raw
       "test"
       (lambda ()
         (ellama-write "test"))
       'word-trailing)
      (should (equal expected (buffer-string))))))

(ert-deftest test-ellama-stream-llm-fake-output-to-buffer ()
  (let* ((ellama-provider
          (make-llm-fake
           :output-to-buffer "*ellama-fake-log*"
           :chat-action-func (lambda () "Fake answer")))
         (ellama-response-process-method 'streaming)
         (ellama-spinner-enabled nil)
         (ellama-fill-paragraphs nil)
         done-text)
    (unwind-protect
        (with-temp-buffer
          (cl-letf (((symbol-function 'sleep-for)
                     (lambda (&rest _args) nil)))
            (ellama-stream "test prompt"
                           :provider ellama-provider
                           :buffer (current-buffer)
                           :on-done (lambda (text) (setq done-text text))))
          (should (equal done-text "Fake answer"))
          (should (equal (buffer-string) "Fake answer"))
          (with-current-buffer (get-buffer-create "*ellama-fake-log*")
            (let ((log (buffer-string)))
              (should (string-match-p "Call to llm-chat-streaming" log))
              (should (string-match-p "test prompt" log)))))
      (let ((buf (get-buffer "*ellama-fake-log*")))
        (when buf
          (kill-buffer buf))))))

(ert-deftest test-ellama-stream-uses-max-tokens ()
  (let* ((provider (make-llm-fake))
         (ellama-provider provider)
         (ellama-max-tokens 7)
         (ellama-response-process-method 'streaming)
         (ellama-spinner-enabled nil)
         (ellama-fill-paragraphs nil)
         captured-prompt
         done-text)
    (cl-letf (((symbol-function 'llm-chat-streaming)
               (lambda (_provider prompt _partial-callback response-callback
                                  _error-callback &optional _multi-output)
                 (setq captured-prompt prompt)
                 (funcall response-callback '(:text "ok"))
                 nil)))
      (with-temp-buffer
        (ellama-stream "test prompt"
                       :provider provider
                       :on-done (lambda (text) (setq done-text text)))))
    (should (equal done-text "ok"))
    (should (= (llm-chat-prompt-max-tokens captured-prompt) 7))))

(ert-deftest test-ellama-stream-max-tokens-argument-overrides-default ()
  (let* ((provider (make-llm-fake))
         (ellama-provider provider)
         (ellama-max-tokens 7)
         (ellama-response-process-method 'streaming)
         (ellama-spinner-enabled nil)
         (ellama-fill-paragraphs nil)
         captured-prompt)
    (cl-letf (((symbol-function 'llm-chat-streaming)
               (lambda (_provider prompt _partial-callback response-callback
                                  _error-callback &optional _multi-output)
                 (setq captured-prompt prompt)
                 (funcall response-callback '(:text "ok"))
                 nil)))
      (with-temp-buffer
        (ellama-stream "test prompt"
                       :provider provider
                       :max-tokens 2)))
    (should (= (llm-chat-prompt-max-tokens captured-prompt) 2))))

(ert-deftest test-ellama-stream-defaults-to-current-buffer-with-active-session ()
  (let* ((ellama-provider
          (make-llm-fake
           :chat-action-func (lambda () "Fake answer")))
         (ellama-response-process-method 'streaming)
         (ellama-spinner-enabled nil)
         (ellama-fill-paragraphs nil)
         (ellama--active-sessions (make-hash-table :test #'equal))
         (ellama--active-session-states (make-hash-table :test #'equal))
         (ellama--current-session-id nil)
         (ellama--current-session-uid nil)
         (session (make-ellama-session :id "test-session"
                                       :provider ellama-provider
                                       :prompt nil))
         (session-buffer (generate-new-buffer " *ellama-test-session*")))
    (unwind-protect
        (progn
          (ellama--register-session session session-buffer t)
          (cl-letf (((symbol-function 'sleep-for)
                     (lambda (&rest _args) nil)))
            (with-temp-buffer
              (ellama-stream "test prompt"
                             :provider ellama-provider)
              (should (equal (buffer-string) "Fake answer"))))
          (with-current-buffer session-buffer
            (should (string-empty-p (buffer-string)))))
      (when (buffer-live-p session-buffer)
        (kill-buffer session-buffer)))))

(ert-deftest test-ellama-stream-does-not-use-global-active-session ()
  (let* ((ellama-provider
          (make-llm-fake
           :chat-action-func (lambda () "Fake answer")))
         (ellama-response-process-method 'streaming)
         (ellama-spinner-enabled nil)
         (ellama-fill-paragraphs nil)
         (ellama--active-sessions (make-hash-table :test #'equal))
         (ellama--active-session-states (make-hash-table :test #'equal))
         (session (make-ellama-session :id "test-session"
                                       :provider ellama-provider
                                       :prompt nil))
         (session-buffer (generate-new-buffer " *ellama-test-session*")))
    (unwind-protect
        (progn
          (ellama--register-session session session-buffer t)
          (cl-letf (((symbol-function 'sleep-for)
                     (lambda (&rest _args) nil)))
            (with-temp-buffer
              (ellama-stream "test prompt"
                             :provider ellama-provider)
              (should (equal (buffer-string) "Fake answer"))))
          (should-not (ellama-session-prompt session))
          (with-current-buffer session-buffer
            (should (string-empty-p (buffer-string)))))
      (when (buffer-live-p session-buffer)
        (kill-buffer session-buffer)))))

(ert-deftest test-ellama-stream-uses-buffer-local-session ()
  (let* ((provider (make-llm-fake
                    :chat-action-func (lambda () "Chat answer")))
         (ellama-provider provider)
         (ellama-response-process-method 'streaming)
         (ellama-spinner-enabled nil)
         (ellama-fill-paragraphs nil)
         (ellama-session-auto-save nil)
         (session (ellama-new-session provider "initial prompt" t))
         (uid (ellama--session-uid session))
         (session-buffer (ellama-get-session-buffer uid)))
    (unwind-protect
        (progn
          (with-current-buffer session-buffer
            (cl-letf (((symbol-function 'sleep-for)
                       (lambda (&rest _args) nil)))
              (ellama-stream "next prompt"))
            (let ((text (buffer-string)))
              (should (string-match-p "Chat answer" text))))
          (should (llm-chat-prompt-p (ellama-session-prompt session))))
      (when (buffer-live-p session-buffer)
        (kill-buffer session-buffer)))))

(ert-deftest test-ellama-plan-and-act-reuses-current-session-buffer ()
  (load-file
   (expand-file-name "ellama-tools.el" ellama-test--root))
  (let* ((provider (make-llm-fake))
         (base-tool (llm-make-tool :name "read_file" :function #'ignore))
         (ellama-provider provider)
         (ellama-providers nil)
         (ellama-tools-enabled (list base-tool))
         (ellama-session-auto-save nil)
         (ellama-chat-display-action-function nil)
         (ellama--active-sessions (make-hash-table :test #'equal))
         (ellama--active-session-states (make-hash-table :test #'equal))
         (session (make-ellama-session :id "agent-session"
                                       :provider provider
                                       :prompt nil
                                       :extra '(:uid "agent-session-uid")))
         (session-buffer (generate-new-buffer " *ellama-agent-chat-test*"))
         (stream-call nil)
         (scroll-call nil))
    (unwind-protect
        (progn
          (with-current-buffer session-buffer
            (org-mode))
          (ellama--register-session session session-buffer t)
          (cl-letf (((symbol-function 'display-buffer)
                     (lambda (&rest _args) nil))
                    ((symbol-function 'ellama--scroll)
                     (lambda (&optional buffer point)
                       (setq scroll-call (list buffer point))))
                    ((symbol-function 'ellama-stream)
                     (lambda (prompt &rest args)
                       (setq stream-call (list prompt args)))))
            (ellama-plan-and-act "Do work"))
          (should (equal (car stream-call) "Do work"))
          (should (eq (plist-get (cadr stream-call) :session)
                      session))
          (should (functionp (plist-get (cadr stream-call) :on-error)))
          (should (functionp (plist-get (cadr stream-call) :on-done)))
          (should (string-match-p
                   "PLAN AND ACT INSTRUCTIONS"
                   (plist-get (cadr stream-call) :system)))
          (should (equal
                   (mapcar #'llm-tool-name
                           (plist-get (ellama-session-extra session) :tools))
                   '("agent_submit_plan"
                     "agent_update_plan"
                     "agent_report_result"
                     "read_file")))
          (should (eq (car scroll-call) session-buffer))
          (with-current-buffer session-buffer
            (let ((text (buffer-string)))
              (should (string-match-p "User:" text))
              (should (string-match-p "Do work" text))
              (should (string-match-p "Ellama Agent Status:" text))
              (should (string-match-p "Planning started" text)))))
      (when (buffer-live-p session-buffer)
        (kill-buffer session-buffer)))))

(ert-deftest test-ellama-plan-and-act-resumes-active-loop ()
  (load-file
   (expand-file-name "ellama-tools.el" ellama-test--root))
  (let* ((provider (make-llm-fake))
         (base-tool (llm-make-tool :name "read_file" :function #'ignore))
         (ellama-provider provider)
         (ellama-providers nil)
         (ellama-tools-enabled (list base-tool))
         (ellama-session-auto-save nil)
         (ellama-chat-display-action-function nil)
         (ellama--active-sessions (make-hash-table :test #'equal))
         (ellama--active-session-states (make-hash-table :test #'equal))
         (state (list :phase 'acting
                      :plan (list (list :id 1
                                        :title "Existing step"
                                        :status 'pending))
                      :completed nil
                      :system "Existing system"))
         (session (make-ellama-session
                   :id "agent-resume"
                   :provider provider
                   :prompt nil
                   :extra (list :uid "agent-resume-uid"
                                :tools (list base-tool)
                                :agent-loop state)))
         (session-buffer (generate-new-buffer " *ellama-agent-resume-test*"))
         (stream-call nil))
    (unwind-protect
        (progn
          (with-current-buffer session-buffer
            (org-mode)
            (insert "** User:\nOriginal\n\n** Ellama:\nPartial\n\n** User:\n"))
          (ellama--register-session session session-buffer t)
          (cl-letf (((symbol-function 'display-buffer)
                     (lambda (&rest _args) nil))
                    ((symbol-function 'ellama-stream)
                     (lambda (prompt &rest args)
                       (setq stream-call (list prompt args)))))
            (ellama-plan-and-act "Correct the approach"))
          (should (equal (car stream-call) "Correct the approach"))
          (should (eq (plist-get (cadr stream-call) :session)
                      session))
          (should (equal (plist-get (cadr stream-call) :system)
                         "Existing system"))
          (should (functionp (plist-get (cadr stream-call) :on-error)))
          (should (functionp (plist-get (cadr stream-call) :on-done)))
          (should (equal (plist-get
                          (car (plist-get
                                (ellama-tools--agent-state session)
                                :plan))
                          :title)
                         "Existing step"))
          (with-current-buffer session-buffer
            (let ((text (buffer-string)))
              (should (string-match-p "Correct the approach" text))
              (should-not (string-match-p "Planning started" text)))))
      (when (buffer-live-p session-buffer)
        (kill-buffer session-buffer)))))

(defun ellama-test--with-temp-image-file (body)
  "Call BODY with a tiny temporary PNG file."
  (let ((file-name (make-temp-file "ellama-image-" nil ".png")))
    (unwind-protect
        (progn
          (with-temp-buffer
            (set-buffer-multibyte nil)
            (insert "\211PNG\r\n\032\n")
            (write-region nil nil file-name nil 'silent))
          (funcall body file-name))
      (when (file-exists-p file-name)
        (delete-file file-name)))))

(defun ellama-test--with-temp-audio-file (body)
  "Call BODY with a tiny temporary WAV file."
  (let ((file-name (make-temp-file "ellama-audio-" nil ".wav")))
    (unwind-protect
        (progn
          (with-temp-buffer
            (set-buffer-multibyte nil)
            (insert "RIFF\0\0\0\0WAVE")
            (write-region nil nil file-name nil 'silent))
          (funcall body file-name))
      (when (file-exists-p file-name)
        (delete-file file-name)))))

(ert-deftest test-ellama-file-to-llm-media ()
  (ellama-test--with-temp-image-file
   (lambda (file-name)
     (let ((media (ellama--file-to-llm-media file-name)))
       (should (llm-media-p media))
       (should (equal (llm-media-mime-type media) "image/png"))
       (should-not (multibyte-string-p (llm-media-data media)))))))

(ert-deftest test-ellama-audio-file-to-llm-media ()
  (ellama-test--with-temp-audio-file
   (lambda (file-name)
     (let ((media (ellama--file-to-llm-media file-name)))
       (should (llm-media-p media))
       (should (equal (llm-media-mime-type media) "audio/wav"))
       (should-not (multibyte-string-p (llm-media-data media)))))))

(ert-deftest test-ellama-stream-builds-multipart-with-images ()
  (ellama-test--with-temp-image-file
   (lambda (file-name)
     (let* ((provider (make-llm-fake))
            (ellama-provider provider)
            (ellama-response-process-method 'streaming)
            (ellama-spinner-enabled nil)
            (ellama-fill-paragraphs nil)
            captured-prompt
            done-text)
       (cl-letf (((symbol-function 'llm-capabilities)
                  (lambda (_provider) '(image-input)))
                 ((symbol-function 'llm-chat-streaming)
                  (lambda (_provider prompt _partial-callback response-callback
                                     _error-callback &optional _multi-output)
                    (setq captured-prompt prompt)
                    (funcall response-callback '(:text "ok"))
                    nil)))
         (with-temp-buffer
           (ellama-stream "Describe image"
                          :provider provider
                          :images (list file-name)
                          :on-done (lambda (text) (setq done-text text)))))
       (should (equal done-text "ok"))
       (let* ((content (llm-chat-prompt-interaction-content
                        (car (llm-chat-prompt-interactions captured-prompt))))
              (parts (llm-multipart-parts content)))
         (should (llm-multipart-p content))
         (should (equal (car parts) "Describe image"))
         (should (llm-media-p (cadr parts)))
         (should (equal (llm-media-mime-type (cadr parts)) "image/png")))))))

(ert-deftest test-ellama-stream-builds-multipart-with-audios ()
  (ellama-test--with-temp-audio-file
   (lambda (file-name)
     (let* ((provider (make-llm-fake))
            (ellama-provider provider)
            (ellama-response-process-method 'streaming)
            (ellama-spinner-enabled nil)
            (ellama-fill-paragraphs nil)
            captured-prompt
            done-text)
       (cl-letf (((symbol-function 'llm-capabilities)
                  (lambda (_provider) '(audio-input)))
                 ((symbol-function 'llm-chat-streaming)
                  (lambda (_provider prompt _partial-callback response-callback
                                     _error-callback &optional _multi-output)
                    (setq captured-prompt prompt)
                    (funcall response-callback '(:text "ok"))
                    nil)))
         (with-temp-buffer
           (ellama-stream "Transcribe audio"
                          :provider provider
                          :audios (list file-name)
                          :on-done (lambda (text) (setq done-text text)))))
       (should (equal done-text "ok"))
       (let* ((content (llm-chat-prompt-interaction-content
                        (car (llm-chat-prompt-interactions captured-prompt))))
              (parts (llm-multipart-parts content)))
         (should (llm-multipart-p content))
         (should (equal (car parts) "Transcribe audio"))
         (should (llm-media-p (cadr parts)))
         (should (equal (llm-media-mime-type (cadr parts)) "audio/wav")))))))

(ert-deftest test-ellama-stream-builds-multipart-with-audio-context ()
  (ellama-test--with-temp-audio-file
   (lambda (file-name)
     (let* ((provider (make-llm-fake))
            (ellama-context-global nil)
            (ellama-context-ephemeral
             (list (ellama-context-element-audio-file :name file-name)))
            (ellama-response-process-method 'streaming)
            (ellama-spinner-enabled nil)
            (ellama-fill-paragraphs nil)
            captured-prompt)
       (cl-letf (((symbol-function 'llm-capabilities)
                  (lambda (_provider) '(audio-input)))
                 ((symbol-function 'llm-chat-streaming)
                  (lambda (_provider prompt _partial-callback response-callback
                                     _error-callback &optional _multi-output)
                    (setq captured-prompt prompt)
                    (funcall response-callback '(:text "ok"))
                    nil)))
         (with-temp-buffer
           (ellama-stream "Transcribe audio" :provider provider)))
       (should-not ellama-context-ephemeral)
       (let* ((content
               (llm-chat-prompt-interaction-content
                (car (llm-chat-prompt-interactions captured-prompt))))
              (parts (llm-multipart-parts content)))
         (should (llm-multipart-p content))
         (should (string-match-p "Transcribe audio" (car parts)))
         (should (llm-media-p (cadr parts)))
         (should (equal (llm-media-mime-type (cadr parts)) "audio/wav")))))))

(ert-deftest test-ellama-stream-rejects-images-without-provider-support ()
  (ellama-test--with-temp-image-file
   (lambda (file-name)
     (let ((provider (make-llm-fake))
           (ellama-response-process-method 'streaming))
       (cl-letf (((symbol-function 'llm-capabilities)
                  (lambda (_provider) nil)))
         (should-error
          (with-temp-buffer
            (ellama-stream "Describe image"
                           :provider provider
                           :images (list file-name)))))))))

(ert-deftest test-ellama-stream-rejects-audios-without-provider-support ()
  (ellama-test--with-temp-audio-file
   (lambda (file-name)
     (let ((provider (make-llm-fake))
           (ellama-response-process-method 'streaming))
       (cl-letf (((symbol-function 'llm-capabilities)
                  (lambda (_provider) nil)))
         (should-error
          (with-temp-buffer
            (ellama-stream "Transcribe audio"
                           :provider provider
                           :audios (list file-name)))))))))

(ert-deftest test-ellama-prompt-attach-pending-tool-media ()
  (ellama-test--with-temp-image-file
   (lambda (file-name)
     (let* ((provider (make-llm-fake))
            (session (make-ellama-session
                      :id "image-tool"
                      :provider provider
                      :prompt (llm-make-chat-prompt "initial")))
            (prompt (ellama-session-prompt session)))
       (ellama--session-add-pending-tool-media session file-name)
       (cl-letf (((symbol-function 'llm-capabilities)
                  (lambda (_provider) '(image-input))))
         (ellama--prompt-attach-pending-tool-media
          session prompt provider))
       (should-not (ellama--session-extra-get session :pending-tool-media))
       (let* ((content (llm-chat-prompt-interaction-content
                        (car (last (llm-chat-prompt-interactions prompt)))))
              (parts (llm-multipart-parts content)))
         (should (llm-multipart-p content))
         (should (string-match-p "Media read by tools" (car parts)))
         (should (llm-media-p (cadr parts))))))))

(ert-deftest test-ellama-ask-image-adds-ephemeral-context ()
  (let (context-call chat-call)
    (cl-letf (((symbol-function 'ellama-context-add-image-file)
               (lambda (file-name &optional ephemeral)
                 (setq context-call (list file-name ephemeral))))
              ((symbol-function 'ellama-chat)
               (lambda (prompt create-session &rest args)
                 (setq chat-call (list prompt create-session args)))))
      (ellama-ask-image "/tmp/image.png" "Describe it" t :ephemeral t))
    (should (equal context-call '("/tmp/image.png" t)))
    (should (equal chat-call '("Describe it" t (:ephemeral t))))))

(ert-deftest test-ellama-chat-with-images-adds-ephemeral-context ()
  (let (context-calls chat-call)
    (cl-letf (((symbol-function 'ellama-context-add-image-file)
               (lambda (file-name &optional ephemeral)
                 (push (list file-name ephemeral) context-calls)))
              ((symbol-function 'ellama-chat)
               (lambda (prompt create-session &rest args)
                 (setq chat-call (list prompt create-session args)))))
      (ellama-chat-with-images
       '("/tmp/one.png" "/tmp/two.png") "Compare them" nil :ephemeral t))
    (should (equal (nreverse context-calls)
                   '(("/tmp/one.png" t) ("/tmp/two.png" t))))
    (should (equal chat-call '("Compare them" nil (:ephemeral t))))))

(ert-deftest test-ellama-ask-audio-adds-ephemeral-context ()
  (let (context-call chat-call)
    (cl-letf (((symbol-function 'ellama-context-add-audio-file)
               (lambda (file-name &optional ephemeral)
                 (setq context-call (list file-name ephemeral))))
              ((symbol-function 'ellama-chat)
               (lambda (prompt create-session &rest args)
                 (setq chat-call (list prompt create-session args)))))
      (ellama-ask-audio "/tmp/audio.wav" "Transcribe it" t :ephemeral t))
    (should (equal context-call '("/tmp/audio.wav" t)))
    (should (equal chat-call '("Transcribe it" t (:ephemeral t))))))

(ert-deftest test-ellama-chat-with-audios-adds-ephemeral-context ()
  (let (context-calls chat-call)
    (cl-letf (((symbol-function 'ellama-context-add-audio-file)
               (lambda (file-name &optional ephemeral)
                 (push (list file-name ephemeral) context-calls)))
              ((symbol-function 'ellama-chat)
               (lambda (prompt create-session &rest args)
                 (setq chat-call (list prompt create-session args)))))
      (ellama-chat-with-audios
       '("/tmp/one.wav" "/tmp/two.wav") "Compare them" nil :ephemeral t))
    (should (equal (nreverse context-calls)
                   '(("/tmp/one.wav" t) ("/tmp/two.wav" t))))
    (should (equal chat-call '("Compare them" nil (:ephemeral t))))))

(ert-deftest test-ellama-audio-recording-command-replaces-placeholders ()
  (let ((ellama-audio-recording-command
         '("recorder" "--seconds" "%d" "--output" "%f")))
    (should (equal (ellama--audio-recording-command "/tmp/a.wav" 12)
                   '("recorder" "--seconds" "12" "--output" "/tmp/a.wav")))))

(ert-deftest test-ellama-audio-recording-command-rejects-duration-placeholder-for-start-stop ()
  (let ((ellama-audio-recording-command
         '("recorder" "--seconds" "%d" "--output" "%f")))
    (should-error (ellama--audio-recording-command "/tmp/a.wav"))))

(ert-deftest test-ellama-audio-recording-command-uses-default-when-nil ()
  (let ((ellama-audio-recording-command nil))
    (cl-letf (((symbol-function 'ellama--default-audio-recording-command)
               (lambda (_file-name &optional duration)
                 (list "recorder" (number-to-string duration) "%f"))))
      (should (equal (ellama--audio-recording-command "/tmp/a.wav" 3)
                     '("recorder" "3" "/tmp/a.wav"))))))

(ert-deftest test-ellama-default-audio-recording-command-uses-ffmpeg-on-macos ()
  (let ((system-type 'darwin)
        (ellama-audio-recording-ffmpeg-filter "normalize"))
    (cl-letf (((symbol-function 'executable-find)
               (lambda (program)
                 (and (equal program "ffmpeg") "/usr/local/bin/ffmpeg"))))
      (should
       (equal
        (ellama--default-audio-recording-command "/tmp/a.wav" 12)
        '("ffmpeg" "-nostdin" "-hide_banner" "-loglevel" "error"
          "-f" "avfoundation" "-i" "none:default" "-af" "normalize"
          "-t" "%d" "-y" "%f"))))))

(ert-deftest test-ellama-default-audio-recording-command-can-disable-filter ()
  (let ((system-type 'darwin)
        (ellama-audio-recording-ffmpeg-filter nil))
    (cl-letf (((symbol-function 'executable-find)
               (lambda (program)
                 (and (equal program "ffmpeg") "/usr/local/bin/ffmpeg"))))
      (should
       (equal
        (ellama--default-audio-recording-command "/tmp/a.wav")
        '("ffmpeg" "-nostdin" "-hide_banner" "-loglevel" "error"
          "-f" "avfoundation" "-i" "none:default" "-y" "%f"))))))

(ert-deftest test-ellama-default-audio-recording-command-uses-arecord-on-linux ()
  (let ((system-type 'gnu/linux))
    (cl-letf (((symbol-function 'executable-find)
               (lambda (program)
                 (and (equal program "arecord") "/usr/bin/arecord"))))
      (should
       (equal
        (ellama--default-audio-recording-command "/tmp/a.wav")
        '("arecord" "-q" "-f" "cd" "%f"))))))

(ert-deftest test-ellama-default-audio-recording-command-falls-back-to-sox ()
  (let ((system-type 'darwin))
    (cl-letf (((symbol-function 'executable-find)
               (lambda (program)
                 (and (equal program "rec") "/usr/local/bin/rec"))))
      (should
       (equal
        (ellama--default-audio-recording-command "/tmp/a.wav" 7)
        '("rec" "%f" "trim" "0" "%d"))))))

(ert-deftest test-ellama-record-audio-includes-recorder-output-in-error ()
  (let ((ellama-audio-recording-command '("recorder" "%f")))
    (cl-letf (((symbol-function 'call-process)
               (lambda (_program _infile destination _display &rest _args)
                 (with-current-buffer (car destination)
                   (insert "microphone unavailable\n"))
                 1)))
      (let ((message
             (condition-case err
                 (progn
                   (ellama-record-audio 1 "/tmp/a.wav")
                   nil)
               (error (error-message-string err)))))
        (should message)
        (should (string-match-p "microphone unavailable" message))))))

(ert-deftest test-ellama-audio-recording-error-suggests-macos-permission ()
  (let ((system-type 'darwin))
    (condition-case err
        (ellama--audio-recording-error
         '("ffmpeg" "-f" "avfoundation") "Abort trap: 6" "")
      (error
       (should
        (string-match-p
         "Privacy & Security > Microphone"
         (error-message-string err)))))))

(ert-deftest test-ellama-audio-recording-shows-recording-lighter ()
  (ellama-test--with-temp-audio-file
   (lambda (file-name)
     (let ((ellama-audio-recording-command '("recorder" "%f"))
           (ellama--audio-recording-process nil)
           (ellama--audio-recording-file nil)
           (live t))
       (ellama-audio-recording-mode -1)
       (cl-letf (((symbol-function 'start-process)
                  (lambda (&rest _args) 'process))
                 ((symbol-function 'set-process-query-on-exit-flag)
                  (lambda (&rest _args) nil))
                 ((symbol-function 'process-live-p)
                  (lambda (_process) live))
                 ((symbol-function 'interrupt-process)
                  (lambda (_process) (setq live nil)))
                 ((symbol-function 'kill-process)
                  (lambda (_process) (setq live nil)))
                 ((symbol-function 'accept-process-output)
                  (lambda (&rest _args) nil)))
         (unwind-protect
             (progn
               (ellama-start-audio-recording file-name)
               (should ellama-audio-recording-mode)
               (should (equal (ellama-stop-audio-recording) file-name))
               (should-not ellama-audio-recording-mode))
           (ellama-audio-recording-mode -1)
           (setq ellama--audio-recording-process nil
                 ellama--audio-recording-file nil)))))))

(ert-deftest test-ellama-chat-send-last-message-displays-ephemeral-image-context ()
  (ellama-test--with-temp-image-file
   (lambda (file-name)
     (let ((ellama-context-global nil)
           (ellama-context-ephemeral
            (list (ellama-context-element-image-file :name file-name)))
           (ellama-major-mode 'org-mode)
           (ellama-fill-paragraphs nil)
           (session (make-ellama-session
                     :id "context-image"
                     :provider (make-llm-fake)))
           captured-prompt)
       (with-temp-buffer
         (org-mode)
         (setq ellama--current-session session)
         (insert "** User:\nDescribe one more image")
         (cl-letf (((symbol-function 'ellama-stream)
                    (lambda (prompt &rest _args)
                      (setq captured-prompt prompt))))
           (ellama-chat-send-last-message))
         (should (equal captured-prompt "Describe one more image"))
         (should (string-match-p "Context:" (buffer-string)))
         (should (string-match-p
                  (regexp-quote
                   (format "[[file:%s][%s]]"
                           file-name
                           (file-name-nondirectory file-name)))
                  (buffer-string))))))))

(ert-deftest test-ellama-chat-send-last-message-refuses-active-request ()
  (let ((session (make-ellama-session
                  :id "busy-chat"
                  :provider (make-llm-fake)))
        (ellama-context-global nil)
        (ellama-context-ephemeral nil))
    (with-temp-buffer
      (org-mode)
      (setq-local ellama--current-session session)
      (setq-local ellama--current-request 'request)
      (insert "** User:\nRepeat this")
      (let ((before (buffer-string)))
        (cl-letf (((symbol-function 'ellama-stream)
                   (lambda (&rest _args)
                     (ert-fail "Unexpected duplicate request"))))
          (should-error
           (ellama-chat-send-last-message)
           :type 'user-error))
        (should (equal before (buffer-string)))))))

(ert-deftest test-ellama-chat-send-last-message-resumes-plan-and-act-loop ()
  (load-file
   (expand-file-name "ellama-tools.el" ellama-test--root))
  (let* ((base-tool (llm-make-tool :name "read_file" :function #'ignore))
         (session (make-ellama-session
                   :id "agent-chat"
                   :provider (make-llm-fake)
                   :extra (list :uid "agent-chat-uid"
                                :tools (list base-tool)
                                :agent-loop
                                (list :phase 'acting
                                      :plan (list (list :id 1
                                                        :title "Fix issue"
                                                        :status 'pending))
                                      :completed nil
                                      :system "Agent system"))))
         (ellama-context-global nil)
         (ellama-context-ephemeral nil)
         (stream-call nil))
    (with-temp-buffer
      (org-mode)
      (setq-local ellama--current-session session)
      (insert "** User:\nGive the agent a correction")
      (cl-letf (((symbol-function 'ellama-stream)
                 (lambda (prompt &rest args)
                   (setq stream-call (list prompt args)))))
        (ellama-chat-send-last-message))
      (should (equal (car stream-call) "Give the agent a correction"))
      (should (eq (plist-get (cadr stream-call) :session) session))
      (should (equal (plist-get (cadr stream-call) :system)
                     "Agent system"))
      (should (functionp (plist-get (cadr stream-call) :on-error)))
      (should (functionp (plist-get (cadr stream-call) :on-done)))
      (should (not (eq (plist-get (cadr stream-call) :on-done)
                       #'ellama-chat-done)))
      (should (equal
               (mapcar #'llm-tool-name
                       (plist-get (cadr stream-call) :tools))
               '("agent_submit_plan"
                 "agent_update_plan"
                 "agent_report_result"
                 "read_file")))
      (should (string-match-p "\\*\\* Ellama:" (buffer-string))))))

(ert-deftest test-ellama-stream-displays-session-buffer-on-generation ()
  (let* ((provider (make-llm-fake
                    :chat-action-func (lambda () "Chat answer")))
         (ellama-provider provider)
         (ellama-response-process-method 'streaming)
         (ellama-spinner-enabled nil)
         (ellama-fill-paragraphs nil)
         (ellama-display-session-buffer-on-generation t)
         (ellama--active-sessions (make-hash-table :test #'equal))
         (ellama--active-session-states (make-hash-table :test #'equal))
         (session (make-ellama-session :id "display-test"
                                       :provider provider
                                       :prompt nil))
         (session-buffer (generate-new-buffer " *ellama-display-test*"))
         displayed-buffer)
    (unwind-protect
        (progn
          (ellama--register-session session session-buffer t)
          (cl-letf (((symbol-function 'sleep-for)
                     (lambda (&rest _args) nil))
                    ((symbol-function 'display-buffer)
                     (lambda (buffer &optional _action)
                       (setq displayed-buffer buffer)
                       buffer)))
            (with-current-buffer session-buffer
              (ellama-stream "next prompt")))
          (should (eq displayed-buffer session-buffer)))
      (when (buffer-live-p session-buffer)
        (kill-buffer session-buffer)))))

(ert-deftest test-ellama-stream-refuses-active-session-request ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (prompt (ellama-session-prompt session)))
    (with-temp-buffer
      (setq-local ellama--current-session session)
      (setq-local ellama--current-request 'request)
      (cl-letf (((symbol-function 'llm-chat-streaming)
                 (lambda (&rest _args)
                   (ert-fail "Unexpected duplicate request"))))
        (should-error
         (ellama-stream "new request" :session session)
         :type 'user-error))
      (should-not
       (member "new request"
               (mapcar #'llm-chat-prompt-interaction-content
                       (llm-chat-prompt-interactions prompt)))))))

(ert-deftest test-ellama-stream-refuses-session-compaction ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (prompt (ellama-session-prompt session)))
    (ellama--session-extra-put session :auto-compact-in-progress t)
    (with-temp-buffer
      (setq-local ellama--current-session session)
      (cl-letf (((symbol-function 'llm-chat-streaming)
                 (lambda (&rest _args)
                   (ert-fail "Unexpected request during compaction"))))
        (should-error
         (ellama-stream "new request" :session session)
         :type 'user-error))
      (should-not
       (member "new request"
               (mapcar #'llm-chat-prompt-interaction-content
                       (llm-chat-prompt-interactions prompt)))))))

(defun ellama-test--compact-session (provider)
  "Return test session with PROVIDER and compactable history."
  (let ((prompt (llm-make-chat-prompt "user 1" :context "System context")))
    (llm-chat-prompt-append-response prompt "assistant 1" 'assistant)
    (llm-chat-prompt-append-response prompt "user 2")
    (llm-chat-prompt-append-response prompt "assistant 2" 'assistant)
    (llm-chat-prompt-append-response prompt "user 3")
    (llm-chat-prompt-append-response prompt "assistant 3" 'assistant)
    (llm-chat-prompt-append-response prompt "user 4")
    (llm-chat-prompt-append-response prompt "assistant 4" 'assistant)
    (make-ellama-session
     :id "compact-test"
     :provider provider
     :prompt prompt
     :extra '(:uid "compact-test"))))

(defun ellama-test--short-compact-session (provider)
  "Return test session with PROVIDER and one user turn."
  (let ((prompt (llm-make-chat-prompt "user 1" :context "System context")))
    (llm-chat-prompt-append-response prompt "assistant 1" 'assistant)
    (make-ellama-session
     :id "short-compact-test"
     :provider provider
     :prompt prompt
     :extra '(:uid "short-compact-test"))))

(ert-deftest test-ellama-session-auto-compact-enabled-by-default ()
  (should ellama-session-auto-compact-enabled))

(ert-deftest test-ellama-stream-preflight-auto-compacts-before-request ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (ellama-response-process-method 'async)
         (ellama-spinner-enabled nil)
         (ellama-session-auto-compact-enabled t)
         (ellama-session-auto-compact-token-threshold 100)
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 2)
         (ellama-session-auto-compact-show-message nil)
         (ellama-session-hide-org-quotes nil)
         (call-count 0)
         compact-callback
         main-prompt
         done-text
         target-buffer
         callback-buffer)
    (cl-letf (((symbol-function 'llm-count-tokens)
               (lambda (_provider _text) 120))
              ((symbol-function 'llm-chat-async)
               (lambda (_provider prompt response-callback
                                  _error-callback &optional _multi-output)
                 (setq call-count (1+ call-count))
                 (if (= call-count 1)
                     (progn
                       (setq compact-callback response-callback)
                       'compact-request)
                   (setq main-prompt prompt)
                   'main-request))))
      (unwind-protect
          (progn
            (setq target-buffer
                  (generate-new-buffer " *ellama-preflight-target*"))
            (setq callback-buffer
                  (generate-new-buffer " *ellama-preflight-callback*"))
            (with-current-buffer target-buffer
              (setq-local ellama--current-session session)
              (ellama-stream "continue after compaction"
                             :session session
                             :on-done (lambda (text)
                                        (setq done-text text))))
            (should (= call-count 1))
            (should compact-callback)
            (should (ellama--session-extra-get
                     session :auto-compact-in-progress))
            (should-not main-prompt)
            (should-not done-text)
            (with-current-buffer callback-buffer
              (funcall compact-callback '(:text "Summary")))
            (should (= call-count 2))
            (should-not (ellama--session-extra-get
                         session :auto-compact-in-progress))
            (with-current-buffer target-buffer
              (should (eq ellama--current-request 'main-request))
              (should ellama--change-group))
            (with-current-buffer callback-buffer
              (should-not (eq ellama--current-request 'main-request))
              (should-not ellama--change-group))
            (should (eq main-prompt (ellama-session-prompt session)))
            (should (string-match-p
                     "continue after compaction"
                     (llm-chat-prompt-to-text main-prompt))))
        (when (buffer-live-p target-buffer)
          (kill-buffer target-buffer))
        (when (buffer-live-p callback-buffer)
          (kill-buffer callback-buffer))))))

(ert-deftest test-ellama-session-auto-compact-needed-above-threshold ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (ellama-session-auto-compact-enabled t)
         (ellama-session-auto-compact-token-threshold 100))
    (should
     (= 110
        (ellama--session-auto-compact-needed-p
         session provider '(:input-tokens 90 :output-tokens 20) "answer")))
    (should-not
     (ellama--session-auto-compact-needed-p
      session provider '(:input-tokens 60 :output-tokens 20) "answer"))))

(ert-deftest test-ellama-session-auto-compact-ignores-missing-token-use ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (ellama-session-auto-compact-enabled t)
         (ellama-session-auto-compact-token-threshold 1))
    (should-not
     (ellama--session-auto-compact-needed-p
      session provider '(:text "answer") "answer"))))

(ert-deftest test-ellama-session-auto-compact-estimates-output-tokens ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (ellama-session-auto-compact-enabled t)
         (ellama-session-auto-compact-token-threshold 100))
    (cl-letf (((symbol-function 'llm-count-tokens)
               (lambda (_provider _text) 12)))
      (should
       (= 107
          (ellama--session-auto-compact-needed-p
           session provider '(:input-tokens 95) "answer"))))))

(ert-deftest test-ellama-session-store-response-token-count ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider)))
    (should
     (= 110
        (ellama--session-store-response-token-count
         session provider '(:input-tokens 90 :output-tokens 20) "answer")))
    (should (= 110 (ellama--session-extra-get session :token-count)))))

(ert-deftest test-ellama-session-compact-rewrites-prompt-and-shows-message ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (prompt (ellama-session-prompt session))
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 2)
         (ellama-session-auto-compact-show-message t))
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (_provider _summary-prompt response-callback
                                  _error-callback &optional _multi-output)
                 (funcall response-callback
                          '(:text "Summary of earlier durable facts."))
                 'request)))
      (with-temp-buffer
        (text-mode)
        (setq-local fill-column 80)
        (insert "assistant output")
        (should
         (ellama--session-compact
          session
          :provider provider
          :buffer (current-buffer)
          :token-count 150))
        (should
         (string-match-p
          "System context"
          (llm-chat-prompt-context prompt)))
        (should
         (equal "System context"
                (llm-chat-prompt-context prompt)))
        (should
         (equal
          (mapcar #'llm-chat-prompt-interaction-content
                  (llm-chat-prompt-interactions prompt))
          '("Previous conversation summary:\n\nSummary of earlier durable facts."
            "user 3" "assistant 3" "user 4" "assistant 4")))
        (should
         (string-match-p
          "Ellama compacted conversation context"
          (buffer-string)))
        (should-not
         (cl-some (lambda (line)
                    (> (length line) fill-column))
                  (split-string (buffer-string) "\n")))))))

(ert-deftest test-ellama-session-compact-reduces-kept-turns ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--short-compact-session provider))
         (prompt (ellama-session-prompt session))
         (summary-prompt-text nil)
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 3)
         (ellama-session-auto-compact-allow-fewer-kept-turns t)
         (ellama-session-auto-compact-show-message t))
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (_provider summary-prompt response-callback
                                  _error-callback &optional _multi-output)
                 (setq summary-prompt-text
                       (llm-chat-prompt-to-text summary-prompt))
                 (funcall response-callback '(:text "Short summary."))
                 'request)))
      (with-temp-buffer
        (text-mode)
        (setq-local fill-column 80)
        (should
         (ellama--session-compact
          session
          :provider provider
          :buffer (current-buffer)
          :token-count 50000))
        (should
         (equal
          (mapcar #'llm-chat-prompt-interaction-content
                  (llm-chat-prompt-interactions prompt))
          '("Previous conversation summary:\n\nShort summary.")))
        (should (string-match-p "User:\nuser 1" summary-prompt-text))
        (should (string-match-p "Assistant:\nassistant 1"
                                summary-prompt-text))
        (should
         (string-match-p
          "Ellama compacted oversized conversation context"
          (buffer-string)))
        (should
         (string-match-p
          "configured to keep 3 recent[[:space:]\n]+turns, kept 0"
          (buffer-string)))
        (should-not
         (cl-some (lambda (line)
                    (> (length line) fill-column))
                  (split-string (buffer-string) "\n")))))))

(ert-deftest test-ellama-session-compact-keeps-opt-out-strict ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--short-compact-session provider))
         (prompt (ellama-session-prompt session))
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 3)
         (ellama-session-auto-compact-allow-fewer-kept-turns nil)
         (message-text nil))
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (&rest _args)
                 (ert-fail "Unexpected compaction request")))
              ((symbol-function 'message)
               (lambda (format-string &rest args)
                 (setq message-text
                       (apply #'format format-string args)))))
      (should-error
       (ellama--session-compact
        session
        :provider provider
        :token-count 50000)
       :type 'error)
      (should-not
       (ellama--session-compact
        session
        :provider provider
        :token-count 50000
        :automatic t))
      (should (string-match-p "Not enough session history" message-text))
      (should
       (equal
        (mapcar #'llm-chat-prompt-interaction-content
                (llm-chat-prompt-interactions prompt))
        '("user 1" "assistant 1"))))))

(ert-deftest test-ellama-response-handler-defers-done-during-auto-compact ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (compact-response-callback nil)
         (done-text nil)
         (ellama-session-auto-compact-enabled t)
         (ellama-session-auto-compact-token-threshold 100)
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 2)
         (ellama-session-auto-compact-show-message nil)
         (ellama-session-hide-org-quotes nil)
         (ellama-spinner-enabled nil))
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (_provider _summary-prompt response-callback
                                  _error-callback &optional _multi-output)
                 (setq compact-response-callback response-callback)
                 'compact-request)))
      (with-temp-buffer
        (setq-local ellama--current-session session)
        (setq-local ellama--change-group (prepare-change-group))
        (activate-change-group ellama--change-group)
        (funcall
         (ellama--response-handler
          #'ignore nil (current-buffer)
          (lambda (text) (setq done-text text))
          #'ignore provider (ellama-session-prompt session) t #'identity)
         '(:text "answer" :input-tokens 90 :output-tokens 20))
        (should compact-response-callback)
        (should-not done-text)
        (funcall compact-response-callback '(:text "Summary"))
        (should (equal done-text "answer"))))))

(ert-deftest test-ellama-response-handler-runs-deferred-done-in-session-buffer ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (old-buffer (generate-new-buffer " *ellama-old-compact-test*"))
         (new-buffer (generate-new-buffer " *ellama-new-compact-test*"))
         (compact-response-callback nil)
         (done-buffer nil)
         (done-text nil)
         (ellama--active-sessions (make-hash-table :test #'equal))
         (ellama-session-auto-compact-enabled t)
         (ellama-session-auto-compact-token-threshold 100)
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 2)
         (ellama-session-auto-compact-show-message nil)
         (ellama-session-hide-org-quotes nil)
         (ellama-spinner-enabled nil))
    (unwind-protect
        (cl-letf (((symbol-function 'llm-chat-async)
                   (lambda (_provider _summary-prompt response-callback
                                      _error-callback &optional _multi-output)
                     (setq compact-response-callback response-callback)
                     'compact-request)))
          (puthash "compact-test" new-buffer ellama--active-sessions)
          (with-current-buffer old-buffer
            (setq-local ellama--current-session session)
            (setq-local ellama--change-group (prepare-change-group))
            (activate-change-group ellama--change-group))
          (funcall
           (ellama--response-handler
            #'ignore nil old-buffer
            (lambda (text)
              (setq done-text text)
              (setq done-buffer (current-buffer)))
            #'ignore provider (ellama-session-prompt session) t #'identity)
           '(:text "answer" :input-tokens 90 :output-tokens 20))
          (should compact-response-callback)
          (should-not done-text)
          (funcall compact-response-callback '(:text "Summary"))
          (should (equal done-text "answer"))
          (should (eq done-buffer new-buffer)))
      (when (buffer-live-p old-buffer)
        (kill-buffer old-buffer))
      (when (buffer-live-p new-buffer)
        (kill-buffer new-buffer)))))

(ert-deftest test-ellama-response-handler-resumes-after-auto-compact-error ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (compact-error-callback nil)
         (done-text nil)
         (ellama-session-auto-compact-enabled t)
         (ellama-session-auto-compact-token-threshold 100)
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 2)
         (ellama-session-auto-compact-show-message nil)
         (ellama-session-hide-org-quotes nil)
         (ellama-spinner-enabled nil))
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (_provider _summary-prompt _response-callback
                                  error-callback &optional _multi-output)
                 (setq compact-error-callback error-callback)
                 'compact-request))
              ((symbol-function 'message)
               (lambda (&rest _args) nil)))
      (with-temp-buffer
        (setq-local ellama--current-session session)
        (setq-local ellama--change-group (prepare-change-group))
        (activate-change-group ellama--change-group)
        (funcall
         (ellama--response-handler
          #'ignore nil (current-buffer)
          (lambda (text) (setq done-text text))
          #'ignore provider (ellama-session-prompt session) t #'identity)
         '(:text "answer" :input-tokens 90 :output-tokens 20))
        (should compact-error-callback)
        (should-not done-text)
        (funcall compact-error-callback 'error "summary failed")
        (should (equal done-text "answer"))))))

(ert-deftest test-ellama-session-compact-restores-mutated-system-context ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (prompt (ellama-session-prompt session))
         (summary-prompt-text nil)
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 2)
         (ellama-session-auto-compact-show-message nil))
    (push (make-llm-chat-prompt-interaction
           :role 'system
           :content "System context")
          (llm-chat-prompt-interactions prompt))
    (setf (llm-chat-prompt-context prompt) nil)
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (_provider summary-prompt response-callback
                                  _error-callback &optional _multi-output)
                 (setq summary-prompt-text
                       (llm-chat-prompt-to-text summary-prompt))
                 (funcall response-callback
                          '(:text "Summary without system message."))
                 'request)))
      (should (ellama--session-compact session :provider provider))
      (should (equal "System context"
                     (llm-chat-prompt-context prompt)))
      (should-not (string-match-p "System context" summary-prompt-text))
      (should-not
       (cl-some
        #'ellama--session-system-interaction-p
        (llm-chat-prompt-interactions prompt)))
      (should
       (equal
        (mapcar #'llm-chat-prompt-interaction-content
                (llm-chat-prompt-interactions prompt))
        '("Previous conversation summary:\n\nSummary without system message."
          "user 3" "assistant 3" "user 4" "assistant 4"))))))

(ert-deftest test-ellama-session-compact-keeps-interactions-added-in-flight ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (prompt (ellama-session-prompt session))
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 2)
         (ellama-session-auto-compact-show-message nil))
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (_provider _summary-prompt response-callback
                                  _error-callback &optional _multi-output)
                 (llm-chat-prompt-append-response prompt "user 5")
                 (llm-chat-prompt-append-response
                  prompt "assistant 5" 'assistant)
                 (funcall response-callback '(:text "Summary"))
                 'request)))
      (should
       (ellama--session-compact
        session
        :provider provider))
      (should
       (equal
        (mapcar #'llm-chat-prompt-interaction-content
                (llm-chat-prompt-interactions prompt))
        '("Previous conversation summary:\n\nSummary"
          "user 3" "assistant 3"
          "user 4" "assistant 4"
          "user 5" "assistant 5"))))))

(ert-deftest test-ellama-session-compact-renders-tool-results-readably ()
  (let* ((prompt (llm-make-chat-prompt "user"))
         (interaction
          (make-llm-chat-prompt-interaction
           :role 'assistant
           :content "assistant"
           :tool-results
           '((grep_in_file . "90:first line\n97:second line")))))
    (push interaction (llm-chat-prompt-interactions prompt))
    (let ((rendered
           (ellama--session-compact-render-interaction interaction)))
      (should (string-match-p "Tool results:" rendered))
      (should (string-match-p "grep_in_file\n  90:first line" rendered))
      (should-not (string-match-p "((grep_in_file" rendered)))))

(ert-deftest test-ellama-session-compact-uses-stored-token-count ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 2)
         (ellama-session-auto-compact-show-message t))
    (ellama--session-extra-put session :token-count 144)
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (_provider _summary-prompt response-callback
                                  _error-callback &optional _multi-output)
                 (funcall response-callback '(:text "Summary"))
                 'request)))
      (with-temp-buffer
        (ellama--session-compact
         session
         :provider provider
         :buffer (current-buffer))
        (should
         (string-match-p
          "estimated context 144 ->"
          (buffer-string)))
        (should
         (= 144
            (ellama--session-extra-get
             session :auto-compact-last-token-count)))))))

(ert-deftest test-ellama-session-compact-replaces-previous-summary-interaction ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (prompt (ellama-session-prompt session))
         (summaries '("Summary one" "Summary two"))
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 1)
         (ellama-session-auto-compact-show-message nil))
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (_provider _summary-prompt response-callback
                                  _error-callback &optional _multi-output)
                 (funcall response-callback
                          `(:text ,(pop summaries)))
                 'request)))
      (should (ellama--session-compact session :provider provider))
      (llm-chat-prompt-append-response prompt "user 5")
      (llm-chat-prompt-append-response prompt "assistant 5" 'assistant)
      (should (ellama--session-compact session :provider provider))
      (should
       (equal
        (mapcar #'llm-chat-prompt-interaction-content
                (llm-chat-prompt-interactions prompt))
        '("Previous conversation summary:\n\nSummary two"
          "user 5" "assistant 5")))
      (should (equal "System context"
                     (llm-chat-prompt-context prompt))))))

(ert-deftest test-ellama-session-compact-shows-compacting-lighter ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (response-callback nil)
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 2)
         (ellama-session-auto-compact-show-message nil))
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (_provider _summary-prompt callback
                                  _error-callback &optional _multi-output)
                 (setq response-callback callback)
                 'request)))
      (with-temp-buffer
        (should-not ellama-compaction-mode)
        (should
         (ellama--session-compact
          session
          :provider provider
          :buffer (current-buffer)))
        (should ellama-compaction-mode)
        (funcall response-callback '(:text "Summary"))
        (should-not ellama-compaction-mode)))))

(ert-deftest test-ellama-session-compact-request-is-cancellable ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (prompt (ellama-session-prompt session))
         (initial-interactions
          (mapcar #'llm-chat-prompt-interaction-content
                  (llm-chat-prompt-interactions prompt)))
         (response-callback nil)
         cancelled-request
         (ellama-spinner-enabled nil)
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 2)
         (ellama-session-auto-compact-show-message nil))
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (_provider _summary-prompt callback
                                  _error-callback &optional _multi-output)
                 (setq response-callback callback)
                 'compact-request))
              ((symbol-function 'llm-cancel-request)
               (lambda (request)
                 (setq cancelled-request request))))
      (with-temp-buffer
        (should
         (ellama--session-compact
          session
          :provider provider
          :buffer (current-buffer)))
        (should response-callback)
        (should (eq ellama--current-request 'compact-request))
        (should ellama-request-mode)
        (should (ellama--session-extra-get
                 session :auto-compact-in-progress))
        (should (eq (ellama--session-extra-get
                     session :auto-compact-request)
                    'compact-request))
        (ellama--cancel-current-request)
        (should (eq cancelled-request 'compact-request))
        (should-not ellama--current-request)
        (should-not ellama-request-mode)
        (should-not (ellama--session-extra-get
                     session :auto-compact-in-progress))
        (should-not (ellama--session-extra-get
                     session :auto-compact-request))
        (funcall response-callback '(:text "Late summary"))
        (should
         (equal
          initial-interactions
          (mapcar #'llm-chat-prompt-interaction-content
                  (llm-chat-prompt-interactions prompt))))))))

(ert-deftest test-ellama-response-handler-cancels-auto-compact-request ()
  (let* ((provider (make-llm-fake))
         (session (ellama-test--compact-session provider))
         (compact-response-callback nil)
         cancelled-request
         done-text
         request-context
         (ellama-session-auto-compact-enabled t)
         (ellama-session-auto-compact-token-threshold 100)
         (ellama-session-auto-compact-provider (make-llm-fake))
         (ellama-session-auto-compact-keep-last-turns 2)
         (ellama-session-auto-compact-show-message nil)
         (ellama-session-hide-org-quotes nil)
         (ellama-spinner-enabled nil))
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (_provider _summary-prompt response-callback
                                  _error-callback &optional _multi-output)
                 (setq compact-response-callback response-callback)
                 'compact-request))
              ((symbol-function 'llm-cancel-request)
               (lambda (request)
                 (setq cancelled-request request))))
      (with-temp-buffer
        (setq-local ellama--current-session session)
        (setq-local ellama--change-group (prepare-change-group))
        (activate-change-group ellama--change-group)
        (setq request-context
              (ellama--set-current-request
               'main-request
               (list (current-buffer))))
        (funcall
         (ellama--response-handler
          #'ignore nil (current-buffer)
          (lambda (text) (setq done-text text))
          #'ignore provider (ellama-session-prompt session) t #'identity
          request-context)
         '(:text "answer" :input-tokens 90 :output-tokens 20))
        (should compact-response-callback)
        (should-not done-text)
        (should (eq ellama--current-request 'compact-request))
        (ellama--cancel-current-request)
        (should (eq cancelled-request 'compact-request))
        (should-not done-text)
        (should-not ellama--current-request)
        (should-not ellama-request-mode)
        (should-not (ellama--session-extra-get
                     session :auto-compact-in-progress))
        (should-not (ellama--session-extra-get
                     session :auto-compact-request))))))

(ert-deftest test-ellama-chat-writes-to-session-buffer ()
  (let* ((provider (make-llm-fake
                    :chat-action-func (lambda () "Chat answer")))
         (ellama-provider provider)
         (ellama-coding-provider provider)
         (ellama-major-mode 'text-mode)
         (ellama-chat-translation-enabled nil)
         (ellama-session-auto-save nil)
         (ellama-response-process-method 'streaming)
         (ellama-spinner-enabled nil)
         (session (ellama-new-session provider "initial prompt" t))
         (uid (ellama--session-uid session))
         (session-buffer (ellama-get-session-buffer uid)))
    (unwind-protect
        (with-temp-buffer
          (insert "origin")
          (cl-letf (((symbol-function 'sleep-for)
                     (lambda (&rest _args) nil)))
            (ellama-chat "next prompt" nil :session-id uid))
          (should (equal (buffer-string) "origin"))
          (with-current-buffer session-buffer
            (let ((text (buffer-string)))
              (should (string-match-p "next prompt" text))
              (should (string-match-p "Chat answer" text)))))
      (when (buffer-live-p session-buffer)
        (kill-buffer session-buffer)))))

(ert-deftest test-ellama-stream-retry-with-llm-fake-tool-call-error ()
  (let* ((call-count 0)
         (error-captured nil)
         (done-text nil)
         (_ (unless (get 'ellama-test-tool-call-error 'error-conditions)
              (define-error 'ellama-test-tool-call-error
                            "Tool call error used in tests"
                            'llm-tool-call-error)))
         (ellama-provider
          (make-llm-fake
           :chat-action-func
           (lambda ()
             (setq call-count (1+ call-count))
             (if (= call-count 1)
                 '(ellama-test-tool-call-error "Temporary tool failure")
               "Recovered answer"))))
         (ellama-response-process-method 'async)
         (ellama-spinner-enabled nil)
         (ellama-fill-paragraphs nil))
    (cl-letf (((symbol-function 'llm-chat-async)
               (lambda (provider prompt response-callback error-callback
                                 &optional _multi-output)
                 (condition-case err
                     (funcall response-callback (llm-chat provider prompt t))
                   (t (funcall error-callback (car err) (cdr err))))
                 nil)))
      (with-temp-buffer
        (ellama-stream "test retry"
                       :provider ellama-provider
                       :buffer (current-buffer)
                       :on-error (lambda (msg) (setq error-captured msg))
                       :on-done (lambda (text) (setq done-text text)))
        (should (= call-count 2))
        (should (null error-captured))
        (should (equal done-text "Recovered answer"))
        (should (equal (buffer-string) "Recovered answer"))))))

(ert-deftest test-ellama-error-handler-deactivates-before-on-error ()
  (let ((ellama-spinner-enabled nil)
        (ellama-undo-on-error nil)
        (active-request-during-callback :unset))
    (with-temp-buffer
      (let* ((request-context (ellama--make-request-context
                               (list (current-buffer))))
             (handler (ellama--error-handler
                       (current-buffer)
                       (lambda (_msg)
                         (setq active-request-during-callback
                               ellama--current-request))
                       nil nil request-context)))
        (setq ellama--change-group (prepare-change-group))
        (activate-change-group ellama--change-group)
        (ellama--set-current-request
         'request (list (current-buffer)) request-context)
        (funcall handler 'error "temporary failure")
        (should (null active-request-during-callback))
        (should (null ellama--current-request))))))

(ert-deftest test-ellama-normalize-tool-use-args-decodes-unibyte-json ()
  (let* ((raw-json
          (encode-coding-string
           "{\"content\":\"├── research_plan.md\",\"file_name\":\"x\"}"
           'utf-8-unix))
         (tool-use
          (make-llm-provider-utils-tool-use
           :id "call"
           :name "write_file"
           :args raw-json))
         (prompt (llm-make-chat-prompt "continue")))
    (setf (llm-chat-prompt-interactions prompt)
          (append
           (llm-chat-prompt-interactions prompt)
           (list
            (make-llm-chat-prompt-interaction
             :role 'assistant
             :content (list tool-use)))))
    (ellama--normalize-prompt-tool-use-args prompt)
    (let* ((args (llm-provider-utils-tool-use-args tool-use))
           (content (cdr (assq 'content args))))
      (should (equal content "├── research_plan.md"))
      (should (multibyte-string-p content))
      (should (json-serialize args)))))

(ert-deftest test-ellama-normalize-tool-use-args-keeps-malformed-json-safe ()
  (let* ((raw-json
          (encode-coding-string
           "{\"content\":\"├── research_plan.md\""
           'utf-8-unix))
         (normalized (ellama--normalize-tool-use-args raw-json)))
    (should (stringp normalized))
    (should (multibyte-string-p normalized))
    (should (json-serialize normalized))))

(ert-deftest test-ellama-json-safe-value-decodes-arguments ()
  (let* ((arguments (encode-coding-string
                     (json-serialize
                      '((content . "├── research_plan.md")
                        (file_name . "x")))
                     'utf-8-unix))
         (request `(:messages [(:tool_calls
                                [(:function (:arguments ,arguments))])]))
         (sanitized (ellama--json-safe-value request))
         (sanitized-arguments
          (plist-get
           (plist-get
            (aref (plist-get (aref (plist-get sanitized :messages) 0)
                             :tool_calls)
                  0)
            :function)
           :arguments)))
    (should-not (multibyte-string-p arguments))
    (should (multibyte-string-p sanitized-arguments))
    (should (json-serialize sanitized))))

(ert-deftest test-ellama-json-safe-value-keeps-dotted-pairs ()
  (let* ((request '(:model "qwen3.6-plus"
                           :enable_search t
                           :search_options ((search_strategy . "agent"))))
         (sanitized (ellama--json-safe-value request)))
    (should (equal sanitized request))
    (should (json-serialize sanitized))))

(ert-deftest test-ellama-does-not-advise-provider-chat-request ()
  (let (ellama-advice)
    (advice-mapc
     (lambda (advice _props)
       (when (and (symbolp advice)
                  (string-prefix-p "ellama-" (symbol-name advice)))
         (push advice ellama-advice)))
     'llm-provider-chat-request)
    (should-not ellama-advice)))

(ert-deftest test-ellama-collect-openai-streaming-tool-uses-uses-max-index ()
  (let* ((data
          [((index . 0)
            (id . "call-1")
            (function
             (name . "shell_command")
             (arguments . "{\"cmd\":\"one\"}")))
           ((index . 1)
            (id . "call-2")
            (function
             (name . "shell_command")
             (arguments . "{\"cmd\":\"two\"}")))])
         (tool-uses
          (llm-provider-utils-openai-collect-streaming-tool-uses data)))
    (should (= (length tool-uses) 2))
    (should (equal (llm-provider-utils-tool-use-id (nth 0 tool-uses))
                   "call-1"))
    (should (equal (llm-provider-utils-tool-use-id (nth 1 tool-uses))
                   "call-2"))
    (should (equal (cdr (assq
                         'cmd
                         (llm-provider-utils-tool-use-args (nth 0 tool-uses))))
                   "one"))
    (should (equal (cdr (assq
                         'cmd
                         (llm-provider-utils-tool-use-args (nth 1 tool-uses))))
                   "two"))))

(ert-deftest test-ellama-stream-retry-tracks-latest-request-for-cancel ()
  (let* ((call-count 0)
         (cancelled-request nil)
         (ellama-response-process-method 'streaming)
         (ellama-spinner-enabled nil)
         (_ (unless (get 'ellama-test-tool-call-error-4 'error-conditions)
              (define-error 'ellama-test-tool-call-error-4
                            "Tool call error used in retry request test"
                            'llm-tool-call-error))))
    (with-temp-buffer
      (cl-letf (((symbol-function 'llm-chat-streaming)
                 (lambda (_provider _prompt _partial-callback _response-callback
                                    error-callback _multi-output)
                   (setq call-count (1+ call-count))
                   (if (= call-count 1)
                       (progn
                         (funcall error-callback
                                  'ellama-test-tool-call-error-4
                                  "Temporary tool failure")
                         'request-1)
                     'request-2)))
                ((symbol-function 'llm-cancel-request)
                 (lambda (request)
                   (setq cancelled-request request))))
        (ellama-stream "retry request tracking test"
                       :provider 'test-provider
                       :buffer (current-buffer)
                       :on-error (lambda (_msg)
                                   (ert-fail "Unexpected on-error call"))
                       :on-done (lambda (_text)
                                  (ert-fail "Unexpected on-done call")))
        (should (= call-count 2))
        (should (eq ellama--current-request 'request-2))
        (condition-case nil
            (call-interactively (key-binding (kbd "C-g") t))
          (quit nil))
        (should (eq cancelled-request 'request-2))
        (should-not ellama-request-mode)))))


(ert-deftest test-ellama-md-to-org-code-simple ()
  (let ((result (ellama--translate-markdown-to-org-filter "Here is your TikZ code for a blue rectangle:
```tex
\\documentclass{article}
\\usepackage{tikz} \\begin{document}
\\begin{tikzpicture} \\node[rectangle, draw=blue] (mynode) {Text};
\\end{tikzpicture}
\\end{document}
```
This code will create a rectangle with a blue border and light
blue filling. You can replace \'Text\' with your desired text
or other TikZ elements.")))
    (should (string-equal result "Here is your TikZ code for a blue rectangle:
#+BEGIN_SRC tex
\\documentclass{article}
\\usepackage{tikz} \\begin{document}
\\begin{tikzpicture} \\node[rectangle, draw=blue] (mynode) {Text};
\\end{tikzpicture}
\\end{document}
#+END_SRC
This code will create a rectangle with a blue border and light
blue filling. You can replace \'Text\' with your desired text
or other TikZ elements."))))

(ert-deftest test-ellama-md-to-org-converter-builtin ()
  (let ((ellama-markdown-to-org-converter 'builtin))
    (cl-letf (((symbol-function 'ellama--translate-markdown-to-org-with-pandoc)
               (lambda (_text)
                 (error "Pandoc should not be called"))))
      (should (string= (ellama--translate-markdown-to-org-filter "**bold**")
                       "*bold*")))))

(ert-deftest test-ellama-md-to-org-converter-auto-pandoc ()
  (let ((ellama-markdown-to-org-converter 'auto))
    (cl-letf (((symbol-function 'ellama--pandoc-available-p)
               (lambda () t))
              ((symbol-function 'ellama--translate-markdown-to-org-with-pandoc)
               (lambda (_text) "pandoc")))
      (should (string= (ellama--translate-markdown-to-org-filter "**bold**")
                       "pandoc")))))

(ert-deftest test-ellama-md-to-org-converter-pandoc-fallback ()
  (let ((ellama-markdown-to-org-converter 'pandoc))
    (cl-letf (((symbol-function 'ellama--translate-markdown-to-org-with-pandoc)
               (lambda (_text)
                 (error "Pandoc failed"))))
      (should (string= (ellama--translate-markdown-to-org-filter "**bold**")
                       "*bold*")))))

(ert-deftest test-ellama-md-to-org-pandoc-normalize ()
  (let ((input "<think>reasoning</think>
Sure! ```emacs-lisp
(message \"ok\")
```"))
    (should (string= (ellama--prepare-markdown-for-pandoc input)
                     (concat "\n\nELLAMA_THINK_BEGIN\n\n"
                             "reasoning\n\nELLAMA_THINK_END\n\n"
                             "Sure! \n```emacs-lisp\n"
                             "(message \"ok\")\n```")))))

(ert-deftest test-ellama-md-to-org-table-builtin ()
  (let ((ellama-markdown-to-org-converter 'builtin))
    (should
     (string=
      (ellama--translate-markdown-to-org-filter
       "| Name | Link |
| :--- | ---: |
| 猫 | [OpenAI](https://openai.com) |")
      "| Name | Link   |
|------+--------|
| 猫   | [[https://openai.com][OpenAI]] |"))))

(ert-deftest test-ellama-md-to-org-table-pandoc ()
  (let ((ellama-markdown-to-org-converter 'pandoc))
    (cl-letf (((symbol-function 'ellama--translate-markdown-to-org-with-pandoc)
               (lambda (_text)
                 "| Name | Description |
|---+---|
| a | much longer |")))
      (should
       (string=
        (ellama--translate-markdown-to-org-filter "ignored")
        "| Name | Description |
|------+-------------|
| a    | much longer |")))))

(ert-deftest test-ellama-md-to-org-table-does-not-fill-long-rows ()
  (let ((ellama-markdown-to-org-converter 'builtin)
        (ellama-fill-paragraphs t)
        (fill-column 30))
    (should
     (string=
      (ellama--translate-markdown-to-org-filter
       "| Name | Description |
| --- | --- |
| item | This description is longer than the fill column |")
      "| Name | Description                                     |
|------+-------------------------------------------------|
| item | This description is longer than the fill column |"))))

(ert-deftest test-ellama-md-to-org-table-skip-src-block ()
  (let ((ellama-markdown-to-org-converter 'builtin))
    (should
     (string=
      (ellama--translate-markdown-to-org-filter
       "```text
|a|long|
|b|c|
```

| A | Longer |
| --- | --- |
| x | y |")
      "#+BEGIN_SRC text
|a|long|
|b|c|
#+END_SRC

| A | Longer |
|---+--------|
| x | y      |"))))

(ert-deftest test-ellama-md-to-org-table-streaming-width-growth ()
  (with-temp-buffer
    (org-mode)
    (let* ((response
            (concat "| Name | Rating |\n"
                    "| --- | --- |\n"
                    "| short | ⭐ |\n"
                    "| longest name | ⭐⭐⭐⭐⭐ (Very easy) |"))
           (ellama-markdown-to-org-converter 'builtin)
           (ellama-auto-scroll nil)
           (insert-text
            (ellama--insert
             (current-buffer) (point)
             #'ellama--translate-markdown-to-org-filter)))
      (dolist (partial
               (ellama-test--fake-stream-partials response 'word-leading))
        (funcall insert-text (string-trim partial)))
      (should
       (string=
        (buffer-string)
        "| Name         | Rating                 |
|--------------+------------------------|
| short        | ⭐                     |
| longest name | ⭐⭐⭐⭐⭐ (Very easy) |")))))

(ert-deftest test-ellama-md-to-org-code-hard ()
  (let ((result (ellama--translate-markdown-to-org-filter "Here is your TikZ code for a blue rectangle:
```
\\documentclass{article}
\\usepackage{tikz} \\begin{document}
\\begin{tikzpicture} \\node[rectangle, draw=blue] (mynode) {Text};
\\end{tikzpicture}
\\end{document}
```
This code will create a rectangle with a blue border and light
blue filling. You can replace \'Text\' with your desired text or other
TikZ elements.")))
    (should (string-equal result "Here is your TikZ code for a blue rectangle:
#+BEGIN_SRC
\\documentclass{article}
\\usepackage{tikz} \\begin{document}
\\begin{tikzpicture} \\node[rectangle, draw=blue] (mynode) {Text};
\\end{tikzpicture}
\\end{document}
#+END_SRC
This code will create a rectangle with a blue border and light
blue filling. You can replace \'Text\' with your desired text or other
TikZ elements."))))

(ert-deftest test-ellama-md-to-org-code-nightmare ()
  (let ((result (ellama--translate-markdown-to-org-filter "Here is your TikZ code for a blue rectangle:
```
\\documentclass{article}
\\usepackage{tikz} \\begin{document}
\\begin{tikzpicture} \\node[rectangle, draw=blue] (mynode) {Text};
\\end{tikzpicture}
\\end{document}```This code will create a rectangle with a blue border and light
blue filling. You can replace \'Text\' with your desired text
or other TikZ elements.")))
    (should (string-equal result "Here is your TikZ code for a blue rectangle:
#+BEGIN_SRC
\\documentclass{article}
\\usepackage{tikz} \\begin{document}
\\begin{tikzpicture} \\node[rectangle, draw=blue] (mynode) {Text};
\\end{tikzpicture}
\\end{document}
#+END_SRC
This code will create a rectangle with a blue border and light
blue filling. You can replace \'Text\' with your desired text
or other TikZ elements."))))

(ert-deftest test-ellama-md-to-org-code-multiple-bad-blocks ()
  (let ((result (ellama--translate-markdown-to-org-filter "Some text:
```
First block
```
other text:
```
Second block
```
more text:
```
third block
```
That's it.")))
    (should (string-equal result "Some text:
#+BEGIN_SRC
First block
#+END_SRC
other text:
#+BEGIN_SRC
Second block
#+END_SRC
more text:
#+BEGIN_SRC
third block
#+END_SRC
That's it."))))

(ert-deftest test-ellama-md-to-org-code-multiple-bad-blocks-good-before ()
  (let ((result (ellama--translate-markdown-to-org-filter "Some text:
```text
First block
```
other text:
```
Second block
```
more text:
```
third block
```
That's it.")))
    (should (string-equal result "Some text:
#+BEGIN_SRC text
First block
#+END_SRC
other text:
#+BEGIN_SRC
Second block
#+END_SRC
more text:
#+BEGIN_SRC
third block
#+END_SRC
That's it."))))

(ert-deftest test-ellama-md-to-org-code-multiple-bad-blocks-good-after ()
  (let ((result (ellama--translate-markdown-to-org-filter "Some text:
```
First block
```
other text:
```
Second block
```
more text:
```text
third block
```
That's it.")))
    (should (string-equal result "Some text:
#+BEGIN_SRC
First block
#+END_SRC
other text:
#+BEGIN_SRC
Second block
#+END_SRC
more text:
#+BEGIN_SRC text
third block
#+END_SRC
That's it."))))

(ert-deftest test-ellama-md-to-org-code-skiped-line-break ()
  (let ((result (ellama--translate-markdown-to-org-filter "Sure! ```emacs-lisp
(message \"ok\")
```")))
    (should (string-equal result "Sure! \n#+BEGIN_SRC emacs-lisp
(message \"ok\")
#+END_SRC"))))

(ert-deftest test-ellama-md-to-org-think-skipped-line-break ()
  (let ((result (ellama--translate-markdown-to-org-filter
                 "Before<think>reasoning</think>After")))
    (should (string-equal result "Before
#+BEGIN_QUOTE
reasoning
#+END_QUOTE
After"))))

(ert-deftest test-ellama-streaming-think-skipped-line-break ()
  (with-temp-buffer
    (org-mode)
    (let ((ellama-markdown-to-org-converter 'builtin)
          (insert-text
           (ellama--insert
            (current-buffer) (point) #'ellama--translate-markdown-to-org-filter)))
      (dolist (text '("Before<th"
                      "Before<think>reasoning"
                      "Before<think>reasoning</think>After"))
        (funcall insert-text text))
      (should (string-equal (buffer-string) "Before
#+BEGIN_QUOTE
reasoning
#+END_QUOTE
After")))))

(ert-deftest test-ellama-normalize-org-block-markers ()
  (let ((result (ellama--normalize-org-block-markers
                 "Before#+begin_center
body
After#+end_center")))
    (should (string-equal result "Before
#+BEGIN_CENTER
body
After
#+END_CENTER"))))

(ert-deftest test-ellama-normalize-org-block-markers-skip-src ()
  (let ((text "#+BEGIN_SRC text
literal#+BEGIN_QUOTE
literal#+END_QUOTE
#+END_SRC"))
    (should (string-equal (ellama--normalize-org-block-markers text) text))))

(ert-deftest test-ellama-md-to-org-inline-fence-long-line ()
  (let* ((long-part (make-string 150000 ?a))
         (text (concat "<think>\n" long-part "```text\nbody\n```\n</think>"))
         (result (ellama--translate-markdown-to-org-filter text)))
    (should (string-match-p "#\\+BEGIN_QUOTE" result))
    (should (string-match-p "#\\+BEGIN_SRC text" result))
    (should (string-match-p "#\\+END_SRC" result))
    (should (string-match-p "#\\+END_QUOTE" result))))

(ert-deftest test-ellama-replace-bad-code-blocks-no-src-blocks ()
  (let ((text "\n#+BEGIN_QUOTE\n((shell_command . ))\n#+END_QUOTE\n"))
    (should (string-equal (ellama--replace-bad-code-blocks text) text))))

(ert-deftest test-ellama-md-to-org-code-inline-latex ()
  (let ((result (ellama--translate-markdown-to-org-filter "_some italic_
$$P_\\theta(Y_T, ..., Y_2|Y_1, x_1, ..., x_T)$$
_more italic_
$P_\\theta$
_more italic_")))
    (should (string-equal result "/some italic/
$$P_\\theta(Y_T, ..., Y_2|Y_1, x_1, ..., x_T)$$
/more italic/
$P_\\theta$
/more italic/"))))


(ert-deftest test-ellama-md-to-org-code-inline-latex-with-code ()
  (let ((result (ellama--translate-markdown-to-org-filter "_some italic_
$$P_\\theta(Y_T, ..., Y_2|Y_1, x_1, ..., x_T)$$
_more italic_
```emacs-lisp
(msg \"ok\")
```
$P_\\theta$
_more italic_")))
    (should (string-equal result "/some italic/
$$P_\\theta(Y_T, ..., Y_2|Y_1, x_1, ..., x_T)$$
/more italic/
#+BEGIN_SRC emacs-lisp
(msg \"ok\")
#+END_SRC
$P_\\theta$
/more italic/"))))

(ert-deftest test-ellama-replace-outside-of-code-blocks-minimal ()
  (let ((result (ellama--replace-outside-of-code-blocks "This")))
    (should (string-equal result "This"))))

(ert-deftest test-ellama-md-to-org-code-snake-case ()
  (let* ((fill-column 70)
         (result (ellama--translate-markdown-to-org-filter "```python
# Example of snake case variables and functions

# Variable names using snake_case
student_name = \"Alice Johnson\"
class_name = \"Mathematics\"
grade_level = 10

# Function name using snake_case
def calculate_average_score(math_score, science_score, english_score):
    average_score = (math_score + science_score + english_score) / 3
    return average_score

# Using the function
student_math_score = 85
student_science_score = 90
student_english_score = 78

average_score = calculate_average_score(student_math_score, student_science_score, student_english_score)
print(f\"The average score of {student_name} in {class_name} is: {average_score:.2f}\")
```

In this example:
- Variable names like `student_name`, `class_name`, and `grade_level` use snake_case.
- The function name `calculate_average_score` also follows the snake_case convention.

Snake case helps improve readability, especially in languages that are sensitive to capitalization like Python.")))
    (should (string-equal result "#+BEGIN_SRC python\n# Example of snake case variables and functions\n\n# Variable names using snake_case\nstudent_name = \"Alice Johnson\"\nclass_name = \"Mathematics\"\ngrade_level = 10\n\n# Function name using snake_case\ndef calculate_average_score(math_score, science_score, english_score):\n    average_score = (math_score + science_score + english_score) / 3\n    return average_score\n\n# Using the function\nstudent_math_score = 85\nstudent_science_score = 90\nstudent_english_score = 78\n\naverage_score = calculate_average_score(student_math_score, student_science_score, student_english_score)\nprint(f\"The average score of {student_name} in {class_name} is: {average_score:.2f}\")\n#+END_SRC\n\nIn this example:\n- Variable names like ~student_name~, ~class_name~, and ~grade_level~\n  use snake_case.\n- The function name ~calculate_average_score~ also follows the\n  snake_case convention.\n\nSnake case helps improve readability, especially in languages that are\nsensitive to capitalization like Python."))))

(ert-deftest test-ellama--fix-file-name ()
  (should (string=
           (ellama--fix-file-name "a/\\?%*:|\"<>.;=")
           "a_____________")))

(ert-deftest test-ellama-md-to-org-code-inline-code ()
  (let ((result (ellama--translate-markdown-to-org-filter "_some italic_
`some_snake_case`
_more italic_
```emacs-lisp
(msg \"ok\")
```
$P_\\theta$
_more italic_")))
    (should (string-equal result "/some italic/
~some_snake_case~
/more italic/
#+BEGIN_SRC emacs-lisp
(msg \"ok\")
#+END_SRC
$P_\\theta$
/more italic/"))))

(ert-deftest test-ellama-md-to-org-inline-code ()
  (let* ((fill-column 80)
         (result (ellama--translate-markdown-to-org-filter "```go
package main
```
### Explanation:
1. **Initialization**: We create a boolean slice `prime` of size `n+1`, where each
index represents whether the number is prime (`true`) or not (`false`).")))
    (should (string= result "#+BEGIN_SRC go
package main
#+END_SRC
*** Explanation:
1. *Initialization*: We create a boolean slice ~prime~ of size ~n+1~, where each
index represents whether the number is prime (~true~) or not (~false~)."))))

(ert-deftest test-ellama-md-to-org-lists ()
  (let* ((fill-column 80)
         (result (ellama--translate-markdown-to-org-filter "<think>Okay, the user asked me to create a list of fruits. Let me think about how to approach this.</think> Here’s a comprehensive list of fruits, categorized for clarity:

---

### **Common Fruits**
1. **Apple**
2. **Banana**
3. **Orange**
4. **Grape**
5. **Strawberry**
6. **Blueberry**

---

### **Additional Notes**
- **Tomatoes** are technically fruits (part of the nightshade family).
- **Coconut** is a tropical fruit, often used in cooking.
- **Papaya** is a versatile fruit with nutritional value.

Let me know if you'd like a simplified version or a specific category (e.g., by region, season, or type)! 🍎🍊")))
    (should (string= result "#+BEGIN_QUOTE
Okay, the user asked me to create a list of fruits. Let me think about how to
approach this.
#+END_QUOTE
 Here’s a comprehensive list of fruits, categorized for clarity:

---

*** *Common Fruits*
1. *Apple*
2. *Banana*
3. *Orange*
4. *Grape*
5. *Strawberry*
6. *Blueberry*

---

*** *Additional Notes*
- *Tomatoes* are technically fruits (part of the nightshade family).
- *Coconut* is a tropical fruit, often used in cooking.
- *Papaya* is a versatile fruit with nutritional value.

Let me know if you'd like a simplified version or a specific category (e.g., by
region, season, or type)! 🍎🍊"))))

(defun ellama-test-max-common-prefix ()
  "Test the `ellama-max-common-prefix` function."
  (should (equal (ellama-max-common-prefix "" "") ""))
  (should (equal (ellama-max-common-prefix "abc" "abcd") "abc"))
  (should (equal (ellama-max-common-prefix "abcd" "abc") "abc"))
  (should (equal (ellama-max-common-prefix "abcdef" "abcefg") "abc"))
  (should (equal (ellama-max-common-prefix "a" "b") ""))
  (should (equal (ellama-max-common-prefix "a" "") ""))
  (should (equal (ellama-max-common-prefix "" "b") "")))

(ert-deftest ellama-test-max-common-prefix ()
  "Run the tests for `ellama-max-common-prefix`."
  (ellama-test-max-common-prefix))

(ert-deftest ellama--string-without-last-two-lines-test ()
  "Test `ellama--string-without-last-two-lines` function."
  (should (equal (ellama--string-without-last-two-lines "Line1\nLine2\nLine3")
                 "Line1"))
  (should (equal (ellama--string-without-last-two-lines "SingleLine")
                 ""))
  (should (equal (ellama--string-without-last-two-lines "")
                 ""))
  (should (equal (ellama--string-without-last-two-lines "Line1\nLine2")
                 "")))


(ert-deftest test-ellama-chat-done-appends-user-header-and-callbacks ()
  (let* ((ellama-major-mode 'org-mode)
         (ellama-user-nick "Tester")
         (ellama-nick-prefix-depth 2)
         (ellama-session-auto-save nil)
         (global-callback-text nil)
         (local-callback-text nil)
         (ellama-chat-done-callback (lambda (text)
                                      (setq global-callback-text text))))
    (with-temp-buffer
      (insert "Assistant output")
      (cl-letf (((symbol-function 'ellama--scroll)
                 (lambda (&optional _buffer _point) nil)))
        (ellama-chat-done "final"
                          (lambda (text)
                            (setq local-callback-text text))))
      (should (equal (buffer-string)
                     "Assistant output\n\n** Tester:\n"))
      (should (equal global-callback-text "final"))
      (should (equal local-callback-text "final")))))

(ert-deftest test-ellama-chat-create-session-flag-forces-new-session ()
  (let* ((provider (make-llm-fake :chat-action-func (lambda () "ok")))
         (ellama-provider provider)
         (ellama-coding-provider provider)
         (ellama-major-mode 'text-mode)
         (ellama-session-auto-save nil)
         (ellama-chat-translation-enabled nil)
         (session-1 (ellama-new-session provider "initial prompt" t))
         (uid-1 (ellama--session-uid session-1))
         (buffer-1 (ellama-get-session-buffer uid-1))
         uid-2
         buffer-2)
    (unwind-protect
        (progn
          (cl-letf (((symbol-function 'ellama-stream)
                     (lambda (&rest _args) :stubbed)))
            (ellama-chat "next prompt" t))
          (setq uid-2 ellama--current-session-uid
                buffer-2 (ellama-get-session-buffer uid-2))
          (should uid-2)
          (should (not (equal uid-1 uid-2)))
          (should (buffer-live-p buffer-2)))
      (when (buffer-live-p buffer-1)
        (kill-buffer buffer-1))
      (when (and buffer-2
                 (buffer-live-p buffer-2)
                 (not (eq buffer-2 buffer-1)))
        (kill-buffer buffer-2)))))


(ert-deftest test-ellama-remove-reasoning ()
  (should (equal
           (ellama-remove-reasoning "<think>\nabc\n</think>\nFinal")
           "Final"))
  (should (equal
           (ellama-remove-reasoning "Before <think>x</think> After")
           "Before  After")))

(ert-deftest test-ellama-mode-derived-helpers ()
  (let ((ellama-major-mode 'org-mode)
        (ellama-nick-prefix-depth 3))
    (should (equal (ellama-get-nick-prefix-for-mode) "***"))
    (should (equal (ellama-get-session-file-extension) "org")))
  (let ((ellama-major-mode 'text-mode)
        (ellama-nick-prefix-depth 2))
    (should (equal (ellama-get-nick-prefix-for-mode) "##"))
    (should (equal (ellama-get-session-file-extension) "md"))))

(ert-deftest test-ellama-chat-rehydrate-after-revert ()
  (let* ((provider (make-llm-fake :chat-action-func (lambda () "ok")))
         (ellama-provider provider)
         (ellama-coding-provider provider)
         (ellama-major-mode 'text-mode)
         (ellama-session-auto-save t)
         (ellama-spinner-enabled nil)
         (ellama-response-process-method 'sync)
         (ellama-sessions-directory (make-temp-file "ellama-test-" t))
         (session (ellama-new-session provider "initial prompt"))
         (uid (ellama--session-uid session))
         (buffer (ellama-get-session-buffer uid))
         file-name)
    (unwind-protect
        (progn
          (with-current-buffer buffer
            (save-buffer)
            (setq file-name (buffer-file-name)))
          (with-temp-file file-name
            (insert "External update\n"))
          (with-current-buffer buffer
            (revert-buffer :ignore-auto :noconfirm)
            (should (ellama-session-p (ellama--resolve-session nil uid))))
          (cl-letf (((symbol-function 'ellama-stream)
                     (lambda (&rest _args) :stubbed)))
            (ellama-chat "repro prompt"))
          (with-current-buffer buffer
            (should ellama-session-mode)
            (should (ellama-session-p ellama--current-session)))
          (should (equal ellama--current-session-uid uid))
          (should (equal ellama--current-session-id
                         (ellama-session-id session))))
      (when (buffer-live-p buffer)
        (kill-buffer buffer)))))

(ert-deftest test-ellama-read-legacy-session-adds-uid ()
  (let* ((file-name (make-temp-file "ellama-session-" nil ".el"))
         (legacy-session (make-ellama-session
                          :id "legacy"
                          :provider :provider
                          :prompt "prompt"
                          :extra '(:dir "/tmp"))))
    (unwind-protect
        (progn
          (with-temp-file file-name
            (insert (prin1-to-string legacy-session)))
          (let ((session (ellama--read-session-from-file file-name)))
            (should (ellama-session-p session))
            (should (equal (ellama-session-id session) "legacy"))
            (should (equal (ellama--session-uid session) "legacy"))))
      (delete-file file-name t))))

(ert-deftest test-ellama-save-session-redacts-callable-provider-key ()
  (let* ((secret "session-secret")
         (provider (make-llm-openai-compatible
                    :url "https://api.example.com/v1"
                    :chat-model "model"
                    :key (lambda () secret)))
         (ellama-provider provider)
         (ellama-session-persist-provider-keys nil)
         (file-name (make-temp-file "ellama-session-" nil ".org"))
         (session-file-name (ellama--get-session-file-name file-name))
         (ellama--current-session
          (make-ellama-session
           :id "redacted"
           :provider provider
           :file file-name
           :prompt (llm-make-chat-prompt "hello")
           :extra '(:dir "/tmp" :uid "redacted")))
         content loaded key)
    (unwind-protect
        (progn
          (ellama--save-session)
          (setq content
                (with-temp-buffer
                  (insert-file-contents session-file-name)
                  (buffer-string)))
          (should-not (string-match-p (regexp-quote secret) content))
          (should-not (string-match-p "#<" content))
          (setq loaded (ellama--read-session-from-file session-file-name))
          (setq key (llm-openai-compatible-key
                     (ellama-session-provider loaded)))
          (should (equal (if (functionp key) (funcall key) key) secret)))
      (delete-file file-name t)
      (delete-file session-file-name t))))

(ert-deftest test-ellama-save-session-restores-key-from-auth-source ()
  (let* ((secret "auth-source-secret")
         (provider (make-llm-openai-compatible
                    :url "https://api.example.com/v1"
                    :chat-model "model"
                    :key (lambda () "runtime-secret")))
         (ellama-provider nil)
         (ellama-session-persist-provider-keys 'auth-source)
         (file-name (make-temp-file "ellama-session-" nil ".org"))
         (session-file-name (ellama--get-session-file-name file-name))
         (ellama--current-session
          (make-ellama-session
           :id "auth-source"
           :provider provider
           :file file-name
           :prompt (llm-make-chat-prompt "hello")
           :extra '(:dir "/tmp" :uid "auth-source")))
         content loaded key captured-args)
    (unwind-protect
        (progn
          (ellama--save-session)
          (setq content
                (with-temp-buffer
                  (insert-file-contents session-file-name)
                  (buffer-string)))
          (should-not (string-match-p "runtime-secret" content))
          (should (string-match-p ":provider-key-ref" content))
          (cl-letf (((symbol-function 'auth-source-search)
                     (lambda (&rest args)
                       (setq captured-args args)
                       (list (list :secret (lambda () secret))))))
            (setq loaded (ellama--read-session-from-file session-file-name)))
          (should (equal (plist-get captured-args :host)
                         "api.example.com"))
          (setq key (llm-openai-compatible-key
                     (ellama-session-provider loaded)))
          (should (equal (if (functionp key) (funcall key) key) secret)))
      (delete-file file-name t)
      (delete-file session-file-name t))))

(ert-deftest test-ellama-read-session-treats-symbol-key-as-redacted ()
  (let* ((secret "configured-secret")
         (ellama-provider (make-llm-openai-compatible
                           :url "https://api.example.com/v1"
                           :chat-model "model"
                           :key (lambda () secret)))
         (file-name (make-temp-file "ellama-session-" nil ".el")))
    (unwind-protect
        (progn
          (with-temp-file file-name
            (insert
             (prin1-to-string
              (make-ellama-session
               :id "symbol-key"
               :provider (make-llm-openai-compatible
                          :url "https://api.example.com/v1"
                          :chat-model "model"
                          :key 'llm-key)
               :prompt (llm-make-chat-prompt "hello")
               :extra '(:dir "/tmp"
                             :uid "symbol-key"
                             :provider-symbol ellama-provider)))))
          (let* ((session (ellama--read-session-from-file file-name))
                 (key (llm-openai-compatible-key
                       (ellama-session-provider session))))
            (should (equal (if (functionp key) (funcall key) key) secret))))
      (delete-file file-name t))))

(ert-deftest test-ellama-save-session-omits-unreadable-tools ()
  (let* ((tool (llm-make-tool
                :function (lambda (&rest _args) "ok")
                :name "demo_tool"
                :description "Demo tool."
                :args nil))
         (ellama-tools-available (list tool))
         (file-name (make-temp-file "ellama-session-" nil ".org"))
         (session-file-name (ellama--get-session-file-name file-name))
         (ellama--current-session
          (make-ellama-session
           :id "tools"
           :provider (make-llm-fake)
           :file file-name
           :prompt (llm-make-chat-prompt "hello" :tools (list tool))
           :extra `(:dir "/tmp" :uid "tools" :tools ,(list tool))))
         content loaded)
    (unwind-protect
        (progn
          (ellama--save-session)
          (setq content
                (with-temp-buffer
                  (insert-file-contents session-file-name)
                  (buffer-string)))
          (should-not (string-match-p "#<" content))
          (should-not (string-match-p "#[0-9]" content))
          (setq loaded (ellama--read-session-from-file session-file-name))
          (should-not (llm-chat-prompt-tools
                       (ellama-session-prompt loaded)))
          (should (equal (mapcar #'llm-tool-name
                                 (plist-get (ellama-session-extra loaded)
                                            :tools))
                         '("demo_tool"))))
      (delete-file file-name t)
      (delete-file session-file-name t))))

(ert-deftest test-ellama-read-session-replaces-unreadable-tool-reference ()
  (let ((file-name (make-temp-file "ellama-session-" nil ".el")))
    (unwind-protect
        (progn
          (with-temp-file file-name
            (insert
             "#s(ellama-session \"bad\" nil nil "
             "#s(llm-chat-prompt nil nil nil (#1) nil nil nil nil nil nil) "
             "nil (:dir \"/tmp\"))"))
          (let ((session (ellama--read-session-from-file file-name)))
            (should (ellama-session-p session))
            (should (equal (ellama-session-id session) "bad"))
            (should-not (llm-chat-prompt-tools
                         (ellama-session-prompt session)))))
      (delete-file file-name t))))

(ert-deftest test-ellama-session-file-candidates-newest-first ()
  (let ((dir (make-temp-file "ellama-sessions-" t)))
    (unwind-protect
        (let ((older (file-name-concat dir "older.org"))
              (middle (file-name-concat dir "middle.org"))
              (newer (file-name-concat dir "newer.org"))
              (hidden (file-name-concat dir ".hidden.org")))
          (dolist (file (list older middle newer hidden))
            (with-temp-file file
              (insert file)))
          (set-file-times older (seconds-to-time 1000))
          (set-file-times middle (seconds-to-time 2000))
          (set-file-times newer (seconds-to-time 3000))
          (should (equal (ellama--session-file-candidates dir)
                         '("newer.org" "middle.org" "older.org"))))
      (delete-directory dir t))))

(ert-deftest test-ellama-presorted-completion-table-keep-order ()
  (let* ((candidates '("newer.org" "middle.org" "older.org"))
         (table (ellama--presorted-completion-table candidates))
         (metadata (completion-metadata "" table nil)))
    (should (equal (all-completions "" table) candidates))
    (should (eq (completion-metadata-get metadata 'display-sort-function)
                #'identity))
    (should (eq (completion-metadata-get metadata 'cycle-sort-function)
                #'identity))))

(ert-deftest test-ellama-session-rename-keep-uid ()
  (let* ((provider (make-llm-fake :chat-action-func (lambda () "ok")))
         (ellama-provider provider)
         (ellama-coding-provider provider)
         (ellama-major-mode 'text-mode)
         (ellama-session-auto-save nil)
         (session (ellama-new-session provider "initial prompt" t))
         (uid (ellama--session-uid session))
         (buffer (ellama-get-session-buffer uid)))
    (unwind-protect
        (with-current-buffer buffer
          (cl-letf (((symbol-function 'read-string)
                     (lambda (&rest _args) "renamed-session")))
            (ellama-session-rename))
          (should (equal (ellama-session-id ellama--current-session)
                         "renamed-session"))
          (should (equal (ellama--session-uid ellama--current-session) uid))
          (should (eq (gethash uid ellama--active-session-states)
                      ellama--current-session))
          (should (equal ellama--current-session-id "renamed-session")))
      (when (buffer-live-p buffer)
        (kill-buffer buffer)))))

(provide 'test-ellama)

;;; test-ellama.el ends here

(ert-deftest test-ellama-extract-diff-no-diff ()
  "Return nil when no diff is available."
  (cl-letf (((symbol-function 'ellama--diff-cached) (lambda () nil))
            ((symbol-function 'ellama--diff) (lambda () nil))
            ((symbol-function 'ellama--in-commit-buffer) (lambda () nil)))
    (should (null (ellama--extract-diff)))))

(ert-deftest test-ellama-extract-diff-fallback-to-unstaged ()
  "Return unstaged diff when staged is empty."
  (cl-letf (((symbol-function 'ellama--diff-cached) (lambda () nil))
            ((symbol-function 'ellama--diff) (lambda () "unstaged-diff"))
            ((symbol-function 'ellama--in-commit-buffer) (lambda () t)))
    (should (equal (ellama--extract-diff) "unstaged-diff"))))

(ert-deftest test-ellama-extract-diff-fallback-to-commit-patch ()
  "Return commit patch when no diff and in commit buffer."
  (cl-letf (((symbol-function 'ellama--diff-cached) (lambda () nil))
            ((symbol-function 'ellama--diff) (lambda () nil))
            ((symbol-function 'ellama--in-commit-buffer) (lambda () t))
            ((symbol-function 'ellama--commit-patch) (lambda () "patch-content")))
    (should (equal (ellama--extract-diff) "patch-content"))))

(ert-deftest test-ellama-extract-diff-prefers-staged ()
  "Return staged diff and ignore other sources."
  (cl-letf (((symbol-function 'ellama--diff-cached) (lambda () "staged-diff"))
            ((symbol-function 'ellama--diff) (lambda () "unstaged-diff")))
    (should (equal (ellama--extract-diff) "staged-diff"))))
