switchroom 0.14.12 → 0.14.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,228 +1,111 @@
1
- import { describe, test, expect } from 'vitest'
2
- import { summarizeToolForTitle, formatPermissionCardBody } from '../permission-title.js'
3
-
4
- describe('summarizeToolForTitle (#186)', () => {
5
- test('Skill: surfaces the skill name in brackets', () => {
6
- const input = JSON.stringify({ skill: 'mail' })
7
- expect(summarizeToolForTitle('Skill', input)).toBe('Skill (mail)')
8
- })
9
-
10
- test('Bash: truncates long commands', () => {
11
- const input = JSON.stringify({
12
- command: 'find /var/log -name "*.log" -mtime -1 -exec gzip {} \\;',
13
- })
14
- const out = summarizeToolForTitle('Bash', input)
15
- expect(out.startsWith('Bash: ')).toBe(true)
16
- expect(out.length).toBeLessThanOrEqual(60)
17
- expect(out.endsWith('…')).toBe(true)
18
- })
19
-
20
- test('Read/Edit/Write: shows basename only', () => {
21
- const input = JSON.stringify({ file_path: '/long/absolute/path/to/server.ts' })
22
- expect(summarizeToolForTitle('Read', input)).toBe('Read: server.ts')
23
- expect(summarizeToolForTitle('Edit', input)).toBe('Edit: server.ts')
24
- expect(summarizeToolForTitle('Write', input)).toBe('Write: server.ts')
25
- })
26
-
27
- test('Glob/Grep: surfaces the pattern', () => {
28
- const input = JSON.stringify({ pattern: '**/*.ts' })
29
- expect(summarizeToolForTitle('Glob', input)).toBe('Glob: **/*.ts')
30
- expect(summarizeToolForTitle('Grep', input)).toBe('Grep: **/*.ts')
31
- })
32
-
33
- test('WebFetch: surfaces the URL', () => {
34
- const input = JSON.stringify({ url: 'https://example.com/some/page' })
35
- expect(summarizeToolForTitle('WebFetch', input)).toBe(
36
- 'WebFetch: https://example.com/some/page',
37
- )
38
- })
39
-
40
- test('falls back to bare toolName for unrecognised tools', () => {
41
- expect(summarizeToolForTitle('SomeCustomTool', JSON.stringify({ x: 1 }))).toBe(
42
- 'SomeCustomTool',
43
- )
44
- })
45
-
46
- test('falls back to bare toolName when input_preview is malformed', () => {
47
- expect(summarizeToolForTitle('Skill', 'not-json')).toBe('Skill')
48
- expect(summarizeToolForTitle('Skill', '')).toBe('Skill')
49
- expect(summarizeToolForTitle('Skill', undefined)).toBe('Skill')
50
- })
1
+ /**
2
+ * Tests for the human-readable permission card text (#186, #1790, and
3
+ * the scoped-card work). Three surfaces:
4
+ * - `naturalAction` — the verb-phrase after "wants to" (no tool ids).
5
+ * - `formatPermissionCardBody` the collapsed-view card body.
6
+ * - `describeGrant` the after-the-fact confirmation, phrased from
7
+ * the *scope the operator chose*.
8
+ */
51
9
 
52
- test('falls back to bare toolName for non-Skill tools when expected key is missing', () => {
53
- const input = JSON.stringify({ unrelated: 'x' })
54
- // Bash has no first-arg fallback (its only identifying field is command).
55
- expect(summarizeToolForTitle('Bash', input)).toBe('Bash')
56
- })
10
+ import { describe, test, expect } from 'vitest'
11
+ import {
12
+ naturalAction,
13
+ describeGrant,
14
+ formatPermissionCardBody,
15
+ } from '../permission-title.js'
16
+ import type { ScopeOption } from '../permission-rule.js'
57
17
 
58
- // #1790 the prior contract was "fall back to bare toolName when no
59
- // skill-name key matched"; that produced operator-hostile cards like
60
- // `🔐 Permission: Skill` with zero context. The Skill summarizer now
61
- // tries `command`, then a first-scalar-arg hint, before giving up.
62
- test('Skill: when no skill-name key matches, falls back to command field (#1790)', () => {
63
- const input = JSON.stringify({ command: 'gen calendar event' })
64
- expect(summarizeToolForTitle('Skill', input)).toBe('Skill: gen calendar event')
65
- })
18
+ const opt = (rule: string): ScopeOption => ({ rule, buttonLabel: 'x', broad: false })
66
19
 
67
- test('Skill: when no skill-name and no command, surfaces the first scalar arg (#1790)', () => {
68
- const input = JSON.stringify({ unrelated: 'x' })
69
- expect(summarizeToolForTitle('Skill', input)).toBe('Skill (unrelated: x)')
20
+ describe('naturalAction built-in tools', () => {
21
+ test('file tools surface the basename', () => {
22
+ const input = JSON.stringify({ file_path: '/a/b/server.ts' })
23
+ expect(naturalAction('Edit', input)).toBe('edit: server.ts')
24
+ expect(naturalAction('Write', input)).toBe('write: server.ts')
25
+ expect(naturalAction('Read', input)).toBe('read: server.ts')
70
26
  })
71
27
 
72
- test('Skill: skips routing-only keys when surfacing first scalar arg (#1790)', () => {
73
- // chat_id / message_thread_id / request_id never help an operator
74
- // decide; the helper skips them and finds the next useful field.
75
- const input = JSON.stringify({
76
- chat_id: '12345',
77
- message_thread_id: '42',
78
- topic: 'morning summary',
79
- })
80
- expect(summarizeToolForTitle('Skill', input)).toBe('Skill (topic: morning summary)')
28
+ test('file tools fall back to a generic phrase without a path', () => {
29
+ expect(naturalAction('Edit', undefined)).toBe('edit files')
30
+ expect(naturalAction('Write', JSON.stringify({ x: 1 }))).toBe('write files')
81
31
  })
82
32
 
83
- test('Bash: collapses internal whitespace before truncating', () => {
84
- const input = JSON.stringify({
85
- command: 'echo \t hello\nworld',
86
- })
87
- expect(summarizeToolForTitle('Bash', input)).toBe('Bash: echo hello world')
33
+ test('Bash surfaces a truncated command', () => {
34
+ const out = naturalAction('Bash', JSON.stringify({ command: 'ls /tmp' }))
35
+ expect(out).toBe('run: ls /tmp')
88
36
  })
89
37
 
90
- test('NotebookEdit: prefers notebook_path when file_path absent', () => {
91
- const input = JSON.stringify({ notebook_path: '/work/analysis.ipynb' })
92
- expect(summarizeToolForTitle('NotebookEdit', input)).toBe(
93
- 'NotebookEdit: analysis.ipynb',
38
+ test('Bash collapses whitespace and truncates long commands', () => {
39
+ const long = naturalAction(
40
+ 'Bash',
41
+ JSON.stringify({ command: 'find /var/log -name "*.log" -mtime -1 -exec gzip {} \\;' }),
94
42
  )
43
+ expect(long.startsWith('run: ')).toBe(true)
44
+ expect(long.endsWith('…')).toBe(true)
95
45
  })
96
46
 
97
- // Skill-name fallbacks the user reported a `🔐 Permission: Skill`
98
- // popup with no brackets. Pre-fix only `input.skill` was checked;
99
- // these tests pin the wider field set so the skill name lands in
100
- // brackets even when Claude Code passes the input in a different
101
- // shape.
102
- test('Skill: falls back to skill_name field', () => {
103
- const input = JSON.stringify({ skill_name: 'calendar' })
104
- expect(summarizeToolForTitle('Skill', input)).toBe('Skill (calendar)')
105
- })
106
-
107
- test('Skill: falls back to skillName field', () => {
108
- const input = JSON.stringify({ skillName: 'garmin' })
109
- expect(summarizeToolForTitle('Skill', input)).toBe('Skill (garmin)')
110
- })
111
-
112
- test('Skill: falls back to bare name field', () => {
113
- const input = JSON.stringify({ name: 'home-assistant' })
114
- expect(summarizeToolForTitle('Skill', input)).toBe('Skill (home-assistant)')
47
+ test('Skill names the skill', () => {
48
+ expect(naturalAction('Skill', JSON.stringify({ skill: 'mail' }))).toBe('use the mail skill')
49
+ expect(naturalAction('Skill', undefined)).toBe('use a skill')
115
50
  })
116
51
 
117
- test('Skill: lifts basename out of a path field', () => {
118
- const input = JSON.stringify({ path: 'skills/coolify/SKILL.md' })
119
- expect(summarizeToolForTitle('Skill', input)).toBe('Skill (coolify)')
52
+ test('search / fetch tools surface their query or url', () => {
53
+ expect(naturalAction('Grep', JSON.stringify({ pattern: '**/*.ts' }))).toBe('search files for: **/*.ts')
54
+ expect(naturalAction('WebSearch', JSON.stringify({ query: 'tide times' }))).toBe('search the web for: tide times')
55
+ expect(naturalAction('WebFetch', JSON.stringify({ url: 'https://x.com' }))).toBe('fetch a web page: https://x.com')
120
56
  })
121
57
 
122
- test('Skill: lifts basename out of a skill_path directory', () => {
123
- const input = JSON.stringify({ skill_path: '/x/.switchroom/skills/mail' })
124
- expect(summarizeToolForTitle('Skill', input)).toBe('Skill (mail)')
58
+ test('agent-ish tools read as plain phrases', () => {
59
+ expect(naturalAction('Task', undefined)).toBe('dispatch a sub-agent')
60
+ expect(naturalAction('TodoWrite', undefined)).toBe('update its task list')
61
+ expect(naturalAction('ExitPlanMode', undefined)).toBe('exit plan mode')
125
62
  })
126
63
 
127
- test('Skill: original `skill` field still wins when multiple are present', () => {
128
- const input = JSON.stringify({ skill: 'mail', name: 'wrong' })
129
- expect(summarizeToolForTitle('Skill', input)).toBe('Skill (mail)')
130
- })
131
-
132
- test('MCP curated: agent-config tools render as human verb-phrases (#1215)', () => {
133
- expect(summarizeToolForTitle('mcp__agent-config__skill_list', undefined)).toBe(
134
- 'List its own installed skills',
135
- )
136
- expect(summarizeToolForTitle('mcp__agent-config__cron_list', undefined)).toBe(
137
- 'List its own scheduled tasks',
138
- )
139
- expect(summarizeToolForTitle('mcp__agent-config__peers_list', undefined)).toBe(
140
- 'List the other agents on this instance',
141
- )
142
- })
143
-
144
- test('MCP curated: hostd tools render as human verb-phrases (#1215)', () => {
145
- expect(summarizeToolForTitle('mcp__hostd__agent_logs', undefined)).toBe(
146
- "Read another agent's container logs",
147
- )
148
- expect(summarizeToolForTitle('mcp__hostd__agent_exec', undefined)).toBe(
149
- 'Run a read-only inspection inside another agent',
150
- )
64
+ test('unknown tool falls back to "use <name>"', () => {
65
+ expect(naturalAction('SomeCustomTool', undefined)).toBe('use SomeCustomTool')
151
66
  })
67
+ })
152
68
 
153
- test('MCP fallback: unknown mcp tool renders as `<server>: <verb with spaces>`', () => {
154
- expect(summarizeToolForTitle('mcp__some-server__do_thing', undefined)).toBe(
155
- 'some-server: do thing',
69
+ describe('naturalAction MCP tools', () => {
70
+ test('curated internal tool reads as a bare verb-phrase', () => {
71
+ expect(naturalAction('mcp__agent-config__skill_list', undefined)).toBe(
72
+ 'list its own installed skills',
156
73
  )
157
74
  })
158
75
 
159
- test('MCP malformed: bare mcp__ prefix without __<server>__<verb> shape is left alone', () => {
160
- expect(summarizeToolForTitle('mcp__bad', undefined)).toBe('mcp__bad')
76
+ test('curated external tool gets a "(Server)" tag', () => {
77
+ expect(naturalAction('mcp__perplexity__search', undefined)).toBe('search the web (Perplexity)')
161
78
  })
162
79
 
163
- // #1790 append a `(key: value)` hint when an MCP tool's preview
164
- // carries a scalar arg. Gives operators context on curated and
165
- // uncurated MCP tools alike without an expand tap.
166
- test('MCP curated tool appends first-arg hint when input_preview present (#1790)', () => {
167
- const input = JSON.stringify({ key: 'coolify/api-token' })
168
- expect(summarizeToolForTitle('mcp__agent-config__config_get', input)).toBe(
169
- 'Read its own merged config (key: coolify/api-token)',
170
- )
80
+ test('uncurated internal tool de-snakes the verb', () => {
81
+ expect(naturalAction('mcp__hostd__do_thing', undefined)).toBe('do thing')
171
82
  })
172
83
 
173
- test('MCP uncurated tool appends first-arg hint (#1790)', () => {
174
- const input = JSON.stringify({ folder_id: 'abc123' })
175
- expect(summarizeToolForTitle('mcp__google-workspace__list_files', input)).toBe(
176
- 'google-workspace: list files (folder_id: abc123)',
177
- )
178
- })
179
-
180
- test('MCP arg hint skips routing-only keys (#1790)', () => {
181
- const input = JSON.stringify({ chat_id: '12345', query: 'budget Q3' })
182
- expect(summarizeToolForTitle('mcp__hindsight__recall', input)).toBe(
183
- 'Recall relevant memories (query: budget Q3)',
84
+ test('uncurated external tool de-snakes and tags the server', () => {
85
+ expect(naturalAction('mcp__google-workspace__list_files', undefined)).toBe(
86
+ 'list files (Google Workspace)',
184
87
  )
185
88
  })
186
89
  })
187
90
 
188
- // ──────────────────────────────────────────────────────────────────────
189
- // #1790 formatPermissionCardBody: multi-line collapsed-view body
190
- // for approval cards. Mirrors the vault_request_access card layout.
191
- // ──────────────────────────────────────────────────────────────────────
192
-
193
- describe('formatPermissionCardBody (#1790)', () => {
194
- test('renders agent · summary, then a why-line, when both are present', () => {
91
+ describe('formatPermissionCardBody', () => {
92
+ test('renders "<Agent> wants to <action>" + why line', () => {
195
93
  const body = formatPermissionCardBody({
196
- toolName: 'Skill',
197
- inputPreview: JSON.stringify({ skill: 'mail' }),
198
- description: 'Compose the morning brief',
199
- agentName: 'clerk',
200
- })
201
- expect(body).toBe(
202
- [
203
- '🔐 <b>clerk</b> · Skill (mail)',
204
- 'why: <i>Compose the morning brief</i>',
205
- ].join('\n'),
206
- )
207
- })
208
-
209
- test('renders "why: <i>not provided</i>" when description is missing', () => {
210
- const body = formatPermissionCardBody({
211
- toolName: 'Bash',
212
- inputPreview: JSON.stringify({ command: 'ls /tmp' }),
213
- description: undefined,
94
+ toolName: 'Edit',
95
+ inputPreview: JSON.stringify({ file_path: '/work/supplement-log.md' }),
96
+ description: 'logging today\'s lifts',
214
97
  agentName: 'gymbro',
215
98
  })
216
99
  expect(body).toBe(
217
- ['🔐 <b>gymbro</b> · Bash: ls /tmp', 'why: <i>not provided</i>'].join('\n'),
100
+ ['🔐 <b>Gymbro</b> wants to edit: supplement-log.md', 'why: <i>logging today\'s lifts</i>'].join('\n'),
218
101
  )
219
102
  })
220
103
 
221
- test('renders "not provided" when description is whitespace-only', () => {
104
+ test('shows "not provided" when description is missing or whitespace', () => {
222
105
  const body = formatPermissionCardBody({
223
106
  toolName: 'Bash',
224
107
  inputPreview: JSON.stringify({ command: 'ls /tmp' }),
225
- description: ' \n ',
108
+ description: ' \n ',
226
109
  agentName: 'gymbro',
227
110
  })
228
111
  expect(body).toContain('why: <i>not provided</i>')
@@ -235,10 +118,10 @@ describe('formatPermissionCardBody (#1790)', () => {
235
118
  description: 'do the thing',
236
119
  agentName: null,
237
120
  })
238
- expect(body).toBe(['🔐 Skill (mail)', 'why: <i>do the thing</i>'].join('\n'))
121
+ expect(body).toBe(['🔐 Use the mail skill', 'why: <i>do the thing</i>'].join('\n'))
239
122
  })
240
123
 
241
- test('HTML-escapes < > & in agentName / summary / description', () => {
124
+ test('HTML-escapes <, >, & in agentName / action / description', () => {
242
125
  const body = formatPermissionCardBody({
243
126
  toolName: 'Bash',
244
127
  inputPreview: JSON.stringify({ command: 'echo "a < b && c > d"' }),
@@ -248,28 +131,22 @@ describe('formatPermissionCardBody (#1790)', () => {
248
131
  expect(body).toContain('&lt;test&gt;')
249
132
  expect(body).toContain('&amp;')
250
133
  expect(body).not.toContain('<test>')
251
- // The literal "<i>not provided</i>" and "<b>...</b>" wrapping tags
252
- // around legitimate fields must survive untouched — only the
253
- // user-supplied content is escaped.
254
134
  expect(body).toContain('<b>')
255
135
  expect(body).toContain('<i>')
256
136
  })
257
137
 
258
138
  test('truncates a very long description with an ellipsis', () => {
259
- const longWhy = 'x'.repeat(500)
260
139
  const body = formatPermissionCardBody({
261
140
  toolName: 'Skill',
262
141
  inputPreview: JSON.stringify({ skill: 'mail' }),
263
- description: longWhy,
142
+ description: 'x'.repeat(500),
264
143
  agentName: 'clerk',
265
144
  })
266
- // 240-char ceiling + trailing ellipsis
267
145
  expect(body).toContain('xxxx…</i>')
268
- // First line still intact
269
- expect(body.split('\n')[0]).toBe('🔐 <b>clerk</b> · Skill (mail)')
146
+ expect(body.split('\n')[0]).toBe('🔐 <b>Clerk</b> wants to use the mail skill')
270
147
  })
271
148
 
272
- test('collapses internal whitespace in description so the layout stays one-line', () => {
149
+ test('collapses internal whitespace in the description', () => {
273
150
  const body = formatPermissionCardBody({
274
151
  toolName: 'Skill',
275
152
  inputPreview: JSON.stringify({ skill: 'mail' }),
@@ -279,3 +156,40 @@ describe('formatPermissionCardBody (#1790)', () => {
279
156
  expect(body).toContain('why: <i>first second paragraph</i>')
280
157
  })
281
158
  })
159
+
160
+ describe('describeGrant — phrased from the chosen scope', () => {
161
+ test('MCP server wildcard → "use any <Server> tool"', () => {
162
+ expect(describeGrant('mcp__perplexity__search', undefined, opt('mcp__perplexity__*'))).toBe(
163
+ 'use any Perplexity tool',
164
+ )
165
+ })
166
+
167
+ test('scoped file rule → "edit <basename>"', () => {
168
+ expect(describeGrant('Edit', undefined, opt('Edit(/work/supplement-log.md)'))).toBe(
169
+ 'edit supplement-log.md',
170
+ )
171
+ expect(describeGrant('Read', undefined, opt('Read(/a/b/notes.md)'))).toBe('read notes.md')
172
+ })
173
+
174
+ test('scoped Bash rule → "run <tok> commands"', () => {
175
+ expect(describeGrant('Bash', undefined, opt('Bash(npm:*)'))).toBe('run npm commands')
176
+ })
177
+
178
+ test('scoped Skill rule → "use the <name> skill"', () => {
179
+ expect(describeGrant('Skill', undefined, opt('Skill(mail)'))).toBe('use the mail skill')
180
+ })
181
+
182
+ test('bare category rules read as "any" grants', () => {
183
+ expect(describeGrant('Edit', undefined, opt('Edit'))).toBe('edit any file')
184
+ expect(describeGrant('Write', undefined, opt('Write'))).toBe('write any file')
185
+ expect(describeGrant('Read', undefined, opt('Read'))).toBe('read any file')
186
+ expect(describeGrant('Bash', undefined, opt('Bash'))).toBe('run any command')
187
+ expect(describeGrant('Skill', undefined, opt('Skill'))).toBe('use any skill')
188
+ })
189
+
190
+ test('exact MCP tool grant falls back to the natural action', () => {
191
+ expect(describeGrant('mcp__perplexity__search', undefined, opt('mcp__perplexity__search'))).toBe(
192
+ 'search the web (Perplexity)',
193
+ )
194
+ })
195
+ })