Skip to content

Commit 8a4e5a4

Browse files
authored
Merge pull request #1333 from entireio/feat/generic-slash-skill-events
Capture generic slash-command skill invocations
2 parents 317a6f9 + 48cac9e commit 8a4e5a4

5 files changed

Lines changed: 340 additions & 1 deletion

File tree

cmd/entire/cli/agent/skill_events.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const (
99
// Skill event source signals.
1010
const (
1111
SkillSignalPiInputSlashCommand = "input_slash_command"
12+
SkillSignalPromptSlashCommand = "prompt_slash_command"
1213
SkillSignalClaudeSkillToolUse = "skill_tool_use"
1314
)
1415

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package agent
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strings"
7+
"time"
8+
)
9+
10+
// skillSlashCommandPattern matches a leading "/<command>" token up to the first
11+
// whitespace, so command arguments are never captured.
12+
var skillSlashCommandPattern = regexp.MustCompile(`^/([A-Za-z0-9][A-Za-z0-9._:/-]*)`)
13+
14+
// filesystemRoots reject pasted absolute paths ("/Users/x", "/tmp/y") that would
15+
// otherwise look like commands. Only matched when the root is followed by another
16+
// path segment, so a bare "/dev" stays a command (see isFilesystemPath).
17+
var filesystemRoots = map[string]struct{}{
18+
"users": {}, "home": {}, "tmp": {}, "usr": {}, "var": {}, "etc": {},
19+
"opt": {}, "mnt": {}, "private": {}, "volumes": {}, "library": {},
20+
"applications": {}, "system": {}, "bin": {}, "sbin": {}, "dev": {},
21+
"proc": {}, "sys": {}, "root": {}, "srv": {}, "run": {}, "boot": {},
22+
"lib": {}, "media": {}, "network": {}, "cores": {},
23+
}
24+
25+
// SkillEventFromPromptSlashCommand returns a skill event for a prompt beginning
26+
// with a "/<command>" slash command. A recorded prompt only contains a slash
27+
// command that was submitted as a turn, so runtime/UI-only commands (/mcp,
28+
// /model, ...) are naturally absent; pasted filesystem paths are rejected.
29+
//
30+
// Only the command token is stored, never the prompt body. Tool-call skills
31+
// (e.g. Claude Code's Skill tool) are captured separately by SkillEventExtractors
32+
// as "tool_invocation" events.
33+
func SkillEventFromPromptSlashCommand(agentName, prompt string, timestamp time.Time) (SkillEvent, bool) {
34+
trimmed := strings.TrimLeft(prompt, " \t\r\n")
35+
match := skillSlashCommandPattern.FindStringSubmatch(trimmed)
36+
if match == nil {
37+
return SkillEvent{}, false
38+
}
39+
40+
token := strings.Trim(match[1], "/")
41+
if token == "" || isFilesystemPath(match[1]) {
42+
return SkillEvent{}, false
43+
}
44+
45+
// Normalize Pi's "/skill:<name>" form to the bare skill name so the generic
46+
// event dedupes against Pi's native input_slash_command event, which records
47+
// the name without the "skill:" namespace.
48+
name := token
49+
if rest, ok := strings.CutPrefix(token, "skill:"); ok {
50+
if rest == "" {
51+
return SkillEvent{}, false
52+
}
53+
name = rest
54+
}
55+
56+
command := "/" + token
57+
event := SkillEvent{
58+
ID: promptSkillEventID(agentName, name, timestamp),
59+
EventType: SkillEventTypePromptInvocation,
60+
Skill: SkillEventSkill{
61+
Name: name,
62+
},
63+
Source: SkillEventSource{
64+
Agent: agentName,
65+
Signal: SkillSignalPromptSlashCommand,
66+
Confidence: SkillConfidenceExplicit,
67+
},
68+
Native: map[string]string{
69+
"command": command,
70+
},
71+
Collapse: SkillEventCollapse{
72+
Target: SkillCollapseTargetUserMessage,
73+
Label: command,
74+
DefaultCollapsed: true,
75+
},
76+
}
77+
if !timestamp.IsZero() {
78+
event.Timestamp = timestamp.UTC().Format(time.RFC3339Nano)
79+
}
80+
return event, true
81+
}
82+
83+
// isFilesystemPath reports whether raw (the captured command token, e.g.
84+
// "Users/alice/x", "dev", "parent/child") is a pasted absolute filesystem path
85+
// rather than a slash command. It matches only when a well-known root segment is
86+
// FOLLOWED by a further path segment, so bare single-token commands that happen
87+
// to collide with a root name (e.g. "/dev", "/run", "/lib") are still treated as
88+
// commands — only "/dev/null", "/Users/alice/...", etc. are rejected.
89+
func isFilesystemPath(raw string) bool {
90+
first, rest, found := strings.Cut(raw, "/")
91+
if !found || rest == "" {
92+
return false
93+
}
94+
_, ok := filesystemRoots[strings.ToLower(first)]
95+
return ok
96+
}
97+
98+
// AppendPromptSlashCommandSkillEvent adds a generic prompt-invocation skill
99+
// event for a "/<command>" prompt. If an agent adapter already surfaced an
100+
// equivalent prompt skill event (for example Pi's pre-expansion input event),
101+
// the adapter event wins and no generic duplicate is appended.
102+
func AppendPromptSlashCommandSkillEvent(events []SkillEvent, agentName, prompt string, timestamp time.Time) []SkillEvent {
103+
event, ok := SkillEventFromPromptSlashCommand(agentName, prompt, timestamp)
104+
if !ok {
105+
return events
106+
}
107+
if hasEquivalentPromptSkillEvent(events, event) {
108+
return events
109+
}
110+
return append(events, event)
111+
}
112+
113+
func promptSkillEventID(agentName, skillName string, timestamp time.Time) string {
114+
if timestamp.IsZero() {
115+
return ""
116+
}
117+
return fmt.Sprintf("prompt-skill-%s-%s-%s", agentName, skillName, timestamp.UTC().Format(time.RFC3339Nano))
118+
}
119+
120+
func hasEquivalentPromptSkillEvent(events []SkillEvent, candidate SkillEvent) bool {
121+
candidateCommand := ""
122+
if candidate.Native != nil {
123+
candidateCommand = candidate.Native["command"]
124+
}
125+
for _, existing := range events {
126+
if existing.EventType != SkillEventTypePromptInvocation || existing.Skill.Name != candidate.Skill.Name {
127+
continue
128+
}
129+
if existing.ID != "" && candidate.ID != "" && existing.ID == candidate.ID {
130+
return true
131+
}
132+
if candidateCommand != "" && existing.Native != nil && existing.Native["command"] == candidateCommand {
133+
return true
134+
}
135+
if existing.Source.Signal == SkillSignalPiInputSlashCommand || existing.Source.Signal == SkillSignalPromptSlashCommand {
136+
return true
137+
}
138+
}
139+
return false
140+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package agent
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestSkillEventFromPromptSlashCommand(t *testing.T) {
9+
t.Parallel()
10+
11+
timestamp := time.Date(2026, 5, 25, 12, 34, 56, 0, time.UTC)
12+
event, ok := SkillEventFromPromptSlashCommand("codex", " /goal Complete ENG-623: ship it", timestamp)
13+
if !ok {
14+
t.Fatal("SkillEventFromPromptSlashCommand() ok = false, want true")
15+
}
16+
if event.EventType != SkillEventTypePromptInvocation {
17+
t.Fatalf("EventType = %q, want %q", event.EventType, SkillEventTypePromptInvocation)
18+
}
19+
if event.Skill.Name != "goal" {
20+
t.Fatalf("Skill.Name = %q, want goal", event.Skill.Name)
21+
}
22+
if event.Source.Agent != "codex" || event.Source.Signal != SkillSignalPromptSlashCommand || event.Source.Confidence != SkillConfidenceExplicit {
23+
t.Fatalf("Source = %+v", event.Source)
24+
}
25+
if event.Timestamp != "2026-05-25T12:34:56Z" {
26+
t.Fatalf("Timestamp = %q", event.Timestamp)
27+
}
28+
if event.Native["command"] != "/goal" {
29+
t.Fatalf("Native command = %q", event.Native["command"])
30+
}
31+
if event.Collapse.Target != SkillCollapseTargetUserMessage || !event.Collapse.DefaultCollapsed {
32+
t.Fatalf("Collapse = %+v", event.Collapse)
33+
}
34+
if event.Collapse.Label != "/goal" {
35+
t.Fatalf("Collapse label = %q", event.Collapse.Label)
36+
}
37+
}
38+
39+
func TestSkillEventFromPromptSlashCommand_Variants(t *testing.T) {
40+
t.Parallel()
41+
42+
cases := []struct {
43+
prompt string
44+
wantName string // "" means: expect no match
45+
}{
46+
{"/review", "review"}, // built-in prompt command — still a skill/prompt
47+
{"/build-feature implement the thing", "build-feature"}, // custom command with args
48+
{"/superpowers:brainstorming", "superpowers:brainstorming"}, // plugin-namespaced
49+
{"/git:commit", "git:commit"}, // gemini colon namespace
50+
{"/parent/child do x", "parent/child"}, // opencode path namespace
51+
{"/start-ticket https://x/y", "start-ticket"}, // url arg ignored
52+
{"\t/skill:trigger-analysis inspect", "trigger-analysis"}, // pi form → bare name
53+
{"/dev", "dev"}, // bare command colliding with a root name — still a command
54+
{"/dev implement the feature", "dev"}, // ditto, with args
55+
{"/Users/alice/notes.md", ""}, // pasted absolute path
56+
{"/dev/null 2>&1", ""}, // root followed by a path segment
57+
{"/tmp/output.log read this", ""}, // pasted path with args
58+
{"please run /review", ""}, // not leading
59+
{"/ spaced", ""}, // no command token
60+
{"/", ""}, // bare slash
61+
{"/skill:", ""}, // empty skill name
62+
{"do the thing", ""}, // no slash
63+
}
64+
for _, tc := range cases {
65+
event, ok := SkillEventFromPromptSlashCommand("codex", tc.prompt, time.Time{})
66+
if tc.wantName == "" {
67+
if ok {
68+
t.Errorf("SkillEventFromPromptSlashCommand(%q) = %+v, true; want false", tc.prompt, event)
69+
}
70+
continue
71+
}
72+
if !ok {
73+
t.Errorf("SkillEventFromPromptSlashCommand(%q) ok = false, want true", tc.prompt)
74+
continue
75+
}
76+
if event.Skill.Name != tc.wantName {
77+
t.Errorf("SkillEventFromPromptSlashCommand(%q) name = %q, want %q", tc.prompt, event.Skill.Name, tc.wantName)
78+
}
79+
}
80+
}
81+
82+
func TestAppendPromptSlashCommandSkillEvent_KeepsNativeAdapterEvent(t *testing.T) {
83+
t.Parallel()
84+
85+
existing := []SkillEvent{
86+
{
87+
ID: "pi-skill-trigger-analysis-1",
88+
EventType: SkillEventTypePromptInvocation,
89+
Skill: SkillEventSkill{Name: "trigger-analysis"},
90+
Source: SkillEventSource{
91+
Agent: "pi",
92+
Signal: SkillSignalPiInputSlashCommand,
93+
Confidence: SkillConfidenceExplicit,
94+
},
95+
Native: map[string]string{"command": "/skill:trigger-analysis"},
96+
},
97+
}
98+
99+
got := AppendPromptSlashCommandSkillEvent(existing, "pi", "/skill:trigger-analysis inspect", time.Now())
100+
if len(got) != 1 {
101+
t.Fatalf("AppendPromptSlashCommandSkillEvent len = %d, want 1", len(got))
102+
}
103+
if got[0].ID != existing[0].ID {
104+
t.Fatalf("AppendPromptSlashCommandSkillEvent replaced native event: %+v", got[0])
105+
}
106+
}

cmd/entire/cli/lifecycle.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,20 @@ func handleLifecycleTurnStart(ctx context.Context, ag agent.Agent, event *agent.
430430
before.ReviewSkills = slices.Clone(state.ReviewSkills)
431431
adoptReviewEnv(logCtx, state, string(ag.Name()))
432432
adoptInvestigateEnv(logCtx, state, string(ag.Name()))
433-
skillEventsChanged := appendEventSkillEventsToState(event, state)
433+
434+
skillEventSource := *event
435+
// Record a skill event for a leading "/<command>" in the raw prompt. Only
436+
// once ownership is known — TurnStart bypasses the owner filter so
437+
// InitializeSession can repair it — and never overriding native adapter events.
438+
if state.AgentType == "" || state.AgentType == ag.Type() {
439+
skillEventSource.SkillEvents = agent.AppendPromptSlashCommandSkillEvent(
440+
skillEventSource.SkillEvents,
441+
string(ag.Name()),
442+
event.Prompt,
443+
event.Timestamp,
444+
)
445+
}
446+
skillEventsChanged := appendEventSkillEventsToState(&skillEventSource, state)
434447
if state.Kind == before.Kind &&
435448
state.ReviewPrompt == before.ReviewPrompt &&
436449
slices.Equal(state.ReviewSkills, before.ReviewSkills) &&

cmd/entire/cli/lifecycle_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,85 @@ func TestHandleLifecycleTurnStart_WritesPromptContent(t *testing.T) {
10991099
}
11001100
}
11011101

1102+
func TestHandleLifecycleTurnStart_RecordsGenericSkillSlashEvent(t *testing.T) {
1103+
// Cannot use t.Parallel() because we use t.Chdir()
1104+
tmpDir := t.TempDir()
1105+
testutil.InitRepo(t, tmpDir)
1106+
testutil.WriteFile(t, tmpDir, "init.txt", "init")
1107+
testutil.GitAdd(t, tmpDir, "init.txt")
1108+
testutil.GitCommit(t, tmpDir, "init")
1109+
t.Chdir(tmpDir)
1110+
paths.ClearWorktreeRootCache()
1111+
1112+
ag := newMockAgent()
1113+
sessionID := "test-generic-skill-slash"
1114+
event := &agent.Event{
1115+
Type: agent.TurnStart,
1116+
SessionID: sessionID,
1117+
Prompt: "/skill:trigger-analysis inspect the implementation",
1118+
Timestamp: time.Date(2026, 5, 25, 12, 34, 56, 0, time.UTC),
1119+
}
1120+
1121+
require.NoError(t, handleLifecycleTurnStart(context.Background(), ag, event))
1122+
1123+
state, err := strategy.LoadSessionState(context.Background(), sessionID)
1124+
require.NoError(t, err)
1125+
require.NotNil(t, state)
1126+
require.Len(t, state.SkillEvents, 1)
1127+
1128+
skillEvent := state.SkillEvents[0]
1129+
require.Equal(t, agent.SkillEventTypePromptInvocation, skillEvent.EventType)
1130+
require.Equal(t, "trigger-analysis", skillEvent.Skill.Name)
1131+
require.Equal(t, string(ag.Name()), skillEvent.Source.Agent)
1132+
require.Equal(t, agent.SkillSignalPromptSlashCommand, skillEvent.Source.Signal)
1133+
require.Equal(t, agent.SkillConfidenceExplicit, skillEvent.Source.Confidence)
1134+
require.Equal(t, state.TurnID, skillEvent.TurnID)
1135+
require.Equal(t, "2026-05-25T12:34:56Z", skillEvent.Timestamp)
1136+
require.Equal(t, "/skill:trigger-analysis", skillEvent.Native["command"])
1137+
require.Equal(t, agent.SkillCollapseTargetUserMessage, skillEvent.Collapse.Target)
1138+
require.True(t, skillEvent.Collapse.DefaultCollapsed)
1139+
}
1140+
1141+
func TestHandleLifecycleTurnStart_DoesNotDuplicateGenericSkillSlashEventFromForwardedHook(t *testing.T) {
1142+
// Cannot use t.Parallel() because we use t.Chdir()
1143+
tmpDir := t.TempDir()
1144+
testutil.InitRepo(t, tmpDir)
1145+
testutil.WriteFile(t, tmpDir, "init.txt", "init")
1146+
testutil.GitAdd(t, tmpDir, "init.txt")
1147+
testutil.GitCommit(t, tmpDir, "init")
1148+
t.Chdir(tmpDir)
1149+
paths.ClearWorktreeRootCache()
1150+
1151+
sessionID := "test-generic-skill-forwarded"
1152+
ownerAgent := newMockAgent()
1153+
forwardedAgent := &mockLifecycleAgent{
1154+
name: "forwarded-agent",
1155+
agentType: "Forwarded Agent",
1156+
transcriptData: []byte(`{"type":"user","message":"test"}`),
1157+
}
1158+
prompt := "/skill:trigger-analysis inspect the implementation"
1159+
1160+
require.NoError(t, handleLifecycleTurnStart(context.Background(), ownerAgent, &agent.Event{
1161+
Type: agent.TurnStart,
1162+
SessionID: sessionID,
1163+
Prompt: prompt,
1164+
Timestamp: time.Date(2026, 5, 25, 12, 34, 56, 0, time.UTC),
1165+
}))
1166+
require.NoError(t, handleLifecycleTurnStart(context.Background(), forwardedAgent, &agent.Event{
1167+
Type: agent.TurnStart,
1168+
SessionID: sessionID,
1169+
Prompt: prompt,
1170+
Timestamp: time.Date(2026, 5, 25, 12, 34, 57, 0, time.UTC),
1171+
}))
1172+
1173+
state, err := strategy.LoadSessionState(context.Background(), sessionID)
1174+
require.NoError(t, err)
1175+
require.NotNil(t, state)
1176+
require.Equal(t, ownerAgent.Type(), state.AgentType)
1177+
require.Len(t, state.SkillEvents, 1)
1178+
require.Equal(t, string(ownerAgent.Name()), state.SkillEvents[0].Source.Agent)
1179+
}
1180+
11021181
func TestHandleLifecycleTurnEnd_BackfillsPromptFromTranscript(t *testing.T) {
11031182
// Cannot use t.Parallel() because we use t.Chdir()
11041183
tmpDir := t.TempDir()

0 commit comments

Comments
 (0)