diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index a856c0fca..6967c4ab5 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -3468,10 +3468,16 @@ func (al *AgentLoop) applyExplicitSkillCommand( if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { return true, true, commandsUnavailableSkillMessage() } - al.setPendingSkills(opts.SessionKey, []string{skillName}) + armedSkills := al.setPendingSkills(opts.SessionKey, []string{skillName}) + if len(armedSkills) == 1 { + return true, true, fmt.Sprintf( + "Skill %q is armed for your next message. Send your next prompt normally, or use /use clear to cancel.", + skillName, + ) + } return true, true, fmt.Sprintf( - "Skill %q is armed for your next message. Send your next prompt normally, or use /use clear to cancel.", - skillName, + "Skills %s are armed for your next message. Send your next prompt normally, or use /use clear to cancel.", + quoteSkillNames(armedSkills), ) } @@ -3603,24 +3609,27 @@ func buildUseCommandHelp(agent *AgentInstance) string { ) } -func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) { +func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) []string { sessionKey = strings.TrimSpace(sessionKey) if sessionKey == "" || len(skillNames) == 0 { - return + return nil } - filtered := make([]string, 0, len(skillNames)) - for _, name := range skillNames { - name = strings.TrimSpace(name) - if name != "" { - filtered = append(filtered, name) + merged := make([]string, 0, len(skillNames)) + if existingValue, ok := al.pendingSkills.Load(sessionKey); ok { + if existingSkills, ok := existingValue.([]string); ok { + merged = append(merged, existingSkills...) } } + merged = append(merged, skillNames...) + + filtered := uniqueSkillNames(merged) if len(filtered) == 0 { - return + return nil } al.pendingSkills.Store(sessionKey, filtered) + return append([]string(nil), filtered...) } func (al *AgentLoop) takePendingSkills(sessionKey string) []string { @@ -3650,6 +3659,31 @@ func (al *AgentLoop) clearPendingSkills(sessionKey string) { al.pendingSkills.Delete(sessionKey) } +func uniqueSkillNames(skillNames []string) []string { + filtered := make([]string, 0, len(skillNames)) + seen := make(map[string]struct{}, len(skillNames)) + for _, name := range skillNames { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if _, exists := seen[name]; exists { + continue + } + seen[name] = struct{}{} + filtered = append(filtered, name) + } + return filtered +} + +func quoteSkillNames(skillNames []string) string { + quoted := make([]string, 0, len(skillNames)) + for _, name := range skillNames { + quoted = append(quoted, fmt.Sprintf("%q", name)) + } + return strings.Join(quoted, ", ") +} + func mapCommandError(result commands.ExecuteResult) string { if result.Command == "" { return fmt.Sprintf("Failed to execute command: %v", result.Err) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 7fe5836b3..c56397bca 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -365,6 +365,60 @@ func TestApplyExplicitSkillCommand_ArmsSkillForNextMessage(t *testing.T) { } } +func TestApplyExplicitSkillCommand_AppendsPendingSkillsForNextMessage(t *testing.T) { + al, cfg, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + + for _, skillName := range []string{"shell", "finance-news"} { + skillDir := filepath.Join(cfg.Agents.Defaults.Workspace, "skills", skillName) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", skillName, err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("# "+skillName+"\n\nSkill test fixture.\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(%s/SKILL.md) error = %v", skillName, err) + } + } + + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + opts := &processOptions{SessionKey: "agent:main:test"} + _, handled, reply := al.applyExplicitSkillCommand("/use shell", agent, opts) + if !handled { + t.Fatal("expected /use shell to be handled") + } + if !strings.Contains(reply, `Skill "shell" is armed for your next message`) { + t.Fatalf("unexpected first reply: %q", reply) + } + + _, handled, reply = al.applyExplicitSkillCommand("/use finance-news", agent, opts) + if !handled { + t.Fatal("expected /use finance-news to be handled") + } + if !strings.Contains(reply, `Skills "shell", "finance-news" are armed for your next message`) { + t.Fatalf("unexpected second reply: %q", reply) + } + + _, handled, reply = al.applyExplicitSkillCommand("/use shell", agent, opts) + if !handled { + t.Fatal("expected duplicate /use shell to be handled") + } + if !strings.Contains(reply, `Skills "shell", "finance-news" are armed for your next message`) { + t.Fatalf("unexpected duplicate reply: %q", reply) + } + + pending := al.takePendingSkills(opts.SessionKey) + if !slices.Equal(pending, []string{"shell", "finance-news"}) { + t.Fatalf("pending skills = %#v, want [shell finance-news]", pending) + } +} + func TestApplyExplicitSkillCommand_InlineMessageMutatesOptions(t *testing.T) { al, cfg, _, _, cleanup := newTestAgentLoop(t) defer cleanup()