branch: externals/ellama
commit b32479ffc3c52a1d5ecbf8192869a86a679ac184
Merge: 205190aac3 e53ead5f84
Author: Sergey Kostyaev <[email protected]>
Commit: GitHub <[email protected]>

    Merge pull request #390 from s-kostyaev/improve-tests
    
    Improve tests and docs
---
 AGENTS.md                              |   1 +
 Makefile                               |  20 +-
 NEWS.org                               |  60 ++++
 README.org                             |  51 +--
 ellama-community-prompts.el            |  27 +-
 ellama-tools.el                        |  19 +-
 ellama.el                              |   2 +-
 ellama.info                            | 125 +++----
 tests/test-ellama-blueprint.el         | 292 +++++++++++++++
 tests/test-ellama-community-prompts.el | 149 ++++++++
 tests/test-ellama-context.el           | 243 +++++++++++++
 tests/test-ellama-manual.el            | 162 +++++++++
 tests/test-ellama-skills.el            | 175 +++++++++
 tests/test-ellama-tools.el             | 583 ++++++++++++++++++++++++++++++
 tests/test-ellama-transient.el         | 275 ++++++++++++++
 tests/test-ellama.el                   | 638 ++++++++++-----------------------
 16 files changed, 2255 insertions(+), 567 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index a20bb84d4b..812222d7c8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,6 +5,7 @@
 1. **Build**: `make build`
 2. **Run unit tests** (ERT): `make test`
 3. **Check native compilation warnings**: `make check-compile-warnings`
+4. **Export manual**: `make manual`
 
 ## Code Style Guidelines
 
diff --git a/Makefile b/Makefile
index 5867ac5550..703af5b049 100644
--- a/Makefile
+++ b/Makefile
@@ -1,15 +1,31 @@
 # Makefile for ellama project
 
-.PHONY: build test check-compile-warnings
+.PHONY: build test check-compile-warnings manual refill-news
 
 build:
        emacs -batch --eval "(package-initialize)" -f batch-byte-compile 
ellama*.el
 
 test:
-       emacs -batch --eval "(package-initialize)" -l ellama.el -l 
tests/test-ellama.el --eval "(ert t)"
+       emacs -batch --eval "(package-initialize)" \
+               -l ellama.el \
+               -l tests/test-ellama.el \
+               -l tests/test-ellama-context.el \
+               -l tests/test-ellama-tools.el \
+               -l tests/test-ellama-skills.el \
+               -l tests/test-ellama-transient.el \
+               -l tests/test-ellama-blueprint.el \
+               -l tests/test-ellama-manual.el \
+               -l tests/test-ellama-community-prompts.el \
+               --eval "(ert t)"
 
 check-compile-warnings:
        emacs --batch --eval "(package-initialize)" --eval "(setq 
native-comp-eln-load-path (list default-directory))" -L . -f 
batch-native-compile ellama*.el
 
+manual:
+       emacs -batch --eval "(package-initialize)" \
+               --eval "(require 'project)" \
+               -l ellama-manual.el \
+               --eval "(ellama-manual-export)"
+
 refill-news:
        emacs -batch --eval "(with-current-buffer (find-file-noselect 
\"./NEWS.org\") (setq fill-column 80) (fill-region (point-min) (point-max)) 
(save-buffer))"
diff --git a/NEWS.org b/NEWS.org
index 92951ac5d2..46738078d0 100644
--- a/NEWS.org
+++ b/NEWS.org
@@ -1,3 +1,63 @@
+* Version 1.12.14
+- Add fake streaming helpers and new stream tests. Introduce
+  ~ellama-test--fake-stream-partials~ and 
~ellama-test--run-with-fake-streaming~
+  to simplify test setup for streaming LLM responses. Refactor existing tests 
to
+  use these helpers. Add tests for ~ellama-stream~ output to a log buffer and
+  for retry logic when a fake tool‑call error is raised. These changes improve
+  test readability and cover new streaming scenarios.
+- Add tests for ellama helper functions and error handling. Introduce unit 
tests
+  covering ~ellama-remove-reasoning~, mode-derived helpers, tool call error
+  detection, error handler retry logic on tool errors, error handling for
+  non-tool errors, chat-done callback behavior, and setup to use local
+  ellama-tools. These tests validate correct behavior and edge cases.
+- Correct tool argument handling, enable‑by‑name, and edit‑file logic. Update
+  ~ellama-tools-wrap-with-confirm~ to use each argument’s plist for type
+  resolution, prevent type removing. Guard ~ellama-tools-enable-by-name-tool~
+  against adding a nil tool when the name is missing. Simplify
+  ~ellama-tools-edit-file-tool~ to use replace-match for robust replacement. 
Add
+  extensive ERT tests covering argument type preservation, enable‑by‑name
+  nil‑check, edit‑file replacement at file start, confirmation caching, reply,
+  and denial flows, file read/write/append/prepend/directory tree/move/line
+  range/patch application validations, role and provider resolution logic,
+  subagent loop step limits, task tool role fallback and priority handling.
+- Add test for context prompt clearing ephemerals. Introduce a new ERT test
+  ~test-ellama-context-prompt-with-context-clears-ephemeral~ that verifies
+  ~ellama-context-prompt-with-context~ returns the combined prompt string and
+  clears the ellama-context-ephemeral list. The test also ensures that
+  ~ellama-context-global~ remains intact after the prompt is generated,
+  confirming that ephemerals are not persisted across prompts.
+- Add comprehensive tests for ellama skill handling. Implemented a suite of 
unit
+  tests covering skill frontmatter parsing, directory scanning with filtering 
of
+  invalid or hidden skills, project directory resolution, ordering of global 
and
+  local skills, and prompt generation for both empty and populated skill lists.
+- Add blueprint tests. Updated the Makefile test target to run the new
+  ellama‑blueprint tests and added tests/test-ellama-blueprint.el which
+  exercises blueprint loading, variable handling, selection, and command
+  execution.
+- Add community prompts tests and refactor download logic. Implemented
+  comprehensive unit tests for community prompts, updated Makefile to run the
+  new test, and refactored the prompt download routine in
+  ~ellama-community-prompts.el~ to simplify directory handling and response
+  processing.
+- Add transient tests for ellama. Introduce comprehensive tests for Ellama
+  transient features, covering model population, provider construction, 
provider
+  selection, system and prompt handling, context summary, argument forwarding,
+  and main menu initialization.
+- Add manual export tests. Add a comprehensive test suite for ellama manual
+  export, verifying that the version macro is included, badge.svg and gif links
+  are stripped, org-export-to-file is called with correct arguments, broken
+  links are bound locally, and an error is raised when the version header is
+  missing.
+- Split ~test-ellama.el~. Move tools and context related test to separated
+  files.
+- Add skills tests and update Makefile. Implemented a comprehensive test suite
+  for Ellama skills, covering front‑matter parsing, directory scanning, project
+  directory resolution, skill aggregation, and prompt generation.
+- Add manual export instructions. Add “Export manual” entry to AGENTS.md and a
+  new ~manual~ target to the Makefile. The target runs ~ellama-manual-export~
+  via Emacs batch mode.
+- Documentation fixes. Minor updates to documentation for clarity and
+  consistency.
 * Version 1.12.13
 - Refactor confirmation system architecture. Split confirmation logic into
   reusable internal functions and improved wrapper factory. The system now
diff --git a/README.org b/README.org
index 033483505a..89b7b62afb 100644
--- a/README.org
+++ b/README.org
@@ -18,14 +18,14 @@ Assistant". Previous sentence was written by Ellama itself.
 
 Just ~M-x~ ~package-install~ @@html:<kbd>@@Enter@@html:</kbd>@@
 ~ellama~ @@html:<kbd>@@Enter@@html:</kbd>@@. By default it uses 
[[https://github.com/jmorganca/ollama][ollama]]
-provider. If you ok with it, you need to install 
[[https://github.com/jmorganca/ollama][ollama]] and pull
+provider. If you are OK with it, you need to install 
[[https://github.com/jmorganca/ollama][ollama]] and pull
 [[https://ollama.com/models][any ollama model]] like this:
 
 #+BEGIN_SRC shell
   ollama pull qwen2.5:3b
 #+END_SRC
 
-You can use ~ellama~ with other model or other llm provider.
+You can use ~ellama~ with other models or another LLM provider.
 Without any configuration, the first available ollama model will be used.
 You can customize ellama configuration like this:
 
@@ -34,7 +34,7 @@ You can customize ellama configuration like this:
     :ensure t
     :bind ("C-c e" . ellama)
     ;; send last message in chat buffer with C-c C-c
-    :hook (org-ctrl-c-ctrl-c-final . ellama-chat-send-last-message)
+    :hook (org-ctrl-c-ctrl-c-hook . ellama-chat-send-last-message)
     :init (setopt ellama-auto-scroll t)
     :config
     ;; show ellama context in header line in all buffers
@@ -43,14 +43,14 @@ You can customize ellama configuration like this:
     (ellama-session-header-line-global-mode +1))
 #+END_SRC
 
-More sofisticated configuration example:
+More sophisticated configuration example:
 
 #+BEGIN_SRC  emacs-lisp
   (use-package ellama
     :ensure t
     :bind ("C-c e" . ellama)
     ;; send last message in chat buffer with C-c C-c
-    :hook (org-ctrl-c-ctrl-c-final . ellama-chat-send-last-message)
+    :hook (org-ctrl-c-ctrl-c-hook . ellama-chat-send-last-message)
     :init
     ;; setup key bindings
     ;; (setopt ellama-keymap-prefix "C-c e")
@@ -190,7 +190,7 @@ More sofisticated configuration example:
 - ~ellama-context-add-selection~: Add selected region to context.
 - ~ellama-context-add-info-node~: Add info node to context.
 - ~ellama-context-reset~: Clear global context.
-- ~ellama-manage-context~: Manage the global context. Inside context
+- ~ellama-context-manage~: Manage the global context. Inside context
     management buffer you can see ellama context elements. Available actions
     with key bindings:
     - ~n~: Move to the next line.
@@ -200,29 +200,24 @@ More sofisticated configuration example:
     - ~a~: Open the transient context menu for adding new elements.
     - ~d~: Remove the context element at the current point.
     - ~RET~: Preview the context element at the current point.
-- ~ellama-preview-context-element-at-point~: Preview ellama context element at
+- ~ellama-context-preview-element-at-point~: Preview ellama context element at
     point. Works inside ellama context management buffer.
-- ~ellama-remove-context-element-at-point~: Remove ellama context element at
+- ~ellama-context-remove-element-at-point~: Remove ellama context element at
     point from global context. Works inside ellama context management buffer.
-- ~ellama-chat-translation-enable~: Chat translation enable.
-- ~ellama-chat-translation-disable~: Chat translation disable.
+- ~ellama-chat-translation-enable~: Enable chat translation.
+- ~ellama-chat-translation-disable~: Disable chat translation.
 - ~ellama-solve-reasoning-problem~: Solve reasoning problem with Abstraction
-    of Thought technique. It uses a chain of multiple messages to LLM and help
+    of Thought technique. It uses a chain of multiple messages to an LLM and 
helps
     it to provide much better answers on reasoning problems. Even small LLMs
-    like phi3-mini provides much better results on reasoning tasks using AoT.
+    like phi3-mini provide much better results on reasoning tasks using AoT.
 - ~ellama-solve-domain-specific-problem~: Solve domain specific problem with
     simple chain. It makes LLMs act like a professional and adds a planning
     step.
 - ~ellama-community-prompts-select-blueprint~: Select a prompt from the
     community prompt collection. The user is prompted to choose a role, and 
then
     a corresponding prompt is inserted into a blueprint buffer.
-- ~ellama-community-prompts-update-variables~: Prompt user for values of
-    variables found in current buffer and update them.
-- ~ellama-response-process-method~: Configure how LLM responses are processed.
-    Options include streaming for real-time output, async for asynchronous
-    processing, or skipping every N messages to reduce resource usage.
-- ~ellama-blueprint-variable-regexp~: Regular expression to match blueprint
-    variables like ~{var_name}~.
+- ~ellama-blueprint-fill-variables~: Prompt user for values of variables
+    found in current blueprint buffer and update them.
 - ~ellama-tools-enable-by-name~: Enable a specific tool by its name. Use
     this command to activate individual tools. Requires the tool name as input.
 - ~ellama-tools-enable-all~: Enable all available tools at once. Use this
@@ -260,7 +255,7 @@ Ellama, using the ~ellama-keymap-prefix~ prefix (not set by 
default):
 | "s c"  | ellama-summarize-killring       | Summarize killring           |
 | "s l"  | ellama-load-session             | Session Load                 |
 | "s r"  | ellama-session-rename           | Session rename               |
-| "s d"  | ellama-session-delete           | Delete delete                |
+| "s d"  | ellama-session-delete           | Session delete               |
 | "s a"  | ellama-session-switch           | Session activate             |
 | "P"    | ellama-proofread                | Proofread                    |
 | "i w"  | ellama-improve-wording          | Improve wording              |
@@ -309,6 +304,10 @@ There are many supported providers: ~ollama~, ~open ai~, 
~vertex~,
   automatically during generation. Disabled by default.
 - ~ellama-fill-paragraphs~: Option to customize ellama paragraphs
   filling behaviour.
+- ~ellama-response-process-method~: Configure how LLM responses are
+  processed.  Options include streaming for real-time output, async for
+  asynchronous processing, or skipping every N messages to reduce resource
+  usage.
 - ~ellama-name-prompt-words-count~: Count of words in prompt to
   generate name.
 - Prompt templates for every command.
@@ -330,7 +329,7 @@ argument generated text string.
   ~ellama-provider~ will be used if not set.
 - ~ellama-coding-provider~: LLM coding tasks provider.
   ~ellama-provider~ will be used if not set.
-- ~ellama-summarization-provider~ LLM summarization provider.
+- ~ellama-summarization-provider~: LLM summarization provider.
   ~ellama-provider~ will be used if not set.
 - ~ellama-show-quotes~: Show quotes content in chat buffer. Disabled
   by default.
@@ -355,11 +354,11 @@ argument generated text string.
   diverse applications.
 - ~ellama-context-posframe-enabled~: Enable showing posframe with
   ellama context.
-- ~ellama-manage-context-display-action-function~: Display action
-  function for ~ellama-render-context~. Default value
+- ~ellama-context-manage-display-action-function~: Display action
+  function for ~ellama-context-manage~. Default value
   ~display-buffer-same-window~.
-- ~ellama-preview-context-element-display-action-function~: Display
-  action function for ~ellama-preview-context-element~.
+- ~ellama-context-preview-element-display-action-function~: Display
+  action function for ~ellama-context-preview-element~.
 - ~ellama-context-line-always-visible~: Make context header or mode line always
   visible, even with empty context.
 - ~ellama-community-prompts-url~: The URL of the community prompts collection.
@@ -384,6 +383,8 @@ argument generated text string.
   blueprints.
 - ~ellama-blueprint-file-extensions~: File extensions recognized as blueprint
   files.
+- ~ellama-blueprint-variable-regexp~: Regular expression to match blueprint
+  variables like ~{var_name}~.
 - ~ellama-skills-global-path~: Path to the global directory containing Agent
   Skills.
 - ~ellama-skills-local-path~: Project-relative path for local Agent Skills.
diff --git a/ellama-community-prompts.el b/ellama-community-prompts.el
index 8c31335c91..761ef50d29 100644
--- a/ellama-community-prompts.el
+++ b/ellama-community-prompts.el
@@ -53,17 +53,21 @@ within your `user-emacs-directory'."
 Downloads the file from `ellama-community-prompts-url` if it does
 not already exist."
   (unless (file-exists-p ellama-community-prompts-file)
-    (let* ((directory (file-name-directory ellama-community-prompts-file))
-           (response (plz 'get ellama-community-prompts-url
-                       :as 'file
-                       :then (lambda (filename)
-                               (rename-file filename 
ellama-community-prompts-file t))
-                       :else (lambda (error)
-                               (message "Failed to download community prompts: 
%s" error)))))
-      (when (and response (not (file-directory-p directory)))
+    (let ((directory (file-name-directory ellama-community-prompts-file)))
+      (unless (file-directory-p directory)
         (make-directory directory t))
-      (when response
-        (message "Community prompts file downloaded successfully.")))))
+      (let ((response (plz 'get ellama-community-prompts-url
+                            :as 'file
+                            :then (lambda (filename)
+                                    (rename-file filename
+                                                 ellama-community-prompts-file
+                                                 t))
+                            :else (lambda (error)
+                                    (message
+                                     "Failed to download community prompts: %s"
+                                     error)))))
+        (when response
+          (message "Community prompts file downloaded successfully."))))))
 
 (defun ellama-community-prompts-parse-csv-line (line)
   "Parse a single CSV LINE into a list of fields, handling quotes.
@@ -133,7 +137,8 @@ Returns the collection of community prompts."
                          line)))
                      (cdr (string-lines
                            (buffer-substring-no-properties
-                            (point-min) (point-max)))))))))
+                            (point-min) (point-max))
+                           t)))))))
   ellama-community-prompts-collection)
 
 ;;;###autoload
diff --git a/ellama-tools.el b/ellama-tools.el
index 4999e2ba25..eba077ef08 100644
--- a/ellama-tools.el
+++ b/ellama-tools.el
@@ -277,11 +277,11 @@ Returns a new tool definition with the :function wrapped."
          (wrapped-args
           (mapcar
            (lambda (arg)
-             (let*
-                 ((type (plist-get tool-plist :type))
-                  (wrapped-type (if (symbolp type)
-                                    type
-                                  (intern type))))
+             (let* ((type (plist-get arg :type))
+                    (wrapped-type
+                     (if (symbolp type)
+                         type
+                       (and type (intern type)))))
                (plist-put arg :type wrapped-type)))
            args))
          (wrapped-func (ellama-tools--make-confirm-wrapper func name)))
@@ -304,7 +304,8 @@ TOOL-PLIST is a property list in the format expected by 
`llm-make-tool'."
   (let* ((tool-name name)
          (tool (seq-find (lambda (tool) (string= tool-name (llm-tool-name 
tool)))
                          ellama-tools-available)))
-    (add-to-list 'ellama-tools-enabled tool)
+    (when tool
+      (add-to-list 'ellama-tools-enabled tool))
     nil))
 
 ;;;###autoload
@@ -556,11 +557,7 @@ Replace OLDCONTENT with NEWCONTENT."
         (coding-system-for-write 'raw-text))
     (when (string-match (regexp-quote oldcontent) content)
       (with-temp-buffer
-        (insert content)
-        (goto-char (match-beginning 0))
-        (delete-region (1+ (match-beginning 0)) (1+ (match-end 0)))
-        (forward-char)
-        (insert newcontent)
+        (insert (replace-match newcontent t t content))
         (write-region (point-min) (point-max) file-name)))))
 
 (ellama-tools-define-tool
diff --git a/ellama.el b/ellama.el
index caa0bbc901..c404c31bca 100644
--- a/ellama.el
+++ b/ellama.el
@@ -6,7 +6,7 @@
 ;; URL: http://github.com/s-kostyaev/ellama
 ;; Keywords: help local tools
 ;; Package-Requires: ((emacs "28.1") (llm "0.24.0") (plz "0.8") (transient 
"0.7") (compat "29.1") (yaml "1.2.3"))
-;; Version: 1.12.13
+;; Version: 1.12.14
 ;; SPDX-License-Identifier: GPL-3.0-or-later
 ;; Created: 8th Oct 2023
 
diff --git a/ellama.info b/ellama.info
index c250274d11..be448f02d4 100644
--- a/ellama.info
+++ b/ellama.info
@@ -122,13 +122,14 @@ File: ellama.info,  Node: Installation,  Next: Commands,  
Prev: Top,  Up: Top
 **************
 
 Just ‘M-x’ ‘package-install’ Enter ‘ellama’ Enter.  By default it uses
-ollama (https://github.com/jmorganca/ollama) provider.  If you ok with
-it, you need to install ollama (https://github.com/jmorganca/ollama) and
-pull any ollama model (https://ollama.com/models) like this:
+ollama (https://github.com/jmorganca/ollama) provider.  If you are OK
+with it, you need to install ollama
+(https://github.com/jmorganca/ollama) and pull any ollama model
+(https://ollama.com/models) like this:
 
      ollama pull qwen2.5:3b
 
-You can use ‘ellama’ with other model or other llm provider.  Without
+You can use ‘ellama’ with other models or another LLM provider.  Without
 any configuration, the first available ollama model will be used.  You
 can customize ellama configuration like this:
 
@@ -136,7 +137,7 @@ can customize ellama configuration like this:
        :ensure t
        :bind ("C-c e" . ellama)
        ;; send last message in chat buffer with C-c C-c
-       :hook (org-ctrl-c-ctrl-c-final . ellama-chat-send-last-message)
+       :hook (org-ctrl-c-ctrl-c-hook . ellama-chat-send-last-message)
        :init (setopt ellama-auto-scroll t)
        :config
        ;; show ellama context in header line in all buffers
@@ -144,13 +145,13 @@ can customize ellama configuration like this:
        ;; show ellama session id in header line in all buffers
        (ellama-session-header-line-global-mode +1))
 
-More sofisticated configuration example:
+More sophisticated configuration example:
 
      (use-package ellama
        :ensure t
        :bind ("C-c e" . ellama)
        ;; send last message in chat buffer with C-c C-c
-       :hook (org-ctrl-c-ctrl-c-final . ellama-chat-send-last-message)
+       :hook (org-ctrl-c-ctrl-c-hook . ellama-chat-send-last-message)
        :init
        ;; setup key bindings
        ;; (setopt ellama-keymap-prefix "C-c e")
@@ -298,7 +299,7 @@ File: ellama.info,  Node: Commands,  Next: Keymap,  Prev: 
Installation,  Up: Top
    • ‘ellama-context-add-selection’: Add selected region to context.
    • ‘ellama-context-add-info-node’: Add info node to context.
    • ‘ellama-context-reset’: Clear global context.
-   • ‘ellama-manage-context’: Manage the global context.  Inside context
+   • ‘ellama-context-manage’: Manage the global context.  Inside context
      management buffer you can see ellama context elements.  Available
      actions with key bindings:
         • ‘n’: Move to the next line.
@@ -308,17 +309,17 @@ File: ellama.info,  Node: Commands,  Next: Keymap,  Prev: 
Installation,  Up: Top
         • ‘a’: Open the transient context menu for adding new elements.
         • ‘d’: Remove the context element at the current point.
         • ‘RET’: Preview the context element at the current point.
-   • ‘ellama-preview-context-element-at-point’: Preview ellama context
+   • ‘ellama-context-preview-element-at-point’: Preview ellama context
      element at point.  Works inside ellama context management buffer.
-   • ‘ellama-remove-context-element-at-point’: Remove ellama context
+   • ‘ellama-context-remove-element-at-point’: Remove ellama context
      element at point from global context.  Works inside ellama context
      management buffer.
-   • ‘ellama-chat-translation-enable’: Chat translation enable.
-   • ‘ellama-chat-translation-disable’: Chat translation disable.
+   • ‘ellama-chat-translation-enable’: Enable chat translation.
+   • ‘ellama-chat-translation-disable’: Disable chat translation.
    • ‘ellama-solve-reasoning-problem’: Solve reasoning problem with
      Abstraction of Thought technique.  It uses a chain of multiple
-     messages to LLM and help it to provide much better answers on
-     reasoning problems.  Even small LLMs like phi3-mini provides much
+     messages to an LLM and helps it to provide much better answers on
+     reasoning problems.  Even small LLMs like phi3-mini provide much
      better results on reasoning tasks using AoT.
    • ‘ellama-solve-domain-specific-problem’: Solve domain specific
      problem with simple chain.  It makes LLMs act like a professional
@@ -327,14 +328,8 @@ File: ellama.info,  Node: Commands,  Next: Keymap,  Prev: 
Installation,  Up: Top
      the community prompt collection.  The user is prompted to choose a
      role, and then a corresponding prompt is inserted into a blueprint
      buffer.
-   • ‘ellama-community-prompts-update-variables’: Prompt user for values
-     of variables found in current buffer and update them.
-   • ‘ellama-response-process-method’: Configure how LLM responses are
-     processed.  Options include streaming for real-time output, async
-     for asynchronous processing, or skipping every N messages to reduce
-     resource usage.
-   • ‘ellama-blueprint-variable-regexp’: Regular expression to match
-     blueprint variables like ‘{var_name}’.
+   • ‘ellama-blueprint-fill-variables’: Prompt user for values of
+     variables found in current blueprint buffer and update them.
    • ‘ellama-tools-enable-by-name’: Enable a specific tool by its name.
      Use this command to activate individual tools.  Requires the tool
      name as input.
@@ -377,7 +372,7 @@ Keymap   Function                          Description
 "s c"    ellama-summarize-killring         Summarize killring
 "s l"    ellama-load-session               Session Load
 "s r"    ellama-session-rename             Session rename
-"s d"    ellama-session-delete             Delete delete
+"s d"    ellama-session-delete             Session delete
 "s a"    ellama-session-switch             Session activate
 "P"      ellama-proofread                  Proofread
 "i w"    ellama-improve-wording            Improve wording
@@ -431,6 +426,10 @@ There are many supported providers: ‘ollama’, ‘open ai’, 
‘vertex’,
      automatically during generation.  Disabled by default.
    • ‘ellama-fill-paragraphs’: Option to customize ellama paragraphs
      filling behaviour.
+   • ‘ellama-response-process-method’: Configure how LLM responses are
+     processed.  Options include streaming for real-time output, async
+     for asynchronous processing, or skipping every N messages to reduce
+     resource usage.
    • ‘ellama-name-prompt-words-count’: Count of words in prompt to
      generate name.
    • Prompt templates for every command.
@@ -452,7 +451,7 @@ argument generated text string.
      ‘ellama-provider’ will be used if not set.
    • ‘ellama-coding-provider’: LLM coding tasks provider.
      ‘ellama-provider’ will be used if not set.
-   • ‘ellama-summarization-provider’ LLM summarization provider.
+   • ‘ellama-summarization-provider’: LLM summarization provider.
      ‘ellama-provider’ will be used if not set.
    • ‘ellama-show-quotes’: Show quotes content in chat buffer.  Disabled
      by default.
@@ -482,11 +481,11 @@ argument generated text string.
      diverse applications.
    • ‘ellama-context-posframe-enabled’: Enable showing posframe with
      ellama context.
-   • ‘ellama-manage-context-display-action-function’: Display action
-     function for ‘ellama-render-context’.  Default value
+   • ‘ellama-context-manage-display-action-function’: Display action
+     function for ‘ellama-context-manage’.  Default value
      ‘display-buffer-same-window’.
-   • ‘ellama-preview-context-element-display-action-function’: Display
-     action function for ‘ellama-preview-context-element’.
+   • ‘ellama-context-preview-element-display-action-function’: Display
+     action function for ‘ellama-context-preview-element’.
    • ‘ellama-context-line-always-visible’: Make context header or mode
      line always visible, even with empty context.
    • ‘ellama-community-prompts-url’: The URL of the community prompts
@@ -517,6 +516,8 @@ argument generated text string.
      project-specific blueprints.
    • ‘ellama-blueprint-file-extensions’: File extensions recognized as
      blueprint files.
+   • ‘ellama-blueprint-variable-regexp’: Regular expression to match
+     blueprint variables like ‘{var_name}’.
    • ‘ellama-skills-global-path’: Path to the global directory
      containing Agent Skills.
    • ‘ellama-skills-local-path’: Project-relative path for local Agent
@@ -1576,40 +1577,40 @@ their use in free software.
 Tag Table:
 Node: Top1379
 Node: Installation3748
-Node: Commands8756
-Node: Keymap16195
-Node: Configuration19028
-Node: Context Management25863
-Node: Transient Menus for Context Management26771
-Node: Managing the Context28385
-Node: Considerations29160
-Node: Minor modes29753
-Node: ellama-context-header-line-mode31741
-Node: ellama-context-header-line-global-mode32566
-Node: ellama-context-mode-line-mode33286
-Node: ellama-context-mode-line-global-mode34134
-Node: Ellama Session Header Line Mode34838
-Node: Enabling and Disabling35407
-Node: Customization35854
-Node: Ellama Session Mode Line Mode36142
-Node: Enabling and Disabling (1)36727
-Node: Customization (1)37174
-Node: Using Blueprints37468
-Node: Key Components of Ellama Blueprints38108
-Node: Creating and Managing Blueprints38715
-Node: Blueprints files39693
-Node: Variable Management40114
-Node: Keymap and Mode40567
-Node: Transient Menus41503
-Node: Running Blueprints programmatically42049
-Node: MCP Integration42636
-Node: Agent Skills43658
-Node: Directory Structure44021
-Node: Creating a Skill45048
-Node: How it works45423
-Node: Acknowledgments45814
-Node: Contributions46525
-Node: GNU Free Documentation License46911
+Node: Commands8762
+Node: Keymap15839
+Node: Configuration18673
+Node: Context Management25874
+Node: Transient Menus for Context Management26782
+Node: Managing the Context28396
+Node: Considerations29171
+Node: Minor modes29764
+Node: ellama-context-header-line-mode31752
+Node: ellama-context-header-line-global-mode32577
+Node: ellama-context-mode-line-mode33297
+Node: ellama-context-mode-line-global-mode34145
+Node: Ellama Session Header Line Mode34849
+Node: Enabling and Disabling35418
+Node: Customization35865
+Node: Ellama Session Mode Line Mode36153
+Node: Enabling and Disabling (1)36738
+Node: Customization (1)37185
+Node: Using Blueprints37479
+Node: Key Components of Ellama Blueprints38119
+Node: Creating and Managing Blueprints38726
+Node: Blueprints files39704
+Node: Variable Management40125
+Node: Keymap and Mode40578
+Node: Transient Menus41514
+Node: Running Blueprints programmatically42060
+Node: MCP Integration42647
+Node: Agent Skills43669
+Node: Directory Structure44032
+Node: Creating a Skill45059
+Node: How it works45434
+Node: Acknowledgments45825
+Node: Contributions46536
+Node: GNU Free Documentation License46922
 
 End Tag Table
 
diff --git a/tests/test-ellama-blueprint.el b/tests/test-ellama-blueprint.el
new file mode 100644
index 0000000000..0876c0e70f
--- /dev/null
+++ b/tests/test-ellama-blueprint.el
@@ -0,0 +1,292 @@
+;;; test-ellama-blueprint.el --- Ellama blueprint tests -*- lexical-binding: 
t; package-lint-main-file: "../ellama.el"; -*-
+
+;; Copyright (C) 2023-2026  Free Software Foundation, Inc.
+
+;; Author: Sergey Kostyaev <[email protected]>
+
+;; 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 blueprint tests.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'ert)
+(require 'ellama-blueprint)
+
+(defun ellama-test--blueprint-index-by-act (blueprints)
+  "Return BLUEPRINTS indexed by :act."
+  (let ((index (make-hash-table :test #'equal)))
+    (dolist (blueprint blueprints)
+      (puthash (plist-get blueprint :act) blueprint index))
+    index))
+
+(ert-deftest test-ellama-blueprint-get-local-dir ()
+  (let ((ellama-blueprint-local-dir "my-blueprints"))
+    (cl-letf (((symbol-function 'ellama-tools-project-root-tool)
+               (lambda () "/tmp/project-root")))
+      (should (equal (ellama-blueprint-get-local-dir)
+                     "/tmp/project-root/my-blueprints")))))
+
+(ert-deftest test-ellama-blueprint-find-files-filters-extensions ()
+  (let* ((root (make-temp-file "ellama-blueprint-find-" t))
+         (nested (expand-file-name "nested" root))
+         (first (expand-file-name "a.ellama-blueprint" root))
+         (second (expand-file-name "b.blueprint" nested))
+         (ignored (expand-file-name "c.txt" nested)))
+    (unwind-protect
+        (progn
+          (make-directory nested t)
+          (with-temp-file first (insert "A"))
+          (with-temp-file second (insert "B"))
+          (with-temp-file ignored (insert "ignored"))
+          (should (equal (sort (mapcar #'file-name-nondirectory
+                                       (ellama-blueprint-find-files root))
+                               #'string<)
+                         '("a.ellama-blueprint" "b.blueprint"))))
+      (when (file-exists-p root)
+        (delete-directory root t)))))
+
+(ert-deftest test-ellama-blueprint-find-files-missing-dir ()
+  (should-not
+   (ellama-blueprint-find-files
+    "/tmp/ellama-blueprint-dir-does-not-exist-12345")))
+
+(ert-deftest test-ellama-blueprint-read-file ()
+  (let ((file (make-temp-file "ellama-blueprint-read-")))
+    (unwind-protect
+        (progn
+          (with-temp-file file
+            (insert "hello"))
+          (should (equal (ellama-blueprint-read-file file) "hello"))
+          (delete-file file)
+          (should-not (ellama-blueprint-read-file file)))
+      (when (file-exists-p file)
+        (delete-file file)))))
+
+(ert-deftest test-ellama-blueprint-load-from-files-global-and-local ()
+  (let* ((global-dir (make-temp-file "ellama-blueprint-global-" t))
+         (local-dir (make-temp-file "ellama-blueprint-local-" t))
+         (global-file (expand-file-name "global.ellama-blueprint" global-dir))
+         (local-file (expand-file-name "local.blueprint" local-dir)))
+    (unwind-protect
+        (progn
+          (with-temp-file global-file
+            (insert "  Global prompt  \n"))
+          (with-temp-file local-file
+            (insert "\nLocal prompt\n"))
+          (let* ((ellama-blueprint-global-dir global-dir)
+                 (loaded
+                  (cl-letf (((symbol-function 'ellama-blueprint-get-local-dir)
+                             (lambda () local-dir)))
+                    (ellama-blueprint-load-from-files)))
+                 (index (ellama-test--blueprint-index-by-act loaded))
+                 (global (gethash "global" index))
+                 (local (gethash "local" index)))
+            (should (= (length loaded) 2))
+            (should (equal (plist-get global :prompt) "Global prompt"))
+            (should (equal (plist-get local :prompt) "Local prompt"))
+            (should (equal (plist-get global :file) global-file))
+            (should (equal (plist-get local :file) local-file))))
+      (when (file-exists-p global-dir)
+        (delete-directory global-dir t))
+      (when (file-exists-p local-dir)
+        (delete-directory local-dir t)))))
+
+(ert-deftest test-ellama-blueprint-get-all-sources-dedupes-by-act ()
+  (let ((ellama-blueprints '((:act "shared" :prompt "user")
+                             (:act "user-only" :prompt "user-only"))))
+    (cl-letf (((symbol-function 'ellama-blueprint-load-from-files)
+               (lambda ()
+                 '((:act "shared" :prompt "file")
+                   (:act "file-only" :prompt "file-only"))))
+              ((symbol-function 'ellama-community-prompts-ensure)
+               (lambda ()
+                 '((:act "shared" :prompt "community")
+                   (:act "community-only" :prompt "community-only")))))
+      (let* ((all (ellama-blueprint-get-all-sources))
+             (acts (mapcar (lambda (blueprint)
+                             (plist-get blueprint :act))
+                           all))
+             (index (ellama-test--blueprint-index-by-act all)))
+        (should (equal acts
+                       '("shared" "file-only" "user-only"
+                         "community-only")))
+        (should (equal (plist-get (gethash "shared" index) :prompt)
+                       "file"))))))
+
+(ert-deftest test-ellama-blueprint-get-variable-list-dedupes ()
+  (with-temp-buffer
+    (insert "Hello {name}, id={id}. Bye {name} from {user_name}.")
+    (should (equal (sort (ellama-blueprint-get-variable-list) #'string<)
+                   '("id" "name" "user_name")))))
+
+(ert-deftest test-ellama-blueprint-set-variable-replaces-all-occurrences ()
+  (with-temp-buffer
+    (insert "Hi {name}. Bye {name}.")
+    (ellama-blueprint-set-variable "name" "Ada")
+    (should (equal (buffer-string) "Hi Ada. Bye Ada."))))
+
+(ert-deftest test-ellama-blueprint-fill-variables-prompts-once-per-variable ()
+  (with-temp-buffer
+    (insert "{name} and {name} are a {role}.")
+    (let ((prompts '()))
+      (cl-letf (((symbol-function 'read-string)
+                 (lambda (prompt &rest _)
+                   (push prompt prompts)
+                   (cond
+                    ((string-match-p "{name}" prompt) "Ada")
+                    ((string-match-p "{role}" prompt) "engineer")
+                    (t "unknown")))))
+        (ellama-blueprint-fill-variables))
+      (should (equal (buffer-string)
+                     "Ada and Ada are a engineer."))
+      (should (= 1 (length (seq-filter
+                            (lambda (prompt)
+                              (string-match-p "{name}" prompt))
+                            prompts))))
+      (should (= 1 (length (seq-filter
+                            (lambda (prompt)
+                              (string-match-p "{role}" prompt))
+                            prompts)))))))
+
+(ert-deftest test-ellama-blueprint-run-fills-variables-and-sends-buffer ()
+  (let ((ellama-blueprints '((:act "welcome"
+                              :prompt "Hello {name} from {city}.")))
+        (sent nil))
+    (cl-letf (((symbol-function 'ellama-community-prompts-ensure)
+               (lambda () nil))
+              ((symbol-function 'ellama-send-buffer-to-new-chat)
+               (lambda ()
+                 (setq sent (buffer-string)))))
+      (ellama-blueprint-run "welcome" '(:name "Ada" :city "Paris"))
+      (should (equal sent "Hello Ada from Paris.")))))
+
+(ert-deftest
+    test-ellama-blueprint-run-prefers-user-over-community-on-duplicate ()
+  (let ((ellama-blueprints '((:act "shared" :prompt "user prompt")))
+        (sent nil))
+    (cl-letf (((symbol-function 'ellama-community-prompts-ensure)
+               (lambda ()
+                 '((:act "shared" :prompt "community prompt"))))
+              ((symbol-function 'ellama-send-buffer-to-new-chat)
+               (lambda ()
+                 (setq sent (buffer-string)))))
+      (ellama-blueprint-run "shared")
+      (should (equal sent "user prompt")))))
+
+(ert-deftest test-ellama-blueprint-select-filters-by-for-devs ()
+  (let ((ellama-blueprint-buffer "*ellama-blueprint-select-test*")
+        (seen-acts nil)
+        (fill-called nil))
+    (unwind-protect
+        (progn
+          (cl-letf (((symbol-function 'ellama-blueprint-get-all-sources)
+                     (lambda ()
+                       '((:act "dev" :prompt "Dev prompt" :for-devs t)
+                         (:act "general" :prompt "General" :for-devs nil))))
+                    ((symbol-function 'completing-read)
+                     (lambda (_prompt acts &rest _)
+                       (setq seen-acts acts)
+                       "dev"))
+                    ((symbol-function 'switch-to-buffer)
+                     (lambda (&rest _args) nil))
+                    ((symbol-function 'ellama-blueprint-fill-variables)
+                     (lambda ()
+                       (setq fill-called t))))
+            (ellama-blueprint-select '(:for-devs t))
+            (with-current-buffer (get-buffer ellama-blueprint-buffer)
+              (should (equal (buffer-string) "Dev prompt"))
+              (should (eq major-mode 'ellama-blueprint-mode))))
+          (should (equal seen-acts '("dev")))
+          (should fill-called))
+      (when (get-buffer ellama-blueprint-buffer)
+        (kill-buffer ellama-blueprint-buffer)))))
+
+(ert-deftest test-ellama-blueprint-select-files-source ()
+  (let ((ellama-blueprint-buffer "*ellama-blueprint-select-files-test*")
+        (fill-called nil))
+    (unwind-protect
+        (progn
+          (cl-letf (((symbol-function 'ellama-blueprint-load-from-files)
+                     (lambda ()
+                       '((:act "from-file" :prompt "File prompt"))))
+                    ((symbol-function 'completing-read)
+                     (lambda (&rest _args) "from-file"))
+                    ((symbol-function 'switch-to-buffer)
+                     (lambda (&rest _args) nil))
+                    ((symbol-function 'ellama-blueprint-fill-variables)
+                     (lambda ()
+                       (setq fill-called t))))
+            (ellama-blueprint-select '(:source files))
+            (with-current-buffer (get-buffer ellama-blueprint-buffer)
+              (should (equal (buffer-string) "File prompt"))
+              (should (eq major-mode 'ellama-blueprint-mode))))
+          (should fill-called))
+      (when (get-buffer ellama-blueprint-buffer)
+        (kill-buffer ellama-blueprint-buffer)))))
+
+(ert-deftest test-ellama-blueprint-create-replaces-existing-blueprint ()
+  (with-temp-buffer
+    (insert "New prompt")
+    (let ((ellama-blueprints
+           '((:act "alpha" :prompt "Old prompt" :for-devs nil)
+             (:act "beta" :prompt "Beta prompt" :for-devs nil)))
+          (saved-values '()))
+      (cl-letf (((symbol-function 'read-string)
+                 (lambda (&rest _args) "alpha"))
+                ((symbol-function 'y-or-n-p)
+                 (lambda (&rest _args) t))
+                ((symbol-function 'customize-save-variable)
+                 (lambda (_symbol value)
+                   (push value saved-values))))
+        (ellama-blueprint-create))
+      (should (= (length ellama-blueprints) 2))
+      (should (equal (mapcar (lambda (blueprint)
+                               (plist-get blueprint :act))
+                             ellama-blueprints)
+                     '("beta" "alpha")))
+      (let ((alpha (cl-find-if (lambda (blueprint)
+                                 (equal (plist-get blueprint :act) "alpha"))
+                               ellama-blueprints)))
+        (should (equal (plist-get alpha :prompt) "New prompt"))
+        (should (eq (plist-get alpha :for-devs) t)))
+      (should saved-values))))
+
+(ert-deftest test-ellama-blueprint-remove-found-updates-and-saves ()
+  (let ((ellama-blueprints '((:act "alpha" :prompt "A")
+                             (:act "beta" :prompt "B")))
+        (saved nil))
+    (cl-letf (((symbol-function 'customize-save-variable)
+               (lambda (_symbol value)
+                 (setq saved value))))
+      (ellama-blueprint-remove "alpha")
+      (should (equal ellama-blueprints '((:act "beta" :prompt "B"))))
+      (should (equal saved ellama-blueprints)))))
+
+(ert-deftest test-ellama-blueprint-remove-missing-does-not-save ()
+  (let ((ellama-blueprints '((:act "alpha" :prompt "A")))
+        (saved nil))
+    (cl-letf (((symbol-function 'customize-save-variable)
+               (lambda (&rest _args)
+                 (setq saved t))))
+      (ellama-blueprint-remove "missing")
+      (should (equal ellama-blueprints '((:act "alpha" :prompt "A"))))
+      (should-not saved))))
+
+(provide 'test-ellama-blueprint)
+
+;;; test-ellama-blueprint.el ends here
diff --git a/tests/test-ellama-community-prompts.el 
b/tests/test-ellama-community-prompts.el
new file mode 100644
index 0000000000..dad0c95019
--- /dev/null
+++ b/tests/test-ellama-community-prompts.el
@@ -0,0 +1,149 @@
+;;; test-ellama-community-prompts.el --- Community prompts tests -*- 
lexical-binding: t; package-lint-main-file: "../ellama.el"; -*-
+
+;; Copyright (C) 2023-2026  Free Software Foundation, Inc.
+
+;; Author: Sergey Kostyaev <[email protected]>
+
+;; 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:
+
+;; Community prompt collection tests.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'ert)
+
+(defconst ellama-community-prompts-test-root
+  (expand-file-name
+   ".."
+   (file-name-directory (or load-file-name buffer-file-name)))
+  "Project root directory for community prompts tests.")
+
+(load-file
+ (expand-file-name
+  "ellama-community-prompts.el"
+  ellama-community-prompts-test-root))
+
+(ert-deftest test-ellama-community-prompts-parse-csv-line-basic ()
+  (should (equal (ellama-community-prompts-parse-csv-line
+                  "writer,Write a poem,FALSE")
+                 '("writer" "Write a poem" "FALSE"))))
+
+(ert-deftest test-ellama-community-prompts-parse-csv-line-quoted-comma ()
+  (should (equal (ellama-community-prompts-parse-csv-line
+                  "writer,\"Prompt, with comma\",FALSE")
+                 '("writer" "Prompt, with comma" "FALSE"))))
+
+(ert-deftest test-ellama-community-prompts-parse-csv-line-escaped-quote ()
+  (should (equal (ellama-community-prompts-parse-csv-line
+                  "writer,\"He said \"\"Hi\"\"\",TRUE")
+                 '("writer" "He said \"Hi\"" "TRUE"))))
+
+(ert-deftest test-ellama-community-prompts-convert-to-plist ()
+  (should (equal (ellama-community-prompts-convert-to-plist
+                  '("dev" "Use tests" "TRUE"))
+                 '(:act "dev" :prompt "Use tests" :for-devs t)))
+  (should (equal (ellama-community-prompts-convert-to-plist
+                  '("all" "General" "FALSE"))
+                 '(:act "all" :prompt "General" :for-devs nil))))
+
+(ert-deftest test-ellama-community-prompts-ensure-file-skip-download ()
+  (let ((ellama-community-prompts-file
+         (make-temp-file "ellama-community-prompts-existing-"))
+        (plz-called nil))
+    (unwind-protect
+        (progn
+          (cl-letf (((symbol-function 'plz)
+                     (lambda (&rest _args)
+                       (setq plz-called t))))
+            (ellama-community-prompts-ensure-file))
+          (should-not plz-called))
+      (when (file-exists-p ellama-community-prompts-file)
+        (delete-file ellama-community-prompts-file)))))
+
+(ert-deftest test-ellama-community-prompts-ensure-file-download ()
+  (let* ((root-dir (make-temp-file "ellama-community-prompts-root-" t))
+         (target-dir (expand-file-name "nested/path" root-dir))
+         (ellama-community-prompts-file
+          (expand-file-name "community-prompts.csv" target-dir))
+         (ellama-community-prompts-url "https://example.invalid/prompts.csv";)
+         (downloaded (make-temp-file "ellama-community-prompts-downloaded-"))
+         (called nil))
+    (unwind-protect
+        (progn
+          (with-temp-file downloaded
+            (insert "act,prompt,for_dev\nwriter,Prompt,FALSE\n"))
+          (cl-letf (((symbol-function 'plz)
+                     (lambda (method url &rest args)
+                       (setq called t)
+                       (should (eq method 'get))
+                       (should (equal url ellama-community-prompts-url))
+                       (should (eq (plist-get args :as) 'file))
+                       (funcall (plist-get args :then) downloaded)
+                       t)))
+            (ellama-community-prompts-ensure-file))
+          (should called)
+          (should (file-exists-p ellama-community-prompts-file))
+          (should (equal (with-temp-buffer
+                           (insert-file-contents ellama-community-prompts-file)
+                           (buffer-string))
+                         "act,prompt,for_dev\nwriter,Prompt,FALSE\n")))
+      (when (file-exists-p ellama-community-prompts-file)
+        (delete-file ellama-community-prompts-file))
+      (when (file-exists-p root-dir)
+        (delete-directory root-dir t))
+      (when (file-exists-p downloaded)
+        (delete-file downloaded)))))
+
+(ert-deftest test-ellama-community-prompts-ensure-loads-and-caches ()
+  (let ((ellama-community-prompts-collection nil)
+        (csv-file (make-temp-file "ellama-community-prompts-csv-")))
+    (unwind-protect
+        (progn
+          (with-temp-file csv-file
+            (insert
+             "act,prompt,for_dev\n"
+             "dev,\"Prompt, with comma\",TRUE\n"
+             "all,General,FALSE\n"))
+          (let ((ellama-community-prompts-file csv-file))
+            (let ((first (ellama-community-prompts-ensure)))
+              (should (equal first
+                             '((:act "dev"
+                                :prompt "Prompt, with comma"
+                                :for-devs t)
+                               (:act "all"
+                                :prompt "General"
+                                :for-devs nil))))
+              (with-temp-file csv-file
+                (insert
+                 "act,prompt,for_dev\n"
+                 "changed,Changed,FALSE\n"))
+              (let ((second (ellama-community-prompts-ensure)))
+                (should (eq first second))
+                (should (= 2 (length second)))))))
+      (when (file-exists-p csv-file)
+        (delete-file csv-file)))))
+
+(ert-deftest test-ellama-community-prompts-select-blueprint ()
+  (let ((called-args nil))
+    (cl-letf (((symbol-function 'ellama-blueprint-select)
+               (lambda (args)
+                 (setq called-args args))))
+      (ellama-community-prompts-select-blueprint)
+      (should (equal called-args '(:source community))))))
+
+(provide 'test-ellama-community-prompts)
+;;; test-ellama-community-prompts.el ends here.
diff --git a/tests/test-ellama-context.el b/tests/test-ellama-context.el
new file mode 100644
index 0000000000..f2616eeacd
--- /dev/null
+++ b/tests/test-ellama-context.el
@@ -0,0 +1,243 @@
+;;; test-ellama-context.el --- Ellama context tests -*- lexical-binding: t; 
package-lint-main-file: "../ellama.el"; -*-
+
+;; Copyright (C) 2023-2026  Free Software Foundation, Inc.
+
+;; Author: Sergey Kostyaev <[email protected]>
+
+;; 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 context tests.
+;;
+
+;;; Code:
+
+(require 'ellama-context)
+(require 'ert)
+
+(ert-deftest test-ellama-context-element-format-buffer-markdown ()
+  (let ((element (ellama-context-element-buffer :name "*scratch*")))
+    (should (equal "```emacs-lisp\n(display-buffer \"*scratch*\")\n```\n"
+                   (ellama-context-element-format element 'markdown-mode)))))
+
+(ert-deftest test-ellama-context-element-format-buffer-org-mode ()
+  (let ((element (ellama-context-element-buffer :name "*scratch*")))
+    (should (equal "[[elisp:(display-buffer \"*scratch*\")][*scratch*]]"
+                   (ellama-context-element-format element 'org-mode)))))
+
+(ert-deftest test-ellama-context-element-format-file-markdown ()
+  (let ((element (ellama-context-element-file :name "LICENSE")))
+    (should (equal "[LICENSE](<LICENSE>)"
+                   (ellama-context-element-format element 'markdown-mode)))))
+
+(ert-deftest test-ellama-context-element-format-file-org-mode ()
+  (let ((element (ellama-context-element-file :name "LICENSE")))
+    (should (equal "[[file:LICENSE][LICENSE]]"
+                   (ellama-context-element-format element 'org-mode)))))
+
+(ert-deftest test-ellama-context-element-format-info-node-markdown ()
+  (let ((element (ellama-context-element-info-node :name "(dir)Top")))
+    (should (equal "```emacs-lisp\n(info \"(dir)Top\")\n```\n"
+                   (ellama-context-element-format element 'markdown-mode)))))
+
+(ert-deftest test-ellama-context-element-format-info-node-org-mode ()
+  (let ((element (ellama-context-element-info-node :name "(dir)Top")))
+    (should (equal "[[(dir)Top][(dir)Top]]"
+                   (ellama-context-element-format element 'org-mode)))))
+
+(ert-deftest test-ellama-context-element-format-text-markdown ()
+  (let ((element (ellama-context-element-text :content "123")))
+    (should (equal "123" (ellama-context-element-format element 
'markdown-mode)))))
+
+(ert-deftest test-ellama-context-element-format-text-org-mode ()
+  (let ((element (ellama-context-element-text :content "123")))
+    (should (equal "123" (ellama-context-element-format element 'org-mode)))))
+
+(ert-deftest 
test-ellama-context-element-format-webpage-quote-disabled-markdown ()
+  (let ((element (ellama-context-element-webpage-quote :name "test name" :url 
"https://example.com/"; :content "1\n\n2"))
+       (ellama-show-quotes nil))
+    (should (string-match "\\[test 
name\\](https://example.com/):\n```emacs-lisp\n(display-buffer 
\"\\*ellama-quote-.+\\*\")\n```\n" (ellama-context-element-format element 
'markdown-mode)))))
+
+(ert-deftest test-ellama-context-element-format-webpage-quote-enabled-markdown 
()
+  (let ((element (ellama-context-element-webpage-quote :name "test name" :url 
"https://example.com/"; :content "1\n\n2"))
+       (ellama-show-quotes t))
+    (should (equal "[test name](https://example.com/):
+> 1
+> 
+> 2
+
+"
+                  (ellama-context-element-format element 'markdown-mode)))))
+
+(ert-deftest 
test-ellama-context-element-format-webpage-quote-disabled-org-mode ()
+  (let ((element (ellama-context-element-webpage-quote :name "test name" :url 
"https://example.com/"; :content "1\n\n2"))
+       (ellama-show-quotes nil))
+    (should (string-match "\\[\\[https://example.com/\\]\\[test name\\]\\] 
\\[\\[elisp:(display-buffer \"\\*ellama-quote-.+\\*\")\\]\\[show\\]\\]" 
(ellama-context-element-format element 'org-mode)))))
+
+(ert-deftest test-ellama-context-element-format-webpage-quote-enabled-org-mode 
()
+  (let ((element (ellama-context-element-webpage-quote :name "test name" :url 
"https://example.com/"; :content "1\n\n* 2"))
+       (ellama-show-quotes t))
+    (should (equal "[[https://example.com/][test name]]:
+#+BEGIN_QUOTE
+1
+
+ * 2
+#+END_QUOTE
+"
+                  (ellama-context-element-format element 'org-mode)))))
+
+(ert-deftest 
test-ellama-context-element-format-info-node-quote-disabled-markdown ()
+  (let ((element (ellama-context-element-info-node-quote :name "(emacs)Top" 
:content "1\n\n2"))
+       (ellama-show-quotes nil))
+    (should (string-match "```emacs-lisp\n(info 
\"(emacs)Top\")\n```\nshow:\n```emacs-lisp\n(display-buffer 
\"\\*ellama-quote-.+\\*\")\n```\n" (ellama-context-element-format element 
'markdown-mode)))))
+
+(ert-deftest 
test-ellama-context-element-format-info-node-quote-enabled-markdown ()
+  (let ((element (ellama-context-element-info-node-quote :name "(emacs)Top" 
:content "1\n\n2"))
+       (ellama-show-quotes t))
+    (should (equal "```emacs-lisp\n(info \"(emacs)Top\")\n```\n> 1\n> \n> 
2\n\n"
+                  (ellama-context-element-format element 'markdown-mode)))))
+
+(ert-deftest 
test-ellama-context-element-format-info-node-quote-disabled-org-mode ()
+  (let ((element (ellama-context-element-info-node-quote :name "(emacs)Top" 
:content "1\n\n2"))
+       (ellama-show-quotes nil))
+    (should (string-match "\\[\\[(emacs)Top\\]\\[(emacs)Top\\]\\] 
\\[\\[elisp:(display-buffer \"\\*ellama-quote-.+\\*\")\\]\\[show\\]\\]" 
(ellama-context-element-format element 'org-mode)))))
+
+(ert-deftest 
test-ellama-context-element-format-info-node-quote-enabled-org-mode ()
+  (let ((element (ellama-context-element-info-node-quote :name "(emacs)Top" 
:content "1\n\n* 2"))
+       (ellama-show-quotes t))
+    (should (equal "[[(emacs)Top][(emacs)Top]]:\n#+BEGIN_QUOTE\n1\n\n * 
2\n#+END_QUOTE\n"
+                  (ellama-context-element-format element 'org-mode)))))
+
+(ert-deftest test-ellama-context-element-format-file-quote-disabled-markdown ()
+  (let ((element (ellama-context-element-file-quote :path "/tmp/test.txt" 
:content "1\n\n2"))
+       (ellama-show-quotes nil))
+    (should (string-match 
"\\[/tmp/test.txt\\](/tmp/test.txt):\n```emacs-lisp\n(display-buffer 
\"\\*ellama-quote-.+\\*\")" (ellama-context-element-format element 
'markdown-mode)))))
+
+(ert-deftest test-ellama-context-element-format-file-quote-enabled-markdown ()
+  (let ((element (ellama-context-element-file-quote :path "/tmp/test.txt" 
:content "1\n\n2"))
+       (ellama-show-quotes t))
+    (should (equal "[/tmp/test.txt](/tmp/test.txt):
+> 1
+> 
+> 2
+
+"
+                  (ellama-context-element-format element 'markdown-mode)))))
+
+(ert-deftest test-ellama-context-element-format-file-quote-disabled-org-mode ()
+  (let ((element (ellama-context-element-file-quote :path "/tmp/test.txt" 
:content "1\n\n2"))
+       (ellama-show-quotes nil))
+    (should (string-match "\\[\\[/tmp/test.txt\\]\\[/tmp/test.txt\\]\\] 
\\[\\[elisp:(display-buffer \"\\*ellama-quote-.+\\*\")\\]\\[show\\]\\]" 
(ellama-context-element-format element 'org-mode)))))
+
+(ert-deftest test-ellama-context-element-format-file-quote-enabled-org-mode ()
+  (let ((element (ellama-context-element-file-quote :path "/tmp/test.txt" 
:content "1\n\n* 2"))
+       (ellama-show-quotes t))
+    (should (equal "[[/tmp/test.txt][/tmp/test.txt]]:
+#+BEGIN_QUOTE
+1
+
+ * 2
+#+END_QUOTE
+"
+                  (ellama-context-element-format element 'org-mode)))))
+
+(ert-deftest test-ellama-context-element-extract-buffer ()
+  (with-temp-buffer
+    (insert "123")
+    (let ((element (ellama-context-element-buffer :name (buffer-name))))
+      (should (equal "123" (ellama-context-element-extract element))))))
+
+(ert-deftest test-ellama-context-element-extract-file ()
+  (let* ((filename (expand-file-name "LICENSE" (locate-dominating-file "." 
".git")))
+         (element (ellama-context-element-file :name filename)))
+    (should (string-match "GNU GENERAL PUBLIC LICENSE"
+                          (ellama-context-element-extract element)))))
+
+(ert-deftest test-ellama-context-element-extract-info-node ()
+  (let ((element (ellama-context-element-info-node :name "(dir)Top")))
+    (should (string-match "This" (ellama-context-element-extract element)))))
+
+(ert-deftest test-ellama-context-element-extract-text ()
+  (let ((element (ellama-context-element-text :content "123")))
+    (should (string-match "123" (ellama-context-element-extract element)))))
+
+(ert-deftest test-ellama-context-element-extract-webpage-quote ()
+  (let ((element (ellama-context-element-webpage-quote :content "123")))
+    (should (equal "123" (ellama-context-element-extract element)))))
+
+(ert-deftest test-ellama-context-element-extract-info-node-quote ()
+  (let ((element (ellama-context-element-info-node-quote :content "123")))
+    (should (equal "123" (ellama-context-element-extract element)))))
+
+(ert-deftest test-ellama-context-element-extract-file-quote ()
+  (let ((element (ellama-context-element-file-quote :content "123")))
+    (should (equal "123" (ellama-context-element-extract element)))))
+
+(ert-deftest test-ellama-context-element-display-buffer ()
+  (with-temp-buffer
+    (let ((element (ellama-context-element-buffer :name (buffer-name))))
+      (should (equal (buffer-name) (ellama-context-element-display 
element))))))
+
+(ert-deftest test-ellama-context-element-display-file ()
+  (let* ((filename (expand-file-name "LICENSE" (locate-dominating-file "." 
".git")))
+         (element (ellama-context-element-file :name filename)))
+    (should (equal (file-name-nondirectory filename) 
(ellama-context-element-display element)))))
+
+(ert-deftest test-ellama-context-element-display-info-node ()
+  (let ((element (ellama-context-element-info-node :name "(dir)Top")))
+    (should (equal "(info \"(dir)Top\")" (ellama-context-element-display 
element)))))
+
+(ert-deftest test-ellama-context-element-display-text ()
+  (let ((element (ellama-context-element-text :content "123")))
+    (should (equal "\"123...\"" (ellama-context-element-display element)))))
+
+(ert-deftest test-ellama-context-element-display-webpage-quote ()
+  (let ((element (ellama-context-element-webpage-quote :name "Example" :url 
"http://example.com"; :content "123")))
+    (should (equal "Example" (ellama-context-element-display element)))))
+
+(ert-deftest test-ellama-context-element-display-info-node-quote ()
+  (let ((element (ellama-context-element-info-node-quote :name "Example" 
:content "123")))
+    (should (equal "(info \"Example\")" (ellama-context-element-display 
element)))))
+
+(ert-deftest test-ellama-context-element-display-file-quote ()
+  (let ((element (ellama-context-element-file-quote :path "/path/to/file" 
:content "123")))
+    (should (equal "file" (ellama-context-element-display element)))))
+
+(ert-deftest test-ellama-context-element-extract-buffer-quote ()
+  (with-temp-buffer
+    (insert "123")
+    (let ((element (ellama-context-element-buffer-quote :name (buffer-name) 
:content "123")))
+      (should (equal "123" (ellama-context-element-extract element))))))
+
+(ert-deftest test-ellama-context-element-display-buffer-quote ()
+  (with-temp-buffer
+    (let ((element (ellama-context-element-buffer-quote :name (buffer-name) 
:content "123")))
+      (should (equal (buffer-name) (ellama-context-element-display 
element))))))
+
+(ert-deftest test-ellama-context-prompt-with-context-clears-ephemeral ()
+  (let ((ellama-context-global
+        (list (ellama-context-element-text :content "global")))
+       (ellama-context-ephemeral
+        (list (ellama-context-element-text :content "ephemeral"))))
+    (should (equal (ellama-context-prompt-with-context "Prompt")
+                  "Context:\nglobal\nephemeral\n\nPrompt"))
+    (should (null ellama-context-ephemeral))
+    (should (equal (mapcar #'ellama-context-element-extract
+                          ellama-context-global)
+                  '("global")))))
+
+(provide 'test-ellama-context)
+
+;;; test-ellama-context.el ends here
diff --git a/tests/test-ellama-manual.el b/tests/test-ellama-manual.el
new file mode 100644
index 0000000000..5bfab5dbba
--- /dev/null
+++ b/tests/test-ellama-manual.el
@@ -0,0 +1,162 @@
+;;; test-ellama-manual.el --- Ellama manual tests -*- lexical-binding: t; 
package-lint-main-file: "../ellama.el"; -*-
+
+;; Copyright (C) 2023-2026  Free Software Foundation, Inc.
+
+;; Author: Sergey Kostyaev <[email protected]>
+
+;; 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 manual tests.
+;;
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'ert)
+(require 'ellama-manual)
+(require 'ox-texinfo)
+
+(defvar org-export-with-broken-links)
+
+(defun ellama-test--with-manual-project (ellama-content readme-content fn)
+  "Call FN in temporary project with ELLAMA-CONTENT and README-CONTENT."
+  (let* ((root (make-temp-file "ellama-manual-" t))
+         (ellama-file (expand-file-name "ellama.el" root))
+         (readme-file (expand-file-name "README.org" root)))
+    (unwind-protect
+        (progn
+          (with-temp-file ellama-file
+            (insert ellama-content))
+          (with-temp-file readme-file
+            (insert readme-content))
+          (funcall fn root))
+      (when (file-exists-p root)
+        (delete-directory root t)))))
+
+(ert-deftest test-ellama-manual-export-includes-version-and-readme ()
+  (let (export-content)
+    (ellama-test--with-manual-project
+     ";; Version: 9.9.1\n"
+     "Manual body."
+     (lambda (root)
+       (cl-letf (((symbol-function 'project-current)
+                  (lambda (&rest _) :project))
+                 ((symbol-function 'project-root)
+                  (lambda (_project) root))
+                 ((symbol-function 'org-export-to-file)
+                  (lambda (&rest _args)
+                    (setq export-content (buffer-string)))))
+         (ellama-manual-export))))
+    (should (string-match-p
+             (regexp-quote "#+MACRO: version 9.9.1")
+             export-content))
+    (should (string-match-p
+             (regexp-quote "Manual body.")
+             export-content))))
+
+(ert-deftest test-ellama-manual-export-removes-badge-svg-links ()
+  (let (export-content)
+    (ellama-test--with-manual-project
+     ";; Version: 1.0.0\n"
+     (concat "[[http://example][file:badge.svg]]\n";
+             "Visible text")
+     (lambda (root)
+       (cl-letf (((symbol-function 'project-current)
+                  (lambda (&rest _) :project))
+                 ((symbol-function 'project-root)
+                  (lambda (_project) root))
+                 ((symbol-function 'org-export-to-file)
+                  (lambda (&rest _args)
+                    (setq export-content (buffer-string)))))
+         (ellama-manual-export))))
+    (should-not (string-match-p (regexp-quote "badge.svg") export-content))
+    (should (string-match-p (regexp-quote "Visible text") export-content))))
+
+(ert-deftest test-ellama-manual-export-removes-gif-image-links ()
+  (let (export-content)
+    (ellama-test--with-manual-project
+     ";; Version: 1.0.0\n"
+     (concat "[[file:imgs/demo.gif]]\n"
+             "Visible text")
+     (lambda (root)
+       (cl-letf (((symbol-function 'project-current)
+                  (lambda (&rest _) :project))
+                 ((symbol-function 'project-root)
+                  (lambda (_project) root))
+                 ((symbol-function 'org-export-to-file)
+                  (lambda (&rest _args)
+                    (setq export-content (buffer-string)))))
+         (ellama-manual-export))))
+    (should-not (string-match-p (regexp-quote "demo.gif") export-content))
+    (should (string-match-p (regexp-quote "Visible text") export-content))))
+
+(ert-deftest test-ellama-manual-export-calls-org-export-to-file-correctly ()
+  (let (export-args)
+    (ellama-test--with-manual-project
+     ";; Version: 1.0.0\n"
+     "Body"
+     (lambda (root)
+       (cl-letf (((symbol-function 'project-current)
+                  (lambda (&rest _) :project))
+                 ((symbol-function 'project-root)
+                  (lambda (_project) root))
+                 ((symbol-function 'org-export-to-file)
+                  (lambda (&rest args)
+                    (setq export-args args))))
+         (ellama-manual-export))))
+    (should (equal (nth 0 export-args) 'texinfo))
+    (should (equal (nth 1 export-args) "ellama.texi"))
+    (should-not (nth 2 export-args))
+    (should-not (nth 3 export-args))
+    (should-not (nth 4 export-args))
+    (should-not (nth 5 export-args))
+    (should-not (nth 6 export-args))
+    (should (eq (nth 7 export-args) #'org-texinfo-compile))))
+
+(ert-deftest test-ellama-manual-export-binds-broken-links-locally ()
+  (let ((org-export-with-broken-links nil)
+        export-binding)
+    (ellama-test--with-manual-project
+     ";; Version: 1.0.0\n"
+     "Body"
+     (lambda (root)
+       (cl-letf (((symbol-function 'project-current)
+                  (lambda (&rest _) :project))
+                 ((symbol-function 'project-root)
+                  (lambda (_project) root))
+                 ((symbol-function 'org-export-to-file)
+                  (lambda (&rest _args)
+                    (setq export-binding org-export-with-broken-links))))
+         (ellama-manual-export))))
+    (should export-binding)
+    (should-not org-export-with-broken-links)))
+
+(ert-deftest test-ellama-manual-export-errors-when-version-missing ()
+  (ellama-test--with-manual-project
+   ";; No version header\n"
+   "Body"
+   (lambda (root)
+     (cl-letf (((symbol-function 'project-current)
+                (lambda (&rest _) :project))
+               ((symbol-function 'project-root)
+                (lambda (_project) root))
+               ((symbol-function 'org-export-to-file)
+                (lambda (&rest _args)
+                  nil)))
+       (should-error (ellama-manual-export) :type 'search-failed)))))
+
+(provide 'test-ellama-manual)
+;;; test-ellama-manual.el ends here.
diff --git a/tests/test-ellama-skills.el b/tests/test-ellama-skills.el
new file mode 100644
index 0000000000..08f1a5763c
--- /dev/null
+++ b/tests/test-ellama-skills.el
@@ -0,0 +1,175 @@
+;;; test-ellama-skills.el --- Ellama skills tests -*- lexical-binding: t; 
package-lint-main-file: "../ellama.el"; -*-
+
+;; Copyright (C) 2023-2026  Free Software Foundation, Inc.
+
+;; Author: Sergey Kostyaev <[email protected]>
+
+;; 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 skills tests.
+;;
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'ellama)
+(require 'ert)
+
+(ert-deftest test-ellama-skills-parse-frontmatter-valid ()
+  (let ((file (make-temp-file "ellama-skill-frontmatter-" nil ".md")))
+    (unwind-protect
+        (progn
+          (with-temp-file file
+            (insert "---\n")
+            (insert "name: Build Skill\n")
+            (insert "description: Build projects with make.\n")
+            (insert "---\n")
+            (insert "# SKILL\n"))
+          (let ((meta (ellama-skills--parse-frontmatter file)))
+            (should (equal (alist-get 'name meta) "Build Skill"))
+            (should
+             (equal (alist-get 'description meta)
+                    "Build projects with make."))))
+      (when (file-exists-p file)
+        (delete-file file)))))
+
+(ert-deftest test-ellama-skills-parse-frontmatter-invalid-or-missing ()
+  (let ((invalid-file (make-temp-file "ellama-skill-invalid-" nil ".md"))
+        (plain-file (make-temp-file "ellama-skill-plain-" nil ".md")))
+    (unwind-protect
+        (progn
+          (with-temp-file invalid-file
+            (insert "---\n")
+            (insert "name: [broken\n")
+            (insert "---\n")
+            (insert "# SKILL\n"))
+          (with-temp-file plain-file
+            (insert "# SKILL\n")
+            (insert "No frontmatter.\n"))
+          (should-not (ellama-skills--parse-frontmatter invalid-file))
+          (should-not (ellama-skills--parse-frontmatter plain-file)))
+      (when (file-exists-p invalid-file)
+        (delete-file invalid-file))
+      (when (file-exists-p plain-file)
+        (delete-file plain-file)))))
+
+(ert-deftest test-ellama-skills-scan-directory-filters-invalid-skills ()
+  (let* ((root (make-temp-file "ellama-skills-root-" t))
+         (valid-dir (expand-file-name "valid-skill" root))
+         (missing-desc-dir (expand-file-name "missing-desc" root))
+         (no-file-dir (expand-file-name "no-skill-file" root))
+         (hidden-dir (expand-file-name ".hidden-skill" root))
+         (valid-file (expand-file-name "SKILL.md" valid-dir))
+         (missing-desc-file (expand-file-name "SKILL.md" missing-desc-dir))
+         (hidden-file (expand-file-name "SKILL.md" hidden-dir)))
+    (unwind-protect
+        (progn
+          (make-directory valid-dir t)
+          (make-directory missing-desc-dir t)
+          (make-directory no-file-dir t)
+          (make-directory hidden-dir t)
+          (with-temp-file valid-file
+            (insert "---\n")
+            (insert "name: Valid Skill\n")
+            (insert "description: Works.\n")
+            (insert "---\n")
+            (insert "# SKILL\n"))
+          (with-temp-file missing-desc-file
+            (insert "---\n")
+            (insert "name: No Description\n")
+            (insert "---\n")
+            (insert "# SKILL\n"))
+          (with-temp-file hidden-file
+            (insert "---\n")
+            (insert "name: Hidden Skill\n")
+            (insert "description: Should be ignored.\n")
+            (insert "---\n")
+            (insert "# SKILL\n"))
+          (let ((skills (ellama-skills--scan-directory root)))
+            (should (= (length skills) 1))
+            (should (equal (ellama-skill-id (car skills)) "valid-skill"))
+            (should
+             (equal (ellama-skill-name (car skills))
+                    "Valid Skill"))
+            (should
+             (equal (ellama-skill-description (car skills))
+                    "Works."))
+            (should (equal (ellama-skill-path (car skills)) valid-dir))
+            (should (equal (ellama-skill-file-path (car skills))
+                           valid-file))))
+      (when (file-exists-p root)
+        (delete-directory root t)))))
+
+(ert-deftest test-ellama-skills-get-project-dir ()
+  (let ((ellama-skills-local-path "my-skills"))
+    (cl-letf (((symbol-function 'ellama-tools-project-root-tool)
+               (lambda () "/tmp/my-project")))
+      (should (equal (ellama-skills-get-project-dir)
+                     "/tmp/my-project/my-skills")))
+    (cl-letf (((symbol-function 'ellama-tools-project-root-tool)
+               (lambda () nil)))
+      (should-not (ellama-skills-get-project-dir)))))
+
+(ert-deftest test-ellama-get-skills-appends-global-then-local ()
+  (let ((ellama-skills-global-path "/tmp/global-skills"))
+    (cl-letf (((symbol-function 'ellama-skills-get-project-dir)
+               (lambda () "/tmp/project-skills"))
+              ((symbol-function 'ellama-skills--scan-directory)
+               (lambda (dir)
+                 (if (equal dir "/tmp/global-skills")
+                     '(global-skill)
+                   '(local-skill)))))
+      (should (equal (ellama-get-skills)
+                     '(global-skill local-skill))))))
+
+(ert-deftest test-ellama-skills-generate-prompt-empty-list ()
+  (cl-letf (((symbol-function 'ellama-get-skills)
+             (lambda () nil)))
+    (should (equal (ellama-skills-generate-prompt) ""))))
+
+(ert-deftest test-ellama-skills-generate-prompt-renders-skills ()
+  (let* ((skill-a (make-ellama-skill
+                   :id "a"
+                   :name "Skill A"
+                   :description "Do A"
+                   :path "/tmp/a"
+                   :file-path "/tmp/a/SKILL.md"))
+         (skill-b (make-ellama-skill
+                   :id "b"
+                   :name "Skill B"
+                   :description "Do B"
+                   :path "/tmp/b"
+                   :file-path "/tmp/b/SKILL.md"))
+         (prompt nil))
+    (cl-letf (((symbol-function 'ellama-get-skills)
+               (lambda () (list skill-a skill-b))))
+      (setq prompt (ellama-skills-generate-prompt)))
+    (should (string-match-p "<available_skills>" prompt))
+    (should (string-match-p "<name>Skill A</name>" prompt))
+    (should (string-match-p "<description>Do A</description>" prompt))
+    (should (string-match-p "<location>/tmp/a/SKILL.md</location>" prompt))
+    (should (string-match-p "<name>Skill B</name>" prompt))
+    (should (string-match-p "<description>Do B</description>" prompt))
+    (should (string-match-p "<location>/tmp/b/SKILL.md</location>" prompt))
+    (should
+     (string-match-p
+      "You have access to the skills listed above\\."
+      prompt))))
+
+
+(provide 'test-ellama-skills)
+
+;;; test-ellama-skills.el ends here
diff --git a/tests/test-ellama-tools.el b/tests/test-ellama-tools.el
new file mode 100644
index 0000000000..7f0bef4083
--- /dev/null
+++ b/tests/test-ellama-tools.el
@@ -0,0 +1,583 @@
+;;; test-ellama-tools.el --- Ellama tools tests -*- lexical-binding: t; 
package-lint-main-file: "../ellama.el"; -*-
+
+;; Copyright (C) 2023-2026  Free Software Foundation, Inc.
+
+;; Author: Sergey Kostyaev <[email protected]>
+
+;; 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 tools tests.
+;;
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'ellama)
+(require 'ert)
+
+(defconst ellama-test-root
+  (expand-file-name
+   ".."
+   (file-name-directory (or load-file-name buffer-file-name)))
+  "Project root directory for test assets.")
+
+(ert-deftest test-ellama--append-tool-error-to-prompt-uses-llm-message ()
+  (let (captured)
+    (cl-letf (((symbol-function 'llm-chat-prompt-append-response)
+              (lambda (_prompt msg role)
+                (setq captured (list msg role)))))
+      (ellama--append-tool-error-to-prompt
+       'prompt
+       "Unknown tool 'search' called"))
+    (should (equal captured
+                  '("Unknown tool 'search' called" system)))))
+
+(ert-deftest test-ellama--tool-call-error-p ()
+  (unless (get 'ellama-test-tool-call-error-2 'error-conditions)
+    (define-error 'ellama-test-tool-call-error-2
+      "Tool call test error"
+      'llm-tool-call-error))
+  (should (ellama--tool-call-error-p 'ellama-test-tool-call-error-2))
+  (should-not (ellama--tool-call-error-p 'error))
+  (should-not (ellama--tool-call-error-p nil)))
+
+(ert-deftest test-ellama--error-handler-retry-on-tool-call-error ()
+  (unless (get 'ellama-test-tool-call-error-3 'error-conditions)
+    (define-error 'ellama-test-tool-call-error-3
+      "Tool call retry error"
+      'llm-tool-call-error))
+  (let ((retry-called nil)
+        (err-called nil)
+        (appended nil))
+    (with-temp-buffer
+      (cl-letf (((symbol-function 'ellama--append-tool-error-to-prompt)
+                 (lambda (_prompt msg)
+                   (setq appended msg))))
+        (let ((handler
+               (ellama--error-handler
+                (current-buffer)
+                (lambda (_msg) (setq err-called t))
+                'prompt
+                (lambda () (setq retry-called t)))))
+          (funcall handler 'ellama-test-tool-call-error-3 "tool failed"))))
+    (should retry-called)
+    (should-not err-called)
+    (should (equal appended "tool failed"))))
+
+(ert-deftest test-ellama--error-handler-calls-errcb-for-non-tool-errors ()
+  (let ((err-msg nil)
+        (request-mode-arg nil)
+        (spinner-stop-called nil)
+        (ellama--change-group (prepare-change-group))
+        (ellama-spinner-enabled t))
+    (with-temp-buffer
+      (setq-local ellama--current-request 'request)
+      (activate-change-group ellama--change-group)
+      (cl-letf (((symbol-function 'cancel-change-group)
+                 (lambda (_cg) nil))
+                ((symbol-function 'spinner-stop)
+                 (lambda () (setq spinner-stop-called t)))
+                ((symbol-function 'ellama-request-mode)
+                 (lambda (arg)
+                   (setq request-mode-arg arg))))
+        (let ((handler
+               (ellama--error-handler
+                (current-buffer)
+                (lambda (msg) (setq err-msg msg))
+                'prompt
+                (lambda () (error "Retry should not run")))))
+          (funcall handler 'error "bad")))
+      (should (null ellama--current-request)))
+    (should (equal err-msg "bad"))
+    (should (equal request-mode-arg -1))
+    (should spinner-stop-called)))
+(defun ellama-test--ensure-local-ellama-tools ()
+  "Ensure tests use local `ellama-tools.el' from project root."
+  (unless (fboundp 'ellama-tools--sanitize-tool-text-output)
+    (load-file (expand-file-name "ellama-tools.el" ellama-test-root))))
+
+(defun ellama-test--wait-shell-command-result (cmd)
+  "Run shell tool CMD and wait for a result string."
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((result :pending)
+       (deadline (+ (float-time) 3.0)))
+    (ellama-tools-shell-command-tool
+     (lambda (res)
+       (setq result res))
+     cmd)
+    (while (and (eq result :pending)
+               (< (float-time) deadline))
+      (accept-process-output nil 0.01))
+    (when (eq result :pending)
+      (ert-fail (format "Timeout while waiting result for: %s" cmd)))
+    result))
+
+(defun ellama-test--named-tool-no-args ()
+  "Return constant string."
+  "zero")
+
+(defun ellama-test--named-tool-one-arg (arg)
+  "Return ARG with prefix."
+  (format "one:%s" arg))
+
+(defun ellama-test--named-tool-two-args (arg1 arg2)
+  "Return ARG1 and ARG2 with prefix."
+  (format "two:%s:%s" arg1 arg2))
+
+(defun ellama-test--make-confirm-wrapper-old (function)
+  "Make wrapper for FUNCTION using old confirm call style."
+  (lambda (&rest args)
+    (apply #'ellama-tools-confirm function args)))
+
+(defun ellama-test--make-confirm-wrapper-new (function name)
+  "Make wrapper for FUNCTION and NAME using wrapper factory."
+  (ellama-tools--make-confirm-wrapper function name))
+
+(defun ellama-test--invoke-confirm-with-yes (wrapper &rest args)
+  "Call WRAPPER with ARGS and auto-answer confirmation with yes.
+Return list with result and prompt."
+  (let ((ellama-tools-confirm-allowed (make-hash-table))
+        (ellama-tools-allow-all nil)
+        (ellama-tools-allowed nil)
+        result
+        prompt)
+    (cl-letf (((symbol-function 'read-char-choice)
+               (lambda (message _choices)
+                 (setq prompt message)
+                 ?y)))
+      (setq result (apply wrapper args)))
+    (list result prompt)))
+
+(ert-deftest test-ellama-shell-command-tool-empty-success-output ()
+  (should
+   (string=
+    (ellama-test--wait-shell-command-result "sh -c 'true'")
+    "Command completed successfully with no output.")))
+
+(ert-deftest test-ellama-shell-command-tool-empty-failure-output ()
+  (should
+   (string-match-p
+    "Command failed with exit code 7 and no output\\."
+    (ellama-test--wait-shell-command-result "sh -c 'exit 7'"))))
+
+(ert-deftest test-ellama-shell-command-tool-returns-stdout ()
+  (should
+   (string=
+    (ellama-test--wait-shell-command-result "printf 'ok\\n'")
+    "ok")))
+
+(ert-deftest test-ellama-shell-command-tool-rejects-binary-output ()
+  (should
+   (string-match-p
+    "binary data"
+    (ellama-test--wait-shell-command-result
+     "awk 'BEGIN { printf \"%c\", 0 }'"))))
+
+(ert-deftest test-ellama-read-file-tool-rejects-binary-content ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((file (make-temp-file "ellama-read-file-bin-")))
+    (unwind-protect
+        (progn
+          (let ((coding-system-for-write 'no-conversion))
+            (with-temp-buffer
+              (set-buffer-multibyte nil)
+              (insert "%PDF-1.5\n%")
+              (insert (char-to-string 143))
+              (insert "\n")
+              (write-region (point-min) (point-max) file nil 'silent)))
+          (let ((result (ellama-tools-read-file-tool file)))
+            (should (string-match-p "binary data" result))
+            (should (string-match-p "bad idea" result))))
+      (when (file-exists-p file)
+        (delete-file file)))))
+
+(ert-deftest test-ellama-read-file-tool-accepts-utf8-markdown-text ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((file (make-temp-file "ellama-read-file-utf8-" nil ".md")))
+    (unwind-protect
+        (progn
+          (with-temp-file file
+            (insert "# Research Plan\n\n")
+            (insert "Sub‑topics: temporal reasoning overview.\n"))
+          (let ((result (ellama-tools-read-file-tool file)))
+            (should-not (string-match-p "binary data" result))
+            (should (string-match-p "Research Plan" result))))
+      (when (file-exists-p file)
+        (delete-file file)))))
+
+(ert-deftest test-ellama-tools-confirm-wrapped-named-no-args-old-and-new ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let* ((old-wrapper (ellama-test--make-confirm-wrapper-old
+                       #'ellama-test--named-tool-no-args))
+         (new-wrapper (ellama-test--make-confirm-wrapper-new
+                       #'ellama-test--named-tool-no-args
+                       "named_tool"))
+         (old-call (ellama-test--invoke-confirm-with-yes old-wrapper))
+         (new-call (ellama-test--invoke-confirm-with-yes new-wrapper)))
+    (should (equal (car old-call) "zero"))
+    (should (equal (car new-call) "zero"))
+    (should
+     (string-match-p
+      "Allow calling ellama-test--named-tool-no-args with arguments: \\?"
+      (cadr old-call)))
+    (should
+     (string-match-p
+      "Allow calling ellama-test--named-tool-no-args with arguments: \\?"
+      (cadr new-call)))))
+
+(ert-deftest test-ellama-tools-confirm-wrapped-named-one-arg-old-and-new ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let* ((old-wrapper (ellama-test--make-confirm-wrapper-old
+                       #'ellama-test--named-tool-one-arg))
+         (new-wrapper (ellama-test--make-confirm-wrapper-new
+                       #'ellama-test--named-tool-one-arg
+                       "named_tool"))
+         (old-call (ellama-test--invoke-confirm-with-yes old-wrapper "A"))
+         (new-call (ellama-test--invoke-confirm-with-yes new-wrapper "A")))
+    (should (equal (car old-call) "one:A"))
+    (should (equal (car new-call) "one:A"))
+    (should
+     (string-match-p
+      "Allow calling ellama-test--named-tool-one-arg with arguments: A\\?"
+      (cadr old-call)))
+    (should
+     (string-match-p
+      "Allow calling ellama-test--named-tool-one-arg with arguments: A\\?"
+      (cadr new-call)))))
+
+(ert-deftest test-ellama-tools-confirm-wrapped-named-two-args-old-and-new ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let* ((old-wrapper (ellama-test--make-confirm-wrapper-old
+                       #'ellama-test--named-tool-two-args))
+         (new-wrapper (ellama-test--make-confirm-wrapper-new
+                       #'ellama-test--named-tool-two-args
+                       "named_tool"))
+         (old-call (ellama-test--invoke-confirm-with-yes old-wrapper "A" "B"))
+         (new-call (ellama-test--invoke-confirm-with-yes new-wrapper "A" "B")))
+    (should (equal (car old-call) "two:A:B"))
+    (should (equal (car new-call) "two:A:B"))
+    (should
+     (string-match-p
+      "Allow calling ellama-test--named-tool-two-args with arguments: A, B\\?"
+      (cadr old-call)))
+    (should
+     (string-match-p
+      "Allow calling ellama-test--named-tool-two-args with arguments: A, B\\?"
+      (cadr new-call)))))
+
+(ert-deftest test-ellama-tools-confirm-prompt-uses-tool-name-for-lambda ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let* ((ellama-tools-confirm-allowed (make-hash-table))
+         (ellama-tools-allow-all nil)
+         (ellama-tools-allowed nil)
+         (tool-plist `(:function ,(lambda (_arg) "ok")
+                       :name "mcp_tool"
+                       :args ((:name "arg" :type string))))
+         (wrapped (ellama-tools-wrap-with-confirm tool-plist))
+         (wrapped-func (plist-get wrapped :function))
+         seen-prompt)
+    (cl-letf (((symbol-function 'read-char-choice)
+               (lambda (prompt _choices)
+                 (setq seen-prompt prompt)
+                 ?n)))
+      (funcall wrapped-func "value"))
+    (should
+     (string-match-p
+      "Allow calling mcp_tool with arguments: value\\?"
+      seen-prompt))))
+
+(ert-deftest test-ellama-tools-wrap-with-confirm-preserves-arg-types ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let* ((tool-plist '(:function ignore
+                       :name "typed_tool"
+                       :args ((:name "a" :type string)
+                              (:name "b" :type number))))
+         (wrapped (ellama-tools-wrap-with-confirm tool-plist))
+         (types (mapcar (lambda (arg) (plist-get arg :type))
+                        (plist-get wrapped :args))))
+    (should (equal types '(string number)))))
+
+(ert-deftest test-ellama-tools-edit-file-tool-replace-at-file-start ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((file (make-temp-file "ellama-edit-start-")))
+    (unwind-protect
+        (progn
+          (with-temp-file file
+            (insert "abcde"))
+          (ellama-tools-edit-file-tool file "ab" "XX")
+          (with-temp-buffer
+            (insert-file-contents file)
+            (should (equal (buffer-string) "XXcde"))))
+      (when (file-exists-p file)
+        (delete-file file)))))
+
+(ert-deftest
+    test-ellama-tools-enable-by-name-tool-missing-name-does-not-add-nil ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((ellama-tools-enabled nil)
+        (ellama-tools-available nil))
+    (ellama-tools-enable-by-name-tool "missing")
+    (should (null ellama-tools-enabled))))
+
+(ert-deftest test-ellama-tools-confirm-answer-always-caches-approval ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((ellama-tools-confirm-allowed (make-hash-table))
+        (ellama-tools-allow-all nil)
+        (ellama-tools-allowed nil)
+        (prompt-count 0))
+    (cl-letf (((symbol-function 'read-char-choice)
+               (lambda (_prompt _choices)
+                 (setq prompt-count (1+ prompt-count))
+                 ?a)))
+      (should (equal (ellama-tools-confirm 'ellama-test--named-tool-one-arg 
"A")
+                     "one:A"))
+      (should (equal (ellama-tools-confirm 'ellama-test--named-tool-one-arg 
"B")
+                     "one:B")))
+    (should (= prompt-count 1))
+    (should (gethash 'ellama-test--named-tool-one-arg
+                     ellama-tools-confirm-allowed))))
+
+(ert-deftest test-ellama-tools-confirm-answer-reply-returns-user-text ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((ellama-tools-confirm-allowed (make-hash-table))
+        (ellama-tools-allow-all nil)
+        (ellama-tools-allowed nil))
+    (cl-letf (((symbol-function 'read-char-choice)
+               (lambda (_prompt _choices) ?r))
+              ((symbol-function 'read-string)
+               (lambda (_prompt &rest _args) "custom reply")))
+      (should (equal
+               (ellama-tools-confirm 'ellama-test--named-tool-one-arg "A")
+               "custom reply")))))
+
+(ert-deftest test-ellama-tools-confirm-answer-no-returns-forbidden ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((ellama-tools-confirm-allowed (make-hash-table))
+        (ellama-tools-allow-all nil)
+        (ellama-tools-allowed nil))
+    (cl-letf (((symbol-function 'read-char-choice)
+               (lambda (_prompt _choices) ?n)))
+      (should (equal
+               (ellama-tools-confirm 'ellama-test--named-tool-one-arg "A")
+               "Forbidden by the user")))))
+
+(ert-deftest test-ellama-read-file-tool-missing-file ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((missing-file
+         (expand-file-name "missing-file-ellama-test.txt"
+                           (make-temp-name temporary-file-directory))))
+    (should (string-match-p "doesn't exists"
+                            (ellama-tools-read-file-tool missing-file)))))
+
+(ert-deftest test-ellama-tools-write-append-prepend-roundtrip ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((file (make-temp-file "ellama-file-tools-")))
+    (unwind-protect
+        (progn
+          (ellama-tools-write-file-tool file "middle")
+          (ellama-tools-append-file-tool file "-tail")
+          (ellama-tools-prepend-file-tool file "head-")
+          (with-temp-buffer
+            (insert-file-contents file)
+            (should (equal (buffer-string) "head-middle-tail"))))
+      (when (file-exists-p file)
+        (delete-file file)))))
+
+(ert-deftest test-ellama-tools-directory-tree-excludes-dotfiles-and-sorts ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let* ((dir (make-temp-file "ellama-tree-" t))
+         (a-file (expand-file-name "a.txt" dir))
+         (b-file (expand-file-name "b.txt" dir))
+         (hidden (expand-file-name ".hidden" dir))
+         (result nil))
+    (unwind-protect
+        (progn
+          (with-temp-file b-file (insert "b"))
+          (with-temp-file a-file (insert "a"))
+          (with-temp-file hidden (insert "h"))
+          (setq result (ellama-tools-directory-tree-tool dir))
+          (should-not (string-match-p "\\.hidden" result))
+          (should (< (string-match-p "a\\.txt" result)
+                     (string-match-p "b\\.txt" result))))
+      (when (file-exists-p dir)
+        (delete-directory dir t)))))
+
+(ert-deftest test-ellama-tools-move-file-success-and-error ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let* ((src (make-temp-file "ellama-move-src-"))
+         (dst (concat src "-dst")))
+    (unwind-protect
+        (progn
+          (with-temp-file src (insert "x"))
+          (ellama-tools-move-file-tool src dst)
+          (should (file-exists-p dst))
+          (should-not (file-exists-p src))
+          (should-error (ellama-tools-move-file-tool src dst) :type 'error))
+      (when (file-exists-p src)
+        (delete-file src))
+      (when (file-exists-p dst)
+        (delete-file dst)))))
+
+(ert-deftest test-ellama-tools-lines-range-boundary ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((file (make-temp-file "ellama-lines-range-")))
+    (unwind-protect
+        (progn
+          (with-temp-file file
+            (insert "alpha\nbeta\ngamma\n"))
+          (let ((single-line
+                 (json-parse-string
+                  (ellama-tools-lines-range-tool file 2 2)))
+                (full-range
+                 (json-parse-string
+                  (ellama-tools-lines-range-tool file 1 3))))
+            (should (equal single-line "beta"))
+            (should (equal full-range "alpha\nbeta\ngamma"))))
+      (when (file-exists-p file)
+        (delete-file file)))))
+
+(ert-deftest test-ellama-tools-apply-patch-validation-branches ()
+  (ellama-test--ensure-local-ellama-tools)
+  (should (equal (ellama-tools-apply-patch-tool nil "patch")
+                 "file-name is required"))
+  (should (equal (ellama-tools-apply-patch-tool "missing-file" nil)
+                 "file missing-file doesn't exists"))
+  (let ((file (make-temp-file "ellama-patch-validate-")))
+    (unwind-protect
+        (should (equal (ellama-tools-apply-patch-tool file nil)
+                       "patch is required"))
+      (when (file-exists-p file)
+        (delete-file file)))))
+
+(ert-deftest test-ellama-tools-role-and-provider-resolution ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let* ((ellama-provider 'default-provider)
+         (ellama-tools-subagent-roles
+          (list (list "all" :tools :all)
+                (list "subset" :tools '("read_file" "task"))))
+         (ellama-tools-available
+          (list (llm-make-tool :name "task" :function #'ignore)
+                (llm-make-tool :name "read_file" :function #'ignore)
+                (llm-make-tool :name "grep" :function #'ignore))))
+    (should-not
+     (member "task"
+             (mapcar #'llm-tool-name (ellama-tools--for-role "all"))))
+    (should (equal
+             (mapcar #'llm-tool-name (ellama-tools--for-role "subset"))
+             '("task" "read_file")))
+    (should (null (ellama-tools--for-role "missing")))
+    (should (eq (ellama-tools--provider-for-role "all")
+                'default-provider))))
+
+(ert-deftest test-ellama-subagent-loop-handler-max-steps-and-continue ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((updated-extra nil)
+        (callback-msg nil)
+        (stream-call nil))
+    (let* ((session-max
+            (make-ellama-session
+             :id "worker-max"
+             :extra (list :task-completed nil
+                          :step-count 2
+                          :max-steps 2
+                          :result-callback (lambda (msg)
+                                             (setq callback-msg msg)))))
+           (ellama--current-session session-max))
+      (cl-letf (((symbol-function 'ellama-tools--set-session-extra)
+                 (lambda (_session extra)
+                   (setq updated-extra extra)))
+                ((symbol-function 'ellama-stream)
+                 (lambda (prompt &rest args)
+                   (setq stream-call (list prompt args)))))
+        (ellama--subagent-loop-handler "ignored")
+        (should (equal callback-msg "Max steps (2) reached."))
+        (should (plist-get updated-extra :task-completed))
+        (setq callback-msg nil)
+        (setq updated-extra nil)
+        (setq stream-call nil)
+        (let* ((session-continue
+                (make-ellama-session
+                 :id "worker-continue"
+                 :extra (list :task-completed nil
+                              :step-count 1
+                              :max-steps 3
+                              :result-callback (lambda (msg)
+                                                 (setq callback-msg msg)))))
+               (ellama--current-session session-continue))
+          (ellama--subagent-loop-handler "ignored")
+          (should (equal (plist-get updated-extra :step-count) 2))
+          (should (equal (car stream-call)
+                         ellama-tools-subagent-continue-prompt))
+          (should (eq (plist-get (cadr stream-call) :session)
+                      session-continue))
+          (should (eq (plist-get (cadr stream-call) :on-done)
+                      #'ellama--subagent-loop-handler))
+          (should (null callback-msg)))))))
+
+(ert-deftest test-ellama-tools-task-tool-role-fallback-and-report-priority ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((ellama--current-session-id "parent-1")
+        (ellama-tools-subagent-default-max-steps 7)
+        (worker (make-ellama-session :id "worker-1"))
+        (resolved-provider nil)
+        (resolved-provider-role nil)
+        (resolved-tools-role nil)
+        (captured-extra nil)
+        (stream-call nil)
+        (role-tool (llm-make-tool :name "read_file" :function #'ignore)))
+    (cl-letf (((symbol-function 'ellama-tools--provider-for-role)
+               (lambda (role)
+                 (setq resolved-provider-role role)
+                 'provider))
+              ((symbol-function 'ellama-tools--for-role)
+               (lambda (role)
+                 (setq resolved-tools-role role)
+                 (list role-tool)))
+              ((symbol-function 'ellama-new-session)
+               (lambda (provider _prompt ephemeral)
+                 (setq resolved-provider provider)
+                 (should ephemeral)
+                 worker))
+              ((symbol-function 'ellama-tools--set-session-extra)
+               (lambda (_session extra)
+                 (setq captured-extra extra)))
+              ((symbol-function 'ellama-stream)
+               (lambda (prompt &rest args)
+                 (setq stream-call (list prompt args))))
+              ((symbol-function 'message)
+               (lambda (&rest _args) nil)))
+      (should (null (ellama-tools-task-tool (lambda (_res) nil)
+                                            "Do work"
+                                            "unknown-role")))
+      (should (eq resolved-provider 'provider))
+      (should (equal resolved-provider-role "general"))
+      (should (equal resolved-tools-role "general"))
+      (should (equal (plist-get captured-extra :role)
+                     "general"))
+      (should (equal (car stream-call) "Do work"))
+      (should (eq (plist-get (cadr stream-call) :session) worker))
+      (should (equal (plist-get (cadr stream-call) :tools)
+                     (plist-get captured-extra :tools)))
+      (should (string=
+               (llm-tool-name
+                (car (plist-get captured-extra :tools)))
+               "report_result"))
+      (should (eq (cadr (plist-get captured-extra :tools))
+                  role-tool)))))
+
+(provide 'test-ellama-tools)
+
+;;; test-ellama-tools.el ends here
diff --git a/tests/test-ellama-transient.el b/tests/test-ellama-transient.el
new file mode 100644
index 0000000000..9f95def1b4
--- /dev/null
+++ b/tests/test-ellama-transient.el
@@ -0,0 +1,275 @@
+;;; test-ellama-transient.el --- Ellama transient tests -*- lexical-binding: 
t; package-lint-main-file: "../ellama.el"; -*-
+
+;; Copyright (C) 2023-2026  Free Software Foundation, Inc.
+
+;; Author: Sergey Kostyaev <[email protected]>
+
+;; 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 transient tests.
+;;
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'ellama)
+(require 'ellama-context)
+(require 'ellama-transient)
+(require 'ert)
+(require 'llm-ollama)
+
+(ert-deftest test-ellama-fill-transient-ollama-model-populates-fields ()
+  (let ((provider (make-llm-ollama
+                   :chat-model "test-model"
+                   :default-chat-temperature 0.2
+                   :host "example.org"
+                   :port 11000
+                   :default-chat-non-standard-params
+                   '(("num_ctx" . 8192))))
+        (ellama-transient-ollama-model-name "")
+        (ellama-transient-temperature 0.7)
+        (ellama-transient-context-length 4096)
+        (ellama-transient-host "localhost")
+        (ellama-transient-port 11434))
+    (ellama-fill-transient-ollama-model provider)
+    (should (equal ellama-transient-ollama-model-name "test-model"))
+    (should (= ellama-transient-temperature 0.2))
+    (should (= ellama-transient-context-length 8192))
+    (should (equal ellama-transient-host "example.org"))
+    (should (= ellama-transient-port 11000))))
+
+(ert-deftest test-ellama-fill-transient-ollama-model-defaults ()
+  (let ((provider (make-llm-ollama
+                   :chat-model "model"
+                   :default-chat-temperature nil
+                   :host "localhost"
+                   :port 11434
+                   :default-chat-non-standard-params nil))
+        (ellama-transient-temperature 0.3)
+        (ellama-transient-context-length 123))
+    (ellama-fill-transient-ollama-model provider)
+    (should (= ellama-transient-temperature 0.7))
+    (should (= ellama-transient-context-length 4096))))
+
+(ert-deftest test-ellama-fill-transient-ollama-model-noop-for-non-ollama ()
+  (let ((ellama-transient-ollama-model-name "keep-model")
+        (ellama-transient-temperature 0.9)
+        (ellama-transient-context-length 2222)
+        (ellama-transient-host "keep-host")
+        (ellama-transient-port 22000))
+    (ellama-fill-transient-ollama-model :not-ollama-provider)
+    (should (equal ellama-transient-ollama-model-name "keep-model"))
+    (should (= ellama-transient-temperature 0.9))
+    (should (= ellama-transient-context-length 2222))
+    (should (equal ellama-transient-host "keep-host"))
+    (should (= ellama-transient-port 22000))))
+
+(ert-deftest
+    test-ellama-construct-ollama-provider-from-transient-passes-all-params ()
+  (let ((ellama-transient-ollama-model-name "model-x")
+        (ellama-transient-temperature 0.61)
+        (ellama-transient-host "localhost")
+        (ellama-transient-port 12000)
+        (ellama-transient-context-length 16384))
+    (let* ((provider (ellama-construct-ollama-provider-from-transient))
+           (params
+            (seq--into-list
+             (llm-ollama-default-chat-non-standard-params provider))))
+      (should (llm-ollama-p provider))
+      (should (equal (llm-ollama-chat-model provider) "model-x"))
+      (should (= (llm-ollama-default-chat-temperature provider) 0.61))
+      (should (equal (llm-ollama-host provider) "localhost"))
+      (should (= (llm-ollama-port provider) 12000))
+      (should (equal params '(("num_ctx" . 16384)))))))
+
+(ert-deftest test-ellama-transient-set-provider-updates-selected-symbol ()
+  (let ((ellama-provider :old-default)
+        (ellama-coding-provider :old-coding))
+    (cl-letf (((symbol-function 'completing-read)
+               (lambda (&rest _args)
+                 "ellama-coding-provider"))
+              ((symbol-function 
'ellama-construct-ollama-provider-from-transient)
+               (lambda ()
+                 :new-provider)))
+      (ellama-transient-set-provider)
+      (should (eq ellama-coding-provider :new-provider))
+      (should (eq ellama-provider :old-default)))))
+
+(ert-deftest
+    
test-ellama-transient-set-provider-resets-session-only-for-default-provider ()
+  (let ((ellama-provider :default-provider)
+        (ellama-coding-provider :coding-provider)
+        (ellama--current-session-id "session-1")
+        (providers '("ellama-provider" "ellama-coding-provider"))
+        (values '(:new-default :new-coding)))
+    (cl-letf (((symbol-function 'completing-read)
+               (lambda (&rest _args)
+                 (prog1 (car providers)
+                   (setq providers (cdr providers)))))
+              ((symbol-function 
'ellama-construct-ollama-provider-from-transient)
+               (lambda ()
+                 (prog1 (car values)
+                   (setq values (cdr values))))))
+      (ellama-transient-set-provider)
+      (should (eq ellama-provider :new-default))
+      (should-not ellama--current-session-id)
+      (setq ellama--current-session-id "session-2")
+      (ellama-transient-set-provider)
+      (should (eq ellama-coding-provider :new-coding))
+      (should (equal ellama--current-session-id "session-2")))))
+
+(ert-deftest
+    
test-ellama-transient-model-get-from-current-session-guard-and-provider-flow
+  ()
+  (let ((called 0))
+    (cl-letf (((symbol-function 'ellama-fill-transient-ollama-model)
+               (lambda (&rest _args)
+                 (cl-incf called))))
+      (let ((ellama--current-session-id nil))
+        (ellama-transient-model-get-from-current-session))
+      (should (= called 0))))
+  (let ((buffer (generate-new-buffer " *ellama-transient-test-session*"))
+        (ellama--current-session-id "session-id")
+        (ellama--current-session
+         (make-ellama-session :provider :session-provider))
+        provided-id
+        provided-provider)
+    (unwind-protect
+        (cl-letf (((symbol-function 'ellama-get-session-buffer)
+                   (lambda (id)
+                     (setq provided-id id)
+                     buffer))
+                  ((symbol-function 'ellama-fill-transient-ollama-model)
+                   (lambda (provider)
+                     (setq provided-provider provider))))
+          (ellama-transient-model-get-from-current-session)
+          (should (equal provided-id "session-id"))
+          (should (eq provided-provider :session-provider)))
+      (when (buffer-live-p buffer)
+        (kill-buffer buffer)))))
+
+(ert-deftest test-ellama-transient-set-system-region-and-prompt-paths ()
+  (let ((ellama-global-system "old"))
+    (with-temp-buffer
+      (insert "alpha beta")
+      (goto-char (point-min))
+      (set-mark (point))
+      (goto-char (+ (point-min) 5))
+      (activate-mark)
+      (ellama-transient-set-system)
+      (should (equal ellama-global-system "alpha"))))
+  (let ((ellama-global-system "old"))
+    (cl-letf (((symbol-function 'read-string)
+               (lambda (&rest _args)
+                 "from prompt")))
+      (ellama-transient-set-system)
+      (should (equal ellama-global-system "from prompt"))))
+  (let ((ellama-global-system "keep"))
+    (cl-letf (((symbol-function 'read-string)
+               (lambda (&rest _args)
+                 "")))
+      (ellama-transient-set-system)
+      (should-not ellama-global-system))))
+
+(ert-deftest test-ellama-transient-system-show-uses-first-line-and-limit ()
+  (let ((ellama-global-system "abcdefghij\nsecond line")
+        (ellama-transient-system-show-limit 5))
+    (should (equal (ellama-transient-system-show)
+                   "System message (abcde)"))))
+
+(ert-deftest test-ellama-context-summary-counts-and-marks-quoted-elements ()
+  (let ((ellama-context-global '(:global))
+        (ellama-context-ephemeral '(:ephemeral)))
+    (cl-letf (((symbol-function 'ellama-context-element-extract)
+               (lambda (element)
+                 (if (eq element :global)
+                     "hello"
+                   "cat")))
+              ((symbol-function 'ellama-context-element-display)
+               (lambda (element)
+                 (if (eq element :global)
+                     "Global file"
+                   "Selection")))
+              ((symbol-function 'ellama-context-element-quote-p)
+               (lambda (element)
+                 (eq element :ephemeral))))
+      (let ((summary (ellama--context-summary)))
+        (should (string-match-p (regexp-quote "Global file") summary))
+        (should (string-match-p
+                 (regexp-quote "Selection (3 chars region)")
+                 summary))
+        (should (string-match-p
+                 (regexp-quote "(total 8 chars)")
+                 summary))))))
+
+(ert-deftest test-ellama-transient-arg-forwarding ()
+  (let ((args '("--new-session" "--ephemeral"))
+        code-review-call
+        ask-line-call
+        ask-selection-call
+        ask-about-call
+        chat-call)
+    (cl-letf (((symbol-function 'ellama-code-review)
+               (lambda (new-session &rest rest)
+                 (setq code-review-call (list new-session rest))))
+              ((symbol-function 'ellama-ask-line)
+               (lambda (new-session &rest rest)
+                 (setq ask-line-call (list new-session rest))))
+              ((symbol-function 'ellama-ask-selection)
+               (lambda (new-session &rest rest)
+                 (setq ask-selection-call (list new-session rest))))
+              ((symbol-function 'ellama-ask-about)
+               (lambda (new-session &rest rest)
+                 (setq ask-about-call (list new-session rest))))
+              ((symbol-function 'ellama-chat)
+               (lambda (prompt new-session &rest rest)
+                 (setq chat-call (list prompt new-session rest))))
+              ((symbol-function 'read-string)
+               (lambda (&rest _args)
+                 "Ask me")))
+      (ellama-transient-code-review args)
+      (ellama-transient-ask-line args)
+      (ellama-transient-ask-selection args)
+      (ellama-transient-ask-about args)
+      (ellama-transient-chat args))
+    (should (equal code-review-call '(t (:ephemeral t))))
+    (should (equal ask-line-call '(t (:ephemeral t))))
+    (should (equal ask-selection-call '(t (:ephemeral t))))
+    (should (equal ask-about-call '(t (:ephemeral t))))
+    (should (equal chat-call '("Ask me" t (:ephemeral t))))))
+
+(ert-deftest
+    test-ellama-transient-main-menu-initializes-model-only-when-empty ()
+  (let ((ellama-provider :provider)
+        (ellama-transient-ollama-model-name "")
+        (fill-calls 0)
+        fill-provider)
+    (cl-letf (((symbol-function 'transient-setup)
+               (lambda (&rest _args) nil))
+              ((symbol-function 'ellama-fill-transient-ollama-model)
+               (lambda (provider)
+                 (cl-incf fill-calls)
+                 (setq fill-provider provider))))
+      (ellama-transient-main-menu)
+      (should (= fill-calls 1))
+      (should (eq fill-provider :provider))
+      (setq ellama-transient-ollama-model-name "model-already-set")
+      (ellama-transient-main-menu)
+      (should (= fill-calls 1)))))
+
+(provide 'test-ellama-transient)
+
+;;; test-ellama-transient.el ends here
diff --git a/tests/test-ellama.el b/tests/test-ellama.el
index 4bc263b9cc..b6527ef0a2 100644
--- a/tests/test-ellama.el
+++ b/tests/test-ellama.el
@@ -26,16 +26,54 @@
 
 (require 'cl-lib)
 (require 'ellama)
-(require 'ellama-context)
 (require 'ellama-transient)
 (require 'ert)
 (require 'llm-fake)
 
-(defconst ellama-test-root
-  (expand-file-name
-   ".."
-   (file-name-directory (or load-file-name buffer-file-name)))
-  "Project root directory for test assets.")
+
+(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))))
 
 (ert-deftest test-ellama--code-filter ()
   (should (equal "" (ellama--code-filter "")))
@@ -44,20 +82,16 @@
 
 (ert-deftest test-ellama-code-improve ()
   (let ((original "(hello)\n")
-        (improved "```lisp\n(hello)\n```")
-        (ellama-provider (make-llm-fake))
-        prev-lines)
+        (improved "```lisp\n(hello)\n```"))
     (with-temp-buffer
       (insert original)
-      (cl-letf (((symbol-function 'llm-chat-streaming)
-                 (lambda (_provider prompt partial-callback response-callback 
_error-callback _multi-output)
-                   (should (string-match original (llm-chat-prompt-to-text 
prompt)))
-                   (dolist (s (string-lines improved))
-                     (funcall partial-callback `(:text ,(concat prev-lines s)))
-                     (setq prev-lines (concat prev-lines s)))
-                   (funcall response-callback `(:text ,improved)))))
-        (ellama-code-improve)
-        (should (equal original (buffer-string)))))))
+      (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)
@@ -73,19 +107,16 @@ 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.")
-        (ellama-provider (make-llm-fake))
-        prev-lines)
+        )
     (with-temp-buffer
       (org-mode)
-      (cl-letf (((symbol-function 'llm-chat-streaming)
-                (lambda (_provider prompt partial-callback response-callback 
_error-callback _multi-output)
-                  (should (string-match "test" (llm-chat-prompt-to-text 
prompt)))
-                  (dolist (s (string-split raw " "))
-                    (funcall partial-callback `(:text ,(concat prev-lines " " 
s)))
-                    (setq prev-lines (concat prev-lines " " s)))
-                  (funcall response-callback `(:text ,raw)))))
-        (ellama-write "test")
-        (should (equal expected (buffer-string)))))))
+      (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)
@@ -435,19 +466,18 @@ circumference with remarkable accuracy!
 
 Let me know if you'd like to see a visual version, or try it with a different
 number! 😊")
-        (ellama-provider (make-llm-fake))
-        prev-lines)
+        )
     (with-temp-buffer
       (org-mode)
-      (cl-letf (((symbol-function 'llm-chat-streaming)
-                (lambda (_provider prompt partial-callback response-callback 
_error-callback _multi-output)
-                  (should (string-match "test" (llm-chat-prompt-to-text 
prompt)))
-                  (dolist (s (string-split raw " "))
-                    (funcall partial-callback `(:text ,(concat prev-lines " " 
s)))
-                    (setq prev-lines (concat prev-lines " " s)))
-                  (funcall response-callback `(:text ,raw)))))
-       (ellama-write "test")
-       (should (equal expected (buffer-substring-no-properties (point-min) 
(point-max))))))))
+      (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)
@@ -463,218 +493,81 @@ Scratch)\"* depends on your *goals, background, and 
learning style*. Here’s a
 detailed comparison to help you decide:
 
 ---")
-       (ellama-provider (make-llm-fake))
-       prev-lines)
+       )
     (with-temp-buffer
       (org-mode)
-      (cl-letf (((symbol-function 'llm-chat-streaming)
-                (lambda (_provider prompt partial-callback response-callback 
_error-callback _multi-output)
-                  (should (string-match "test" (llm-chat-prompt-to-text 
prompt)))
-                  (dolist (s (string-split raw " "))
-                    (funcall partial-callback `(:text ,(concat prev-lines s " 
")))
-                    (setq prev-lines (concat prev-lines s " ")))
-                  (funcall response-callback `(:text ,raw)))))
-       (ellama-write "test")
-       (should (equal expected (buffer-string)))))))
-
-(ert-deftest test-ellama-context-element-format-buffer-markdown ()
-  (let ((element (ellama-context-element-buffer :name "*scratch*")))
-    (should (equal "```emacs-lisp\n(display-buffer \"*scratch*\")\n```\n"
-                   (ellama-context-element-format element 'markdown-mode)))))
-
-(ert-deftest test-ellama-context-element-format-buffer-org-mode ()
-  (let ((element (ellama-context-element-buffer :name "*scratch*")))
-    (should (equal "[[elisp:(display-buffer \"*scratch*\")][*scratch*]]"
-                   (ellama-context-element-format element 'org-mode)))))
-
-(ert-deftest test-ellama-context-element-format-file-markdown ()
-  (let ((element (ellama-context-element-file :name "LICENSE")))
-    (should (equal "[LICENSE](<LICENSE>)"
-                   (ellama-context-element-format element 'markdown-mode)))))
-
-(ert-deftest test-ellama-context-element-format-file-org-mode ()
-  (let ((element (ellama-context-element-file :name "LICENSE")))
-    (should (equal "[[file:LICENSE][LICENSE]]"
-                   (ellama-context-element-format element 'org-mode)))))
-
-(ert-deftest test-ellama-context-element-format-info-node-markdown ()
-  (let ((element (ellama-context-element-info-node :name "(dir)Top")))
-    (should (equal "```emacs-lisp\n(info \"(dir)Top\")\n```\n"
-                   (ellama-context-element-format element 'markdown-mode)))))
-
-(ert-deftest test-ellama-context-element-format-info-node-org-mode ()
-  (let ((element (ellama-context-element-info-node :name "(dir)Top")))
-    (should (equal "[[(dir)Top][(dir)Top]]"
-                   (ellama-context-element-format element 'org-mode)))))
-
-(ert-deftest test-ellama-context-element-format-text-markdown ()
-  (let ((element (ellama-context-element-text :content "123")))
-    (should (equal "123" (ellama-context-element-format element 
'markdown-mode)))))
-
-(ert-deftest test-ellama-context-element-format-text-org-mode ()
-  (let ((element (ellama-context-element-text :content "123")))
-    (should (equal "123" (ellama-context-element-format element 'org-mode)))))
-
-(ert-deftest 
test-ellama-context-element-format-webpage-quote-disabled-markdown ()
-  (let ((element (ellama-context-element-webpage-quote :name "test name" :url 
"https://example.com/"; :content "1\n\n2"))
-       (ellama-show-quotes nil))
-    (should (string-match "\\[test 
name\\](https://example.com/):\n```emacs-lisp\n(display-buffer 
\"\\*ellama-quote-.+\\*\")\n```\n" (ellama-context-element-format element 
'markdown-mode)))))
-
-(ert-deftest test-ellama-context-element-format-webpage-quote-enabled-markdown 
()
-  (let ((element (ellama-context-element-webpage-quote :name "test name" :url 
"https://example.com/"; :content "1\n\n2"))
-       (ellama-show-quotes t))
-    (should (equal "[test name](https://example.com/):
-> 1
-> 
-> 2
-
-"
-                  (ellama-context-element-format element 'markdown-mode)))))
-
-(ert-deftest 
test-ellama-context-element-format-webpage-quote-disabled-org-mode ()
-  (let ((element (ellama-context-element-webpage-quote :name "test name" :url 
"https://example.com/"; :content "1\n\n2"))
-       (ellama-show-quotes nil))
-    (should (string-match "\\[\\[https://example.com/\\]\\[test name\\]\\] 
\\[\\[elisp:(display-buffer \"\\*ellama-quote-.+\\*\")\\]\\[show\\]\\]" 
(ellama-context-element-format element 'org-mode)))))
-
-(ert-deftest test-ellama-context-element-format-webpage-quote-enabled-org-mode 
()
-  (let ((element (ellama-context-element-webpage-quote :name "test name" :url 
"https://example.com/"; :content "1\n\n* 2"))
-       (ellama-show-quotes t))
-    (should (equal "[[https://example.com/][test name]]:
-#+BEGIN_QUOTE
-1
-
- * 2
-#+END_QUOTE
-"
-                  (ellama-context-element-format element 'org-mode)))))
-
-(ert-deftest 
test-ellama-context-element-format-info-node-quote-disabled-markdown ()
-  (let ((element (ellama-context-element-info-node-quote :name "(emacs)Top" 
:content "1\n\n2"))
-       (ellama-show-quotes nil))
-    (should (string-match "```emacs-lisp\n(info 
\"(emacs)Top\")\n```\nshow:\n```emacs-lisp\n(display-buffer 
\"\\*ellama-quote-.+\\*\")\n```\n" (ellama-context-element-format element 
'markdown-mode)))))
-
-(ert-deftest 
test-ellama-context-element-format-info-node-quote-enabled-markdown ()
-  (let ((element (ellama-context-element-info-node-quote :name "(emacs)Top" 
:content "1\n\n2"))
-       (ellama-show-quotes t))
-    (should (equal "```emacs-lisp\n(info \"(emacs)Top\")\n```\n> 1\n> \n> 
2\n\n"
-                  (ellama-context-element-format element 'markdown-mode)))))
-
-(ert-deftest 
test-ellama-context-element-format-info-node-quote-disabled-org-mode ()
-  (let ((element (ellama-context-element-info-node-quote :name "(emacs)Top" 
:content "1\n\n2"))
-       (ellama-show-quotes nil))
-    (should (string-match "\\[\\[(emacs)Top\\]\\[(emacs)Top\\]\\] 
\\[\\[elisp:(display-buffer \"\\*ellama-quote-.+\\*\")\\]\\[show\\]\\]" 
(ellama-context-element-format element 'org-mode)))))
-
-(ert-deftest 
test-ellama-context-element-format-info-node-quote-enabled-org-mode ()
-  (let ((element (ellama-context-element-info-node-quote :name "(emacs)Top" 
:content "1\n\n* 2"))
-       (ellama-show-quotes t))
-    (should (equal "[[(emacs)Top][(emacs)Top]]:\n#+BEGIN_QUOTE\n1\n\n * 
2\n#+END_QUOTE\n"
-                  (ellama-context-element-format element 'org-mode)))))
-
-(ert-deftest test-ellama-context-element-format-file-quote-disabled-markdown ()
-  (let ((element (ellama-context-element-file-quote :path "/tmp/test.txt" 
:content "1\n\n2"))
-       (ellama-show-quotes nil))
-    (should (string-match 
"\\[/tmp/test.txt\\](/tmp/test.txt):\n```emacs-lisp\n(display-buffer 
\"\\*ellama-quote-.+\\*\")" (ellama-context-element-format element 
'markdown-mode)))))
-
-(ert-deftest test-ellama-context-element-format-file-quote-enabled-markdown ()
-  (let ((element (ellama-context-element-file-quote :path "/tmp/test.txt" 
:content "1\n\n2"))
-       (ellama-show-quotes t))
-    (should (equal "[/tmp/test.txt](/tmp/test.txt):
-> 1
-> 
-> 2
-
-"
-                  (ellama-context-element-format element 'markdown-mode)))))
-
-(ert-deftest test-ellama-context-element-format-file-quote-disabled-org-mode ()
-  (let ((element (ellama-context-element-file-quote :path "/tmp/test.txt" 
:content "1\n\n2"))
-       (ellama-show-quotes nil))
-    (should (string-match "\\[\\[/tmp/test.txt\\]\\[/tmp/test.txt\\]\\] 
\\[\\[elisp:(display-buffer \"\\*ellama-quote-.+\\*\")\\]\\[show\\]\\]" 
(ellama-context-element-format element 'org-mode)))))
-
-(ert-deftest test-ellama-context-element-format-file-quote-enabled-org-mode ()
-  (let ((element (ellama-context-element-file-quote :path "/tmp/test.txt" 
:content "1\n\n* 2"))
-       (ellama-show-quotes t))
-    (should (equal "[[/tmp/test.txt][/tmp/test.txt]]:
-#+BEGIN_QUOTE
-1
-
- * 2
-#+END_QUOTE
-"
-                  (ellama-context-element-format element 'org-mode)))))
-
-(ert-deftest test-ellama-context-element-extract-buffer ()
-  (with-temp-buffer
-    (insert "123")
-    (let ((element (ellama-context-element-buffer :name (buffer-name))))
-      (should (equal "123" (ellama-context-element-extract element))))))
-
-(ert-deftest test-ellama-context-element-extract-file ()
-  (let* ((filename (expand-file-name "LICENSE" (locate-dominating-file "." 
".git")))
-         (element (ellama-context-element-file :name filename)))
-    (should (string-match "GNU GENERAL PUBLIC LICENSE"
-                          (ellama-context-element-extract element)))))
-
-(ert-deftest test-ellama-context-element-extract-info-node ()
-  (let ((element (ellama-context-element-info-node :name "(dir)Top")))
-    (should (string-match "This" (ellama-context-element-extract element)))))
-
-(ert-deftest test-ellama-context-element-extract-text ()
-  (let ((element (ellama-context-element-text :content "123")))
-    (should (string-match "123" (ellama-context-element-extract element)))))
-
-(ert-deftest test-ellama-context-element-extract-webpage-quote ()
-  (let ((element (ellama-context-element-webpage-quote :content "123")))
-    (should (equal "123" (ellama-context-element-extract element)))))
-
-(ert-deftest test-ellama-context-element-extract-info-node-quote ()
-  (let ((element (ellama-context-element-info-node-quote :content "123")))
-    (should (equal "123" (ellama-context-element-extract element)))))
-
-(ert-deftest test-ellama-context-element-extract-file-quote ()
-  (let ((element (ellama-context-element-file-quote :content "123")))
-    (should (equal "123" (ellama-context-element-extract element)))))
-
-(ert-deftest test-ellama-context-element-display-buffer ()
-  (with-temp-buffer
-    (let ((element (ellama-context-element-buffer :name (buffer-name))))
-      (should (equal (buffer-name) (ellama-context-element-display 
element))))))
-
-(ert-deftest test-ellama-context-element-display-file ()
-  (let* ((filename (expand-file-name "LICENSE" (locate-dominating-file "." 
".git")))
-         (element (ellama-context-element-file :name filename)))
-    (should (equal (file-name-nondirectory filename) 
(ellama-context-element-display element)))))
-
-(ert-deftest test-ellama-context-element-display-info-node ()
-  (let ((element (ellama-context-element-info-node :name "(dir)Top")))
-    (should (equal "(info \"(dir)Top\")" (ellama-context-element-display 
element)))))
-
-(ert-deftest test-ellama-context-element-display-text ()
-  (let ((element (ellama-context-element-text :content "123")))
-    (should (equal "\"123...\"" (ellama-context-element-display element)))))
-
-(ert-deftest test-ellama-context-element-display-webpage-quote ()
-  (let ((element (ellama-context-element-webpage-quote :name "Example" :url 
"http://example.com"; :content "123")))
-    (should (equal "Example" (ellama-context-element-display element)))))
-
-(ert-deftest test-ellama-context-element-display-info-node-quote ()
-  (let ((element (ellama-context-element-info-node-quote :name "Example" 
:content "123")))
-    (should (equal "(info \"Example\")" (ellama-context-element-display 
element)))))
-
-(ert-deftest test-ellama-context-element-display-file-quote ()
-  (let ((element (ellama-context-element-file-quote :path "/path/to/file" 
:content "123")))
-    (should (equal "file" (ellama-context-element-display element)))))
-
-(ert-deftest test-ellama-context-element-extract-buffer-quote ()
-  (with-temp-buffer
-    (insert "123")
-    (let ((element (ellama-context-element-buffer-quote :name (buffer-name) 
:content "123")))
-      (should (equal "123" (ellama-context-element-extract element))))))
-
-(ert-deftest test-ellama-context-element-display-buffer-quote ()
-  (with-temp-buffer
-    (let ((element (ellama-context-element-buffer-quote :name (buffer-name) 
:content "123")))
-      (should (equal (buffer-name) (ellama-context-element-display 
element))))))
+      (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-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-md-to-org-code-simple ()
   (let ((result (ellama--translate-markdown-to-org-filter "Here is your TikZ 
code for a blue rectangle:
@@ -1029,211 +922,46 @@ region, season, or type)! 🍎🍊"))))
   (should (equal (ellama--string-without-last-two-lines "Line1\nLine2")
                  "")))
 
-(ert-deftest test-ellama--append-tool-error-to-prompt-uses-llm-message ()
-  (let (captured)
-    (cl-letf (((symbol-function 'llm-chat-prompt-append-response)
-              (lambda (_prompt msg role)
-                (setq captured (list msg role)))))
-      (ellama--append-tool-error-to-prompt
-       'prompt
-       "Unknown tool 'search' called"))
-    (should (equal captured
-                  '("Unknown tool 'search' called" system)))))
-
-(defun ellama-test--ensure-local-ellama-tools ()
-  "Ensure tests use local `ellama-tools.el' from project root."
-  (unless (fboundp 'ellama-tools--sanitize-tool-text-output)
-    (load-file (expand-file-name "ellama-tools.el" ellama-test-root))))
-
-(defun ellama-test--wait-shell-command-result (cmd)
-  "Run shell tool CMD and wait for a result string."
-  (ellama-test--ensure-local-ellama-tools)
-  (let ((result :pending)
-       (deadline (+ (float-time) 3.0)))
-    (ellama-tools-shell-command-tool
-     (lambda (res)
-       (setq result res))
-     cmd)
-    (while (and (eq result :pending)
-               (< (float-time) deadline))
-      (accept-process-output nil 0.01))
-    (when (eq result :pending)
-      (ert-fail (format "Timeout while waiting result for: %s" cmd)))
-    result))
-
-(defun ellama-test--named-tool-no-args ()
-  "Return constant string."
-  "zero")
-
-(defun ellama-test--named-tool-one-arg (arg)
-  "Return ARG with prefix."
-  (format "one:%s" arg))
-
-(defun ellama-test--named-tool-two-args (arg1 arg2)
-  "Return ARG1 and ARG2 with prefix."
-  (format "two:%s:%s" arg1 arg2))
-
-(defun ellama-test--make-confirm-wrapper-old (function)
-  "Make wrapper for FUNCTION using old confirm call style."
-  (lambda (&rest args)
-    (apply #'ellama-tools-confirm function args)))
-
-(defun ellama-test--make-confirm-wrapper-new (function name)
-  "Make wrapper for FUNCTION and NAME using wrapper factory."
-  (ellama-tools--make-confirm-wrapper function name))
-
-(defun ellama-test--invoke-confirm-with-yes (wrapper &rest args)
-  "Call WRAPPER with ARGS and auto-answer confirmation with yes.
-Return list with result and prompt."
-  (let ((ellama-tools-confirm-allowed (make-hash-table))
-        (ellama-tools-allow-all nil)
-        (ellama-tools-allowed nil)
-        result
-        prompt)
-    (cl-letf (((symbol-function 'read-char-choice)
-               (lambda (message _choices)
-                 (setq prompt message)
-                 ?y)))
-      (setq result (apply wrapper args)))
-    (list result prompt)))
-
-(ert-deftest test-ellama-shell-command-tool-empty-success-output ()
-  (should
-   (string=
-    (ellama-test--wait-shell-command-result "sh -c 'true'")
-    "Command completed successfully with no output.")))
-
-(ert-deftest test-ellama-shell-command-tool-empty-failure-output ()
-  (should
-   (string-match-p
-    "Command failed with exit code 7 and no output\\."
-    (ellama-test--wait-shell-command-result "sh -c 'exit 7'"))))
-
-(ert-deftest test-ellama-shell-command-tool-returns-stdout ()
-  (should
-   (string=
-    (ellama-test--wait-shell-command-result "printf 'ok\\n'")
-    "ok")))
-
-(ert-deftest test-ellama-shell-command-tool-rejects-binary-output ()
-  (should
-   (string-match-p
-    "binary data"
-    (ellama-test--wait-shell-command-result
-     "awk 'BEGIN { printf \"%c\", 0 }'"))))
-
-(ert-deftest test-ellama-read-file-tool-rejects-binary-content ()
-  (ellama-test--ensure-local-ellama-tools)
-  (let ((file (make-temp-file "ellama-read-file-bin-")))
-    (unwind-protect
-        (progn
-          (let ((coding-system-for-write 'no-conversion))
-            (with-temp-buffer
-              (set-buffer-multibyte nil)
-              (insert "%PDF-1.5\n%")
-              (insert (char-to-string 143))
-              (insert "\n")
-              (write-region (point-min) (point-max) file nil 'silent)))
-          (let ((result (ellama-tools-read-file-tool file)))
-            (should (string-match-p "binary data" result))
-            (should (string-match-p "bad idea" result))))
-      (when (file-exists-p file)
-        (delete-file file)))))
-
-(ert-deftest test-ellama-read-file-tool-accepts-utf8-markdown-text ()
-  (ellama-test--ensure-local-ellama-tools)
-  (let ((file (make-temp-file "ellama-read-file-utf8-" nil ".md")))
-    (unwind-protect
-        (progn
-          (with-temp-file file
-            (insert "# Research Plan\n\n")
-            (insert "Sub‑topics: temporal reasoning overview.\n"))
-          (let ((result (ellama-tools-read-file-tool file)))
-            (should-not (string-match-p "binary data" result))
-            (should (string-match-p "Research Plan" result))))
-      (when (file-exists-p file)
-        (delete-file file)))))
-
-(ert-deftest test-ellama-tools-confirm-wrapped-named-no-args-old-and-new ()
-  (ellama-test--ensure-local-ellama-tools)
-  (let* ((old-wrapper (ellama-test--make-confirm-wrapper-old
-                       #'ellama-test--named-tool-no-args))
-         (new-wrapper (ellama-test--make-confirm-wrapper-new
-                       #'ellama-test--named-tool-no-args
-                       "named_tool"))
-         (old-call (ellama-test--invoke-confirm-with-yes old-wrapper))
-         (new-call (ellama-test--invoke-confirm-with-yes new-wrapper)))
-    (should (equal (car old-call) "zero"))
-    (should (equal (car new-call) "zero"))
-    (should
-     (string-match-p
-      "Allow calling ellama-test--named-tool-no-args with arguments: \\?"
-      (cadr old-call)))
-    (should
-     (string-match-p
-      "Allow calling ellama-test--named-tool-no-args with arguments: \\?"
-      (cadr new-call)))))
-
-(ert-deftest test-ellama-tools-confirm-wrapped-named-one-arg-old-and-new ()
-  (ellama-test--ensure-local-ellama-tools)
-  (let* ((old-wrapper (ellama-test--make-confirm-wrapper-old
-                       #'ellama-test--named-tool-one-arg))
-         (new-wrapper (ellama-test--make-confirm-wrapper-new
-                       #'ellama-test--named-tool-one-arg
-                       "named_tool"))
-         (old-call (ellama-test--invoke-confirm-with-yes old-wrapper "A"))
-         (new-call (ellama-test--invoke-confirm-with-yes new-wrapper "A")))
-    (should (equal (car old-call) "one:A"))
-    (should (equal (car new-call) "one:A"))
-    (should
-     (string-match-p
-      "Allow calling ellama-test--named-tool-one-arg with arguments: A\\?"
-      (cadr old-call)))
-    (should
-     (string-match-p
-      "Allow calling ellama-test--named-tool-one-arg with arguments: A\\?"
-      (cadr new-call)))))
-
-(ert-deftest test-ellama-tools-confirm-wrapped-named-two-args-old-and-new ()
-  (ellama-test--ensure-local-ellama-tools)
-  (let* ((old-wrapper (ellama-test--make-confirm-wrapper-old
-                       #'ellama-test--named-tool-two-args))
-         (new-wrapper (ellama-test--make-confirm-wrapper-new
-                       #'ellama-test--named-tool-two-args
-                       "named_tool"))
-         (old-call (ellama-test--invoke-confirm-with-yes old-wrapper "A" "B"))
-         (new-call (ellama-test--invoke-confirm-with-yes new-wrapper "A" "B")))
-    (should (equal (car old-call) "two:A:B"))
-    (should (equal (car new-call) "two:A:B"))
-    (should
-     (string-match-p
-      "Allow calling ellama-test--named-tool-two-args with arguments: A, B\\?"
-      (cadr old-call)))
-    (should
-     (string-match-p
-      "Allow calling ellama-test--named-tool-two-args with arguments: A, B\\?"
-      (cadr new-call)))))
-
-(ert-deftest test-ellama-tools-confirm-prompt-uses-tool-name-for-lambda ()
-  (ellama-test--ensure-local-ellama-tools)
-  (let* ((ellama-tools-confirm-allowed (make-hash-table))
-         (ellama-tools-allow-all nil)
-         (ellama-tools-allowed nil)
-         (tool-plist `(:function ,(lambda (_arg) "ok")
-                       :name "mcp_tool"
-                       :args ((:name "arg" :type string))))
-         (wrapped (ellama-tools-wrap-with-confirm tool-plist))
-         (wrapped-func (plist-get wrapped :function))
-         seen-prompt)
-    (cl-letf (((symbol-function 'read-char-choice)
-               (lambda (prompt _choices)
-                 (setq seen-prompt prompt)
-                 ?n)))
-      (funcall wrapped-func "value"))
-    (should
-     (string-match-p
-      "Allow calling mcp_tool with arguments: value\\?"
-      seen-prompt))))
+
+(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-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"))))
 
 (provide 'test-ellama)
 


Reply via email to