feat(skills): add channel commands to list and force installed skills

This commit is contained in:
afjcjsbx 2026-03-22 15:33:25 +01:00
parent 2f6f25dc58
commit d7d2bf69bf
16 changed files with 575 additions and 3 deletions

View file

@ -33,3 +33,23 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
3. Obtain the HTTP API Token
4. Fill in the Token in the configuration file
5. (Optional) Configure `allow_from` to restrict which user IDs can interact (you can get IDs via `@userinfobot`)
## Built-in Commands
Telegram auto-registers PicoClaw's top-level bot commands at startup, including `/start`, `/help`, `/show`, `/list`, and `/use`.
Skill-related commands:
- `/list skills` lists the installed skills visible to the current agent.
- `/use <skill> <message>` forces a skill for a single request.
- `/use <skill>` arms the skill for your next message in the same chat.
- `/use clear` clears a pending skill override.
Examples:
```text
/list skills
/use git explain how to squash the last 3 commits
/use git
explain how to squash the last 3 commits
```

View file

@ -33,3 +33,23 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器
3. 获取 HTTP API Token
4. 将 Token 填入配置文件中
5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID)
## 内置命令
Telegram 会在启动时自动注册 PicoClaw 的顶级 Bot 命令,包括 `/start``/help``/show``/list``/use`
与技能相关的命令:
- `/list skills`:列出当前 Agent 可见的已安装技能。
- `/use <skill> <message>`:只在本次请求中强制使用指定技能。
- `/use <skill>`:为同一聊天中的下一条消息预先启用该技能。
- `/use clear`:清除待应用的技能覆盖。
示例:
```text
/list skills
/use git explain how to squash the last 3 commits
/use italiapersonalfinance
dammi le ultime news
```

View file

@ -60,11 +60,18 @@ picoclaw gateway
**4. Telegram command menu (auto-registered at startup)**
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`, `/use`) so command menu and runtime behavior stay in sync.
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
You can also manage installed skills directly from Telegram:
- `/list skills`
- `/use <skill> <message>`
- `/use <skill>` and then send the actual request in the next message
- `/use clear`
**4. Advanced Formatting**
You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks.

View file

@ -65,6 +65,24 @@ For advanced/test setups, you can override the builtin skills root with:
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### Using Skills From Chat Channels
Once skills are installed, you can inspect and force them directly from a chat channel:
- `/list skills` shows the installed skill names available to the current agent.
- `/use <skill> <message>` forces a specific skill for a single request.
- `/use <skill>` arms that skill for your next message in the same chat session.
- `/use clear` cancels a pending skill override created by `/use <skill>`.
Examples:
```text
/list skills
/use git explain how to squash the last 3 commits
/use italiapersonalfinance
dammi le ultime news
```
### Unified Command Execution Policy
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.

View file

@ -63,11 +63,18 @@ picoclaw gateway
**4. Telegram 命令菜单(启动时自动注册)**
PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start``/help``/show``/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start``/help``/show``/list``/use`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
你也可以直接在 Telegram 中管理已安装技能:
- `/list skills`
- `/use <skill> <message>`
- `/use <skill>`,然后在下一条消息里发送真正的请求
- `/use clear`
</details>
<details>

View file

@ -65,6 +65,24 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### 在聊天频道中使用技能
技能安装完成后,可以直接在聊天频道里查看并显式启用它们:
- `/list skills`:显示当前 Agent 可用的已安装技能名称。
- `/use <skill> <message>`:只对当前这一条请求强制使用指定技能。
- `/use <skill>`:为同一会话中的下一条消息预先启用该技能。
- `/use clear`:取消通过 `/use <skill>` 设置的待应用技能。
示例:
```text
/list skills
/use git explain how to squash the last 3 commits
/use italiapersonalfinance
dammi le ultime news
```
### 统一命令执行策略
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。

View file

@ -497,6 +497,7 @@ func (cb *ContextBuilder) BuildMessages(
currentMessage string,
media []string,
channel, chatID, senderID, senderDisplayName string,
activeSkills ...string,
) []providers.Message {
messages := []providers.Message{}
@ -530,6 +531,11 @@ func (cb *ContextBuilder) BuildMessages(
{Type: "text", Text: dynamicCtx},
}
if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" {
stringParts = append(stringParts, skillsText)
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: skillsText})
}
if summary != "" {
summaryText := fmt.Sprintf(
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
@ -737,6 +743,68 @@ func (cb *ContextBuilder) AddAssistantMessage(
return messages
}
func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
if cb.skillsLoader == nil || len(skillNames) == 0 {
return ""
}
var ordered []string
seen := make(map[string]struct{}, len(skillNames))
for _, name := range skillNames {
canonical, ok := cb.ResolveSkillName(name)
if !ok {
continue
}
if _, exists := seen[canonical]; exists {
continue
}
seen[canonical] = struct{}{}
ordered = append(ordered, canonical)
}
if len(ordered) == 0 {
return ""
}
content := cb.skillsLoader.LoadSkillsForContext(ordered)
if strings.TrimSpace(content) == "" {
return ""
}
return fmt.Sprintf(`# Active Skills
The following skills are active for this request. Follow them when relevant.
%s`, content)
}
func (cb *ContextBuilder) ListSkillNames() []string {
if cb.skillsLoader == nil {
return nil
}
allSkills := cb.skillsLoader.ListSkills()
names := make([]string, 0, len(allSkills))
for _, skill := range allSkills {
names = append(names, skill.Name)
}
return names
}
func (cb *ContextBuilder) ResolveSkillName(name string) (string, bool) {
name = strings.TrimSpace(name)
if name == "" || cb.skillsLoader == nil {
return "", false
}
for _, skill := range cb.skillsLoader.ListSkills() {
if strings.EqualFold(skill.Name, name) {
return skill.Name, true
}
}
return "", false
}
// GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills()

View file

@ -48,6 +48,7 @@ type AgentLoop struct {
transcriber voice.Transcriber
cmdRegistry *commands.Registry
mcp mcpRuntime
pendingSkills sync.Map
mu sync.RWMutex
reloadFunc func() error
// Track active requests for safe provider cleanup
@ -62,6 +63,7 @@ type processOptions struct {
SenderID string // Current sender ID for dynamic context
SenderDisplayName string // Current sender display name for dynamic context
UserMessage string // User message content (may include prefix)
ForcedSkills []string // Skills explicitly requested for this message
Media []string // media:// refs from inbound message
DefaultResponse string // Response when LLM returns empty
EnableSummary bool // Whether to trigger summarization
@ -782,6 +784,15 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return response, nil
}
if pending := al.takePendingSkills(opts.SessionKey); len(pending) > 0 {
opts.ForcedSkills = append(opts.ForcedSkills, pending...)
logger.InfoCF("agent", "Applying pending skill override",
map[string]any{
"session_key": opts.SessionKey,
"skills": strings.Join(pending, ","),
})
}
return al.runAgentLoop(ctx, agent, opts)
}
@ -915,6 +926,7 @@ func (al *AgentLoop) runAgentLoop(
opts.ChatID,
opts.SenderID,
opts.SenderDisplayName,
activeSkillNames(agent, opts)...,
)
// Resolve media:// refs: images→base64 data URLs, non-images→local paths in content
@ -1226,6 +1238,7 @@ func (al *AgentLoop) runLLMIteration(
messages = agent.ContextBuilder.BuildMessages(
newHistory, newSummary, "",
nil, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName,
activeSkillNames(agent, opts)...,
)
continue
}
@ -1942,6 +1955,10 @@ func (al *AgentLoop) handleCommand(
return "", false
}
if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched {
return reply, handled
}
if al.cmdRegistry == nil {
return "", false
}
@ -1998,6 +2015,9 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
return nil
},
}
if agent != nil && agent.ContextBuilder != nil {
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
}
rt.ReloadConfig = func() error {
if al.reloadFunc == nil {
return fmt.Errorf("reload not configured")
@ -2057,6 +2077,146 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
return rt
}
func activeSkillNames(agent *AgentInstance, opts processOptions) []string {
var out []string
seen := make(map[string]struct{})
appendNames := func(names []string) {
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
}
if agent != nil {
appendNames(agent.SkillsFilter)
}
appendNames(opts.ForcedSkills)
return out
}
func (al *AgentLoop) applyExplicitSkillCommand(
raw string,
agent *AgentInstance,
opts *processOptions,
) (matched bool, handled bool, reply string) {
commandName, ok := commands.CommandName(raw)
if !ok || commandName != "use" {
return false, false, ""
}
if agent == nil || agent.ContextBuilder == nil {
return true, true, commandsUnavailableSkillMessage()
}
fields := strings.Fields(strings.TrimSpace(raw))
if len(fields) < 2 {
return true, true, buildUseCommandHelp(agent)
}
if strings.EqualFold(fields[1], "clear") || strings.EqualFold(fields[1], "off") {
al.clearPendingSkills(opts.SessionKey)
return true, true, "Cleared pending skill override."
}
canonicalSkill, ok := agent.ContextBuilder.ResolveSkillName(fields[1])
if !ok {
return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", fields[1])
}
if len(fields) == 2 {
al.setPendingSkills(opts.SessionKey, []string{canonicalSkill})
return true, true, fmt.Sprintf(
"Skill %q is armed for your next message.\nSend your next request normally, or use /use clear to cancel.",
canonicalSkill,
)
}
message := strings.TrimSpace(strings.Join(fields[2:], " "))
if message == "" {
return true, true, buildUseCommandHelp(agent)
}
opts.UserMessage = message
opts.ForcedSkills = append(opts.ForcedSkills, canonicalSkill)
return true, false, ""
}
func commandsUnavailableSkillMessage() string {
return "Skill selection is unavailable in the current context."
}
func buildUseCommandHelp(agent *AgentInstance) string {
if agent == nil || agent.ContextBuilder == nil {
return "Usage: /use <skill> [message]"
}
names := agent.ContextBuilder.ListSkillNames()
if len(names) == 0 {
return "Usage: /use <skill> [message]\nNo installed skills found."
}
return fmt.Sprintf(
"Usage: /use <skill> [message]\n\nInstalled Skills:\n- %s\n\nUse /use <skill> to apply a skill to your next message, or /use <skill> <message> to force it immediately.",
strings.Join(names, "\n- "),
)
}
func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" || len(skillNames) == 0 {
return
}
filtered := make([]string, 0, len(skillNames))
for _, name := range skillNames {
name = strings.TrimSpace(name)
if name != "" {
filtered = append(filtered, name)
}
}
if len(filtered) == 0 {
return
}
al.pendingSkills.Store(sessionKey, filtered)
}
func (al *AgentLoop) takePendingSkills(sessionKey string) []string {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" {
return nil
}
value, ok := al.pendingSkills.LoadAndDelete(sessionKey)
if !ok {
return nil
}
skills, ok := value.([]string)
if !ok {
return nil
}
return append([]string(nil), skills...)
}
func (al *AgentLoop) clearPendingSkills(sessionKey string) {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" {
return
}
al.pendingSkills.Delete(sessionKey)
}
func mapCommandError(result commands.ExecuteResult) string {
if result.Command == "" {
return fmt.Sprintf("Failed to execute command: %v", result.Err)

View file

@ -132,6 +132,163 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
}
}
func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) {
tmpDir := t.TempDir()
skillDir := filepath.Join(tmpDir, "skills", "shell")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("mkdir skill dir: %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("# shell\n\nPrefer concise shell commands and explain them briefly."),
0o644,
); err != nil {
t.Fatalf("write skill file: %v", err)
}
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
response, err := al.processMessage(context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
Content: "/use shell explain how to list files",
})
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if response != "Mock response" {
t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
}
if len(provider.lastMessages) == 0 {
t.Fatal("provider did not receive any messages")
}
systemPrompt := provider.lastMessages[0].Content
if !strings.Contains(systemPrompt, "# Active Skills") {
t.Fatalf("system prompt missing active skills section:\n%s", systemPrompt)
}
if !strings.Contains(systemPrompt, "### Skill: shell") {
t.Fatalf("system prompt missing requested skill content:\n%s", systemPrompt)
}
lastMessage := provider.lastMessages[len(provider.lastMessages)-1]
if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" {
t.Fatalf("last provider message = %+v, want rewritten user message", lastMessage)
}
}
func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
agent := al.GetRegistry().GetDefaultAgent()
opts := processOptions{}
reply, handled := al.handleCommand(context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
Content: "/use missing explain how to list files",
}, agent, &opts)
if !handled {
t.Fatal("expected /use with unknown skill to be handled")
}
if !strings.Contains(reply, "Unknown skill: missing") {
t.Fatalf("reply = %q, want unknown skill error", reply)
}
}
func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) {
tmpDir := t.TempDir()
skillDir := filepath.Join(tmpDir, "skills", "shell")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("mkdir skill dir: %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("# shell\n\nPrefer concise shell commands and explain them briefly."),
0o644,
); err != nil {
t.Fatalf("write skill file: %v", err)
}
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
response, err := al.processMessage(context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
Content: "/use shell",
})
if err != nil {
t.Fatalf("processMessage() arm error = %v", err)
}
if !strings.Contains(response, `Skill "shell" is armed for your next message.`) {
t.Fatalf("arm response = %q, want armed confirmation", response)
}
response, err = al.processMessage(context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
Content: "explain how to list files",
})
if err != nil {
t.Fatalf("processMessage() follow-up error = %v", err)
}
if response != "Mock response" {
t.Fatalf("follow-up response = %q, want %q", response, "Mock response")
}
if len(provider.lastMessages) == 0 {
t.Fatal("provider did not receive any messages")
}
systemPrompt := provider.lastMessages[0].Content
if !strings.Contains(systemPrompt, "### Skill: shell") {
t.Fatalf("system prompt missing pending skill content:\n%s", systemPrompt)
}
lastMessage := provider.lastMessages[len(provider.lastMessages)-1]
if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" {
t.Fatalf("last provider message = %+v, want unchanged follow-up user message", lastMessage)
}
}
func TestRecordLastChannel(t *testing.T) {
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
defer cleanup()

View file

@ -10,6 +10,7 @@ func BuiltinDefinitions() []Definition {
helpCommand(),
showCommand(),
listCommand(),
useCommand(),
switchCommand(),
checkCommand(),
clearCommand(),

View file

@ -39,9 +39,14 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
if !strings.Contains(reply, "/show [model|channel|agents]") {
t.Fatalf("/help reply missing /show usage, got %q", reply)
}
if !strings.Contains(reply, "/list [models|channels|agents]") {
if !strings.Contains(reply, "/list [models|channels|agents|skills]") {
t.Fatalf("/help reply missing /list usage, got %q", reply)
}
if !strings.Contains(reply, "/use <skill> <message>") {
if !strings.Contains(reply, "/use <skill> [message]") {
t.Fatalf("/help reply missing /use usage, got %q", reply)
}
}
}
func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
@ -143,3 +148,43 @@ func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) {
t.Fatalf("/list agents reply=%q, want agent IDs", reply)
}
}
func TestBuiltinListSkills_UsesRuntimeSkillNames(t *testing.T) {
rt := &Runtime{
ListSkillNames: func() []string {
return []string{"shell", "git"}
},
}
defs := BuiltinDefinitions()
ex := NewExecutor(NewRegistry(defs), rt)
var reply string
res := ex.Execute(context.Background(), Request{
Text: "/list skills",
Reply: func(text string) error {
reply = text
return nil
},
})
if res.Outcome != OutcomeHandled {
t.Fatalf("/list skills: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
}
if !strings.Contains(reply, "shell") || !strings.Contains(reply, "git") {
t.Fatalf("/list skills reply=%q, want installed skill names", reply)
}
}
func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) {
defs := BuiltinDefinitions()
ex := NewExecutor(NewRegistry(defs), nil)
res := ex.Execute(context.Background(), Request{
Text: "/use shell run ls",
})
if res.Outcome != OutcomePassthrough {
t.Fatalf("/use outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
}
if res.Command != "use" {
t.Fatalf("/use command=%q, want=%q", res.Command, "use")
}
}

View file

@ -47,6 +47,23 @@ func listCommand() Definition {
Description: "Registered agents",
Handler: agentsHandler(),
},
{
Name: "skills",
Description: "Installed skills",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil || rt.ListSkillNames == nil {
return req.Reply(unavailableMsg)
}
names := rt.ListSkillNames()
if len(names) == 0 {
return req.Reply("No installed skills")
}
return req.Reply(fmt.Sprintf(
"Installed Skills:\n- %s\n\nUse /use <skill> <message> to force one for a single request, or /use <skill> to apply it to your next message.",
strings.Join(names, "\n- "),
))
},
},
},
}
}

9
pkg/commands/cmd_use.go Normal file
View file

@ -0,0 +1,9 @@
package commands
func useCommand() Definition {
return Definition{
Name: "use",
Description: "Force a specific installed skill for one request",
Usage: "/use <skill> [message]",
}
}

View file

@ -41,6 +41,11 @@ func parseCommandName(input string) (string, bool) {
return name, true
}
// CommandName returns the normalized command name for an input if present.
func CommandName(input string) (string, bool) {
return parseCommandName(input)
}
func trimCommandPrefix(token string) (string, bool) {
for _, prefix := range commandPrefixes {
if strings.HasPrefix(token, prefix) {

View file

@ -10,6 +10,7 @@ type Runtime struct {
GetModelInfo func() (name, provider string)
ListAgentIDs func() []string
ListDefinitions func() []Definition
ListSkillNames func() []string
GetEnabledChannels func() []string
SwitchModel func(value string) (oldModel string, err error)
SwitchChannel func(value string) error

View file

@ -61,6 +61,9 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
GetEnabledChannels: func() []string {
return []string{"telegram"}
},
ListSkillNames: func() []string {
return []string{"shell"}
},
}
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
@ -82,4 +85,20 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
if !strings.Contains(reply, "telegram") {
t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply)
}
reply = ""
res = ex.Execute(context.Background(), Request{
Channel: "whatsapp",
Text: "/list skills",
Reply: func(text string) error {
reply = text
return nil
},
})
if res.Outcome != OutcomeHandled {
t.Fatalf("whatsapp /list skills outcome=%v, want=%v", res.Outcome, OutcomeHandled)
}
if !strings.Contains(reply, "shell") {
t.Fatalf("whatsapp /list skills reply=%q, expected installed skills content", reply)
}
}