feat(core): per-workspace Agent Skills listed in the system prompt, /skills in Telegram
Skills live in <cwd>/.claude/skills/<name>/SKILL.md (frontmatter name + description). SkillCatalog re-reads them on every run and the prompt lists name, description and path; the model reads the full SKILL.md with the read tool when a task matches.
This commit is contained in:
@@ -78,7 +78,10 @@ Read `docs/architecture.md` first. Key points:
|
||||
`~/.volcanic/agent/sessions/<scope>/` (`VOLCANIC_AGENT_DIR` overrides the root). Compaction entries replace older
|
||||
messages when context is built.
|
||||
- **System prompt** pulls project context from the working directory: `AGENTS.md`, or `CLAUDE.md` if there is no
|
||||
`AGENTS.md` (never both), plus `PERSONA.md` and `MEMORY.md`. File names match case-insensitively.
|
||||
`AGENTS.md` (never both), plus `PERSONA.md` and `MEMORY.md`. File names match case-insensitively. `SkillCatalog`
|
||||
lists the cwd's Agent Skills (`.claude/skills/<dir>/SKILL.md`, frontmatter `name`/`description`) in a `<skills>`
|
||||
section on every run; the model reads a SKILL.md with `read`. Only the cwd, so chats never see each other's skills.
|
||||
The Claude Code provider keeps the CLI's own skills off (`--setting-sources ""`, `--disable-slash-commands`).
|
||||
- **Extension discovery**: host code, then `<cwd>/.volcanic/settings.json` / `.volcanic/extensions/*.dll`, then the same
|
||||
under `~/.volcanic/agent`. Earlier sources win name collisions. Each assembly loads in its own
|
||||
`AssemblyLoadContext` and shares `Volcanic.AgenticCore.*`, `Microsoft.Extensions.*` and `System.*` with the host.
|
||||
@@ -167,7 +170,7 @@ Coolify API after pushing: `POST $COOLIFY_URL/api/v1/deploy?uuid=r8b5tgty5v5svat
|
||||
`test(...)`, `chore: ...`.
|
||||
- Design specs and implementation plans live in `docs/superpowers/specs/` and `docs/superpowers/plans/`. Update the
|
||||
relevant spec and the user docs in `docs/` when behaviour changes.
|
||||
- By design, the port leaves out pi's terminal UI, themes, keybindings, skills/prompt templates, OAuth logins and
|
||||
- By design, the port leaves out pi's terminal UI, themes, keybindings, prompt templates, OAuth logins and
|
||||
session-tree navigation commands.
|
||||
|
||||
<!-- release-notes-config
|
||||
|
||||
@@ -105,9 +105,10 @@ event model; slash commands; provider registration; API key resolution; built-in
|
||||
|
||||
Beyond pi: the working directory may also hold `PERSONA.md` (the agent's personality) and `MEMORY.md` (memories
|
||||
the agent maintains itself with `edit`/`write`); both are appended to the system prompt when present. File names
|
||||
match case-insensitively.
|
||||
match case-insensitively. Agent Skills (Anthropic's `SKILL.md` format) in `<cwd>/.claude/skills/<name>/SKILL.md` are
|
||||
listed by name and description in the system prompt on every run; the model reads a skill's file when it needs it.
|
||||
|
||||
Not ported (by design): the terminal UI, themes, keybindings, renderers, skills and prompt templates,
|
||||
Not ported (by design): the terminal UI, themes, keybindings, renderers, prompt templates,
|
||||
OAuth logins, image generation models, session tree navigation commands.
|
||||
|
||||
License: MIT. Fixtures under `tests/.../ClaudeCode/Fixtures` are from pi-claude-code-provider (MIT).
|
||||
|
||||
@@ -146,7 +146,7 @@ The compose file binds them to directories under `/media/disk1/containers/volcan
|
||||
|
||||
| Volume | Path | Contents |
|
||||
|---|---|---|
|
||||
| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/<chatId>`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/`, the Google Calendar token (`.volcanic/google-calendar/token.json`), scheduled jobs (`.volcanic/scheduler/jobs.json`), A2A callers and tasks, remote agents and watches (`.volcanic/a2a/`, [a2a.md](a2a.md#files), [a2a.md](a2a.md#client)) |
|
||||
| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/<chatId>`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, skills (`.claude/skills/<name>/SKILL.md`), `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/`, the Google Calendar token (`.volcanic/google-calendar/token.json`), scheduled jobs (`.volcanic/scheduler/jobs.json`), A2A callers and tasks, remote agents and watches (`.volcanic/a2a/`, [a2a.md](a2a.md#files), [a2a.md](a2a.md#client)) |
|
||||
| `agent` | `/data/agent` | Sessions (`sessions/telegram-<chatId>/`), `settings.json`, `extensions/*.dll`, chat time zones (`telegram/chat-settings.json`); a `scheduler/jobs.json` from before jobs were per chat is moved into the workspaces on start |
|
||||
| `claude` | `/data/claude` | The Claude login (credentials and onboarding state) |
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section
|
||||
| `TELEGRAM_BOT_TOKEN` / `Telegram:BotToken` | — | Required |
|
||||
| `TELEGRAM_ALLOWED_USER_IDS` / `Telegram:AllowedUserIds` | — | Required, comma separated. The bot refuses to start without it |
|
||||
| `ADMIN_USER_ID` / `Telegram:AdminUserId` | — (off) | The Telegram user id that may use `/impersonate` (see [Impersonation](#impersonation)). Need not be on the allow-list. Must be positive |
|
||||
| `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `<id>` works in `<root>/<id>`; put `AGENTS.md`, `PERSONA.md`, `MEMORY.md` there |
|
||||
| `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `<id>` works in `<root>/<id>`; put `AGENTS.md`, `PERSONA.md`, `MEMORY.md` and `.claude/skills/` there |
|
||||
| `Telegram:Model` | `claude-code/claude-opus-5-5` | Any `provider/model` the Claude Code provider serves |
|
||||
| `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of the "Working..." status message |
|
||||
| `Telegram:InputDebounce` | `00:00:00.6` | How long the bot waits after a message for more before sending them to the agent as one request. `0` disables the wait; messages still being read together (e.g. a photo still downloading and a text after it) are still sent as one request. Must not be negative |
|
||||
@@ -179,6 +179,24 @@ as photos (up to 10 MB), other files as documents (up to 50 MB), with an optiona
|
||||
photo Telegram rejects (for example for its dimensions) is resent as a document. `send` accepts only files inside the
|
||||
chat's workspace and delivers only to that chat.
|
||||
|
||||
### Skills
|
||||
|
||||
Each chat can have its own [Agent Skills](https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview) in its
|
||||
workspace: `<workspace>/.claude/skills/<name>/SKILL.md`, a YAML frontmatter with `name` and `description` followed by
|
||||
the instructions, with scripts or reference files next to it if needed. The system prompt lists every skill's name,
|
||||
description and path, re-read on every run, so a skill the agent (or you) just wrote is used from the next message
|
||||
on, no `/new` needed. The model reads the full SKILL.md with `read` when a task matches. Skills without a
|
||||
`description` are skipped. A chat only sees the skills in its own workspace. `/skills` lists them.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: trip-planner
|
||||
description: Plans trips and books hotels. Use when the user asks about travel.
|
||||
---
|
||||
1. Ask for dates and budget first.
|
||||
...
|
||||
```
|
||||
|
||||
### Scheduling
|
||||
|
||||
Ask the agent in plain words. It has a `schedule` tool with two kinds of job:
|
||||
|
||||
@@ -8,6 +8,7 @@ using Volcanic.AgenticCore.A2A.Server.Tasks;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Extensions.A2A;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Features.SystemPrompt;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
@@ -29,6 +30,7 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
|
||||
new() { Command = "compact", Description = "Summarise the conversation to free context" },
|
||||
new() { Command = "thinking", Description = "Show or set the thinking level" },
|
||||
new() { Command = "timezone", Description = "Show or set this chat's time zone" },
|
||||
new() { Command = "skills", Description = "Skills in this chat's workspace" },
|
||||
.. SchedulerSetup.Menu,
|
||||
TriggersSetup.MenuCommand,
|
||||
new() { Command = "a2a", Description = "A2A callers, remote agents and tasks of this chat" },
|
||||
@@ -122,6 +124,10 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
|
||||
await ReplyAsync(chat, triggers is null ? TriggersSetup.DisabledReply : "**Group triggers**\n" + triggers.Describe(chat.ChatId, chat.Session.TimeZone), cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
|
||||
case "skills":
|
||||
await ReplyAsync(chat, Skills(chat), cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
|
||||
case "jobs":
|
||||
await ReplyAsync(chat, Jobs(chat), cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
@@ -157,6 +163,7 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
|
||||
/compact – summarise the conversation to free context
|
||||
/thinking [level] – show or set the thinking level
|
||||
/timezone [IANA name|reset] – this chat's time zone
|
||||
/skills – skills in my workspace
|
||||
""" + SchedulerSetup.HelpLines + "\n/a2a – A2A callers, remote agents and recent tasks of this chat" + TriggersSetup.HelpLines + GoogleCalendarSetup.HelpLines(chat.Session.Commands)
|
||||
+ (chat.Session.Commands.Any(c => c.Name == "mcp_add") ? RemoteMcpSetup.HelpLines : "");
|
||||
|
||||
@@ -216,6 +223,14 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
|
||||
: error;
|
||||
}
|
||||
|
||||
private static string Skills(ChatContext chat)
|
||||
{
|
||||
var skills = SkillCatalog.Load(chat.Session.Cwd);
|
||||
var where = $"`{Path.GetRelativePath(chat.Session.Cwd, SkillCatalog.Directory(chat.Session.Cwd))}/<name>/SKILL.md`";
|
||||
if (skills.Count == 0) return $"No skills yet. Ask me to write one, or put it in {where}.";
|
||||
return $"**Skills** ({where})\n" + string.Join('\n', skills.Select(s => $"• **{s.Name}** – {s.Description}"));
|
||||
}
|
||||
|
||||
private string Jobs(ChatContext chat)
|
||||
{
|
||||
if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply;
|
||||
|
||||
@@ -27,4 +27,13 @@ public static class DefaultPrompts
|
||||
To remember something for future sessions, update that file with the edit or write tool. Keep it concise,
|
||||
organised, and current: merge duplicates and remove entries that are no longer true.
|
||||
""";
|
||||
|
||||
public static string SkillsPreamble(string directory) => $"""
|
||||
Skills are reusable instructions for specific tasks, one per directory: {directory}/<skill-name>/SKILL.md.
|
||||
A SKILL.md starts with YAML frontmatter (name, and a description of what the skill does and when to use it),
|
||||
followed by the instructions; scripts and reference files may sit next to it.
|
||||
When a task matches a skill's description, read its SKILL.md with the read tool before you start and follow it.
|
||||
Resolve relative paths in a skill against the skill's directory.
|
||||
To create or improve a skill, write or edit its SKILL.md; the list below is re-read on every run.
|
||||
""";
|
||||
}
|
||||
|
||||
126
src/Volcanic.AgenticCore/Features/SystemPrompt/SkillCatalog.cs
Normal file
126
src/Volcanic.AgenticCore/Features/SystemPrompt/SkillCatalog.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Volcanic.AgenticCore.Features.SystemPrompt;
|
||||
|
||||
/// <summary>A skill found in the workspace: frontmatter name and description, and the absolute path of its SKILL.md.</summary>
|
||||
public sealed record Skill(string Name, string Description, string Path);
|
||||
|
||||
/// <summary>
|
||||
/// Agent Skills (Anthropic's format) from the working directory only: <c><cwd>/.claude/skills/<dir>/SKILL.md</c>,
|
||||
/// a YAML frontmatter with <c>name</c> and <c>description</c>, then the instructions. Scanned on every call, so a skill
|
||||
/// written by the agent shows up in the next run's system prompt. Skills without a description are skipped; the name
|
||||
/// defaults to the directory name. The model only sees the list and reads a SKILL.md itself (progressive disclosure).
|
||||
/// </summary>
|
||||
public static class SkillCatalog
|
||||
{
|
||||
public const int MaxSkills = 100;
|
||||
public const int MaxDescriptionLength = 1024;
|
||||
private const int MaxFrontmatterBytes = 16 * 1024;
|
||||
|
||||
public static string Directory(string cwd) => System.IO.Path.Combine(cwd, ".claude", "skills");
|
||||
|
||||
public static IReadOnlyList<Skill> Load(string cwd)
|
||||
{
|
||||
var root = Directory(cwd);
|
||||
IEnumerable<string> dirs;
|
||||
try
|
||||
{
|
||||
if (!System.IO.Directory.Exists(root)) return [];
|
||||
dirs = System.IO.Directory.EnumerateDirectories(root).Order(StringComparer.Ordinal).ToList();
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return []; }
|
||||
|
||||
var skills = new List<Skill>();
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
if (skills.Count >= MaxSkills) break;
|
||||
var file = SystemPromptBuilder.FindFile(dir, "SKILL.md");
|
||||
if (file is null || Parse(file, System.IO.Path.GetFileName(dir)) is not { } skill) continue;
|
||||
if (skills.Any(s => string.Equals(s.Name, skill.Name, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
skills.Add(skill);
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
/// <summary>Reads the frontmatter of one SKILL.md. Null when it is unreadable or has no description.</summary>
|
||||
public static Skill? Parse(string path, string fallbackName)
|
||||
{
|
||||
string text;
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(path);
|
||||
var buffer = new char[MaxFrontmatterBytes];
|
||||
text = new string(buffer, 0, reader.ReadBlock(buffer, 0, buffer.Length));
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return null; }
|
||||
|
||||
var fields = Frontmatter(text);
|
||||
var name = fields.GetValueOrDefault("name");
|
||||
var description = fields.GetValueOrDefault("description");
|
||||
if (string.IsNullOrWhiteSpace(description)) return null;
|
||||
description = Collapse(description);
|
||||
if (description.Length > MaxDescriptionLength) description = description[..MaxDescriptionLength] + "…";
|
||||
return new Skill(string.IsNullOrWhiteSpace(name) ? fallbackName : Collapse(name), description, path);
|
||||
}
|
||||
|
||||
/// <summary>The prompt section: where skills live, how to use and write them, and the list.</summary>
|
||||
public static string Render(string cwd, IReadOnlyList<Skill> skills)
|
||||
{
|
||||
var sb = new StringBuilder(DefaultPrompts.SkillsPreamble(Directory(cwd)));
|
||||
if (skills.Count == 0) return sb.Append("\n\nNo skills yet.").ToString();
|
||||
sb.Append("\n\n<available_skills>");
|
||||
foreach (var skill in skills)
|
||||
{
|
||||
sb.Append("\n <skill>\n <name>").Append(Escape(skill.Name)).Append("</name>\n <description>")
|
||||
.Append(Escape(skill.Description)).Append("</description>\n <location>").Append(Escape(skill.Path))
|
||||
.Append("</location>\n </skill>");
|
||||
}
|
||||
return sb.Append("\n</available_skills>").ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Top-level <c>key: value</c> pairs between the leading <c>---</c> lines. Handles quoted values, <c>|</c>/<c>></c>
|
||||
/// block scalars and indented continuation lines; anything else YAML can do is ignored.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, string> Frontmatter(string text)
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var lines = text.Replace("\r\n", "\n").Split('\n');
|
||||
if (lines.Length == 0 || lines[0].Trim() != "---") return fields;
|
||||
|
||||
string? key = null;
|
||||
var value = new StringBuilder();
|
||||
void Flush()
|
||||
{
|
||||
if (key is not null) fields[key] = Unquote(value.ToString().Trim());
|
||||
key = null;
|
||||
value.Clear();
|
||||
}
|
||||
|
||||
for (var i = 1; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
if (line.Trim() == "---") break;
|
||||
if (line.Length > 0 && !char.IsWhiteSpace(line[0]) && line.IndexOf(':') is var colon and > 0)
|
||||
{
|
||||
Flush();
|
||||
key = line[..colon].Trim();
|
||||
var rest = line[(colon + 1)..].Trim();
|
||||
if (rest is not ("|" or ">" or "|-" or ">-" or "|+" or ">+")) value.Append(rest);
|
||||
}
|
||||
else if (key is not null && line.Trim().Length > 0)
|
||||
{
|
||||
value.Append(value.Length > 0 ? " " : "").Append(line.Trim());
|
||||
}
|
||||
}
|
||||
Flush();
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static string Unquote(string value) =>
|
||||
value.Length >= 2 && (value[0] == '"' && value[^1] == '"' || value[0] == '\'' && value[^1] == '\'') ? value[1..^1] : value;
|
||||
|
||||
private static string Collapse(string value) => string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
private static string Escape(string value) => value.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace Volcanic.AgenticCore.Features.SystemPrompt;
|
||||
/// <summary>
|
||||
/// Composes the system prompt: base prompt, tools section (from <see cref="ITool.PromptSnippet"/>), guidelines,
|
||||
/// extra sections, project context files (AGENTS.md, else CLAUDE.md, per directory walking up from cwd), the agent's
|
||||
/// persona (PERSONA.md in cwd), its memory (MEMORY.md in cwd), then date, time zone and cwd. File names match case-insensitively.
|
||||
/// persona (PERSONA.md in cwd), its memory (MEMORY.md in cwd), its skills (<see cref="SkillCatalog"/>), then date, time zone and cwd. File names match case-insensitively.
|
||||
/// </summary>
|
||||
public sealed class SystemPromptBuilder
|
||||
{
|
||||
@@ -68,6 +68,8 @@ public sealed class SystemPromptBuilder
|
||||
// The memory section appears whenever the file exists, even empty, so the agent knows where to write.
|
||||
var memory = ReadWorkspaceFile(DefaultPrompts.MemoryFile);
|
||||
if (memory.Path is not null) AppendSection(sb, "memory", DefaultPrompts.MemoryPreamble(memory.Path) + (string.IsNullOrWhiteSpace(memory.Content) ? "" : "\n\n" + memory.Content));
|
||||
|
||||
AppendSection(sb, "skills", SkillCatalog.Render(_cwd, SkillCatalog.Load(_cwd)));
|
||||
}
|
||||
|
||||
AppendSection(sb, "environment", EnvironmentSection(_time.GetUtcNow(), _zone(), _cwd));
|
||||
@@ -113,7 +115,7 @@ public sealed class SystemPromptBuilder
|
||||
return path is null ? (null, null) : (path, TryRead(path, MaxProjectContextBytes));
|
||||
}
|
||||
|
||||
private static string? FindFile(string dir, string name)
|
||||
internal static string? FindFile(string dir, string name)
|
||||
{
|
||||
var exact = Path.Combine(dir, name);
|
||||
if (File.Exists(exact)) return exact;
|
||||
|
||||
48
tests/Volcanic.AgenticCore.Telegram.Tests/SkillsChatTests.cs
Normal file
48
tests/Volcanic.AgenticCore.Telegram.Tests/SkillsChatTests.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
public sealed class SkillsChatTests
|
||||
{
|
||||
private static void WriteSkill(string workspace, string dir, string name, string description)
|
||||
{
|
||||
var path = Path.Combine(workspace, ".claude", "skills", dir);
|
||||
Directory.CreateDirectory(path);
|
||||
File.WriteAllText(Path.Combine(path, "SKILL.md"), $"---\nname: {name}\ndescription: {description}\n---\nInstructions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skills_lists_only_this_chats_workspace_skills()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
await host.SendAsync("/skills");
|
||||
Assert.Equal("No skills yet. Ask me to write one, or put it in .claude/skills/<name>/SKILL.md.", host.LastReply());
|
||||
|
||||
WriteSkill(host.WorkspaceOf(), "trips", "trip-planner", "Plans trips");
|
||||
WriteSkill(host.WorkspaceOf(2002), "secret", "other-chat", "Belongs to another chat");
|
||||
await host.SendAsync("/skills");
|
||||
|
||||
Assert.Equal("Skills (.claude/skills/<name>/SKILL.md)\n• trip-planner – Plans trips", host.LastReply());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_model_sees_skills_written_during_the_session_on_the_next_run()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
host.Faux.Enqueue(FauxTurn.Say("one"), FauxTurn.Say("two"));
|
||||
await host.SendAsync("hi");
|
||||
await host.SettleAsync();
|
||||
Assert.Contains("No skills yet.", host.Faux.Requests.Last().SystemPrompt);
|
||||
|
||||
WriteSkill(host.WorkspaceOf(), "trips", "trip-planner", "Plans trips");
|
||||
WriteSkill(host.WorkspaceOf(2002), "secret", "other-chat", "Belongs to another chat");
|
||||
await host.SendAsync("again");
|
||||
await host.SettleAsync();
|
||||
|
||||
var prompt = host.Faux.Requests.Last().SystemPrompt!;
|
||||
Assert.Contains("<name>trip-planner</name>", prompt);
|
||||
Assert.Contains(Path.Combine(host.WorkspaceOf(), ".claude", "skills", "trips", "SKILL.md"), prompt);
|
||||
Assert.DoesNotContain("other-chat", prompt);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,60 @@ public class SystemPromptBuilderTests
|
||||
}
|
||||
}
|
||||
|
||||
public class SkillCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void Lists_workspace_skills_and_picks_up_new_ones_on_the_next_render()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N"));
|
||||
var skills = Path.Combine(dir, ".claude", "skills");
|
||||
Directory.CreateDirectory(Path.Combine(skills, "pdf"));
|
||||
Directory.CreateDirectory(Path.Combine(skills, "no-description"));
|
||||
Directory.CreateDirectory(Path.Combine(skills, "folded"));
|
||||
File.WriteAllText(Path.Combine(skills, "pdf", "SKILL.md"), "---\nname: pdf-tools\ndescription: \"Fill <PDF> forms\"\n---\n# Body stays out");
|
||||
File.WriteAllText(Path.Combine(skills, "no-description", "SKILL.md"), "---\nname: nope\n---\nbody");
|
||||
File.WriteAllText(Path.Combine(skills, "folded", "skill.md"), "---\ndescription: >\n Plans trips\n and books hotels\n---\n");
|
||||
try
|
||||
{
|
||||
var builder = new SystemPromptBuilder(dir, () => [], new FakeTimeProvider(DateTimeOffset.UnixEpoch), () => TimeZoneInfo.Utc);
|
||||
var prompt = builder.Render(builder.DefaultOptions("BASE"));
|
||||
|
||||
Assert.Contains($"{skills}/<skill-name>/SKILL.md", prompt);
|
||||
Assert.Contains("<name>pdf-tools</name>\n <description>Fill <PDF> forms</description>\n <location>" + Path.Combine(skills, "pdf", "SKILL.md"), prompt);
|
||||
Assert.Contains("<name>folded</name>\n <description>Plans trips and books hotels</description>", prompt);
|
||||
Assert.DoesNotContain("nope", prompt);
|
||||
Assert.DoesNotContain("Body stays out", prompt);
|
||||
var order = new[] { "<skills>", "<environment>" }.Select(t => prompt.IndexOf(t, StringComparison.Ordinal)).ToList();
|
||||
Assert.Equal(order.Order(), order);
|
||||
|
||||
Directory.CreateDirectory(Path.Combine(skills, "zeta"));
|
||||
File.WriteAllText(Path.Combine(skills, "zeta", "SKILL.md"), "---\nname: zeta\ndescription: Added mid-session\n---\n");
|
||||
Assert.Contains("<name>zeta</name>", builder.Render(builder.DefaultOptions("BASE")));
|
||||
Assert.Equal(["folded", "pdf-tools", "zeta"], SkillCatalog.Load(dir).Select(s => s.Name));
|
||||
|
||||
var narrow = builder.DefaultOptions("BASE");
|
||||
narrow.IncludeProjectContext = false;
|
||||
Assert.DoesNotContain("<skills>", builder.Render(narrow));
|
||||
}
|
||||
finally { Directory.Delete(dir, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_skills_the_section_still_says_where_to_write_them()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
Assert.Empty(SkillCatalog.Load(dir));
|
||||
var section = SkillCatalog.Render(dir, []);
|
||||
Assert.Contains(Path.Combine(dir, ".claude", "skills"), section);
|
||||
Assert.EndsWith("No skills yet.", section);
|
||||
}
|
||||
finally { Directory.Delete(dir, true); }
|
||||
}
|
||||
}
|
||||
|
||||
public class CompactorTests
|
||||
{
|
||||
private static AssistantMessage Assistant(string text, int? usageTokens = null) => new()
|
||||
|
||||
Reference in New Issue
Block a user