Make slash commands case-insensitive
- commit
- 210ef9833cad0af33933661218f8236f6d3e5bf5
- author
- patriceckhart · · 2026-07-25 15:28 UTC
- parent
- 34e0352
- signature
- unsigned
- refs
- tag: v0.3.8
15 files · +240 -66
M README.md +2 -2
| @@ -261,7 +261,7 @@ Both interfaces share the same event schema, so transcripts captured by one can | |||
| 261 | 261 | ||
| 262 | 262 | ## Slash commands | |
| 263 | 263 | ||
| 264 | - | Type `/` in the TUI to open the autocomplete popup. Available commands: | |
| 264 | + | Slash command names are case-insensitive in the TUI and messaging backends; arguments keep their original case. Type `/` in the TUI to open the autocomplete popup. Available commands: | |
| 265 | 265 | ||
| 266 | 266 | | Command | Description | | |
| 267 | 267 | |---|---| | |
| @@ -978,7 +978,7 @@ Setup flow: | |||
| 978 | 978 | 3. Run `zot telegram-bot run` in the directory you want the agent to operate in. | |
| 979 | 979 | 4. Open your bot on telegram, send `/start`. The first user to do this claims the bridge (stored as `allowed_user_id`); every other user is rejected. | |
| 980 | 980 | ||
| 981 | - | From then on, any DM you send is forwarded to the agent as a user prompt. Attached photos or `image/*` documents are downloaded and passed to vision-capable models. In-bot telegram commands: `/help`, `/status`, `/stop` (cancel the current turn). Config lives in `$ZOT_HOME/bot.json` (mode 0600). | |
| 981 | + | From then on, any DM you send is forwarded to the agent as a user prompt. Attached photos or `image/*` documents are downloaded and passed to vision-capable models. In-bot telegram commands are case-insensitive: `/help`, `/status`, `/stop` (cancel the current turn). Config lives in `$ZOT_HOME/bot.json` (mode 0600). | |
| 982 | 982 | ||
| 983 | 983 | Starting either Telegram bridge removes any webhook configured for that bot before long polling begins, while preserving pending updates. Telegram does not allow webhooks and `getUpdates` polling at the same time, so do not share the bot token with another service that expects to keep a webhook active. | |
| 984 | 984 | ||
| @@ -261,7 +261,7 @@ Both interfaces share the same event schema, so transcripts captured by one can | |||||
| 261 | 261 | ||||
| 262 | ## Slash commands | 262 | ## Slash commands | ||
| 263 | 263 | ||||
| 264 | - | Type `/` in the TUI to open the autocomplete popup. Available commands: | 264 | + | Slash command names are case-insensitive in the TUI and messaging backends; arguments keep their original case. Type `/` in the TUI to open the autocomplete popup. Available commands: |
| 265 | 265 | ||||
| 266 | | Command | Description | | 266 | | Command | Description | | ||
| 267 | |---|---| | 267 | |---|---| | ||
| @@ -978,7 +978,7 @@ Setup flow: | |||||
| 978 | 3. Run `zot telegram-bot run` in the directory you want the agent to operate in. | 978 | 3. Run `zot telegram-bot run` in the directory you want the agent to operate in. | ||
| 979 | 4. Open your bot on telegram, send `/start`. The first user to do this claims the bridge (stored as `allowed_user_id`); every other user is rejected. | 979 | 4. Open your bot on telegram, send `/start`. The first user to do this claims the bridge (stored as `allowed_user_id`); every other user is rejected. | ||
| 980 | 980 | ||||
| 981 | - | From then on, any DM you send is forwarded to the agent as a user prompt. Attached photos or `image/*` documents are downloaded and passed to vision-capable models. In-bot telegram commands: `/help`, `/status`, `/stop` (cancel the current turn). Config lives in `$ZOT_HOME/bot.json` (mode 0600). | 981 | + | From then on, any DM you send is forwarded to the agent as a user prompt. Attached photos or `image/*` documents are downloaded and passed to vision-capable models. In-bot telegram commands are case-insensitive: `/help`, `/status`, `/stop` (cancel the current turn). Config lives in `$ZOT_HOME/bot.json` (mode 0600). |
| 982 | 982 | ||||
| 983 | Starting either Telegram bridge removes any webhook configured for that bot before long polling begins, while preserving pending updates. Telegram does not allow webhooks and `getUpdates` polling at the same time, so do not share the bot token with another service that expects to keep a webhook active. | 983 | Starting either Telegram bridge removes any webhook configured for that bot before long polling begins, while preserving pending updates. Telegram does not allow webhooks and `getUpdates` polling at the same time, so do not share the bot token with another service that expects to keep a webhook active. | ||
| 984 | 984 | ||||
M docs/extensions.md +2 -0
| @@ -185,6 +185,8 @@ other stdout frame before `hello`. | |||
| 185 | 185 | "description":"current weather for a city"} | |
| 186 | 186 | ``` | |
| 187 | 187 | ||
| 188 | + | Command names are matched case-insensitively. zot sends `command_invoked.name` using the canonical spelling registered here. Registrations that differ only by case conflict, and the first registration remains active. | |
| 189 | + | ||
| 188 | 190 | #### `register_tool` | |
| 189 | 191 | ||
| 190 | 192 | Registers a tool the LLM can call. `schema` is a JSON Schema object | |
| @@ -185,6 +185,8 @@ other stdout frame before `hello`. | |||||
| 185 | "description":"current weather for a city"} | 185 | "description":"current weather for a city"} | ||
| 186 | ``` | 186 | ``` | ||
| 187 | 187 | ||||
| 188 | + | Command names are matched case-insensitively. zot sends `command_invoked.name` using the canonical spelling registered here. Registrations that differ only by case conflict, and the first registration remains active. | |||
| 189 | + | ||||
| 188 | #### `register_tool` | 190 | #### `register_tool` | ||
| 189 | 191 | ||||
| 190 | Registers a tool the LLM can call. `schema` is a JSON Schema object | 192 | Registers a tool the LLM can call. `schema` is a JSON Schema object | ||
M packages/agent/extensions/manager.go +28 -16
| @@ -132,6 +132,11 @@ type HostHooks interface { | |||
| 132 | 132 | ClosePanel(extName, panelID string) | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | + | type commandRegistration struct { | |
| 136 | + | ext *Extension | |
| 137 | + | name string | |
| 138 | + | } | |
| 139 | + | ||
| 135 | 140 | // Manager owns every extension subprocess for the lifetime of zot. | |
| 136 | 141 | type Manager struct { | |
| 137 | 142 | zotHome string | |
| @@ -144,11 +149,10 @@ type Manager struct { | |||
| 144 | 149 | mu sync.RWMutex | |
| 145 | 150 | ext map[string]*Extension // keyed by manifest name | |
| 146 | 151 | ||
| 147 | - | // commandIndex maps a slash-command name (without the leading /) | |
| 148 | - | // to the extension that registered it. First-come-first-served: | |
| 149 | - | // later registrations of the same command are dropped with a | |
| 150 | - | // warning. | |
| 151 | - | commandIndex map[string]*Extension | |
| 152 | + | // commandIndex maps a lower-cased slash-command name (without the | |
| 153 | + | // leading /) to its canonical registration. First-come-first-served: | |
| 154 | + | // later registrations that differ only by case are dropped with a warning. | |
| 155 | + | commandIndex map[string]commandRegistration | |
| 152 | 156 | ||
| 153 | 157 | // toolIndex maps an extension-defined tool name to its owning | |
| 154 | 158 | // extension. Same first-come-first-served rule as commandIndex. | |
| @@ -175,7 +179,7 @@ func New(zotHome, cwd, zotVersion, provider, model string, hooks HostHooks) *Man | |||
| 175 | 179 | model: model, | |
| 176 | 180 | hooks: hooks, | |
| 177 | 181 | ext: map[string]*Extension{}, | |
| 178 | - | commandIndex: map[string]*Extension{}, | |
| 182 | + | commandIndex: map[string]commandRegistration{}, | |
| 179 | 183 | toolIndex: map[string]*Extension{}, | |
| 180 | 184 | } | |
| 181 | 185 | } | |
| @@ -418,7 +422,7 @@ func (m *Manager) Reload(ctx context.Context, grace time.Duration) ReloadStats { | |||
| 418 | 422 | explicit := append([]string(nil), m.explicitPaths...) | |
| 419 | 423 | stats.Stopped = len(old) | |
| 420 | 424 | m.ext = map[string]*Extension{} | |
| 421 | - | m.commandIndex = map[string]*Extension{} | |
| 425 | + | m.commandIndex = map[string]commandRegistration{} | |
| 422 | 426 | m.toolIndex = map[string]*Extension{} | |
| 423 | 427 | m.explicitPaths = nil | |
| 424 | 428 | callback := m.onReload | |
| @@ -654,8 +658,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 654 | 658 | // future invocations don't dangle. The subprocess is gone; we | |
| 655 | 659 | // won't hear back about its commands or tool calls anymore. | |
| 656 | 660 | m.mu.Lock() | |
| 657 | - | for name, owner := range m.commandIndex { | |
| 658 | - | if owner == ext { | |
| 661 | + | for name, registration := range m.commandIndex { | |
| 662 | + | if registration.ext == ext { | |
| 659 | 663 | delete(m.commandIndex, name) | |
| 660 | 664 | } | |
| 661 | 665 | } | |
| @@ -690,9 +694,10 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 690 | 694 | // keep the race detector happy. | |
| 691 | 695 | m.mu.Lock() | |
| 692 | 696 | ext.commands = append(ext.commands, rc) | |
| 693 | - | _, shadowed := m.commandIndex[rc.Name] | |
| 697 | + | key := strings.ToLower(rc.Name) | |
| 698 | + | _, shadowed := m.commandIndex[key] | |
| 694 | 699 | if !shadowed { | |
| 695 | - | m.commandIndex[rc.Name] = ext | |
| 700 | + | m.commandIndex[key] = commandRegistration{ext: ext, name: rc.Name} | |
| 696 | 701 | } | |
| 697 | 702 | m.mu.Unlock() | |
| 698 | 703 | if shadowed { | |
| @@ -915,10 +920,11 @@ func (m *Manager) Diagnostics() []ExtensionDiagnostic { | |||
| 915 | 920 | default: | |
| 916 | 921 | } | |
| 917 | 922 | for _, c := range ext.commands { | |
| 923 | + | registration, ok := m.commandIndex[strings.ToLower(c.Name)] | |
| 918 | 924 | diag.Commands = append(diag.Commands, RegisteredCommandDiagnostic{ | |
| 919 | 925 | Name: c.Name, | |
| 920 | 926 | Description: c.Description, | |
| 921 | - | Active: m.commandIndex[c.Name] == ext, | |
| 927 | + | Active: ok && registration.ext == ext && registration.name == c.Name, | |
| 922 | 928 | }) | |
| 923 | 929 | } | |
| 924 | 930 | for _, t := range ext.tools { | |
| @@ -941,6 +947,10 @@ func (m *Manager) Commands() []CommandInfo { | |||
| 941 | 947 | var out []CommandInfo | |
| 942 | 948 | for _, ext := range m.ext { | |
| 943 | 949 | for _, c := range ext.commands { | |
| 950 | + | registration, ok := m.commandIndex[strings.ToLower(c.Name)] | |
| 951 | + | if !ok || registration.ext != ext || registration.name != c.Name { | |
| 952 | + | continue | |
| 953 | + | } | |
| 944 | 954 | out = append(out, CommandInfo{ | |
| 945 | 955 | Extension: ext.Manifest.Name, | |
| 946 | 956 | Name: c.Name, | |
| @@ -1048,15 +1058,15 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess | |||
| 1048 | 1058 | func (m *Manager) HasCommand(name string) bool { | |
| 1049 | 1059 | m.mu.RLock() | |
| 1050 | 1060 | defer m.mu.RUnlock() | |
| 1051 | - | _, ok := m.commandIndex[name] | |
| 1061 | + | _, ok := m.commandIndex[strings.ToLower(name)] | |
| 1052 | 1062 | return ok | |
| 1053 | 1063 | } | |
| 1054 | 1064 | ||
| 1055 | 1065 | func (m *Manager) CommandOwner(name string) string { | |
| 1056 | 1066 | m.mu.RLock() | |
| 1057 | 1067 | defer m.mu.RUnlock() | |
| 1058 | - | if ext, ok := m.commandIndex[name]; ok && ext != nil { | |
| 1059 | - | return ext.Manifest.Name | |
| 1068 | + | if registration, ok := m.commandIndex[strings.ToLower(name)]; ok && registration.ext != nil { | |
| 1069 | + | return registration.ext.Manifest.Name | |
| 1060 | 1070 | } | |
| 1061 | 1071 | return "" | |
| 1062 | 1072 | } | |
| @@ -1067,11 +1077,13 @@ func (m *Manager) CommandOwner(name string) string { | |||
| 1067 | 1077 | // (prompt / insert / display). | |
| 1068 | 1078 | func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Duration) (extproto.CommandResponseFromExt, error) { | |
| 1069 | 1079 | m.mu.RLock() | |
| 1070 | - | ext, ok := m.commandIndex[name] | |
| 1080 | + | registration, ok := m.commandIndex[strings.ToLower(name)] | |
| 1071 | 1081 | m.mu.RUnlock() | |
| 1072 | 1082 | if !ok { | |
| 1073 | 1083 | return extproto.CommandResponseFromExt{}, fmt.Errorf("no extension registered for /%s", name) | |
| 1074 | 1084 | } | |
| 1085 | + | ext := registration.ext | |
| 1086 | + | name = registration.name | |
| 1075 | 1087 | ||
| 1076 | 1088 | id := newCorrelationID() | |
| 1077 | 1089 | ch := make(chan extproto.CommandResponseFromExt, 1) | |
| @@ -132,6 +132,11 @@ type HostHooks interface { | |||||
| 132 | ClosePanel(extName, panelID string) | 132 | ClosePanel(extName, panelID string) | ||
| 133 | } | 133 | } | ||
| 134 | 134 | ||||
| 135 | + | type commandRegistration struct { | |||
| 136 | + | ext *Extension | |||
| 137 | + | name string | |||
| 138 | + | } | |||
| 139 | + | ||||
| 135 | // Manager owns every extension subprocess for the lifetime of zot. | 140 | // Manager owns every extension subprocess for the lifetime of zot. | ||
| 136 | type Manager struct { | 141 | type Manager struct { | ||
| 137 | zotHome string | 142 | zotHome string | ||
| @@ -144,11 +149,10 @@ type Manager struct { | |||||
| 144 | mu sync.RWMutex | 149 | mu sync.RWMutex | ||
| 145 | ext map[string]*Extension // keyed by manifest name | 150 | ext map[string]*Extension // keyed by manifest name | ||
| 146 | 151 | ||||
| 147 | - | // commandIndex maps a slash-command name (without the leading /) | 152 | + | // commandIndex maps a lower-cased slash-command name (without the |
| 148 | - | // to the extension that registered it. First-come-first-served: | 153 | + | // leading /) to its canonical registration. First-come-first-served: |
| 149 | - | // later registrations of the same command are dropped with a | 154 | + | // later registrations that differ only by case are dropped with a warning. |
| 150 | - | // warning. | 155 | + | commandIndex map[string]commandRegistration |
| 151 | - | commandIndex map[string]*Extension | |||
| 152 | 156 | ||||
| 153 | // toolIndex maps an extension-defined tool name to its owning | 157 | // toolIndex maps an extension-defined tool name to its owning | ||
| 154 | // extension. Same first-come-first-served rule as commandIndex. | 158 | // extension. Same first-come-first-served rule as commandIndex. | ||
| @@ -175,7 +179,7 @@ func New(zotHome, cwd, zotVersion, provider, model string, hooks HostHooks) *Man | |||||
| 175 | model: model, | 179 | model: model, | ||
| 176 | hooks: hooks, | 180 | hooks: hooks, | ||
| 177 | ext: map[string]*Extension{}, | 181 | ext: map[string]*Extension{}, | ||
| 178 | - | commandIndex: map[string]*Extension{}, | 182 | + | commandIndex: map[string]commandRegistration{}, |
| 179 | toolIndex: map[string]*Extension{}, | 183 | toolIndex: map[string]*Extension{}, | ||
| 180 | } | 184 | } | ||
| 181 | } | 185 | } | ||
| @@ -418,7 +422,7 @@ func (m *Manager) Reload(ctx context.Context, grace time.Duration) ReloadStats { | |||||
| 418 | explicit := append([]string(nil), m.explicitPaths...) | 422 | explicit := append([]string(nil), m.explicitPaths...) | ||
| 419 | stats.Stopped = len(old) | 423 | stats.Stopped = len(old) | ||
| 420 | m.ext = map[string]*Extension{} | 424 | m.ext = map[string]*Extension{} | ||
| 421 | - | m.commandIndex = map[string]*Extension{} | 425 | + | m.commandIndex = map[string]commandRegistration{} |
| 422 | m.toolIndex = map[string]*Extension{} | 426 | m.toolIndex = map[string]*Extension{} | ||
| 423 | m.explicitPaths = nil | 427 | m.explicitPaths = nil | ||
| 424 | callback := m.onReload | 428 | callback := m.onReload | ||
| @@ -654,8 +658,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||||
| 654 | // future invocations don't dangle. The subprocess is gone; we | 658 | // future invocations don't dangle. The subprocess is gone; we | ||
| 655 | // won't hear back about its commands or tool calls anymore. | 659 | // won't hear back about its commands or tool calls anymore. | ||
| 656 | m.mu.Lock() | 660 | m.mu.Lock() | ||
| 657 | - | for name, owner := range m.commandIndex { | 661 | + | for name, registration := range m.commandIndex { |
| 658 | - | if owner == ext { | 662 | + | if registration.ext == ext { |
| 659 | delete(m.commandIndex, name) | 663 | delete(m.commandIndex, name) | ||
| 660 | } | 664 | } | ||
| 661 | } | 665 | } | ||
| @@ -690,9 +694,10 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||||
| 690 | // keep the race detector happy. | 694 | // keep the race detector happy. | ||
| 691 | m.mu.Lock() | 695 | m.mu.Lock() | ||
| 692 | ext.commands = append(ext.commands, rc) | 696 | ext.commands = append(ext.commands, rc) | ||
| 693 | - | _, shadowed := m.commandIndex[rc.Name] | 697 | + | key := strings.ToLower(rc.Name) |
| 698 | + | _, shadowed := m.commandIndex[key] | |||
| 694 | if !shadowed { | 699 | if !shadowed { | ||
| 695 | - | m.commandIndex[rc.Name] = ext | 700 | + | m.commandIndex[key] = commandRegistration{ext: ext, name: rc.Name} |
| 696 | } | 701 | } | ||
| 697 | m.mu.Unlock() | 702 | m.mu.Unlock() | ||
| 698 | if shadowed { | 703 | if shadowed { | ||
| @@ -915,10 +920,11 @@ func (m *Manager) Diagnostics() []ExtensionDiagnostic { | |||||
| 915 | default: | 920 | default: | ||
| 916 | } | 921 | } | ||
| 917 | for _, c := range ext.commands { | 922 | for _, c := range ext.commands { | ||
| 923 | + | registration, ok := m.commandIndex[strings.ToLower(c.Name)] | |||
| 918 | diag.Commands = append(diag.Commands, RegisteredCommandDiagnostic{ | 924 | diag.Commands = append(diag.Commands, RegisteredCommandDiagnostic{ | ||
| 919 | Name: c.Name, | 925 | Name: c.Name, | ||
| 920 | Description: c.Description, | 926 | Description: c.Description, | ||
| 921 | - | Active: m.commandIndex[c.Name] == ext, | 927 | + | Active: ok && registration.ext == ext && registration.name == c.Name, |
| 922 | }) | 928 | }) | ||
| 923 | } | 929 | } | ||
| 924 | for _, t := range ext.tools { | 930 | for _, t := range ext.tools { | ||
| @@ -941,6 +947,10 @@ func (m *Manager) Commands() []CommandInfo { | |||||
| 941 | var out []CommandInfo | 947 | var out []CommandInfo | ||
| 942 | for _, ext := range m.ext { | 948 | for _, ext := range m.ext { | ||
| 943 | for _, c := range ext.commands { | 949 | for _, c := range ext.commands { | ||
| 950 | + | registration, ok := m.commandIndex[strings.ToLower(c.Name)] | |||
| 951 | + | if !ok || registration.ext != ext || registration.name != c.Name { | |||
| 952 | + | continue | |||
| 953 | + | } | |||
| 944 | out = append(out, CommandInfo{ | 954 | out = append(out, CommandInfo{ | ||
| 945 | Extension: ext.Manifest.Name, | 955 | Extension: ext.Manifest.Name, | ||
| 946 | Name: c.Name, | 956 | Name: c.Name, | ||
| @@ -1048,15 +1058,15 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess | |||||
| 1048 | func (m *Manager) HasCommand(name string) bool { | 1058 | func (m *Manager) HasCommand(name string) bool { | ||
| 1049 | m.mu.RLock() | 1059 | m.mu.RLock() | ||
| 1050 | defer m.mu.RUnlock() | 1060 | defer m.mu.RUnlock() | ||
| 1051 | - | _, ok := m.commandIndex[name] | 1061 | + | _, ok := m.commandIndex[strings.ToLower(name)] |
| 1052 | return ok | 1062 | return ok | ||
| 1053 | } | 1063 | } | ||
| 1054 | 1064 | ||||
| 1055 | func (m *Manager) CommandOwner(name string) string { | 1065 | func (m *Manager) CommandOwner(name string) string { | ||
| 1056 | m.mu.RLock() | 1066 | m.mu.RLock() | ||
| 1057 | defer m.mu.RUnlock() | 1067 | defer m.mu.RUnlock() | ||
| 1058 | - | if ext, ok := m.commandIndex[name]; ok && ext != nil { | 1068 | + | if registration, ok := m.commandIndex[strings.ToLower(name)]; ok && registration.ext != nil { |
| 1059 | - | return ext.Manifest.Name | 1069 | + | return registration.ext.Manifest.Name |
| 1060 | } | 1070 | } | ||
| 1061 | return "" | 1071 | return "" | ||
| 1062 | } | 1072 | } | ||
| @@ -1067,11 +1077,13 @@ func (m *Manager) CommandOwner(name string) string { | |||||
| 1067 | // (prompt / insert / display). | 1077 | // (prompt / insert / display). | ||
| 1068 | func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Duration) (extproto.CommandResponseFromExt, error) { | 1078 | func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Duration) (extproto.CommandResponseFromExt, error) { | ||
| 1069 | m.mu.RLock() | 1079 | m.mu.RLock() | ||
| 1070 | - | ext, ok := m.commandIndex[name] | 1080 | + | registration, ok := m.commandIndex[strings.ToLower(name)] |
| 1071 | m.mu.RUnlock() | 1081 | m.mu.RUnlock() | ||
| 1072 | if !ok { | 1082 | if !ok { | ||
| 1073 | return extproto.CommandResponseFromExt{}, fmt.Errorf("no extension registered for /%s", name) | 1083 | return extproto.CommandResponseFromExt{}, fmt.Errorf("no extension registered for /%s", name) | ||
| 1074 | } | 1084 | } | ||
| 1085 | + | ext := registration.ext | |||
| 1086 | + | name = registration.name | |||
| 1075 | 1087 | ||||
| 1076 | id := newCorrelationID() | 1088 | id := newCorrelationID() | ||
| 1077 | ch := make(chan extproto.CommandResponseFromExt, 1) | 1089 | ch := make(chan extproto.CommandResponseFromExt, 1) | ||
M packages/agent/extensions/manager_test.go +35 -9
| @@ -82,12 +82,19 @@ func writeMockExtension(t *testing.T, root string) { | |||
| 82 | 82 | // the manager to send command_invoked. | |
| 83 | 83 | script := `#!/bin/sh | |
| 84 | 84 | printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}' | |
| 85 | - | printf '%s\n' '{"type":"register_command","name":"ping","description":"ping/pong"}' | |
| 85 | + | printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}' | |
| 86 | 86 | while IFS= read -r line; do | |
| 87 | 87 | case "$line" in | |
| 88 | 88 | *'"type":"command_invoked"'*) | |
| 89 | 89 | id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') | |
| 90 | - | printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}" | |
| 90 | + | case "$line" in | |
| 91 | + | *'"name":"Ping"'*) | |
| 92 | + | printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}" | |
| 93 | + | ;; | |
| 94 | + | *) | |
| 95 | + | printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"error\":\"non-canonical command name\"}" | |
| 96 | + | ;; | |
| 97 | + | esac | |
| 91 | 98 | ;; | |
| 92 | 99 | *'"type":"shutdown"'*) | |
| 93 | 100 | printf '%s\n' '{"type":"shutdown_ack"}' | |
| @@ -168,19 +175,22 @@ func TestManagerSpawnAndInvoke(t *testing.T) { | |||
| 168 | 175 | cmds := mgr.Commands() | |
| 169 | 176 | found := false | |
| 170 | 177 | for _, c := range cmds { | |
| 171 | - | if c.Name == "ping" { | |
| 178 | + | if c.Name == "Ping" { | |
| 172 | 179 | found = true | |
| 173 | 180 | break | |
| 174 | 181 | } | |
| 175 | 182 | } | |
| 176 | 183 | if !found { | |
| 177 | - | t.Fatalf("expected command 'ping', got %#v", cmds) | |
| 184 | + | t.Fatalf("expected canonical command 'Ping', got %#v", cmds) | |
| 178 | 185 | } | |
| 179 | - | if !mgr.HasCommand("ping") { | |
| 180 | - | t.Fatal("HasCommand(\"ping\") = false") | |
| 186 | + | if !mgr.HasCommand("PING") { | |
| 187 | + | t.Fatal("HasCommand(\"PING\") = false") | |
| 188 | + | } | |
| 189 | + | if owner := mgr.CommandOwner("pInG"); owner != "mock" { | |
| 190 | + | t.Fatalf("CommandOwner(\"pInG\") = %q, want mock", owner) | |
| 181 | 191 | } | |
| 182 | 192 | ||
| 183 | - | resp, err := mgr.Invoke(context.Background(), "ping", "", 2*time.Second) | |
| 193 | + | resp, err := mgr.Invoke(context.Background(), "PING", "", 2*time.Second) | |
| 184 | 194 | if err != nil { | |
| 185 | 195 | t.Fatalf("invoke: %v", err) | |
| 186 | 196 | } | |
| @@ -215,7 +225,8 @@ func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) { | |||
| 215 | 225 | } | |
| 216 | 226 | ||
| 217 | 227 | writeDiagExtension("a-first", `#!/bin/sh | |
| 218 | - | printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["tools"]}' | |
| 228 | + | printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}' | |
| 229 | + | printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}' | |
| 219 | 230 | printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}' | |
| 220 | 231 | printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":' | |
| 221 | 232 | printf '%s\n' '{"type":"ready"}' | |
| @@ -226,7 +237,8 @@ while IFS= read -r line; do | |||
| 226 | 237 | done | |
| 227 | 238 | `) | |
| 228 | 239 | writeDiagExtension("b-second", `#!/bin/sh | |
| 229 | - | printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["tools"]}' | |
| 240 | + | printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}' | |
| 241 | + | printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}' | |
| 230 | 242 | printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}' | |
| 231 | 243 | printf '%s\n' '{"type":"ready"}' | |
| 232 | 244 | while IFS= read -r line; do | |
| @@ -255,6 +267,7 @@ done | |||
| 255 | 267 | } | |
| 256 | 268 | ||
| 257 | 269 | var shadowedTool bool | |
| 270 | + | var activeCommands, shadowedCommands int | |
| 258 | 271 | var conflictMessage bool | |
| 259 | 272 | for _, d := range diags { | |
| 260 | 273 | if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") { | |
| @@ -265,10 +278,23 @@ done | |||
| 265 | 278 | shadowedTool = true | |
| 266 | 279 | } | |
| 267 | 280 | } | |
| 281 | + | for _, command := range d.Commands { | |
| 282 | + | if !strings.EqualFold(command.Name, "casetest") { | |
| 283 | + | continue | |
| 284 | + | } | |
| 285 | + | if command.Active { | |
| 286 | + | activeCommands++ | |
| 287 | + | } else { | |
| 288 | + | shadowedCommands++ | |
| 289 | + | } | |
| 290 | + | } | |
| 268 | 291 | } | |
| 269 | 292 | if !shadowedTool { | |
| 270 | 293 | t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags) | |
| 271 | 294 | } | |
| 295 | + | if activeCommands != 1 || shadowedCommands != 1 { | |
| 296 | + | t.Fatalf("case-only command registrations: active=%d shadowed=%d; diagnostics=%#v", activeCommands, shadowedCommands, diags) | |
| 297 | + | } | |
| 272 | 298 | if !conflictMessage { | |
| 273 | 299 | t.Fatalf("expected conflict diagnostic, got %#v", diags) | |
| 274 | 300 | } | |
| @@ -82,12 +82,19 @@ func writeMockExtension(t *testing.T, root string) { | |||||
| 82 | // the manager to send command_invoked. | 82 | // the manager to send command_invoked. | ||
| 83 | script := `#!/bin/sh | 83 | script := `#!/bin/sh | ||
| 84 | printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}' | 84 | printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}' | ||
| 85 | - | printf '%s\n' '{"type":"register_command","name":"ping","description":"ping/pong"}' | 85 | + | printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}' |
| 86 | while IFS= read -r line; do | 86 | while IFS= read -r line; do | ||
| 87 | case "$line" in | 87 | case "$line" in | ||
| 88 | *'"type":"command_invoked"'*) | 88 | *'"type":"command_invoked"'*) | ||
| 89 | id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') | 89 | id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') | ||
| 90 | - | printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}" | 90 | + | case "$line" in |
| 91 | + | *'"name":"Ping"'*) | |||
| 92 | + | printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}" | |||
| 93 | + | ;; | |||
| 94 | + | *) | |||
| 95 | + | printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"error\":\"non-canonical command name\"}" | |||
| 96 | + | ;; | |||
| 97 | + | esac | |||
| 91 | ;; | 98 | ;; | ||
| 92 | *'"type":"shutdown"'*) | 99 | *'"type":"shutdown"'*) | ||
| 93 | printf '%s\n' '{"type":"shutdown_ack"}' | 100 | printf '%s\n' '{"type":"shutdown_ack"}' | ||
| @@ -168,19 +175,22 @@ func TestManagerSpawnAndInvoke(t *testing.T) { | |||||
| 168 | cmds := mgr.Commands() | 175 | cmds := mgr.Commands() | ||
| 169 | found := false | 176 | found := false | ||
| 170 | for _, c := range cmds { | 177 | for _, c := range cmds { | ||
| 171 | - | if c.Name == "ping" { | 178 | + | if c.Name == "Ping" { |
| 172 | found = true | 179 | found = true | ||
| 173 | break | 180 | break | ||
| 174 | } | 181 | } | ||
| 175 | } | 182 | } | ||
| 176 | if !found { | 183 | if !found { | ||
| 177 | - | t.Fatalf("expected command 'ping', got %#v", cmds) | 184 | + | t.Fatalf("expected canonical command 'Ping', got %#v", cmds) |
| 178 | } | 185 | } | ||
| 179 | - | if !mgr.HasCommand("ping") { | 186 | + | if !mgr.HasCommand("PING") { |
| 180 | - | t.Fatal("HasCommand(\"ping\") = false") | 187 | + | t.Fatal("HasCommand(\"PING\") = false") |
| 188 | + | } | |||
| 189 | + | if owner := mgr.CommandOwner("pInG"); owner != "mock" { | |||
| 190 | + | t.Fatalf("CommandOwner(\"pInG\") = %q, want mock", owner) | |||
| 181 | } | 191 | } | ||
| 182 | 192 | ||||
| 183 | - | resp, err := mgr.Invoke(context.Background(), "ping", "", 2*time.Second) | 193 | + | resp, err := mgr.Invoke(context.Background(), "PING", "", 2*time.Second) |
| 184 | if err != nil { | 194 | if err != nil { | ||
| 185 | t.Fatalf("invoke: %v", err) | 195 | t.Fatalf("invoke: %v", err) | ||
| 186 | } | 196 | } | ||
| @@ -215,7 +225,8 @@ func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) { | |||||
| 215 | } | 225 | } | ||
| 216 | 226 | ||||
| 217 | writeDiagExtension("a-first", `#!/bin/sh | 227 | writeDiagExtension("a-first", `#!/bin/sh | ||
| 218 | - | printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["tools"]}' | 228 | + | printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}' |
| 229 | + | printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}' | |||
| 219 | printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}' | 230 | printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}' | ||
| 220 | printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":' | 231 | printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":' | ||
| 221 | printf '%s\n' '{"type":"ready"}' | 232 | printf '%s\n' '{"type":"ready"}' | ||
| @@ -226,7 +237,8 @@ while IFS= read -r line; do | |||||
| 226 | done | 237 | done | ||
| 227 | `) | 238 | `) | ||
| 228 | writeDiagExtension("b-second", `#!/bin/sh | 239 | writeDiagExtension("b-second", `#!/bin/sh | ||
| 229 | - | printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["tools"]}' | 240 | + | printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}' |
| 241 | + | printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}' | |||
| 230 | printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}' | 242 | printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}' | ||
| 231 | printf '%s\n' '{"type":"ready"}' | 243 | printf '%s\n' '{"type":"ready"}' | ||
| 232 | while IFS= read -r line; do | 244 | while IFS= read -r line; do | ||
| @@ -255,6 +267,7 @@ done | |||||
| 255 | } | 267 | } | ||
| 256 | 268 | ||||
| 257 | var shadowedTool bool | 269 | var shadowedTool bool | ||
| 270 | + | var activeCommands, shadowedCommands int | |||
| 258 | var conflictMessage bool | 271 | var conflictMessage bool | ||
| 259 | for _, d := range diags { | 272 | for _, d := range diags { | ||
| 260 | if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") { | 273 | if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") { | ||
| @@ -265,10 +278,23 @@ done | |||||
| 265 | shadowedTool = true | 278 | shadowedTool = true | ||
| 266 | } | 279 | } | ||
| 267 | } | 280 | } | ||
| 281 | + | for _, command := range d.Commands { | |||
| 282 | + | if !strings.EqualFold(command.Name, "casetest") { | |||
| 283 | + | continue | |||
| 284 | + | } | |||
| 285 | + | if command.Active { | |||
| 286 | + | activeCommands++ | |||
| 287 | + | } else { | |||
| 288 | + | shadowedCommands++ | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 268 | } | 291 | } | ||
| 269 | if !shadowedTool { | 292 | if !shadowedTool { | ||
| 270 | t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags) | 293 | t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags) | ||
| 271 | } | 294 | } | ||
| 295 | + | if activeCommands != 1 || shadowedCommands != 1 { | |||
| 296 | + | t.Fatalf("case-only command registrations: active=%d shadowed=%d; diagnostics=%#v", activeCommands, shadowedCommands, diags) | |||
| 297 | + | } | |||
| 272 | if !conflictMessage { | 298 | if !conflictMessage { | ||
| 273 | t.Fatalf("expected conflict diagnostic, got %#v", diags) | 299 | t.Fatalf("expected conflict diagnostic, got %#v", diags) | ||
| 274 | } | 300 | } | ||
M packages/agent/modes/bot/commands.go +22 -0
| @@ -2,6 +2,28 @@ package bot | |||
| 2 | 2 | ||
| 3 | 3 | import "strings" | |
| 4 | 4 | ||
| 5 | + | // ParseCommand recognizes the built-in slash commands shared by messaging | |
| 6 | + | // adapters. Command names are case-insensitive, and trailing arguments do not | |
| 7 | + | // affect recognition. | |
| 8 | + | func ParseCommand(text string) (Command, bool) { | |
| 9 | + | fields := strings.Fields(text) | |
| 10 | + | if len(fields) == 0 { | |
| 11 | + | return 0, false | |
| 12 | + | } | |
| 13 | + | switch strings.ToLower(fields[0]) { | |
| 14 | + | case "/start": | |
| 15 | + | return CmdStart, true | |
| 16 | + | case "/help": | |
| 17 | + | return CmdHelp, true | |
| 18 | + | case "/status": | |
| 19 | + | return CmdStatus, true | |
| 20 | + | case "/stop": | |
| 21 | + | return CmdStop, true | |
| 22 | + | default: | |
| 23 | + | return 0, false | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 5 | 27 | // IsStopCommand reports whether text should abort the active turn. | |
| 6 | 28 | // Users often type plain "stop" rather than bot-style "/stop"; keep | |
| 7 | 29 | // this intentionally narrow so normal prompts like "stop doing X" | |
| @@ -2,6 +2,28 @@ package bot | |||||
| 2 | 2 | ||||
| 3 | import "strings" | 3 | import "strings" | ||
| 4 | 4 | ||||
| 5 | + | // ParseCommand recognizes the built-in slash commands shared by messaging | |||
| 6 | + | // adapters. Command names are case-insensitive, and trailing arguments do not | |||
| 7 | + | // affect recognition. | |||
| 8 | + | func ParseCommand(text string) (Command, bool) { | |||
| 9 | + | fields := strings.Fields(text) | |||
| 10 | + | if len(fields) == 0 { | |||
| 11 | + | return 0, false | |||
| 12 | + | } | |||
| 13 | + | switch strings.ToLower(fields[0]) { | |||
| 14 | + | case "/start": | |||
| 15 | + | return CmdStart, true | |||
| 16 | + | case "/help": | |||
| 17 | + | return CmdHelp, true | |||
| 18 | + | case "/status": | |||
| 19 | + | return CmdStatus, true | |||
| 20 | + | case "/stop": | |||
| 21 | + | return CmdStop, true | |||
| 22 | + | default: | |||
| 23 | + | return 0, false | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 5 | // IsStopCommand reports whether text should abort the active turn. | 27 | // IsStopCommand reports whether text should abort the active turn. | ||
| 6 | // Users often type plain "stop" rather than bot-style "/stop"; keep | 28 | // Users often type plain "stop" rather than bot-style "/stop"; keep | ||
| 7 | // this intentionally narrow so normal prompts like "stop doing X" | 29 | // this intentionally narrow so normal prompts like "stop doing X" | ||
A packages/agent/modes/bot/commands_test.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | package bot | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestParseCommandCaseInsensitive(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | text string | |
| 8 | + | want Command | |
| 9 | + | ok bool | |
| 10 | + | }{ | |
| 11 | + | {text: "/start", want: CmdStart, ok: true}, | |
| 12 | + | {text: " /HELP ", want: CmdHelp, ok: true}, | |
| 13 | + | {text: "/Status", want: CmdStatus, ok: true}, | |
| 14 | + | {text: "/STOP", want: CmdStop, ok: true}, | |
| 15 | + | {text: "/stop please", want: CmdStop, ok: true}, | |
| 16 | + | {text: "status", ok: false}, | |
| 17 | + | {text: "", ok: false}, | |
| 18 | + | } | |
| 19 | + | for _, tt := range tests { | |
| 20 | + | got, ok := ParseCommand(tt.text) | |
| 21 | + | if ok != tt.ok || ok && got != tt.want { | |
| 22 | + | t.Errorf("ParseCommand(%q) = (%v, %v), want (%v, %v)", tt.text, got, ok, tt.want, tt.ok) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestIsStopCommand(t *testing.T) { | |
| 28 | + | for _, text := range []string{"stop", " STOP ", "Stop"} { | |
| 29 | + | if !IsStopCommand(text) { | |
| 30 | + | t.Errorf("IsStopCommand(%q) = false", text) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | for _, text := range []string{"/stop", "stop please", "please stop", ""} { | |
| 34 | + | if IsStopCommand(text) { | |
| 35 | + | t.Errorf("IsStopCommand(%q) = true", text) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | package bot | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestParseCommandCaseInsensitive(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | text string | |||
| 8 | + | want Command | |||
| 9 | + | ok bool | |||
| 10 | + | }{ | |||
| 11 | + | {text: "/start", want: CmdStart, ok: true}, | |||
| 12 | + | {text: " /HELP ", want: CmdHelp, ok: true}, | |||
| 13 | + | {text: "/Status", want: CmdStatus, ok: true}, | |||
| 14 | + | {text: "/STOP", want: CmdStop, ok: true}, | |||
| 15 | + | {text: "/stop please", want: CmdStop, ok: true}, | |||
| 16 | + | {text: "status", ok: false}, | |||
| 17 | + | {text: "", ok: false}, | |||
| 18 | + | } | |||
| 19 | + | for _, tt := range tests { | |||
| 20 | + | got, ok := ParseCommand(tt.text) | |||
| 21 | + | if ok != tt.ok || ok && got != tt.want { | |||
| 22 | + | t.Errorf("ParseCommand(%q) = (%v, %v), want (%v, %v)", tt.text, got, ok, tt.want, tt.ok) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestIsStopCommand(t *testing.T) { | |||
| 28 | + | for _, text := range []string{"stop", " STOP ", "Stop"} { | |||
| 29 | + | if !IsStopCommand(text) { | |||
| 30 | + | t.Errorf("IsStopCommand(%q) = false", text) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | for _, text := range []string{"/stop", "stop please", "please stop", ""} { | |||
| 34 | + | if IsStopCommand(text) { | |||
| 35 | + | t.Errorf("IsStopCommand(%q) = true", text) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | } | |||
M packages/agent/modes/interactive.go +1 -1
| @@ -4059,7 +4059,7 @@ func longestCommonPrefix(ss []string) string { | |||
| 4059 | 4059 | ||
| 4060 | 4060 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |
| 4061 | 4061 | parts := strings.Fields(cmd) | |
| 4062 | - | switch parts[0] { | |
| 4062 | + | switch strings.ToLower(parts[0]) { | |
| 4063 | 4063 | case "/exit": | |
| 4064 | 4064 | return true | |
| 4065 | 4065 | case "/clear": | |
| @@ -4059,7 +4059,7 @@ func longestCommonPrefix(ss []string) string { | |||||
| 4059 | 4059 | ||||
| 4060 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | 4060 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | ||
| 4061 | parts := strings.Fields(cmd) | 4061 | parts := strings.Fields(cmd) | ||
| 4062 | - | switch parts[0] { | 4062 | + | switch strings.ToLower(parts[0]) { |
| 4063 | case "/exit": | 4063 | case "/exit": | ||
| 4064 | return true | 4064 | return true | ||
| 4065 | case "/clear": | 4065 | case "/clear": | ||
A packages/agent/modes/slash_case_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestRunSlashCommandNameCaseInsensitive(t *testing.T) { | |
| 10 | + | i := &Interactive{} | |
| 11 | + | if !i.runSlash(context.Background(), "/Exit") { | |
| 12 | + | t.Fatal("/Exit did not dispatch to /exit") | |
| 13 | + | } | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func TestRunSlashPreservesArgumentCase(t *testing.T) { | |
| 17 | + | var got string | |
| 18 | + | i := &Interactive{cfg: InteractiveConfig{ | |
| 19 | + | ChangeCWD: func(path string) error { | |
| 20 | + | got = path | |
| 21 | + | return errors.New("stop after capture") | |
| 22 | + | }, | |
| 23 | + | }} | |
| 24 | + | i.runSlash(context.Background(), "/CD /Users/Pat/Mixed Case") | |
| 25 | + | if got != "/Users/Pat/Mixed Case" { | |
| 26 | + | t.Fatalf("ChangeCWD path = %q, want original argument case", got) | |
| 27 | + | } | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestRunSlashCommandNameCaseInsensitive(t *testing.T) { | |||
| 10 | + | i := &Interactive{} | |||
| 11 | + | if !i.runSlash(context.Background(), "/Exit") { | |||
| 12 | + | t.Fatal("/Exit did not dispatch to /exit") | |||
| 13 | + | } | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func TestRunSlashPreservesArgumentCase(t *testing.T) { | |||
| 17 | + | var got string | |||
| 18 | + | i := &Interactive{cfg: InteractiveConfig{ | |||
| 19 | + | ChangeCWD: func(path string) error { | |||
| 20 | + | got = path | |||
| 21 | + | return errors.New("stop after capture") | |||
| 22 | + | }, | |||
| 23 | + | }} | |||
| 24 | + | i.runSlash(context.Background(), "/CD /Users/Pat/Mixed Case") | |||
| 25 | + | if got != "/Users/Pat/Mixed Case" { | |||
| 26 | + | t.Fatalf("ChangeCWD path = %q, want original argument case", got) | |||
| 27 | + | } | |||
| 28 | + | } | |||
M packages/agent/modes/slash_suggest.go +13 -6
| @@ -24,7 +24,7 @@ type slashCommand struct { | |||
| 24 | 24 | // or rebuild the agent) need a quiet state; the rest run alongside | |
| 25 | 25 | // the streaming response without trouble. | |
| 26 | 26 | func slashCancelsTurn(head string) bool { | |
| 27 | - | switch head { | |
| 27 | + | switch strings.ToLower(head) { | |
| 28 | 28 | case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd": | |
| 29 | 29 | return true | |
| 30 | 30 | } | |
| @@ -86,7 +86,13 @@ type slashSuggester struct { | |||
| 86 | 86 | // randomises). | |
| 87 | 87 | func (s *slashSuggester) SetExtra(cmds []slashCommand) { | |
| 88 | 88 | sorted := append([]slashCommand(nil), cmds...) | |
| 89 | - | sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) | |
| 89 | + | sort.Slice(sorted, func(i, j int) bool { | |
| 90 | + | left, right := strings.ToLower(sorted[i].Name), strings.ToLower(sorted[j].Name) | |
| 91 | + | if left == right { | |
| 92 | + | return sorted[i].Name < sorted[j].Name | |
| 93 | + | } | |
| 94 | + | return left < right | |
| 95 | + | }) | |
| 90 | 96 | s.extra = sorted | |
| 91 | 97 | } | |
| 92 | 98 | ||
| @@ -113,7 +119,7 @@ func (s *slashSuggester) allCatalog() []slashCommand { | |||
| 113 | 119 | for _, c := range s.extra { | |
| 114 | 120 | dup := false | |
| 115 | 121 | for _, b := range base { | |
| 116 | - | if b.Name == c.Name { | |
| 122 | + | if strings.EqualFold(b.Name, c.Name) { | |
| 117 | 123 | dup = true | |
| 118 | 124 | break | |
| 119 | 125 | } | |
| @@ -189,12 +195,12 @@ func isKnownSlashCommand(text string) bool { | |||
| 189 | 195 | head = text[:i] | |
| 190 | 196 | } | |
| 191 | 197 | for _, c := range slashCatalog { | |
| 192 | - | if c.Name == head { | |
| 198 | + | if strings.EqualFold(c.Name, head) { | |
| 193 | 199 | return true | |
| 194 | 200 | } | |
| 195 | 201 | } | |
| 196 | 202 | for _, h := range hiddenSlashCommands { | |
| 197 | - | if h == head { | |
| 203 | + | if strings.EqualFold(h, head) { | |
| 198 | 204 | return true | |
| 199 | 205 | } | |
| 200 | 206 | } | |
| @@ -222,6 +228,7 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||
| 222 | 228 | if idx := strings.IndexByte(input, ' '); idx >= 0 { | |
| 223 | 229 | return nil | |
| 224 | 230 | } | |
| 231 | + | matchPrefix := strings.ToLower(input) | |
| 225 | 232 | var out []slashCommand | |
| 226 | 233 | for _, c := range s.allCatalog() { | |
| 227 | 234 | if c.Header { | |
| @@ -231,7 +238,7 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||
| 231 | 238 | out = append(out, c) | |
| 232 | 239 | continue | |
| 233 | 240 | } | |
| 234 | - | if strings.HasPrefix(c.Name, input) { | |
| 241 | + | if strings.HasPrefix(strings.ToLower(c.Name), matchPrefix) { | |
| 235 | 242 | out = append(out, c) | |
| 236 | 243 | } | |
| 237 | 244 | } | |
| @@ -24,7 +24,7 @@ type slashCommand struct { | |||||
| 24 | // or rebuild the agent) need a quiet state; the rest run alongside | 24 | // or rebuild the agent) need a quiet state; the rest run alongside | ||
| 25 | // the streaming response without trouble. | 25 | // the streaming response without trouble. | ||
| 26 | func slashCancelsTurn(head string) bool { | 26 | func slashCancelsTurn(head string) bool { | ||
| 27 | - | switch head { | 27 | + | switch strings.ToLower(head) { |
| 28 | case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd": | 28 | case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd": | ||
| 29 | return true | 29 | return true | ||
| 30 | } | 30 | } | ||
| @@ -86,7 +86,13 @@ type slashSuggester struct { | |||||
| 86 | // randomises). | 86 | // randomises). | ||
| 87 | func (s *slashSuggester) SetExtra(cmds []slashCommand) { | 87 | func (s *slashSuggester) SetExtra(cmds []slashCommand) { | ||
| 88 | sorted := append([]slashCommand(nil), cmds...) | 88 | sorted := append([]slashCommand(nil), cmds...) | ||
| 89 | - | sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) | 89 | + | sort.Slice(sorted, func(i, j int) bool { |
| 90 | + | left, right := strings.ToLower(sorted[i].Name), strings.ToLower(sorted[j].Name) | |||
| 91 | + | if left == right { | |||
| 92 | + | return sorted[i].Name < sorted[j].Name | |||
| 93 | + | } | |||
| 94 | + | return left < right | |||
| 95 | + | }) | |||
| 90 | s.extra = sorted | 96 | s.extra = sorted | ||
| 91 | } | 97 | } | ||
| 92 | 98 | ||||
| @@ -113,7 +119,7 @@ func (s *slashSuggester) allCatalog() []slashCommand { | |||||
| 113 | for _, c := range s.extra { | 119 | for _, c := range s.extra { | ||
| 114 | dup := false | 120 | dup := false | ||
| 115 | for _, b := range base { | 121 | for _, b := range base { | ||
| 116 | - | if b.Name == c.Name { | 122 | + | if strings.EqualFold(b.Name, c.Name) { |
| 117 | dup = true | 123 | dup = true | ||
| 118 | break | 124 | break | ||
| 119 | } | 125 | } | ||
| @@ -189,12 +195,12 @@ func isKnownSlashCommand(text string) bool { | |||||
| 189 | head = text[:i] | 195 | head = text[:i] | ||
| 190 | } | 196 | } | ||
| 191 | for _, c := range slashCatalog { | 197 | for _, c := range slashCatalog { | ||
| 192 | - | if c.Name == head { | 198 | + | if strings.EqualFold(c.Name, head) { |
| 193 | return true | 199 | return true | ||
| 194 | } | 200 | } | ||
| 195 | } | 201 | } | ||
| 196 | for _, h := range hiddenSlashCommands { | 202 | for _, h := range hiddenSlashCommands { | ||
| 197 | - | if h == head { | 203 | + | if strings.EqualFold(h, head) { |
| 198 | return true | 204 | return true | ||
| 199 | } | 205 | } | ||
| 200 | } | 206 | } | ||
| @@ -222,6 +228,7 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||||
| 222 | if idx := strings.IndexByte(input, ' '); idx >= 0 { | 228 | if idx := strings.IndexByte(input, ' '); idx >= 0 { | ||
| 223 | return nil | 229 | return nil | ||
| 224 | } | 230 | } | ||
| 231 | + | matchPrefix := strings.ToLower(input) | |||
| 225 | var out []slashCommand | 232 | var out []slashCommand | ||
| 226 | for _, c := range s.allCatalog() { | 233 | for _, c := range s.allCatalog() { | ||
| 227 | if c.Header { | 234 | if c.Header { | ||
| @@ -231,7 +238,7 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||||
| 231 | out = append(out, c) | 238 | out = append(out, c) | ||
| 232 | continue | 239 | continue | ||
| 233 | } | 240 | } | ||
| 234 | - | if strings.HasPrefix(c.Name, input) { | 241 | + | if strings.HasPrefix(strings.ToLower(c.Name), matchPrefix) { |
| 235 | out = append(out, c) | 242 | out = append(out, c) | ||
| 236 | } | 243 | } | ||
| 237 | } | 244 | } | ||
M packages/agent/modes/slash_suggest_test.go +22 -0
| @@ -39,6 +39,28 @@ func TestSlashSuggesterHasSwarm(t *testing.T) { | |||
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | |
| 43 | + | s := newSlashSuggester() | |
| 44 | + | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | |
| 45 | + | t.Fatalf("/EX did not suggest /exit: %v", got) | |
| 46 | + | } | |
| 47 | + | if !isKnownSlashCommand("/Exit") { | |
| 48 | + | t.Fatal("/Exit was not recognized as a built-in command") | |
| 49 | + | } | |
| 50 | + | if !slashCancelsTurn("/CLEAR") { | |
| 51 | + | t.Fatal("/CLEAR did not retain /clear cancellation semantics") | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) { | |
| 56 | + | s := newSlashSuggester() | |
| 57 | + | s.SetExtra([]slashCommand{{Name: "/EXIT", Desc: "extension exit"}}) | |
| 58 | + | matches := commandNames(s.matches("/exit")) | |
| 59 | + | if len(matches) != 1 || matches[0] != "/exit" { | |
| 60 | + | t.Fatalf("matches = %v, want only built-in /exit", matches) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 42 | 64 | func commandNames(cmds []slashCommand) []string { | |
| 43 | 65 | out := make([]string, 0, len(cmds)) | |
| 44 | 66 | for _, c := range cmds { | |
| @@ -39,6 +39,28 @@ func TestSlashSuggesterHasSwarm(t *testing.T) { | |||||
| 39 | } | 39 | } | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | |||
| 43 | + | s := newSlashSuggester() | |||
| 44 | + | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | |||
| 45 | + | t.Fatalf("/EX did not suggest /exit: %v", got) | |||
| 46 | + | } | |||
| 47 | + | if !isKnownSlashCommand("/Exit") { | |||
| 48 | + | t.Fatal("/Exit was not recognized as a built-in command") | |||
| 49 | + | } | |||
| 50 | + | if !slashCancelsTurn("/CLEAR") { | |||
| 51 | + | t.Fatal("/CLEAR did not retain /clear cancellation semantics") | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) { | |||
| 56 | + | s := newSlashSuggester() | |||
| 57 | + | s.SetExtra([]slashCommand{{Name: "/EXIT", Desc: "extension exit"}}) | |||
| 58 | + | matches := commandNames(s.matches("/exit")) | |||
| 59 | + | if len(matches) != 1 || matches[0] != "/exit" { | |||
| 60 | + | t.Fatalf("matches = %v, want only built-in /exit", matches) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 42 | func commandNames(cmds []slashCommand) []string { | 64 | func commandNames(cmds []slashCommand) []string { | ||
| 43 | out := make([]string, 0, len(cmds)) | 65 | out := make([]string, 0, len(cmds)) | ||
| 44 | for _, c := range cmds { | 66 | for _, c := range cmds { | ||
M packages/agent/modes/swarm_dialog.go +7 -4
| @@ -699,8 +699,7 @@ func (d *swarmDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg | |||
| 699 | 699 | // vs. as a task description. | |
| 700 | 700 | func containsSlashModelLine(buf string) bool { | |
| 701 | 701 | for _, ln := range strings.Split(buf, "\n") { | |
| 702 | - | t := strings.TrimSpace(ln) | |
| 703 | - | if t == "/model" || strings.HasPrefix(t, "/model ") { | |
| 702 | + | if isSlashModelLine(ln) { | |
| 704 | 703 | return true | |
| 705 | 704 | } | |
| 706 | 705 | } | |
| @@ -713,12 +712,16 @@ func containsSlashModelLine(buf string) bool { | |||
| 713 | 712 | // is always exactly one full line in practice — the user typed | |
| 714 | 713 | // /model<Enter> — but we tolerate inline placement at the start of | |
| 715 | 714 | // a line just in case. | |
| 715 | + | func isSlashModelLine(line string) bool { | |
| 716 | + | fields := strings.Fields(strings.TrimSpace(line)) | |
| 717 | + | return len(fields) > 0 && strings.EqualFold(fields[0], "/model") | |
| 718 | + | } | |
| 719 | + | ||
| 716 | 720 | func stripSlashModelLine(buf string) string { | |
| 717 | 721 | lines := strings.Split(buf, "\n") | |
| 718 | 722 | out := lines[:0] | |
| 719 | 723 | for _, ln := range lines { | |
| 720 | - | t := strings.TrimSpace(ln) | |
| 721 | - | if t == "/model" || strings.HasPrefix(t, "/model ") { | |
| 724 | + | if isSlashModelLine(ln) { | |
| 722 | 725 | continue | |
| 723 | 726 | } | |
| 724 | 727 | out = append(out, ln) | |
| @@ -699,8 +699,7 @@ func (d *swarmDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg | |||||
| 699 | // vs. as a task description. | 699 | // vs. as a task description. | ||
| 700 | func containsSlashModelLine(buf string) bool { | 700 | func containsSlashModelLine(buf string) bool { | ||
| 701 | for _, ln := range strings.Split(buf, "\n") { | 701 | for _, ln := range strings.Split(buf, "\n") { | ||
| 702 | - | t := strings.TrimSpace(ln) | 702 | + | if isSlashModelLine(ln) { |
| 703 | - | if t == "/model" || strings.HasPrefix(t, "/model ") { | |||
| 704 | return true | 703 | return true | ||
| 705 | } | 704 | } | ||
| 706 | } | 705 | } | ||
| @@ -713,12 +712,16 @@ func containsSlashModelLine(buf string) bool { | |||||
| 713 | // is always exactly one full line in practice — the user typed | 712 | // is always exactly one full line in practice — the user typed | ||
| 714 | // /model<Enter> — but we tolerate inline placement at the start of | 713 | // /model<Enter> — but we tolerate inline placement at the start of | ||
| 715 | // a line just in case. | 714 | // a line just in case. | ||
| 715 | + | func isSlashModelLine(line string) bool { | |||
| 716 | + | fields := strings.Fields(strings.TrimSpace(line)) | |||
| 717 | + | return len(fields) > 0 && strings.EqualFold(fields[0], "/model") | |||
| 718 | + | } | |||
| 719 | + | ||||
| 716 | func stripSlashModelLine(buf string) string { | 720 | func stripSlashModelLine(buf string) string { | ||
| 717 | lines := strings.Split(buf, "\n") | 721 | lines := strings.Split(buf, "\n") | ||
| 718 | out := lines[:0] | 722 | out := lines[:0] | ||
| 719 | for _, ln := range lines { | 723 | for _, ln := range lines { | ||
| 720 | - | t := strings.TrimSpace(ln) | 724 | + | if isSlashModelLine(ln) { |
| 721 | - | if t == "/model" || strings.HasPrefix(t, "/model ") { | |||
| 722 | continue | 725 | continue | ||
| 723 | } | 726 | } | ||
| 724 | out = append(out, ln) | 727 | out = append(out, ln) | ||
M packages/agent/modes/swarm_dialog_test.go +1 -0
| @@ -1029,6 +1029,7 @@ func TestStripSlashModelLine(t *testing.T) { | |||
| 1029 | 1029 | {"/model\nfoo", "foo"}, | |
| 1030 | 1030 | {"foo\n/model\nbar", "foo\nbar"}, | |
| 1031 | 1031 | {"/model gpt-5", ""}, | |
| 1032 | + | {"/MODEL GPT-5", ""}, | |
| 1032 | 1033 | {"a /model in prose", "a /model in prose"}, // not first non-blank token → keep | |
| 1033 | 1034 | {"", ""}, | |
| 1034 | 1035 | {"plain task", "plain task"}, | |
| @@ -1029,6 +1029,7 @@ func TestStripSlashModelLine(t *testing.T) { | |||||
| 1029 | {"/model\nfoo", "foo"}, | 1029 | {"/model\nfoo", "foo"}, | ||
| 1030 | {"foo\n/model\nbar", "foo\nbar"}, | 1030 | {"foo\n/model\nbar", "foo\nbar"}, | ||
| 1031 | {"/model gpt-5", ""}, | 1031 | {"/model gpt-5", ""}, | ||
| 1032 | + | {"/MODEL GPT-5", ""}, | |||
| 1032 | {"a /model in prose", "a /model in prose"}, // not first non-blank token → keep | 1033 | {"a /model in prose", "a /model in prose"}, // not first non-blank token → keep | ||
| 1033 | {"", ""}, | 1034 | {"", ""}, | ||
| 1034 | {"plain task", "plain task"}, | 1035 | {"plain task", "plain task"}, | ||
M packages/agent/modes/telegram/adapter.go +4 -13
| @@ -115,8 +115,9 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update, | |||
| 115 | 115 | ||
| 116 | 116 | // Pairing: first user who sends /start claims the bridge. | |
| 117 | 117 | text := strings.TrimSpace(msg.Text) | |
| 118 | + | command, isCommand := bot.ParseCommand(text) | |
| 118 | 119 | if a.Cfg.AllowedUserID == 0 { | |
| 119 | - | if strings.HasPrefix(text, "/start") { | |
| 120 | + | if isCommand && command == bot.CmdStart { | |
| 120 | 121 | a.Cfg.AllowedUserID = msg.From.ID | |
| 121 | 122 | _ = a.Save(*a.Cfg) | |
| 122 | 123 | _ = a.Client.SendMessage(ctx, msg.Chat.ID, | |
| @@ -144,18 +145,8 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update, | |||
| 144 | 145 | } | |
| 145 | 146 | ||
| 146 | 147 | // Built-in commands that bypass the agent. | |
| 147 | - | switch text { | |
| 148 | - | case "/start": | |
| 149 | - | commandHandler(bot.CmdStart, inbound) | |
| 150 | - | return | |
| 151 | - | case "/help": | |
| 152 | - | commandHandler(bot.CmdHelp, inbound) | |
| 153 | - | return | |
| 154 | - | case "/status": | |
| 155 | - | commandHandler(bot.CmdStatus, inbound) | |
| 156 | - | return | |
| 157 | - | case "/stop": | |
| 158 | - | commandHandler(bot.CmdStop, inbound) | |
| 148 | + | if isCommand { | |
| 149 | + | commandHandler(command, inbound) | |
| 159 | 150 | return | |
| 160 | 151 | } | |
| 161 | 152 | if bot.IsStopCommand(text) { | |
| @@ -115,8 +115,9 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update, | |||||
| 115 | 115 | ||||
| 116 | // Pairing: first user who sends /start claims the bridge. | 116 | // Pairing: first user who sends /start claims the bridge. | ||
| 117 | text := strings.TrimSpace(msg.Text) | 117 | text := strings.TrimSpace(msg.Text) | ||
| 118 | + | command, isCommand := bot.ParseCommand(text) | |||
| 118 | if a.Cfg.AllowedUserID == 0 { | 119 | if a.Cfg.AllowedUserID == 0 { | ||
| 119 | - | if strings.HasPrefix(text, "/start") { | 120 | + | if isCommand && command == bot.CmdStart { |
| 120 | a.Cfg.AllowedUserID = msg.From.ID | 121 | a.Cfg.AllowedUserID = msg.From.ID | ||
| 121 | _ = a.Save(*a.Cfg) | 122 | _ = a.Save(*a.Cfg) | ||
| 122 | _ = a.Client.SendMessage(ctx, msg.Chat.ID, | 123 | _ = a.Client.SendMessage(ctx, msg.Chat.ID, | ||
| @@ -144,18 +145,8 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update, | |||||
| 144 | } | 145 | } | ||
| 145 | 146 | ||||
| 146 | // Built-in commands that bypass the agent. | 147 | // Built-in commands that bypass the agent. | ||
| 147 | - | switch text { | 148 | + | if isCommand { |
| 148 | - | case "/start": | 149 | + | commandHandler(command, inbound) |
| 149 | - | commandHandler(bot.CmdStart, inbound) | |||
| 150 | - | return | |||
| 151 | - | case "/help": | |||
| 152 | - | commandHandler(bot.CmdHelp, inbound) | |||
| 153 | - | return | |||
| 154 | - | case "/status": | |||
| 155 | - | commandHandler(bot.CmdStatus, inbound) | |||
| 156 | - | return | |||
| 157 | - | case "/stop": | |||
| 158 | - | commandHandler(bot.CmdStop, inbound) | |||
| 159 | return | 150 | return | ||
| 160 | } | 151 | } | ||
| 161 | if bot.IsStopCommand(text) { | 152 | if bot.IsStopCommand(text) { | ||
M packages/agent/modes/telegram/api_test.go +21 -0
| @@ -75,6 +75,27 @@ func TestClientDeleteWebhookRejectsAPIFailure(t *testing.T) { | |||
| 75 | 75 | } | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | + | func TestAdapterCommandsAreCaseInsensitive(t *testing.T) { | |
| 79 | + | cfg := Config{AllowedUserID: 42} | |
| 80 | + | adapter := NewAdapter(nil, &cfg, func(Config) error { return nil }) | |
| 81 | + | var got bot.Command | |
| 82 | + | var called bool | |
| 83 | + | adapter.handleUpdate(context.Background(), Update{Message: &Message{ | |
| 84 | + | MessageID: 1, | |
| 85 | + | From: &User{ID: 42}, | |
| 86 | + | Chat: Chat{ID: 7, Type: "private"}, | |
| 87 | + | Text: "/STOP", | |
| 88 | + | }}, func(bot.InboundMessage) { | |
| 89 | + | t.Fatal("uppercase slash command was forwarded as a prompt") | |
| 90 | + | }, func(command bot.Command, _ bot.InboundMessage) { | |
| 91 | + | got = command | |
| 92 | + | called = true | |
| 93 | + | }) | |
| 94 | + | if !called || got != bot.CmdStop { | |
| 95 | + | t.Fatalf("command = %v, called = %v, want CmdStop", got, called) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 78 | 99 | func TestAdapterRunDeletesWebhookBeforePolling(t *testing.T) { | |
| 79 | 100 | ctx, cancel := context.WithCancel(context.Background()) | |
| 80 | 101 | defer cancel() | |
| @@ -75,6 +75,27 @@ func TestClientDeleteWebhookRejectsAPIFailure(t *testing.T) { | |||||
| 75 | } | 75 | } | ||
| 76 | } | 76 | } | ||
| 77 | 77 | ||||
| 78 | + | func TestAdapterCommandsAreCaseInsensitive(t *testing.T) { | |||
| 79 | + | cfg := Config{AllowedUserID: 42} | |||
| 80 | + | adapter := NewAdapter(nil, &cfg, func(Config) error { return nil }) | |||
| 81 | + | var got bot.Command | |||
| 82 | + | var called bool | |||
| 83 | + | adapter.handleUpdate(context.Background(), Update{Message: &Message{ | |||
| 84 | + | MessageID: 1, | |||
| 85 | + | From: &User{ID: 42}, | |||
| 86 | + | Chat: Chat{ID: 7, Type: "private"}, | |||
| 87 | + | Text: "/STOP", | |||
| 88 | + | }}, func(bot.InboundMessage) { | |||
| 89 | + | t.Fatal("uppercase slash command was forwarded as a prompt") | |||
| 90 | + | }, func(command bot.Command, _ bot.InboundMessage) { | |||
| 91 | + | got = command | |||
| 92 | + | called = true | |||
| 93 | + | }) | |||
| 94 | + | if !called || got != bot.CmdStop { | |||
| 95 | + | t.Fatalf("command = %v, called = %v, want CmdStop", got, called) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 78 | func TestAdapterRunDeletesWebhookBeforePolling(t *testing.T) { | 99 | func TestAdapterRunDeletesWebhookBeforePolling(t *testing.T) { | ||
| 79 | ctx, cancel := context.WithCancel(context.Background()) | 100 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 80 | defer cancel() | 101 | defer cancel() | ||
M packages/agent/modes/telegram/bridge.go +16 -15
| @@ -349,13 +349,14 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||
| 349 | 349 | } | |
| 350 | 350 | ||
| 351 | 351 | text := strings.TrimSpace(msg.Text) | |
| 352 | + | command, isCommand := bot.ParseCommand(text) | |
| 352 | 353 | ||
| 353 | 354 | // Pairing: first user who sends /start claims the bridge. | |
| 354 | 355 | b.mu.Lock() | |
| 355 | 356 | paired := b.Config.AllowedUserID | |
| 356 | 357 | b.mu.Unlock() | |
| 357 | 358 | if paired == 0 { | |
| 358 | - | if strings.HasPrefix(text, "/start") { | |
| 359 | + | if isCommand && command == bot.CmdStart { | |
| 359 | 360 | b.mu.Lock() | |
| 360 | 361 | b.Config.AllowedUserID = msg.From.ID | |
| 361 | 362 | b.chatID = msg.Chat.ID | |
| @@ -387,20 +388,20 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||
| 387 | 388 | b.mu.Unlock() | |
| 388 | 389 | ||
| 389 | 390 | // Built-in commands that bypass the agent. | |
| 390 | - | switch text { | |
| 391 | - | case "/start", "/help": | |
| 392 | - | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 393 | - | "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.", | |
| 394 | - | msg.MessageID) | |
| 395 | - | return | |
| 396 | - | case "/status": | |
| 397 | - | _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID) | |
| 398 | - | return | |
| 399 | - | case "/stop": | |
| 400 | - | b.Host.CancelTurn() | |
| 401 | - | b.stopWorking() | |
| 402 | - | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 403 | - | "cancelled the current turn.", msg.MessageID) | |
| 391 | + | if isCommand { | |
| 392 | + | switch command { | |
| 393 | + | case bot.CmdStart, bot.CmdHelp: | |
| 394 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 395 | + | "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.", | |
| 396 | + | msg.MessageID) | |
| 397 | + | case bot.CmdStatus: | |
| 398 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID) | |
| 399 | + | case bot.CmdStop: | |
| 400 | + | b.Host.CancelTurn() | |
| 401 | + | b.stopWorking() | |
| 402 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 403 | + | "cancelled the current turn.", msg.MessageID) | |
| 404 | + | } | |
| 404 | 405 | return | |
| 405 | 406 | } | |
| 406 | 407 | if isStopCommand(text) { | |
| @@ -349,13 +349,14 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||||
| 349 | } | 349 | } | ||
| 350 | 350 | ||||
| 351 | text := strings.TrimSpace(msg.Text) | 351 | text := strings.TrimSpace(msg.Text) | ||
| 352 | + | command, isCommand := bot.ParseCommand(text) | |||
| 352 | 353 | ||||
| 353 | // Pairing: first user who sends /start claims the bridge. | 354 | // Pairing: first user who sends /start claims the bridge. | ||
| 354 | b.mu.Lock() | 355 | b.mu.Lock() | ||
| 355 | paired := b.Config.AllowedUserID | 356 | paired := b.Config.AllowedUserID | ||
| 356 | b.mu.Unlock() | 357 | b.mu.Unlock() | ||
| 357 | if paired == 0 { | 358 | if paired == 0 { | ||
| 358 | - | if strings.HasPrefix(text, "/start") { | 359 | + | if isCommand && command == bot.CmdStart { |
| 359 | b.mu.Lock() | 360 | b.mu.Lock() | ||
| 360 | b.Config.AllowedUserID = msg.From.ID | 361 | b.Config.AllowedUserID = msg.From.ID | ||
| 361 | b.chatID = msg.Chat.ID | 362 | b.chatID = msg.Chat.ID | ||
| @@ -387,20 +388,20 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||||
| 387 | b.mu.Unlock() | 388 | b.mu.Unlock() | ||
| 388 | 389 | ||||
| 389 | // Built-in commands that bypass the agent. | 390 | // Built-in commands that bypass the agent. | ||
| 390 | - | switch text { | 391 | + | if isCommand { |
| 391 | - | case "/start", "/help": | 392 | + | switch command { |
| 392 | - | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | 393 | + | case bot.CmdStart, bot.CmdHelp: |
| 393 | - | "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.", | 394 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, |
| 394 | - | msg.MessageID) | 395 | + | "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.", |
| 395 | - | return | 396 | + | msg.MessageID) |
| 396 | - | case "/status": | 397 | + | case bot.CmdStatus: |
| 397 | - | _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID) | 398 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID) |
| 398 | - | return | 399 | + | case bot.CmdStop: |
| 399 | - | case "/stop": | 400 | + | b.Host.CancelTurn() |
| 400 | - | b.Host.CancelTurn() | 401 | + | b.stopWorking() |
| 401 | - | b.stopWorking() | 402 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, |
| 402 | - | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | 403 | + | "cancelled the current turn.", msg.MessageID) |
| 403 | - | "cancelled the current turn.", msg.MessageID) | 404 | + | } |
| 404 | return | 405 | return | ||
| 405 | } | 406 | } | ||
| 406 | if isStopCommand(text) { | 407 | if isStopCommand(text) { | ||