@raidou/pi-pm-subagents 0.1.1

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.
Files changed (69) hide show
  1. package/.prettierrc +7 -0
  2. package/AGENTS.md +1 -0
  3. package/README.md +85 -0
  4. package/README.zh-CN.md +85 -0
  5. package/agents/explorer.md +10 -0
  6. package/agents/planner.md +16 -0
  7. package/agents/researcher.md +22 -0
  8. package/agents/reviewer.md +10 -0
  9. package/eslint.config.mjs +14 -0
  10. package/example-prompts/coordinator.md +21 -0
  11. package/package.json +48 -0
  12. package/pm-subagents-prompts/coordinator.md +16 -0
  13. package/pnpm-workspace.yaml +5 -0
  14. package/src/bash-readonly.test.ts +331 -0
  15. package/src/bash-readonly.ts +205 -0
  16. package/src/coordinator/coordinator.test.ts +28 -0
  17. package/src/coordinator/coordinator.ts +275 -0
  18. package/src/custom-select.test.ts +91 -0
  19. package/src/custom-select.ts +209 -0
  20. package/src/index.ts +87 -0
  21. package/src/models-config/models-config.test.ts +88 -0
  22. package/src/models-config/models-config.ts +205 -0
  23. package/src/models-config/scoped-models-editor.test.ts +189 -0
  24. package/src/models-config/scoped-models-editor.ts +412 -0
  25. package/src/models-config/subagent-model-constants.ts +2 -0
  26. package/src/models-config/subagent-model-cycle.ts +53 -0
  27. package/src/models-config/subagent-model-utils.test.ts +250 -0
  28. package/src/models-config/subagent-model-utils.ts +52 -0
  29. package/src/pm-mode.test.ts +324 -0
  30. package/src/pm-mode.ts +142 -0
  31. package/src/prompts/mode.test.ts +289 -0
  32. package/src/prompts/mode.ts +31 -0
  33. package/src/prompts/roles.test.ts +724 -0
  34. package/src/prompts/roles.ts +119 -0
  35. package/src/subagent/activity.test.ts +230 -0
  36. package/src/subagent/activity.ts +60 -0
  37. package/src/subagent/batcher.test.ts +198 -0
  38. package/src/subagent/batcher.ts +51 -0
  39. package/src/subagent/consts.ts +1 -0
  40. package/src/subagent/demo.ts +773 -0
  41. package/src/subagent/fleet.test.ts +1758 -0
  42. package/src/subagent/fleet.ts +376 -0
  43. package/src/subagent/identity.test.ts +31 -0
  44. package/src/subagent/identity.ts +16 -0
  45. package/src/subagent/manager.test.ts +392 -0
  46. package/src/subagent/manager.ts +277 -0
  47. package/src/subagent/tools.ts +314 -0
  48. package/src/subagent/viewer.ts +305 -0
  49. package/src/types.ts +15 -0
  50. package/src/ui/border-view.ts +50 -0
  51. package/src/ui/review-pager.ts +146 -0
  52. package/src/ui/scroll-view.test.ts +190 -0
  53. package/src/ui/scroll-view.ts +155 -0
  54. package/src/utils/format.test.ts +76 -0
  55. package/src/utils/format.ts +67 -0
  56. package/src/utils/fs.ts +9 -0
  57. package/src/utils/markdown.test.ts +442 -0
  58. package/src/utils/markdown.ts +79 -0
  59. package/src/utils/messages.test.ts +436 -0
  60. package/src/utils/messages.ts +131 -0
  61. package/src/utils/model-ref.test.ts +42 -0
  62. package/src/utils/model-ref.ts +44 -0
  63. package/src/utils/state.test.ts +98 -0
  64. package/src/utils/state.ts +45 -0
  65. package/src/utils/tools.ts +48 -0
  66. package/src/utils/truncate.test.ts +41 -0
  67. package/src/utils/truncate.ts +59 -0
  68. package/tsconfig.json +24 -0
  69. package/vitest.config.ts +8 -0
@@ -0,0 +1,436 @@
1
+ import type { AgentMessage } from '@earendil-works/pi-agent-core'
2
+ import type {
3
+ AssistantMessage,
4
+ TextContent,
5
+ ThinkingContent,
6
+ ToolCall,
7
+ ToolResultMessage,
8
+ UserMessage,
9
+ } from '@earendil-works/pi-ai'
10
+ import { describe, expect, it } from 'vitest'
11
+
12
+ import {
13
+ formatToolNameWithArgs,
14
+ lastAssistantText,
15
+ lastMessageText,
16
+ messageText,
17
+ } from './messages.js'
18
+
19
+ function user(text: string): UserMessage {
20
+ return {
21
+ role: 'user',
22
+ content: [{ type: 'text', text }],
23
+ timestamp: Date.now(),
24
+ }
25
+ }
26
+
27
+ function assistant(opts: {
28
+ text?: string
29
+ toolCall?: string
30
+ toolCallArgs?: Record<string, unknown>
31
+ toolCallId?: string
32
+ }): AssistantMessage {
33
+ const content: (TextContent | ThinkingContent | ToolCall)[] = []
34
+ if (opts.text) {
35
+ content.push({ type: 'text', text: opts.text })
36
+ }
37
+ if (opts.toolCall) {
38
+ content.push({
39
+ type: 'toolCall',
40
+ id: opts.toolCallId || 'call-123',
41
+ name: opts.toolCall,
42
+ arguments: opts.toolCallArgs || {},
43
+ })
44
+ }
45
+ return {
46
+ role: 'assistant',
47
+ content,
48
+ api: 'openai-responses',
49
+ provider: 'openai',
50
+ model: 'gpt-4',
51
+ usage: {
52
+ input: 0,
53
+ output: 0,
54
+ cacheRead: 0,
55
+ cacheWrite: 0,
56
+ totalTokens: 0,
57
+ cost: {
58
+ input: 0,
59
+ output: 0,
60
+ cacheRead: 0,
61
+ cacheWrite: 0,
62
+ total: 0,
63
+ },
64
+ },
65
+ stopReason: 'stop',
66
+ timestamp: Date.now(),
67
+ }
68
+ }
69
+
70
+ function toolResultText(text: string, isError = false): ToolResultMessage {
71
+ return {
72
+ role: 'toolResult',
73
+ toolCallId: 'call-123',
74
+ toolName: 'read',
75
+ content: [{ type: 'text', text }],
76
+ isError,
77
+ timestamp: Date.now(),
78
+ }
79
+ }
80
+
81
+ function toolResultMessage(
82
+ toolCallId: string,
83
+ toolName: string,
84
+ text: string,
85
+ isError = false,
86
+ ): ToolResultMessage {
87
+ return {
88
+ role: 'toolResult',
89
+ toolCallId,
90
+ toolName,
91
+ content: [{ type: 'text', text }],
92
+ isError,
93
+ timestamp: Date.now(),
94
+ }
95
+ }
96
+
97
+ describe('lastAssistantText', () => {
98
+ it('returns the text from the last assistant message', () => {
99
+ const messages: AgentMessage[] = [
100
+ user('task'),
101
+ assistant({ text: 'I will read it' }),
102
+ ]
103
+ const result = lastAssistantText(messages)
104
+ expect(result).toBe('I will read it')
105
+ })
106
+
107
+ it('finds the last assistant message when toolResult follows', () => {
108
+ const messages: AgentMessage[] = [
109
+ user('task'),
110
+ assistant({ text: 'Reading file', toolCall: 'read' }),
111
+ toolResultText('content'),
112
+ ]
113
+ const result = lastAssistantText(messages)
114
+ expect(result).toBe('Reading file')
115
+ })
116
+
117
+ it('finds the last assistant message when multiple assistants exist', () => {
118
+ const messages: AgentMessage[] = [
119
+ user('task'),
120
+ assistant({ text: 'first' }),
121
+ toolResultText('result'),
122
+ assistant({ text: 'second' }),
123
+ ]
124
+ const result = lastAssistantText(messages)
125
+ expect(result).toBe('second')
126
+ })
127
+
128
+ it('returns undefined when only user messages exist', () => {
129
+ const messages: AgentMessage[] = [user('task'), user('follow-up')]
130
+ const result = lastAssistantText(messages)
131
+ expect(result).toBeUndefined()
132
+ })
133
+
134
+ it('returns undefined when the last message is toolResult', () => {
135
+ const messages: AgentMessage[] = [
136
+ user('task'),
137
+ assistant({ toolCall: 'read' }),
138
+ toolResultText('File content here'),
139
+ ]
140
+ const result = lastAssistantText(messages)
141
+ expect(result).toBeUndefined()
142
+ })
143
+
144
+ it('returns undefined for empty messages array', () => {
145
+ const messages: AgentMessage[] = []
146
+ const result = lastAssistantText(messages)
147
+ expect(result).toBeUndefined()
148
+ })
149
+ })
150
+
151
+ describe('lastAssistantText with maxBytes', () => {
152
+ it('returns text unchanged when under byte limit', () => {
153
+ const messages: AgentMessage[] = [
154
+ user('task'),
155
+ assistant({ text: 'Short text' }),
156
+ ]
157
+ const result = lastAssistantText(messages, 100)
158
+ expect(result).toBe('Short text')
159
+ })
160
+
161
+ it('truncates text with suffix when over byte limit', () => {
162
+ const messages: AgentMessage[] = [
163
+ user('task'),
164
+ assistant({ text: 'a'.repeat(1000) }),
165
+ ]
166
+ const result = lastAssistantText(messages, 50)
167
+ const defaultSuffix = '\n\n[Output truncated.]'
168
+ expect(result).toContain(defaultSuffix)
169
+ expect(result?.length).toBeLessThanOrEqual(50 + defaultSuffix.length + 10)
170
+ })
171
+
172
+ it('returns undefined when no valid message exists regardless of maxBytes', () => {
173
+ const messages: AgentMessage[] = [user('task')]
174
+ const result = lastAssistantText(messages, 100)
175
+ expect(result).toBeUndefined()
176
+ })
177
+
178
+ it('does not truncate when maxBytes is undefined', () => {
179
+ const messages: AgentMessage[] = [
180
+ user('task'),
181
+ assistant({ text: 'a'.repeat(1000) }),
182
+ ]
183
+ const result = lastAssistantText(messages)
184
+ expect(result).toBe('a'.repeat(1000))
185
+ })
186
+ })
187
+
188
+ describe('lastMessageText', () => {
189
+ it('returns the text from the last assistant message', () => {
190
+ const messages: AgentMessage[] = [
191
+ user('task'),
192
+ assistant({ text: 'I will read it' }),
193
+ ]
194
+ const result = lastMessageText(messages)
195
+ expect(result).toBe('I will read it')
196
+ })
197
+
198
+ it('returns undefined when only a user message exists', () => {
199
+ const messages: AgentMessage[] = [user('task')]
200
+ const result = lastMessageText(messages)
201
+ expect(result).toBeUndefined()
202
+ })
203
+
204
+ it('returns undefined when the last message is not assistant or toolResult', () => {
205
+ const messages: AgentMessage[] = [user('task'), user('follow-up')]
206
+ const result = lastMessageText(messages)
207
+ expect(result).toBeUndefined()
208
+ })
209
+
210
+ it('returns tool result text when the last message is a toolResult with content', () => {
211
+ const messages: AgentMessage[] = [
212
+ user('task'),
213
+ assistant({ toolCall: 'read' }),
214
+ toolResultText('File content here'),
215
+ ]
216
+ const result = lastMessageText(messages)
217
+ expect(result).toBe('read\nFile content here')
218
+ })
219
+
220
+ it('returns undefined when toolResult has empty text content', () => {
221
+ const messages: AgentMessage[] = [
222
+ user('task'),
223
+ assistant({ toolCall: 'read' }),
224
+ toolResultText(''),
225
+ ]
226
+ const result = lastMessageText(messages)
227
+ expect(result).toBeUndefined()
228
+ })
229
+
230
+ it('returns tool result text when assistant only made tool calls and never spoke again', () => {
231
+ const messages: AgentMessage[] = [
232
+ user('task'),
233
+ assistant({ text: 'I will read it' }),
234
+ assistant({ toolCall: 'read' }),
235
+ toolResultText('Tool output'),
236
+ ]
237
+ const result = lastMessageText(messages)
238
+ expect(result).toBe('read\nTool output')
239
+ })
240
+
241
+ it('returns tool result text even when isError is true', () => {
242
+ const messages: AgentMessage[] = [
243
+ user('task'),
244
+ assistant({ toolCall: 'read' }),
245
+ toolResultText('Error: file not found', true),
246
+ ]
247
+ const result = lastMessageText(messages)
248
+ expect(result).toBe('read\nError: file not found')
249
+ })
250
+
251
+ it('returns tool result text when last message is toolResult, ignoring earlier assistant text', () => {
252
+ const messages: AgentMessage[] = [
253
+ assistant({ text: 'hello' }),
254
+ toolResultText('world'),
255
+ ]
256
+ const result = lastMessageText(messages)
257
+ expect(result).toBe('read\nworld')
258
+ })
259
+
260
+ it('returns assistant text when toolResult has empty content', () => {
261
+ const messages: AgentMessage[] = [
262
+ assistant({ text: 'only assistant' }),
263
+ toolResultText(''),
264
+ ]
265
+ const result = lastMessageText(messages)
266
+ expect(result).toBe('only assistant')
267
+ })
268
+
269
+ it('returns tool result text when toolResult is the only message', () => {
270
+ const messages: AgentMessage[] = [toolResultText('only tool')]
271
+ const result = lastMessageText(messages)
272
+ expect(result).toBe('read\nonly tool')
273
+ })
274
+
275
+ it('returns assistant text when last message is assistant', () => {
276
+ const messages: AgentMessage[] = [assistant({ text: 'last is assistant' })]
277
+ const result = lastMessageText(messages)
278
+ expect(result).toBe('last is assistant')
279
+ })
280
+
281
+ it('returns tool result text when assistant has no text but toolResult does', () => {
282
+ const messages: AgentMessage[] = [
283
+ assistant({}),
284
+ toolResultText('tool wins'),
285
+ ]
286
+ const result = lastMessageText(messages)
287
+ expect(result).toBe('read\ntool wins')
288
+ })
289
+
290
+ it('returns undefined for empty messages array', () => {
291
+ const messages: AgentMessage[] = []
292
+ const result = lastMessageText(messages)
293
+ expect(result).toBeUndefined()
294
+ })
295
+
296
+ it('returns tool result text with formatted toolName and arguments when matching ToolCall exists', () => {
297
+ const messages: AgentMessage[] = [
298
+ user('task'),
299
+ assistant({ toolCall: 'read', toolCallArgs: { path: 'x' } }),
300
+ toolResultText('File content here'),
301
+ ]
302
+ const result = lastMessageText(messages)
303
+ expect(result).toBe('read({"path":"x"})\nFile content here')
304
+ })
305
+
306
+ it('returns previous assistant text when last assistant has no text', () => {
307
+ const messages: AgentMessage[] = [
308
+ user('task'),
309
+ assistant({ text: 'first assistant' }),
310
+ assistant({ toolCall: 'read' }),
311
+ ]
312
+ const result = lastMessageText(messages)
313
+ expect(result).toBe('first assistant')
314
+ })
315
+
316
+ it('returns undefined when no assistant has text', () => {
317
+ const messages: AgentMessage[] = [
318
+ user('task'),
319
+ assistant({ toolCall: 'read' }),
320
+ assistant({}),
321
+ ]
322
+ const result = lastMessageText(messages)
323
+ expect(result).toBeUndefined()
324
+ })
325
+ })
326
+
327
+ describe('lastMessageText with maxBytes', () => {
328
+ it('returns text unchanged when under byte limit', () => {
329
+ const messages: AgentMessage[] = [
330
+ user('task'),
331
+ assistant({ text: 'Short text' }),
332
+ ]
333
+ const result = lastMessageText(messages, 100)
334
+ expect(result).toBe('Short text')
335
+ })
336
+
337
+ it('truncates text with suffix when over byte limit', () => {
338
+ const messages: AgentMessage[] = [
339
+ user('task'),
340
+ assistant({ text: 'a'.repeat(1000) }),
341
+ ]
342
+ const result = lastMessageText(messages, 50)
343
+ const defaultSuffix = '\n\n[Output truncated.]'
344
+ expect(result).toContain(defaultSuffix)
345
+ expect(result?.length).toBeLessThanOrEqual(50 + defaultSuffix.length + 10)
346
+ })
347
+
348
+ it('returns undefined when no valid message exists regardless of maxBytes', () => {
349
+ const messages: AgentMessage[] = [user('task')]
350
+ const result = lastMessageText(messages, 100)
351
+ expect(result).toBeUndefined()
352
+ })
353
+
354
+ it('does not truncate when maxBytes is undefined', () => {
355
+ const messages: AgentMessage[] = [
356
+ user('task'),
357
+ assistant({ text: 'a'.repeat(1000) }),
358
+ ]
359
+ const result = lastMessageText(messages)
360
+ expect(result).toBe('a'.repeat(1000))
361
+ })
362
+ })
363
+
364
+ describe('messageText', () => {
365
+ it('returns empty string for undefined message', () => {
366
+ expect(messageText(undefined)).toBe('')
367
+ })
368
+
369
+ it('returns text from assistant message', () => {
370
+ const msg = assistant({ text: 'Hello world' })
371
+ expect(messageText(msg)).toBe('Hello world')
372
+ })
373
+
374
+ it('returns tool result text without prefix', () => {
375
+ const msg = toolResultText('output')
376
+ expect(messageText(msg)).toBe('output')
377
+ })
378
+
379
+ it('returns empty string for empty tool result text', () => {
380
+ const msg = toolResultText('')
381
+ expect(messageText(msg)).toBe('')
382
+ })
383
+
384
+ it('returns empty string for message with only tool calls', () => {
385
+ const msg = assistant({ toolCall: 'read' })
386
+ expect(messageText(msg)).toBe('')
387
+ })
388
+ })
389
+
390
+ describe('formatToolNameWithArgs', () => {
391
+ it('returns toolName with arguments when matching ToolCall has arguments', () => {
392
+ const messages: AgentMessage[] = [
393
+ user('task'),
394
+ assistant({ toolCall: 'read', toolCallArgs: { path: 'file.txt' } }),
395
+ ]
396
+ const toolResult = toolResultText('content')
397
+ const result = formatToolNameWithArgs(toolResult, messages)
398
+ expect(result).toBe('read({"path":"file.txt"})')
399
+ })
400
+
401
+ it('returns toolName without parens when matching ToolCall has empty arguments', () => {
402
+ const messages: AgentMessage[] = [
403
+ user('task'),
404
+ assistant({ toolCall: 'read', toolCallArgs: {} }),
405
+ ]
406
+ const toolResult = toolResultText('content')
407
+ const result = formatToolNameWithArgs(toolResult, messages)
408
+ expect(result).toBe('read')
409
+ })
410
+
411
+ it('returns toolName without parens when no matching ToolCall exists', () => {
412
+ const messages: AgentMessage[] = [user('task')]
413
+ const toolResult = toolResultText('content')
414
+ const result = formatToolNameWithArgs(toolResult, messages)
415
+ expect(result).toBe('read')
416
+ })
417
+
418
+ it('returns toolName with complex arguments when matching ToolCall has complex arguments', () => {
419
+ const messages: AgentMessage[] = [
420
+ user('task'),
421
+ assistant({
422
+ toolCall: 'write',
423
+ toolCallArgs: {
424
+ path: 'file.txt',
425
+ content: 'hello world',
426
+ overwrite: true,
427
+ },
428
+ }),
429
+ ]
430
+ const toolResult = toolResultMessage('call-123', 'write', 'done')
431
+ const result = formatToolNameWithArgs(toolResult, messages)
432
+ expect(result).toBe(
433
+ 'write({"path":"file.txt","content":"hello world","overwrite":true})',
434
+ )
435
+ })
436
+ })
@@ -0,0 +1,131 @@
1
+ import type { AgentMessage } from '@earendil-works/pi-agent-core'
2
+ import type {
3
+ AssistantMessage,
4
+ TextContent,
5
+ ThinkingContent,
6
+ ToolCall,
7
+ ToolResultMessage,
8
+ } from '@earendil-works/pi-ai'
9
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
10
+
11
+ import { truncateToBytes } from './format.js'
12
+ import { PLUGIN_KEY } from './state.ts'
13
+
14
+ function isAssistantMessage(
15
+ message: AgentMessage | undefined,
16
+ ): message is AssistantMessage {
17
+ return message?.role === 'assistant' && Array.isArray(message.content)
18
+ }
19
+
20
+ function isToolResultMessage(
21
+ message: AgentMessage | undefined,
22
+ ): message is ToolResultMessage {
23
+ return (
24
+ message?.role === 'toolResult' &&
25
+ 'content' in message &&
26
+ Array.isArray(message.content)
27
+ )
28
+ }
29
+
30
+ function isToolCall(
31
+ block: TextContent | ThinkingContent | ToolCall,
32
+ ): block is ToolCall {
33
+ return block.type === 'toolCall'
34
+ }
35
+
36
+ function findToolCall(
37
+ toolCallId: string,
38
+ messages: readonly AgentMessage[],
39
+ ): ToolCall | undefined {
40
+ for (const message of messages) {
41
+ if (isAssistantMessage(message)) {
42
+ for (const block of message.content) {
43
+ if (isToolCall(block) && block.id === toolCallId) {
44
+ return block
45
+ }
46
+ }
47
+ }
48
+ }
49
+ return undefined
50
+ }
51
+
52
+ export function formatToolNameWithArgs(
53
+ message: ToolResultMessage,
54
+ messages: readonly AgentMessage[],
55
+ ): string {
56
+ const toolCall = findToolCall(message.toolCallId, messages)
57
+ if (toolCall && Object.keys(toolCall.arguments).length > 0) {
58
+ return `${message.toolName}(${JSON.stringify(toolCall.arguments)})`
59
+ }
60
+ return message.toolName
61
+ }
62
+
63
+ export function messageText(message: AgentMessage | undefined): string {
64
+ if (!message) return ''
65
+ if (isAssistantMessage(message) || isToolResultMessage(message)) {
66
+ const text = message.content
67
+ .filter((block): block is TextContent => block.type === 'text')
68
+ .map((block) => block.text)
69
+ .join('\n')
70
+ .trim()
71
+ return text
72
+ }
73
+ return ''
74
+ }
75
+
76
+ export function lastAssistantText(
77
+ messages: readonly AgentMessage[],
78
+ maxBytes?: number,
79
+ ): string | undefined {
80
+ for (let i = messages.length - 1; i >= 0; i--) {
81
+ const message = messages[i]
82
+ if (isAssistantMessage(message)) {
83
+ const text = messageText(message)
84
+ if (!text) continue
85
+ const result =
86
+ maxBytes !== undefined ? truncateToBytes(text, maxBytes) : text
87
+ return result || undefined
88
+ }
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ export function lastMessageText(
94
+ messages: readonly AgentMessage[],
95
+ maxBytes?: number,
96
+ ): string | undefined {
97
+ if (messages.length === 0) return undefined
98
+
99
+ for (let i = messages.length - 1; i >= 0; i--) {
100
+ const message = messages[i]
101
+
102
+ if (isAssistantMessage(message)) {
103
+ const text = messageText(message)
104
+ if (!text) continue
105
+ return maxBytes !== undefined ? truncateToBytes(text, maxBytes) : text
106
+ }
107
+
108
+ if (isToolResultMessage(message)) {
109
+ const text = messageText(message)
110
+ if (!text) continue
111
+ const name = formatToolNameWithArgs(message, messages)
112
+ const composed = `${name}\n${text}`
113
+ return maxBytes !== undefined
114
+ ? truncateToBytes(composed, maxBytes)
115
+ : composed
116
+ }
117
+ }
118
+
119
+ return undefined
120
+ }
121
+
122
+ export function notifyAgentMessage(pi: ExtensionAPI, content: string) {
123
+ pi.sendMessage(
124
+ {
125
+ customType: PLUGIN_KEY,
126
+ content,
127
+ display: true,
128
+ },
129
+ { deliverAs: 'steer', triggerTurn: true },
130
+ )
131
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { parseModelRef } from './model-ref.js'
4
+
5
+ describe('parseModelRef', () => {
6
+ it('parses valid provider/id format', () => {
7
+ expect(parseModelRef('openai/gpt-4')).toEqual({
8
+ provider: 'openai',
9
+ id: 'gpt-4',
10
+ })
11
+ })
12
+
13
+ it('parses provider/id with dashes in id', () => {
14
+ expect(parseModelRef('anthropic/claude-3-5-sonnet')).toEqual({
15
+ provider: 'anthropic',
16
+ id: 'claude-3-5-sonnet',
17
+ })
18
+ })
19
+
20
+ it('returns undefined for missing id', () => {
21
+ expect(parseModelRef('openai/')).toBeUndefined()
22
+ })
23
+
24
+ it('returns undefined for missing provider', () => {
25
+ expect(parseModelRef('/gpt-4')).toBeUndefined()
26
+ })
27
+
28
+ it('returns undefined for no slashes', () => {
29
+ expect(parseModelRef('gpt-4')).toBeUndefined()
30
+ })
31
+
32
+ it('returns undefined for empty string', () => {
33
+ expect(parseModelRef('')).toBeUndefined()
34
+ })
35
+
36
+ it('parses provider with underscores', () => {
37
+ expect(parseModelRef('my_provider/model-name')).toEqual({
38
+ provider: 'my_provider',
39
+ id: 'model-name',
40
+ })
41
+ })
42
+ })
@@ -0,0 +1,44 @@
1
+ import type { Api, Model } from '@earendil-works/pi-ai'
2
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
3
+
4
+ import { MODEL_DEFAULT } from '../models-config/subagent-model-constants.js'
5
+
6
+ export interface ModelOption {
7
+ key: string
8
+ text: string
9
+ provider: string
10
+ }
11
+
12
+ export function modelRefOf(model: Model<Api>): string {
13
+ return `${model.provider}/${model.id}`
14
+ }
15
+
16
+ export function modelOptionOf(model: Model<Api>): ModelOption {
17
+ return {
18
+ key: modelRefOf(model),
19
+ text: `${model.provider}/${model.name}`,
20
+ provider: model.provider,
21
+ }
22
+ }
23
+
24
+ export function parseModelRef(
25
+ ref: string,
26
+ ): { provider: string; id: string } | undefined {
27
+ const [provider, id] = ref.split('/')
28
+ if (!provider || !id) return undefined
29
+ return { provider, id }
30
+ }
31
+
32
+ export function resolveModelRef(
33
+ ctx: ExtensionContext,
34
+ ref: readonly (string | undefined)[],
35
+ ): Model<Api> | undefined {
36
+ for (const r of ref) {
37
+ if (!r || r === MODEL_DEFAULT) continue
38
+ const parsed = parseModelRef(r)
39
+ if (!parsed) continue
40
+ const model = ctx.modelRegistry.find(parsed.provider, parsed.id)
41
+ if (model) return model
42
+ }
43
+ return undefined
44
+ }