aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-25 19:17:22 +0200
committerDanilo M. <danix@danix.xyz>2026-08-25 19:17:22 +0200
commit728d15912899d9c66838f0937931f61d75d217d4 (patch)
tree6e218ba3adc540be47654f25695ad5aca0b0bb1f
parent6ae195baa09bc217d51f3fb0a38312dfdcc85fdb (diff)
downloadllamachat-728d15912899d9c66838f0937931f61d75d217d4.tar.gz
llamachat-728d15912899d9c66838f0937931f61d75d217d4.zip
fix: keep one-shot skills loaded until NewHEADmaster
-rw-r--r--CHANGELOG.md8
-rw-r--r--README.md7
-rw-r--r--docs/superpowers/specs/2026-08-25-skills-design.md5
-rw-r--r--llamachat/ui.py8
-rwxr-xr-xtest_llamachat.py64
5 files changed, 78 insertions, 14 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb8fb42..c4f6473 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -76,10 +76,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Skills. Instruction files read from `~/.agents/skills/*/SKILL.md` can be
loaded into the model's context on demand: the model calls a `load_skill`
tool, or you type `/skill-name` or mention a skill's name. Loaded skills
- stay for the whole session in chat mode, drop after the reply in one-shot
- mode, and show as removable chips. The backend tool loop now dispatches
- between `web_search` and `load_skill`; `max_searches` caps total tool
- rounds per turn.
+ show as removable chips and stay loaded for the whole session in chat
+ mode; one-shot mode keeps them in memory until New or the chip is clicked,
+ never in history. The backend tool loop now dispatches between `web_search`
+ and `load_skill`; `max_searches` caps total tool rounds per turn.
### Fixed
diff --git a/README.md b/README.md
index 349c21d..3e05f00 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,8 @@ persistent process and toggles like a scratchpad from a Hyprland keybind.
loaded into the model's context on demand. The model can call a
`load_skill` tool when a task matches one; you can type `/skill-name` or
mention a skill's name. Loaded skills show as removable chips, stay for
- the whole session in chat mode, and drop after the reply in one-shot mode.
+ the whole session in chat mode, and in one-shot mode stay in memory until
+ New or the chip is clicked (never written to history).
- **Tray icon** for show/hide/quit, hosted by waybar's tray module.
Not in this version: RAG or embedding search over history, multi-user
@@ -491,7 +492,9 @@ A skill is loaded into the model's context on demand, three ways:
- You mention a skill's name anywhere in the message.
Loaded skills show as removable chips above the input. They stay loaded for
-the whole session in chat mode and drop after the reply in one-shot mode.
+the whole session in chat mode. One-shot mode keeps them in memory (never in
+history) until New is pressed or the chip is clicked, so a skill loaded in a
+scratch window stays active for the rest of it.
`max_searches` caps the total tool rounds per turn here too, so skills load
and searches share the same per-turn budget.
diff --git a/docs/superpowers/specs/2026-08-25-skills-design.md b/docs/superpowers/specs/2026-08-25-skills-design.md
index 20a3c0f..99baee6 100644
--- a/docs/superpowers/specs/2026-08-25-skills-design.md
+++ b/docs/superpowers/specs/2026-08-25-skills-design.md
@@ -13,8 +13,9 @@ with `name` and `description`, then the instructions.
This adds a skills subsystem: llamachat reads that directory, lets the model
load a skill on demand through a tool call, and lets the user load one by
typing `/name` or mentioning the name naturally. A loaded skill's text stays
-in context for the whole conversation in chat mode, and for the single reply
-in one-shot mode.
+in context for the whole conversation in chat mode. One-shot mode keeps it
+in memory (never in history) until New or the chip is clicked, so a skill
+loaded in a scratch window stays active for the rest of it.
## Skills directory and format
diff --git a/llamachat/ui.py b/llamachat/ui.py
index 58c0156..db705c3 100644
--- a/llamachat/ui.py
+++ b/llamachat/ui.py
@@ -1301,8 +1301,8 @@ class ChatWindow(QMainWindow):
"""Record a skill as loaded; True when it was not already present.
Persisted to the session in chat mode so reopening the conversation
- restores it. One-shot mode keeps the set in memory only; it is
- cleared when the reply finishes.
+ restores it. One-shot mode keeps the set in memory only: the chip
+ stays until New is pressed or the chip is clicked.
"""
if not self.cfg.skills_enabled or name in self.loaded_skills:
return False
@@ -1590,10 +1590,6 @@ class ChatWindow(QMainWindow):
@Slot()
def _on_stream_finished(self) -> None:
- # One-shot mode: a skill loaded mid-reply is gone when the reply ends.
- if self.mode == MODE_ONESHOT:
- self.loaded_skills = []
- self._refresh_skill_chips()
if self.assistant_message_id is not None:
self.history.update_message(
self.assistant_message_id,
diff --git a/test_llamachat.py b/test_llamachat.py
index 71a2c35..8fb7a0e 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -3773,6 +3773,69 @@ def test_skill_parts():
print("ok skill parts")
+def test_oneshot_skill_persists_until_new():
+ """A loaded skill survives a one-shot reply and is cleared by New.
+
+ One-shot used to drop skills when the reply finished, which made the
+ loaded-skill chip flash and vanish. The chip is the indicator that a
+ skill is active, so it must stay until the user clears it or starts a
+ new conversation.
+ """
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+
+ from llamachat import backend as _backend
+ from llamachat import config as _config
+ from llamachat import models as _models
+ from llamachat.ui import ChatWindow
+
+ app = QApplication.instance() or QApplication([])
+
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp = Path(tmp)
+ skill_dir = tmp / "skills"
+ (skill_dir / "handoff" / "SKILL.md").parent.mkdir(parents=True)
+ (skill_dir / "handoff" / "SKILL.md").write_text(
+ "---\nname: handoff\ndescription: prepare a session handoff.\n"
+ "---\n\n# handoff\nWrite the handoff.\n"
+ )
+
+ cfg = _config.load(tmp / "config.toml")
+ cfg.prompts_dir = tmp / "prompts"
+ cfg.db_path = tmp / "t.db"
+ cfg.skills_dir = skill_dir
+ cfg.skills_enabled = True
+ history = db.History(cfg.db_path)
+ client = _backend.MultiClient(cfg.providers, cfg.request_timeout)
+ presets = _config.parse_presets(cfg.presets_path)
+ store = _models.ModelStore(cfg.models_path)
+ window = ChatWindow(cfg, history, client, presets, store)
+
+ window.session_id = history.create_session("oneshot", "m")
+ text = window._load_skills_from_text("prepare a handoff please")
+ assert text == "prepare a handoff please"
+ assert "handoff" in window.loaded_skills
+ assert window.skill_chips, "a loaded skill must show a chip"
+
+ # A finished one-shot reply must keep the skill loaded until New.
+ window.assistant_message_id = history.add_message(
+ window.session_id, "assistant", "here is the handoff"
+ )
+ window.assistant_buffer = "here is the handoff"
+ window._on_stream_finished()
+ assert "handoff" in window.loaded_skills, "one-shot must keep the skill"
+ assert window.skill_chips, "the chip must stay after a one-shot reply"
+
+ # New drops the skill and the chip.
+ window.new_session()
+ assert window.loaded_skills == []
+ assert not window.skill_chips
+
+ window.close()
+ history.close()
+ print("ok oneshot skill persists until new")
+
+
if __name__ == "__main__":
test_presets()
test_real_presets()
@@ -3848,4 +3911,5 @@ if __name__ == "__main__":
test_skills_db()
test_skills_column_migration()
test_skill_parts()
+ test_oneshot_skill_persists_until_new()
print("\nall checks passed")