@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.
- package/.prettierrc +7 -0
- package/AGENTS.md +1 -0
- package/README.md +85 -0
- package/README.zh-CN.md +85 -0
- package/agents/explorer.md +10 -0
- package/agents/planner.md +16 -0
- package/agents/researcher.md +22 -0
- package/agents/reviewer.md +10 -0
- package/eslint.config.mjs +14 -0
- package/example-prompts/coordinator.md +21 -0
- package/package.json +48 -0
- package/pm-subagents-prompts/coordinator.md +16 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/bash-readonly.test.ts +331 -0
- package/src/bash-readonly.ts +205 -0
- package/src/coordinator/coordinator.test.ts +28 -0
- package/src/coordinator/coordinator.ts +275 -0
- package/src/custom-select.test.ts +91 -0
- package/src/custom-select.ts +209 -0
- package/src/index.ts +87 -0
- package/src/models-config/models-config.test.ts +88 -0
- package/src/models-config/models-config.ts +205 -0
- package/src/models-config/scoped-models-editor.test.ts +189 -0
- package/src/models-config/scoped-models-editor.ts +412 -0
- package/src/models-config/subagent-model-constants.ts +2 -0
- package/src/models-config/subagent-model-cycle.ts +53 -0
- package/src/models-config/subagent-model-utils.test.ts +250 -0
- package/src/models-config/subagent-model-utils.ts +52 -0
- package/src/pm-mode.test.ts +324 -0
- package/src/pm-mode.ts +142 -0
- package/src/prompts/mode.test.ts +289 -0
- package/src/prompts/mode.ts +31 -0
- package/src/prompts/roles.test.ts +724 -0
- package/src/prompts/roles.ts +119 -0
- package/src/subagent/activity.test.ts +230 -0
- package/src/subagent/activity.ts +60 -0
- package/src/subagent/batcher.test.ts +198 -0
- package/src/subagent/batcher.ts +51 -0
- package/src/subagent/consts.ts +1 -0
- package/src/subagent/demo.ts +773 -0
- package/src/subagent/fleet.test.ts +1758 -0
- package/src/subagent/fleet.ts +376 -0
- package/src/subagent/identity.test.ts +31 -0
- package/src/subagent/identity.ts +16 -0
- package/src/subagent/manager.test.ts +392 -0
- package/src/subagent/manager.ts +277 -0
- package/src/subagent/tools.ts +314 -0
- package/src/subagent/viewer.ts +305 -0
- package/src/types.ts +15 -0
- package/src/ui/border-view.ts +50 -0
- package/src/ui/review-pager.ts +146 -0
- package/src/ui/scroll-view.test.ts +190 -0
- package/src/ui/scroll-view.ts +155 -0
- package/src/utils/format.test.ts +76 -0
- package/src/utils/format.ts +67 -0
- package/src/utils/fs.ts +9 -0
- package/src/utils/markdown.test.ts +442 -0
- package/src/utils/markdown.ts +79 -0
- package/src/utils/messages.test.ts +436 -0
- package/src/utils/messages.ts +131 -0
- package/src/utils/model-ref.test.ts +42 -0
- package/src/utils/model-ref.ts +44 -0
- package/src/utils/state.test.ts +98 -0
- package/src/utils/state.ts +45 -0
- package/src/utils/tools.ts +48 -0
- package/src/utils/truncate.test.ts +41 -0
- package/src/utils/truncate.ts +59 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
2
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { MODEL_DEFAULT } from './subagent-model-constants.js'
|
|
6
|
+
import {
|
|
7
|
+
cycleSubagentModel,
|
|
8
|
+
formatSubagentModelLabel,
|
|
9
|
+
resolveSubagentModelForSpawn,
|
|
10
|
+
} from './subagent-model-utils.js'
|
|
11
|
+
|
|
12
|
+
describe('cycleSubagentModel', () => {
|
|
13
|
+
describe('empty scope', () => {
|
|
14
|
+
it('throws', () => {
|
|
15
|
+
expect(() => cycleSubagentModel([], undefined, 1)).toThrow(
|
|
16
|
+
'subagent model scope must be non-empty',
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('single element scope', () => {
|
|
22
|
+
it('always returns index 0 regardless of direction', () => {
|
|
23
|
+
const scope = ['anthropic/claude-haiku-4-5']
|
|
24
|
+
expect(cycleSubagentModel(scope, undefined, 1)).toEqual({
|
|
25
|
+
ref: 'anthropic/claude-haiku-4-5',
|
|
26
|
+
index: 0,
|
|
27
|
+
poolSize: 1,
|
|
28
|
+
})
|
|
29
|
+
expect(
|
|
30
|
+
cycleSubagentModel(scope, 'anthropic/claude-haiku-4-5', 1),
|
|
31
|
+
).toEqual({
|
|
32
|
+
ref: 'anthropic/claude-haiku-4-5',
|
|
33
|
+
index: 0,
|
|
34
|
+
poolSize: 1,
|
|
35
|
+
})
|
|
36
|
+
expect(
|
|
37
|
+
cycleSubagentModel(scope, 'anthropic/claude-haiku-4-5', -1),
|
|
38
|
+
).toEqual({
|
|
39
|
+
ref: 'anthropic/claude-haiku-4-5',
|
|
40
|
+
index: 0,
|
|
41
|
+
poolSize: 1,
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('multiple elements scope', () => {
|
|
47
|
+
const scope = ['model-a', 'model-b', 'model-c']
|
|
48
|
+
|
|
49
|
+
it('cycles forward wrapping around', () => {
|
|
50
|
+
expect(cycleSubagentModel(scope, 'model-a', 1)).toEqual({
|
|
51
|
+
ref: 'model-b',
|
|
52
|
+
index: 1,
|
|
53
|
+
poolSize: 3,
|
|
54
|
+
})
|
|
55
|
+
expect(cycleSubagentModel(scope, 'model-b', 1)).toEqual({
|
|
56
|
+
ref: 'model-c',
|
|
57
|
+
index: 2,
|
|
58
|
+
poolSize: 3,
|
|
59
|
+
})
|
|
60
|
+
expect(cycleSubagentModel(scope, 'model-c', 1)).toEqual({
|
|
61
|
+
ref: 'model-a',
|
|
62
|
+
index: 0,
|
|
63
|
+
poolSize: 3,
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('cycles backward wrapping around', () => {
|
|
68
|
+
expect(cycleSubagentModel(scope, 'model-a', -1)).toEqual({
|
|
69
|
+
ref: 'model-c',
|
|
70
|
+
index: 2,
|
|
71
|
+
poolSize: 3,
|
|
72
|
+
})
|
|
73
|
+
expect(cycleSubagentModel(scope, 'model-c', -1)).toEqual({
|
|
74
|
+
ref: 'model-b',
|
|
75
|
+
index: 1,
|
|
76
|
+
poolSize: 3,
|
|
77
|
+
})
|
|
78
|
+
expect(cycleSubagentModel(scope, 'model-b', -1)).toEqual({
|
|
79
|
+
ref: 'model-a',
|
|
80
|
+
index: 0,
|
|
81
|
+
poolSize: 3,
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('handles undefined currentRef as first element', () => {
|
|
86
|
+
expect(cycleSubagentModel(scope, undefined, 1)).toEqual({
|
|
87
|
+
ref: 'model-b',
|
|
88
|
+
index: 1,
|
|
89
|
+
poolSize: 3,
|
|
90
|
+
})
|
|
91
|
+
expect(cycleSubagentModel(scope, undefined, -1)).toEqual({
|
|
92
|
+
ref: 'model-c',
|
|
93
|
+
index: 2,
|
|
94
|
+
poolSize: 3,
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('handles currentRef not in scope as first element', () => {
|
|
99
|
+
expect(cycleSubagentModel(scope, 'unknown-model', 1)).toEqual({
|
|
100
|
+
ref: 'model-b',
|
|
101
|
+
index: 1,
|
|
102
|
+
poolSize: 3,
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('formatSubagentModelLabel', () => {
|
|
109
|
+
it('returns ref for empty scope', () => {
|
|
110
|
+
expect(formatSubagentModelLabel([], 'model-a')).toBe('model-a')
|
|
111
|
+
expect(formatSubagentModelLabel([], MODEL_DEFAULT)).toBe(MODEL_DEFAULT)
|
|
112
|
+
expect(formatSubagentModelLabel([], undefined)).toBe(MODEL_DEFAULT)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('shows ref with index and pool size when scope is non-empty', () => {
|
|
116
|
+
const scope = ['model-a', 'model-b', 'model-c']
|
|
117
|
+
expect(formatSubagentModelLabel(scope, 'model-a')).toBe('model-a (1/3)')
|
|
118
|
+
expect(formatSubagentModelLabel(scope, 'model-b')).toBe('model-b (2/3)')
|
|
119
|
+
expect(formatSubagentModelLabel(scope, 'model-c')).toBe('model-c (3/3)')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('shows ref without index when scope is empty', () => {
|
|
123
|
+
expect(formatSubagentModelLabel([], 'model-a')).toBe('model-a')
|
|
124
|
+
expect(formatSubagentModelLabel([], MODEL_DEFAULT)).toBe(MODEL_DEFAULT)
|
|
125
|
+
expect(formatSubagentModelLabel([], undefined)).toBe(MODEL_DEFAULT)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('shows ref without index when ref not found in scope', () => {
|
|
129
|
+
const scope = ['model-a', 'model-b']
|
|
130
|
+
expect(formatSubagentModelLabel(scope, 'unknown')).toBe('unknown')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('shows MODEL_DEFAULT without index when ref is undefined and not in scope', () => {
|
|
134
|
+
const scope = ['model-a', 'model-b']
|
|
135
|
+
expect(formatSubagentModelLabel(scope, undefined)).toBe(MODEL_DEFAULT)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('shows MODEL_DEFAULT with index when ref is undefined and DEFAULT is in scope', () => {
|
|
139
|
+
const scope = [MODEL_DEFAULT, 'model-a', 'model-b']
|
|
140
|
+
expect(formatSubagentModelLabel(scope, undefined)).toBe('DEFAULT (1/3)')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('resolveSubagentModelForSpawn', () => {
|
|
145
|
+
function makeFakeModel(provider: string, id: string): Model<Api> {
|
|
146
|
+
return {
|
|
147
|
+
provider,
|
|
148
|
+
id,
|
|
149
|
+
name: `${provider}/${id}`,
|
|
150
|
+
api: 'openai-responses',
|
|
151
|
+
baseUrl: `https://${provider}.example.com`,
|
|
152
|
+
reasoning: false,
|
|
153
|
+
input: ['text'],
|
|
154
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
155
|
+
contextWindow: 128000,
|
|
156
|
+
maxTokens: 8192,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function makeFakeCtx(
|
|
161
|
+
registry: Record<string, Model<Api>>,
|
|
162
|
+
currentModel: Model<Api>,
|
|
163
|
+
): ExtensionContext {
|
|
164
|
+
return {
|
|
165
|
+
modelRegistry: {
|
|
166
|
+
find: (provider: string, id: string) =>
|
|
167
|
+
registry[`${provider}/${id}`] ?? null,
|
|
168
|
+
getAvailable: () => Object.values(registry),
|
|
169
|
+
getModel: (provider: string, id: string) =>
|
|
170
|
+
registry[`${provider}/${id}`] ?? null,
|
|
171
|
+
getProviders: () => [],
|
|
172
|
+
getModels: () => [],
|
|
173
|
+
checkAuth: async () => undefined,
|
|
174
|
+
refresh: async () => ({ aborted: false, errors: new Map() }),
|
|
175
|
+
getAuth: async () => undefined,
|
|
176
|
+
login: async () => {
|
|
177
|
+
throw new Error('not implemented')
|
|
178
|
+
},
|
|
179
|
+
logout: async () => {},
|
|
180
|
+
stream: () => {
|
|
181
|
+
throw new Error('not implemented')
|
|
182
|
+
},
|
|
183
|
+
complete: async () => {
|
|
184
|
+
throw new Error('not implemented')
|
|
185
|
+
},
|
|
186
|
+
streamSimple: () => {
|
|
187
|
+
throw new Error('not implemented')
|
|
188
|
+
},
|
|
189
|
+
completeSimple: async () => {
|
|
190
|
+
throw new Error('not implemented')
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
model: currentModel,
|
|
194
|
+
} as unknown as ExtensionContext
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const modelA = makeFakeModel('provider-a', 'model-a')
|
|
198
|
+
const modelB = makeFakeModel('provider-b', 'model-b')
|
|
199
|
+
const ctxModel = makeFakeModel('default', 'ctx-model')
|
|
200
|
+
|
|
201
|
+
it('returns sessionModel when it is a valid model ref', () => {
|
|
202
|
+
const ctx = makeFakeCtx(
|
|
203
|
+
{ 'provider-a/model-a': modelA, 'provider-b/model-b': modelB },
|
|
204
|
+
ctxModel,
|
|
205
|
+
)
|
|
206
|
+
const result = resolveSubagentModelForSpawn(
|
|
207
|
+
ctx,
|
|
208
|
+
undefined,
|
|
209
|
+
'provider-a/model-a',
|
|
210
|
+
)
|
|
211
|
+
expect(result).toBe(modelA)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('falls back to ctx.model when both roleModel and sessionModel are undefined', () => {
|
|
215
|
+
const ctx = makeFakeCtx({ 'default/ctx-model': ctxModel }, ctxModel)
|
|
216
|
+
const result = resolveSubagentModelForSpawn(ctx, undefined, undefined)
|
|
217
|
+
expect(result).toBe(ctxModel)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('roleModel takes priority over sessionModel', () => {
|
|
221
|
+
const ctx = makeFakeCtx(
|
|
222
|
+
{ 'provider-a/model-a': modelA, 'provider-b/model-b': modelB },
|
|
223
|
+
ctxModel,
|
|
224
|
+
)
|
|
225
|
+
const result = resolveSubagentModelForSpawn(
|
|
226
|
+
ctx,
|
|
227
|
+
'provider-b/model-b',
|
|
228
|
+
'provider-a/model-a',
|
|
229
|
+
)
|
|
230
|
+
expect(result).toBe(modelB)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('falls back to ctx.model when sessionModel is not in registry', () => {
|
|
234
|
+
const ctx = makeFakeCtx({}, ctxModel)
|
|
235
|
+
const result = resolveSubagentModelForSpawn(ctx, undefined, 'unknown/model')
|
|
236
|
+
expect(result).toBe(ctxModel)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('falls back to ctx.model when sessionModel is MODEL_DEFAULT', () => {
|
|
240
|
+
const ctx = makeFakeCtx({ 'default/ctx-model': ctxModel }, ctxModel)
|
|
241
|
+
const result = resolveSubagentModelForSpawn(ctx, undefined, MODEL_DEFAULT)
|
|
242
|
+
expect(result).toBe(ctxModel)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('sessionModel undefined falls back to MODEL_DEFAULT then ctx.model', () => {
|
|
246
|
+
const ctx = makeFakeCtx({ 'default/ctx-model': ctxModel }, ctxModel)
|
|
247
|
+
const result = resolveSubagentModelForSpawn(ctx, undefined, undefined)
|
|
248
|
+
expect(result).toBe(ctxModel)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
2
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
import { resolveModelRef } from '../utils/model-ref.js'
|
|
5
|
+
import { MODEL_DEFAULT } from './subagent-model-constants.js'
|
|
6
|
+
|
|
7
|
+
export interface CycleResult {
|
|
8
|
+
readonly ref: string
|
|
9
|
+
readonly index: number
|
|
10
|
+
readonly poolSize: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function cycleSubagentModel(
|
|
14
|
+
scope: readonly string[],
|
|
15
|
+
currentRef: string | undefined,
|
|
16
|
+
direction: 1 | -1,
|
|
17
|
+
): CycleResult {
|
|
18
|
+
if (scope.length === 0) {
|
|
19
|
+
throw new Error('subagent model scope must be non-empty')
|
|
20
|
+
}
|
|
21
|
+
const currentIndex = currentRef ? scope.indexOf(currentRef) : -1
|
|
22
|
+
const safeIndex = currentIndex < 0 ? 0 : currentIndex
|
|
23
|
+
const clampedNextIndex =
|
|
24
|
+
(((safeIndex + direction) % scope.length) + scope.length) % scope.length
|
|
25
|
+
return {
|
|
26
|
+
ref: scope[clampedNextIndex] ?? MODEL_DEFAULT,
|
|
27
|
+
index: clampedNextIndex,
|
|
28
|
+
poolSize: scope.length,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveSubagentModelForSpawn(
|
|
33
|
+
ctx: ExtensionContext,
|
|
34
|
+
roleModel: string | undefined,
|
|
35
|
+
sessionModel: string | undefined,
|
|
36
|
+
): Model<Api> | undefined {
|
|
37
|
+
const ref = roleModel ?? sessionModel ?? MODEL_DEFAULT
|
|
38
|
+
return resolveModelRef(ctx, [ref]) ?? ctx.model
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function formatSubagentModelLabel(
|
|
42
|
+
scope: readonly string[],
|
|
43
|
+
currentRef: string | undefined,
|
|
44
|
+
): string {
|
|
45
|
+
if (scope.length === 0) {
|
|
46
|
+
return currentRef ?? MODEL_DEFAULT
|
|
47
|
+
}
|
|
48
|
+
const ref = currentRef ?? MODEL_DEFAULT
|
|
49
|
+
const index = scope.indexOf(ref)
|
|
50
|
+
if (index < 0) return ref
|
|
51
|
+
return `${ref} (${index + 1}/${scope.length})`
|
|
52
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
2
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { BASH_READONLY_TOOL_NAME } from './bash-readonly.js'
|
|
6
|
+
import {
|
|
7
|
+
applyModeModel,
|
|
8
|
+
calculateModeTools,
|
|
9
|
+
type ToolConfig,
|
|
10
|
+
WRITE_TOOLS,
|
|
11
|
+
} from './pm-mode.js'
|
|
12
|
+
import { baseToolsOf, restoreTools } from './pm-mode.js'
|
|
13
|
+
import type { PmSubagentState } from './types.js'
|
|
14
|
+
import { createState } from './utils/state.js'
|
|
15
|
+
|
|
16
|
+
function fakePi(initialActive: string[]): ExtensionAPI & {
|
|
17
|
+
activeTools: string[]
|
|
18
|
+
} {
|
|
19
|
+
const pi = {
|
|
20
|
+
activeTools: initialActive,
|
|
21
|
+
getActiveTools() {
|
|
22
|
+
return [...pi.activeTools]
|
|
23
|
+
},
|
|
24
|
+
setActiveTools(names: string[]) {
|
|
25
|
+
pi.activeTools = [...names]
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
return pi as ExtensionAPI & { activeTools: string[] }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('calculateModeTools / restoreTools diff mechanism', () => {
|
|
32
|
+
it('preserves tools registered during the mode and restores bash on exit', () => {
|
|
33
|
+
const pi = fakePi(['read', 'write', 'bash'])
|
|
34
|
+
const state: PmSubagentState = createState()
|
|
35
|
+
pi.setActiveTools(calculateModeTools(pi, state))
|
|
36
|
+
expect(pi.activeTools).toEqual(['read', BASH_READONLY_TOOL_NAME])
|
|
37
|
+
|
|
38
|
+
pi.activeTools.push('newtool')
|
|
39
|
+
|
|
40
|
+
restoreTools(pi, state)
|
|
41
|
+
expect(pi.activeTools).toContain('newtool')
|
|
42
|
+
expect(pi.activeTools).toContain('write')
|
|
43
|
+
expect(pi.activeTools).toContain('bash')
|
|
44
|
+
expect(pi.activeTools).not.toContain(BASH_READONLY_TOOL_NAME)
|
|
45
|
+
expect(state.modeDiffTools).toBeUndefined()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('replays persisted modeTools idempotently and restores on exit', () => {
|
|
49
|
+
const pi = fakePi(['read', BASH_READONLY_TOOL_NAME, 'grep'])
|
|
50
|
+
const state: PmSubagentState = {
|
|
51
|
+
...createState(),
|
|
52
|
+
modeDiffTools: { added: [BASH_READONLY_TOOL_NAME], removed: ['bash'] },
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pi.setActiveTools(calculateModeTools(pi, state))
|
|
56
|
+
expect(pi.activeTools).toEqual(['read', BASH_READONLY_TOOL_NAME, 'grep'])
|
|
57
|
+
expect(state.modeDiffTools).toEqual({
|
|
58
|
+
added: [BASH_READONLY_TOOL_NAME],
|
|
59
|
+
removed: ['bash'],
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
restoreTools(pi, state)
|
|
63
|
+
expect(pi.activeTools).toEqual(['read', 'grep', 'bash'])
|
|
64
|
+
expect(state.modeDiffTools).toBeUndefined()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('baseToolsOf includes tools registered during the mode', () => {
|
|
68
|
+
const pi = fakePi(['read', BASH_READONLY_TOOL_NAME, 'newtool'])
|
|
69
|
+
const state: PmSubagentState = {
|
|
70
|
+
...createState(),
|
|
71
|
+
modeDiffTools: { added: [BASH_READONLY_TOOL_NAME], removed: ['bash'] },
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const base = baseToolsOf(pi, state)
|
|
75
|
+
expect(base).toContain('bash')
|
|
76
|
+
expect(base).toContain('newtool')
|
|
77
|
+
expect(base).toContain('read')
|
|
78
|
+
expect(base).not.toContain(BASH_READONLY_TOOL_NAME)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('baseToolsOf falls back to current active tools without a diff', () => {
|
|
82
|
+
const pi = fakePi(['read', 'bash'])
|
|
83
|
+
const state = createState()
|
|
84
|
+
expect(baseToolsOf(pi, state)).toEqual(['read', 'bash'])
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('calculateModeTools', () => {
|
|
89
|
+
it('defaults to read-only behavior when no config is provided', () => {
|
|
90
|
+
const base = ['read', 'write', 'edit', 'bash', 'grep', 'find']
|
|
91
|
+
const result = calculateModeTools(fakePi(base), createState(), {})
|
|
92
|
+
expect(result).toContain('read')
|
|
93
|
+
expect(result).toContain(BASH_READONLY_TOOL_NAME)
|
|
94
|
+
expect(result).toContain('grep')
|
|
95
|
+
expect(result).toContain('find')
|
|
96
|
+
expect(result).not.toContain('write')
|
|
97
|
+
expect(result).not.toContain('edit')
|
|
98
|
+
expect(result).not.toContain('bash')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('adds extra tools to the result', () => {
|
|
102
|
+
const base = ['read', 'write', 'bash']
|
|
103
|
+
const config: ToolConfig = {
|
|
104
|
+
extraTools: ['subagent_delegate', 'subagent_kill'],
|
|
105
|
+
}
|
|
106
|
+
const result = calculateModeTools(fakePi(base), createState(), config)
|
|
107
|
+
expect(result).toContain('read')
|
|
108
|
+
expect(result).toContain(BASH_READONLY_TOOL_NAME)
|
|
109
|
+
expect(result).toContain('subagent_delegate')
|
|
110
|
+
expect(result).toContain('subagent_kill')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('removes tools specified in removeTools', () => {
|
|
114
|
+
const base = ['read', 'write', 'bash', 'grep', 'find']
|
|
115
|
+
const config: ToolConfig = {
|
|
116
|
+
removeTools: ['grep', 'find'],
|
|
117
|
+
}
|
|
118
|
+
const result = calculateModeTools(fakePi(base), createState(), config)
|
|
119
|
+
expect(result).toContain('read')
|
|
120
|
+
expect(result).toContain(BASH_READONLY_TOOL_NAME)
|
|
121
|
+
expect(result).not.toContain('grep')
|
|
122
|
+
expect(result).not.toContain('find')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('applies tools whitelist when tools is specified', () => {
|
|
126
|
+
const base = ['read', 'write', 'bash', 'grep', 'find']
|
|
127
|
+
const config: ToolConfig = {
|
|
128
|
+
tools: ['read', BASH_READONLY_TOOL_NAME],
|
|
129
|
+
}
|
|
130
|
+
const result = calculateModeTools(fakePi(base), createState(), config)
|
|
131
|
+
expect(result).toEqual(['read', BASH_READONLY_TOOL_NAME])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('combines extraTools and removeTools correctly', () => {
|
|
135
|
+
const base = ['read', 'write', 'bash', 'grep', 'find']
|
|
136
|
+
const config: ToolConfig = {
|
|
137
|
+
extraTools: ['subagent_delegate'],
|
|
138
|
+
removeTools: ['grep'],
|
|
139
|
+
}
|
|
140
|
+
const result = calculateModeTools(fakePi(base), createState(), config)
|
|
141
|
+
expect(result).toContain('read')
|
|
142
|
+
expect(result).toContain(BASH_READONLY_TOOL_NAME)
|
|
143
|
+
expect(result).toContain('find')
|
|
144
|
+
expect(result).toContain('subagent_delegate')
|
|
145
|
+
expect(result).not.toContain('grep')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('combines tools, extraTools, and removeTools correctly', () => {
|
|
149
|
+
const base = ['read', 'write', 'bash', 'grep', 'find', 'ls']
|
|
150
|
+
const config: ToolConfig = {
|
|
151
|
+
tools: ['read', 'grep', 'subagent_delegate'],
|
|
152
|
+
extraTools: ['subagent_delegate'],
|
|
153
|
+
removeTools: ['find', 'ls'],
|
|
154
|
+
}
|
|
155
|
+
const result = calculateModeTools(fakePi(base), createState(), config)
|
|
156
|
+
expect(result).toContain('read')
|
|
157
|
+
expect(result).toContain('grep')
|
|
158
|
+
expect(result).toContain('subagent_delegate')
|
|
159
|
+
expect(result).not.toContain(BASH_READONLY_TOOL_NAME)
|
|
160
|
+
expect(result).not.toContain('find')
|
|
161
|
+
expect(result).not.toContain('ls')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('handles empty arrays correctly', () => {
|
|
165
|
+
const base = ['read', 'write', 'bash']
|
|
166
|
+
const config: ToolConfig = {
|
|
167
|
+
tools: [],
|
|
168
|
+
extraTools: [],
|
|
169
|
+
removeTools: [],
|
|
170
|
+
}
|
|
171
|
+
const result = calculateModeTools(fakePi(base), createState(), config)
|
|
172
|
+
expect(result).toEqual([])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('handles undefined fields correctly', () => {
|
|
176
|
+
const base = ['read', 'write', 'bash', 'grep']
|
|
177
|
+
const config: ToolConfig = {
|
|
178
|
+
tools: undefined,
|
|
179
|
+
extraTools: undefined,
|
|
180
|
+
removeTools: undefined,
|
|
181
|
+
}
|
|
182
|
+
const result = calculateModeTools(fakePi(base), createState(), config)
|
|
183
|
+
expect(result).toContain('read')
|
|
184
|
+
expect(result).toContain(BASH_READONLY_TOOL_NAME)
|
|
185
|
+
expect(result).toContain('grep')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('removes all write tools from base', () => {
|
|
189
|
+
const base = ['read', 'write', 'edit', 'bash']
|
|
190
|
+
const result = calculateModeTools(fakePi(base), createState(), {})
|
|
191
|
+
for (const tool of WRITE_TOOLS) {
|
|
192
|
+
expect(result).not.toContain(tool)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('keeps extraTools even when tools whitelist does not include them', () => {
|
|
197
|
+
const config: ToolConfig = {
|
|
198
|
+
tools: ['read', 'grep'],
|
|
199
|
+
extraTools: ['subagent_delegate'],
|
|
200
|
+
}
|
|
201
|
+
const result = calculateModeTools(fakePi([]), createState(), config)
|
|
202
|
+
expect(result).toContain('read')
|
|
203
|
+
expect(result).toContain('grep')
|
|
204
|
+
expect(result).toContain('subagent_delegate')
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('applyModeModel', () => {
|
|
209
|
+
const mockModel: Model<Api> = {
|
|
210
|
+
provider: 'anthropic',
|
|
211
|
+
id: 'claude-sonnet-4-5',
|
|
212
|
+
name: 'Claude Sonnet 4.5',
|
|
213
|
+
} as Model<Api>
|
|
214
|
+
|
|
215
|
+
const mockState: PmSubagentState = {
|
|
216
|
+
mode: undefined,
|
|
217
|
+
modeDiffTools: undefined,
|
|
218
|
+
previousModel: undefined,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const setModelMock = vi.fn().mockResolvedValue(true)
|
|
222
|
+
const findModelMock = vi.fn().mockReturnValue(mockModel)
|
|
223
|
+
const notifyMock = vi.fn()
|
|
224
|
+
|
|
225
|
+
const mockPi = {
|
|
226
|
+
setModel: setModelMock,
|
|
227
|
+
} as unknown as Parameters<typeof applyModeModel>[0]
|
|
228
|
+
|
|
229
|
+
const mockCtx = {
|
|
230
|
+
model: mockModel,
|
|
231
|
+
modelRegistry: {
|
|
232
|
+
find: findModelMock,
|
|
233
|
+
},
|
|
234
|
+
ui: {
|
|
235
|
+
notify: notifyMock,
|
|
236
|
+
theme: {},
|
|
237
|
+
},
|
|
238
|
+
} as unknown as Parameters<typeof applyModeModel>[2]
|
|
239
|
+
|
|
240
|
+
beforeEach(() => {
|
|
241
|
+
vi.clearAllMocks()
|
|
242
|
+
mockState.previousModel = undefined
|
|
243
|
+
setModelMock.mockResolvedValue(true)
|
|
244
|
+
findModelMock.mockReturnValue(mockModel)
|
|
245
|
+
mockCtx.model = mockModel
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('does nothing when modelRef is undefined', async () => {
|
|
249
|
+
await applyModeModel(mockPi, mockState, mockCtx, undefined)
|
|
250
|
+
expect(mockState.previousModel).toBeUndefined()
|
|
251
|
+
expect(setModelMock).not.toHaveBeenCalled()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('does nothing when modelRef is empty string', async () => {
|
|
255
|
+
await applyModeModel(mockPi, mockState, mockCtx, '')
|
|
256
|
+
expect(mockState.previousModel).toBeUndefined()
|
|
257
|
+
expect(setModelMock).not.toHaveBeenCalled()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('successfully switches to valid model', async () => {
|
|
261
|
+
const modelRef = 'anthropic/claude-sonnet-4-5'
|
|
262
|
+
await applyModeModel(mockPi, mockState, mockCtx, modelRef)
|
|
263
|
+
|
|
264
|
+
expect(mockState.previousModel).toBe('anthropic/claude-sonnet-4-5')
|
|
265
|
+
expect(findModelMock).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4-5')
|
|
266
|
+
expect(setModelMock).toHaveBeenCalledWith(mockModel)
|
|
267
|
+
expect(notifyMock).not.toHaveBeenCalled()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('shows warning for invalid model ref', async () => {
|
|
271
|
+
const modelRef = 'invalid-model-ref'
|
|
272
|
+
await applyModeModel(mockPi, mockState, mockCtx, modelRef)
|
|
273
|
+
|
|
274
|
+
expect(mockState.previousModel).toBeUndefined()
|
|
275
|
+
expect(notifyMock).toHaveBeenCalledWith(
|
|
276
|
+
'Invalid model ref: invalid-model-ref',
|
|
277
|
+
'warning',
|
|
278
|
+
)
|
|
279
|
+
expect(setModelMock).not.toHaveBeenCalled()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('shows warning when model is not found', async () => {
|
|
283
|
+
findModelMock.mockReturnValue(undefined)
|
|
284
|
+
const modelRef = 'anthropic/claude-sonnet-4-5'
|
|
285
|
+
await applyModeModel(mockPi, mockState, mockCtx, modelRef)
|
|
286
|
+
|
|
287
|
+
expect(mockState.previousModel).toBeUndefined()
|
|
288
|
+
expect(notifyMock).toHaveBeenCalledWith(
|
|
289
|
+
'Invalid model ref: anthropic/claude-sonnet-4-5',
|
|
290
|
+
'warning',
|
|
291
|
+
)
|
|
292
|
+
expect(setModelMock).not.toHaveBeenCalled()
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('shows error when API key is not available', async () => {
|
|
296
|
+
setModelMock.mockResolvedValue(false)
|
|
297
|
+
const modelRef = 'anthropic/claude-sonnet-4-5'
|
|
298
|
+
await applyModeModel(mockPi, mockState, mockCtx, modelRef)
|
|
299
|
+
|
|
300
|
+
expect(mockState.previousModel).toBeUndefined()
|
|
301
|
+
expect(notifyMock).toHaveBeenCalledWith(
|
|
302
|
+
'No API key for this model',
|
|
303
|
+
'error',
|
|
304
|
+
)
|
|
305
|
+
expect(setModelMock).toHaveBeenCalledWith(mockModel)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('skips recording previousModel if it already exists', async () => {
|
|
309
|
+
mockState.previousModel = 'openai/gpt-4'
|
|
310
|
+
const modelRef = 'anthropic/claude-sonnet-4-5'
|
|
311
|
+
await applyModeModel(mockPi, mockState, mockCtx, modelRef)
|
|
312
|
+
|
|
313
|
+
expect(mockState.previousModel).toBe('openai/gpt-4')
|
|
314
|
+
expect(setModelMock).toHaveBeenCalledWith(mockModel)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('does not record previousModel when current model is undefined', async () => {
|
|
318
|
+
mockCtx.model = undefined
|
|
319
|
+
const modelRef = 'anthropic/claude-sonnet-4-5'
|
|
320
|
+
await applyModeModel(mockPi, mockState, mockCtx, modelRef)
|
|
321
|
+
expect(mockState.previousModel).toBeUndefined()
|
|
322
|
+
expect(setModelMock).toHaveBeenCalledWith(mockModel)
|
|
323
|
+
})
|
|
324
|
+
})
|