feat(skills): session-start and turn-end hooks and protected paths declared in SKILL.md

A skill's SKILL.md frontmatter may declare session-start (output into
<skill_session_context>, at session start, /new, resume and after compaction),
turn-end (after every run, in the background; output or a failure reaches the
next run as a hidden notice, repeats suppressed until a clean run) and
protected-paths (write/edit refused inside them). Hooks run from the skill
directory with VOLCANIC_WORKSPACE, VOLCANIC_SKILL_DIR and the chat's TZ; waits
are bounded by each hook's timeout and failures never break a run. /skills
shows declared hooks and broken fields.
This commit is contained in:
EugeneTes
2026-10-16 12:27:44 +00:00
parent d51438e665
commit 644f72068c
20 changed files with 1282 additions and 15 deletions

View File

@@ -26,6 +26,21 @@ public sealed class SkillsChatTests
Assert.Equal("Skills (.volcanic/skills/<name>/)\n• trip-planner – Plans trips", host.LastReply());
}
[Fact]
public async Task Skills_shows_declared_hooks_protected_paths_and_broken_hook_fields()
{
await using var host = new ChatHost();
var dir = Path.Combine(host.WorkspaceOf(), ".volcanic", "skills", "gtd");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "SKILL.md"),
"---\nname: gtd\ndescription: Tasks\nsession-start: gtd brief\nturn-end: gtd check --quiet\nturn-end-timeout: 500\nprotected-paths: Входящие/, Сегодня/\n---\n");
await host.SendAsync("/skills");
Assert.Equal("Skills (.volcanic/skills/<name>/)\n• gtd – Tasks (+ hooks: session-start; 2 protected paths)\n" +
" ⚠️ .volcanic/skills/gtd/SKILL.md: turn-end-timeout: \"500\" is not a whole number of seconds from 1 to 120", host.LastReply());
}
[Fact]
public async Task The_model_sees_skills_written_during_the_session_on_the_next_run()
{

View File

@@ -0,0 +1,367 @@
using System.Diagnostics;
using System.Text;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Skills;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Testing;
using Volcanic.AgenticCore.Tests.Support;
namespace Volcanic.AgenticCore.Tests.Features;
public class SkillHookFilesTests : IDisposable
{
private readonly string _cwd = Path.Combine(Path.GetTempPath(), "volcanic-skill-hooks", Guid.NewGuid().ToString("N"));
public SkillHookFilesTests() => Directory.CreateDirectory(_cwd);
public void Dispose() { try { Directory.Delete(_cwd, true); } catch (IOException) { } }
private SkillHookSet? Read(string frontmatter, string dir = "gtd")
{
var skillDir = Path.Combine(SkillFiles.Root(_cwd), dir);
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), $"---\n{frontmatter}\n---\nBody\n");
return SkillHookFiles.Read(_cwd, skillDir);
}
[Fact]
public void Reads_every_field_with_defaults_and_comments()
{
var set = Read("""
name: gtd
description: GTD
session-start: bin/gtd brief # start
turn-end: "bin/gtd check --quiet"
turn-end-timeout: 20
protected-paths: Входящие/, Сегодня , ./Проекты//
protected-hint: use gtd move
""")!;
Assert.Equal("gtd", set.Skill);
Assert.Equal(new SkillHookCommand("bin/gtd brief", TimeSpan.FromSeconds(30)), set.SessionStart);
Assert.Equal(new SkillHookCommand("bin/gtd check --quiet", TimeSpan.FromSeconds(20)), set.TurnEnd);
Assert.Equal(["Входящие", "Сегодня", "Проекты"], set.ProtectedPaths);
Assert.Equal("use gtd move", set.ProtectedHint);
Assert.Empty(set.Errors);
Assert.Equal("hooks: session-start, turn-end; 3 protected paths", set.Summary());
}
[Fact]
public void A_flow_list_with_quotes_works_and_NFD_becomes_NFC()
{
var nfd = "Мой".Normalize(NormalizationForm.FormD);
var set = Read($"description: x\nprotected-paths: [\"Входящие/\", '{nfd}/']")!;
Assert.Equal(["Входящие", "Мой".Normalize(NormalizationForm.FormC)], set.ProtectedPaths);
}
[Fact]
public void A_skill_without_the_fields_declares_nothing()
{
var set = Read("name: plain\ndescription: nothing else")!;
Assert.True(set.IsEmpty);
Assert.Empty(SkillHookFiles.ReadAll(_cwd));
}
[Theory]
[InlineData("session-start-timeout: 0\nsession-start: x", "session-start-timeout")]
[InlineData("turn-end-timeout: 121\nturn-end: x", "turn-end-timeout")]
[InlineData("turn-end-timeout: soon\nturn-end: x", "turn-end-timeout")]
[InlineData("turn-end-timeout: 10", "turn-end-timeout without turn-end")]
[InlineData("session-start: ", "the command is empty")]
[InlineData("protected-paths: /etc", "must be relative")]
[InlineData("protected-paths: ../other", "inside the workspace")]
[InlineData("protected-paths: .", "inside the workspace")]
[InlineData("protected-paths: .volcanic", "the skills themselves")]
[InlineData("protected-paths: .volcanic/skills/gtd", "the skills themselves")]
public void Broken_fields_are_reported_and_the_rest_still_works(string fields, string error)
{
var set = Read("description: x\nprotected-paths: Сегодня\n" + fields)!;
Assert.Contains(set.Errors, e => e.Contains(error, StringComparison.Ordinal));
Assert.False(set.IsEmpty);
if (!fields.StartsWith("protected-paths", StringComparison.Ordinal)) Assert.Equal(["Сегодня"], set.ProtectedPaths);
}
[Fact]
public void The_name_falls_back_to_the_directory()
{
Assert.Equal("dir-name", Read("turn-end: x", "dir-name")!.Skill);
}
}
public class SkillHooksSessionTests : IAsyncLifetime
{
private static readonly TimeZoneInfo Warsaw = TimeZoneInfo.FindSystemTimeZoneById("Europe/Warsaw");
private TestHost _host = null!;
public Task InitializeAsync()
{
_host = new TestHost();
return Task.CompletedTask;
}
public async Task DisposeAsync() => await _host.DisposeAsync();
private string SkillDir(string name = "gtd") => Path.Combine(SkillFiles.Root(_host.Cwd), name);
private void WriteSkill(string fields, string name = "gtd")
{
Directory.CreateDirectory(SkillDir(name));
File.WriteAllText(Path.Combine(SkillDir(name), "SKILL.md"), $"---\nname: {name}\ndescription: Test skill\n{fields}\n---\nBody\n");
}
/// <summary>Lets the background turn-end hook of the last run finish before the test changes what it reads.</summary>
private static Task Settle() => Task.Delay(TimeSpan.FromSeconds(1));
private Task<AgentSession> Session() => _host.CreateSessionAsync(b => b.TimeZone = Warsaw);
private string SystemPrompt(int request) => _host.Faux.Requests[request].SystemPrompt ?? "";
/// <summary>The turn-end notices the model got after the prompt of request <paramref name="request"/> (custom messages reach it as user messages).</summary>
private List<Message> Notices(int request)
{
static bool IsNotice(Message m) => m is UserMessage && m.Text.StartsWith("[Skill hook", StringComparison.Ordinal);
var messages = _host.Faux.Requests[request].Messages.ToList();
var prompt = messages.FindLastIndex(m => m is UserMessage && !IsNotice(m));
return messages.Skip(prompt + 1).Where(IsNotice).ToList();
}
[Fact]
public async Task Session_start_output_is_in_the_system_prompt_with_the_chat_zone_and_skill_environment()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("session-start: echo \"dir=$(pwd) skill=$VOLCANIC_SKILL_DIR ws=$VOLCANIC_WORKSPACE tz=$TZ\"");
_host.Faux.Enqueue(FauxTurn.Say("ok"));
await using var session = await Session();
await session.PromptAsync("hi");
var prompt = SystemPrompt(0);
Assert.Contains("<skill_session_context>", prompt);
Assert.Contains("<context skill=\"gtd\" command=\"echo &quot;dir=", prompt);
Assert.Contains("(Europe/Warsaw)\">", prompt);
var dir = Path.GetFullPath(SkillDir());
Assert.Contains($"dir={dir} skill={dir} ws={Path.GetFullPath(_host.Cwd)} tz=Europe/Warsaw", prompt);
Assert.True(prompt.IndexOf("<skill_session_context>", StringComparison.Ordinal) > prompt.IndexOf("<skills>", StringComparison.Ordinal));
}
[Fact]
public async Task Session_start_runs_once_per_session_and_again_after_new()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("session-start: echo run >> count.txt; wc -l < count.txt");
_host.Faux.Enqueue(FauxTurn.Say("1"), FauxTurn.Say("2"), FauxTurn.Say("3"));
await using var session = await Session();
await session.PromptAsync("a");
await session.PromptAsync("b");
Assert.Contains(">\n1\n</context>", SystemPrompt(1));
await session.NewSessionAsync();
await session.PromptAsync("c");
Assert.Contains(">\n2\n</context>", SystemPrompt(2));
}
[Fact]
public async Task A_failing_session_start_is_reported_and_the_session_answers()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("session-start: echo partial; echo broken >&2; exit 3");
_host.Faux.Enqueue(FauxTurn.Say("still here"));
await using var session = await Session();
await session.PromptAsync("hi");
var prompt = SystemPrompt(0);
Assert.Contains("partial\n[session-start hook failed: exit 3]\nbroken", prompt);
Assert.Equal("still here", session.Messages.OfType<AssistantMessage>().Last().Text);
}
[Fact]
public async Task A_slow_session_start_holds_the_first_run_only_until_its_timeout()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("session-start: sleep 20\nsession-start-timeout: 1");
_host.Faux.Enqueue(FauxTurn.Say("ok"));
await using var session = await Session();
var watch = Stopwatch.StartNew();
await session.PromptAsync("hi");
Assert.True(watch.Elapsed < TimeSpan.FromSeconds(5), watch.Elapsed.ToString());
Assert.Matches("session-start hook (still running after 1 s|failed: timed out after 1 s)", SystemPrompt(0));
}
[Fact]
public async Task Turn_end_output_reaches_the_next_run_once_and_again_after_a_clean_run()
{
if (OperatingSystem.IsWindows()) return;
// The hook reports whatever problem.txt says; an empty file means all is well.
WriteSkill("turn-end: cat \"$VOLCANIC_WORKSPACE/problem.txt\"");
var problem = Path.Combine(_host.Cwd, "problem.txt");
File.WriteAllText(problem, "Сегодня/x.md: no date");
_host.Faux.Enqueue(FauxTurn.Say("1"), FauxTurn.Say("2"), FauxTurn.Say("3"), FauxTurn.Say("4"), FauxTurn.Say("5"), FauxTurn.Say("6"));
await using var session = await Session();
await session.PromptAsync("first");
Assert.Empty(Notices(0));
await session.PromptAsync("second");
var notice = Assert.Single(Notices(1));
Assert.Equal("[Skill hook \"gtd\" turn-end (`cat \"$VOLCANIC_WORKSPACE/problem.txt\"`, exit 0) after your previous turn:]\nСегодня/x.md: no date", notice.Text);
var stored = Assert.Single(session.Messages.OfType<CustomMessage>());
Assert.Equal("skill_hook", stored.CustomType);
Assert.False(stored.Display);
await session.PromptAsync("third"); // the same problem again: quiet
Assert.Empty(Notices(2));
await Settle();
File.WriteAllText(problem, "");
await session.PromptAsync("fourth"); // gets the third run's (repeated) check: still quiet
Assert.Empty(Notices(3));
await Settle();
File.WriteAllText(problem, "Сегодня/x.md: no date");
await session.PromptAsync("fifth"); // gets the fourth run's clean check
Assert.Empty(Notices(4));
await session.PromptAsync("sixth"); // the problem came back after a clean check: news again
Assert.Single(Notices(5));
}
[Fact]
public async Task A_clean_turn_end_adds_nothing_and_a_failing_one_shows_its_exit_and_stderr()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("turn-end: test -f fail || exit 0; echo oops >&2; exit 2");
_host.Faux.Enqueue(FauxTurn.Say("1"), FauxTurn.Say("2"), FauxTurn.Say("3"));
await using var session = await Session();
await session.PromptAsync("a");
await session.PromptAsync("b");
Assert.Empty(Notices(1));
await Settle();
File.WriteAllText(Path.Combine(SkillDir(), "fail"), "");
await session.PromptAsync("c"); // b's check ran before the file existed
await session.PromptAsync("d");
Assert.Contains("exit 2) after your previous turn:]\nstderr:\noops", Assert.Single(Notices(3)).Text);
}
[Fact]
public async Task Turn_end_never_delays_the_run_and_the_next_run_waits_at_most_its_timeout()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("turn-end: sleep 20\nturn-end-timeout: 2");
_host.Faux.Enqueue(FauxTurn.Say("1"), FauxTurn.Say("2"), FauxTurn.Say("3"));
await using var session = await Session();
var watch = Stopwatch.StartNew();
await session.PromptAsync("a");
Assert.True(watch.Elapsed < TimeSpan.FromSeconds(1.5), $"first run took {watch.Elapsed}");
watch.Restart();
await session.PromptAsync("b");
Assert.True(watch.Elapsed < TimeSpan.FromSeconds(3.5), $"second run took {watch.Elapsed}");
Assert.True(watch.Elapsed >= TimeSpan.FromSeconds(1), $"second run took {watch.Elapsed}");
}
[Fact]
public async Task A_turn_end_timeout_is_reported()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("turn-end: sleep 20\nturn-end-timeout: 1");
_host.Faux.Enqueue(FauxTurn.Say("1"), FauxTurn.Say("2"));
await using var session = await Session();
await session.PromptAsync("a");
await Task.Delay(TimeSpan.FromSeconds(2.5)); // the hook was killed after 1 s
await session.PromptAsync("b");
Assert.Contains("timed out after 1 s) after your previous turn:]", Assert.Single(Notices(1)).Text);
}
[Fact]
public async Task Write_and_edit_are_refused_inside_protected_paths_and_read_works()
{
WriteSkill("protected-paths: Сегодня/, Входящие\nprotected-hint: use gtd move");
var today = Path.Combine(_host.Cwd, "Сегодня");
Directory.CreateDirectory(today);
File.WriteAllText(Path.Combine(today, "a.md"), "old");
_host.Faux.Enqueue(
FauxTurn.Tool("write", new { path = "Сегодня/new.md", content = "x" }),
FauxTurn.Tool("edit", new { path = "Сегодня/a.md", edits = new[] { new { oldText = "old", newText = "new" } } }),
FauxTurn.Tool("write", new { path = Path.Combine(_host.Cwd, "Входящие", "deep", "b.md"), content = "x" }),
FauxTurn.Tool("write", new { path = "notes/../Сегодня/c.md", content = "x" }),
FauxTurn.Tool("read", new { path = "Сегодня/a.md" }),
FauxTurn.Tool("write", new { path = "Сегодня-архив/ok.md", content = "x" }),
FauxTurn.Say("done"));
await using var session = await Session();
await session.PromptAsync("go");
var results = session.Messages.OfType<ToolResultMessage>().ToList();
Assert.Equal(6, results.Count);
foreach (var refused in results.Take(4))
{
Assert.True(refused.IsError);
Assert.Contains("which the skill \"gtd\" manages: change it through the skill's CLI (see .volcanic/skills/gtd/SKILL.md), not with write or edit. Reading is allowed. Hint: use gtd move", refused.Text);
}
Assert.Contains("\"Сегодня/new.md\" is inside \"Сегодня/\"", results[0].Text);
Assert.Equal("old", File.ReadAllText(Path.Combine(today, "a.md")));
Assert.False(File.Exists(Path.Combine(today, "new.md")));
Assert.False(Directory.Exists(Path.Combine(_host.Cwd, "Входящие")));
Assert.False(results[4].IsError);
Assert.Contains("old", results[4].Text);
Assert.False(results[5].IsError);
Assert.Contains("<protected_paths>Сегодня/, Входящие/</protected_paths>", SystemPrompt(0));
}
[Fact]
public async Task A_symlink_or_NFD_spelling_does_not_get_around_a_protected_path()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("protected-paths: Мой");
Directory.CreateDirectory(Path.Combine(_host.Cwd, "Мой"));
Directory.CreateSymbolicLink(Path.Combine(_host.Cwd, "link"), Path.Combine(_host.Cwd, "Мой"));
_host.Faux.Enqueue(
FauxTurn.Tool("write", new { path = "link/a.md", content = "x" }),
FauxTurn.Tool("write", new { path = "Мой".Normalize(NormalizationForm.FormD) + "/b.md", content = "x" }),
FauxTurn.Say("done"));
await using var session = await Session();
await session.PromptAsync("go");
Assert.All(session.Messages.OfType<ToolResultMessage>(), r => Assert.True(r.IsError, r.Text));
Assert.Empty(Directory.EnumerateFileSystemEntries(Path.Combine(_host.Cwd, "Мой")));
}
[Fact]
public async Task Skills_without_hook_fields_change_nothing()
{
WriteSkill("");
_host.Faux.Enqueue(FauxTurn.Tool("write", new { path = "Сегодня/a.md", content = "x" }), FauxTurn.Say("1"), FauxTurn.Say("2"));
await using var session = await Session();
await session.PromptAsync("a");
await session.PromptAsync("b");
Assert.DoesNotContain("<skill_session_context>", SystemPrompt(0));
Assert.DoesNotContain("</protected_paths>", SystemPrompt(0));
Assert.Empty(Notices(2));
Assert.False(session.Messages.OfType<ToolResultMessage>().Single().IsError);
}
[Fact]
public async Task Skill_hooks_can_be_turned_off()
{
await using var host = new TestHost(o => o.SkillHooks = false);
var dir = Path.Combine(SkillFiles.Root(host.Cwd), "gtd");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "SKILL.md"), "---\ndescription: x\nsession-start: echo hello\nprotected-paths: Сегодня\n---\n");
host.Faux.Enqueue(FauxTurn.Tool("write", new { path = "Сегодня/a.md", content = "x" }), FauxTurn.Say("1"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("a");
Assert.DoesNotContain("<skill_session_context>", host.Faux.Requests[0].SystemPrompt);
Assert.False(session.Messages.OfType<ToolResultMessage>().Single().IsError);
}
}