@pikku/inspector 0.12.70 → 0.12.72
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/CHANGELOG.md +49 -0
- package/dist/add/add-file-with-factory.js +96 -14
- package/dist/add/add-rpc-invocations.js +48 -0
- package/dist/error-codes.d.ts +1 -0
- package/dist/error-codes.js +1 -0
- package/dist/inspector.js +3 -0
- package/dist/types.d.ts +12 -0
- package/dist/utils/filter-inspector-state.js +17 -0
- package/dist/utils/post-process.d.ts +10 -0
- package/dist/utils/post-process.js +87 -0
- package/dist/utils/serialize-inspector-state.d.ts +3 -0
- package/dist/utils/serialize-inspector-state.js +6 -0
- package/package.json +3 -3
- package/src/add/add-file-with-factory.test.ts +134 -0
- package/src/add/add-file-with-factory.ts +109 -17
- package/src/add/add-rpc-invocations.test.ts +124 -0
- package/src/add/add-rpc-invocations.ts +52 -0
- package/src/add/add-scenario-step.test.ts +61 -0
- package/src/error-codes.ts +1 -0
- package/src/inspector.ts +3 -0
- package/src/types.ts +12 -0
- package/src/utils/filter-inspector-state.test.ts +79 -0
- package/src/utils/filter-inspector-state.ts +19 -0
- package/src/utils/post-process.test.ts +42 -0
- package/src/utils/post-process.ts +110 -0
- package/src/utils/serialize-inspector-state.ts +17 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -13,6 +13,106 @@ const wrapperFunctionMap: Record<string, string> = {
|
|
|
13
13
|
pikkuServerLifecycle: 'ServerLifecycle',
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* What an addon's `pikkuAddonServices` factory takes from the parent: the names
|
|
18
|
+
* destructured off its second parameter, either in the parameter list or from a
|
|
19
|
+
* `const { … } = existingServices` in the body.
|
|
20
|
+
*/
|
|
21
|
+
const extractForwardedServices = (
|
|
22
|
+
functionNode: ts.ArrowFunction | ts.FunctionExpression
|
|
23
|
+
): string[] => {
|
|
24
|
+
const forwarded: string[] = []
|
|
25
|
+
const secondParam = functionNode.parameters[1]
|
|
26
|
+
if (!secondParam) return forwarded
|
|
27
|
+
|
|
28
|
+
const collectBinding = (pattern: ts.ObjectBindingPattern) => {
|
|
29
|
+
for (const elem of pattern.elements) {
|
|
30
|
+
if (elem.dotDotDotToken) continue
|
|
31
|
+
const name =
|
|
32
|
+
elem.propertyName && ts.isIdentifier(elem.propertyName)
|
|
33
|
+
? elem.propertyName.text
|
|
34
|
+
: ts.isIdentifier(elem.name)
|
|
35
|
+
? elem.name.text
|
|
36
|
+
: undefined
|
|
37
|
+
if (name) forwarded.push(name)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (ts.isObjectBindingPattern(secondParam.name)) {
|
|
42
|
+
collectBinding(secondParam.name)
|
|
43
|
+
return forwarded
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!ts.isIdentifier(secondParam.name)) return forwarded
|
|
47
|
+
const paramName = secondParam.name.text
|
|
48
|
+
const body = functionNode.body
|
|
49
|
+
if (!ts.isBlock(body)) return forwarded
|
|
50
|
+
|
|
51
|
+
const visit = (node: ts.Node) => {
|
|
52
|
+
if (
|
|
53
|
+
ts.isVariableDeclaration(node) &&
|
|
54
|
+
ts.isObjectBindingPattern(node.name) &&
|
|
55
|
+
node.initializer &&
|
|
56
|
+
ts.isIdentifier(node.initializer) &&
|
|
57
|
+
node.initializer.text === paramName
|
|
58
|
+
) {
|
|
59
|
+
collectBinding(node.name)
|
|
60
|
+
}
|
|
61
|
+
ts.forEachChild(node, visit)
|
|
62
|
+
}
|
|
63
|
+
ts.forEachChild(body, visit)
|
|
64
|
+
|
|
65
|
+
return forwarded
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The service names the factory's returned object literals name. A spread is
|
|
70
|
+
* skipped — it carries the parent's bag through rather than naming anything —
|
|
71
|
+
* and a name the factory took off the parent is a forward, not a creation.
|
|
72
|
+
*/
|
|
73
|
+
const extractReturnedServices = (
|
|
74
|
+
functionNode: ts.ArrowFunction | ts.FunctionExpression
|
|
75
|
+
): string[] => {
|
|
76
|
+
const returned: string[] = []
|
|
77
|
+
|
|
78
|
+
const collect = (expression: ts.Expression) => {
|
|
79
|
+
while (ts.isParenthesizedExpression(expression)) {
|
|
80
|
+
expression = expression.expression
|
|
81
|
+
}
|
|
82
|
+
if (!ts.isObjectLiteralExpression(expression)) return
|
|
83
|
+
for (const property of expression.properties) {
|
|
84
|
+
if (ts.isSpreadAssignment(property)) continue
|
|
85
|
+
const name = property.name
|
|
86
|
+
if (name && (ts.isIdentifier(name) || ts.isStringLiteral(name))) {
|
|
87
|
+
returned.push(name.text)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const body = functionNode.body
|
|
93
|
+
if (!ts.isBlock(body)) {
|
|
94
|
+
collect(body)
|
|
95
|
+
return returned
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const visit = (node: ts.Node) => {
|
|
99
|
+
if (
|
|
100
|
+
ts.isFunctionDeclaration(node) ||
|
|
101
|
+
ts.isFunctionExpression(node) ||
|
|
102
|
+
ts.isArrowFunction(node)
|
|
103
|
+
) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
if (ts.isReturnStatement(node) && node.expression) {
|
|
107
|
+
collect(node.expression)
|
|
108
|
+
}
|
|
109
|
+
ts.forEachChild(node, visit)
|
|
110
|
+
}
|
|
111
|
+
ts.forEachChild(body, visit)
|
|
112
|
+
|
|
113
|
+
return returned
|
|
114
|
+
}
|
|
115
|
+
|
|
16
116
|
export const addFileWithFactory = (
|
|
17
117
|
node: ts.Node,
|
|
18
118
|
checker: ts.TypeChecker,
|
|
@@ -77,23 +177,15 @@ export const addFileWithFactory = (
|
|
|
77
177
|
|
|
78
178
|
// Extract existing services an addon needs from the parent
|
|
79
179
|
// (second parameter of pikkuAddonServices callback)
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
functionNode
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
elem.propertyName && ts.isIdentifier(elem.propertyName)
|
|
90
|
-
? elem.propertyName.text
|
|
91
|
-
: ts.isIdentifier(elem.name)
|
|
92
|
-
? elem.name.text
|
|
93
|
-
: undefined
|
|
94
|
-
if (name) {
|
|
95
|
-
state.addonRequiredParentServices.push(name)
|
|
96
|
-
}
|
|
180
|
+
if (wrapperFunctionName === 'pikkuAddonServices' && functionNode) {
|
|
181
|
+
state.addonServicesFactorySeen = true
|
|
182
|
+
const forwarded = new Set(extractForwardedServices(functionNode))
|
|
183
|
+
for (const name of forwarded) {
|
|
184
|
+
state.addonRequiredParentServices.push(name)
|
|
185
|
+
}
|
|
186
|
+
for (const name of extractReturnedServices(functionNode)) {
|
|
187
|
+
if (!forwarded.has(name)) {
|
|
188
|
+
state.addonCreatedServices.push(name)
|
|
97
189
|
}
|
|
98
190
|
}
|
|
99
191
|
}
|
|
@@ -161,3 +161,127 @@ export async function doWork() {
|
|
|
161
161
|
}
|
|
162
162
|
})
|
|
163
163
|
})
|
|
164
|
+
|
|
165
|
+
describe('add-rpc-invocations — invokedAgentsByFile', () => {
|
|
166
|
+
test('a body-level runAgent() is attributed to its source file', async () => {
|
|
167
|
+
const { state, dir } = await inspectFiles({
|
|
168
|
+
'caller.ts': `
|
|
169
|
+
declare const runAgent: (name: string, input: unknown) => Promise<unknown>
|
|
170
|
+
export async function askTheHouse() {
|
|
171
|
+
return runAgent('houseAssistant', { message: 'hi' })
|
|
172
|
+
}
|
|
173
|
+
`,
|
|
174
|
+
'other.ts': `
|
|
175
|
+
export const unrelated = () => 'no agents here'
|
|
176
|
+
`,
|
|
177
|
+
})
|
|
178
|
+
try {
|
|
179
|
+
const byFile = state.agents.invokedAgentsByFile
|
|
180
|
+
assert.strictEqual(byFile.size, 1)
|
|
181
|
+
const [file, agents] = [...byFile.entries()][0]!
|
|
182
|
+
assert.ok(file.endsWith('caller.ts'))
|
|
183
|
+
assert.deepStrictEqual([...agents], ['houseAssistant'])
|
|
184
|
+
} finally {
|
|
185
|
+
await rm(dir, { recursive: true, force: true })
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('rpc.agent.run() is an agent invocation too', async () => {
|
|
190
|
+
const { state, dir } = await inspectFiles({
|
|
191
|
+
'caller.ts': `
|
|
192
|
+
declare const rpc: { agent: { run: (name: string, input: unknown) => Promise<unknown> } }
|
|
193
|
+
export async function askTheHouse() {
|
|
194
|
+
return rpc.agent.run('houseAssistant', { message: 'hi' })
|
|
195
|
+
}
|
|
196
|
+
`,
|
|
197
|
+
})
|
|
198
|
+
try {
|
|
199
|
+
const agents = [...state.agents.invokedAgentsByFile.values()][0]!
|
|
200
|
+
assert.deepStrictEqual([...agents], ['houseAssistant'])
|
|
201
|
+
} finally {
|
|
202
|
+
await rm(dir, { recursive: true, force: true })
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('a non-null assertion on the rpc receiver is still an invocation', async () => {
|
|
207
|
+
const { state, dir } = await inspectFiles({
|
|
208
|
+
'caller.ts': `
|
|
209
|
+
declare const rpc: { agent: { stream: (name: string, input: unknown) => Promise<unknown> } } | undefined
|
|
210
|
+
export async function askTheHouse() {
|
|
211
|
+
return rpc!.agent.stream('houseAssistant', { message: 'hi' })
|
|
212
|
+
}
|
|
213
|
+
`,
|
|
214
|
+
})
|
|
215
|
+
try {
|
|
216
|
+
const agents = [...state.agents.invokedAgentsByFile.values()][0]!
|
|
217
|
+
assert.deepStrictEqual([...agents], ['houseAssistant'])
|
|
218
|
+
} finally {
|
|
219
|
+
await rm(dir, { recursive: true, force: true })
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('multiple agent calls in one file accumulate under that file', async () => {
|
|
224
|
+
const { state, dir } = await inspectFiles({
|
|
225
|
+
'caller.ts': `
|
|
226
|
+
declare const runAgent: (name: string, input: unknown) => Promise<unknown>
|
|
227
|
+
declare const rpc: { agent: { run: (name: string, input: unknown) => Promise<unknown> } }
|
|
228
|
+
export async function askEveryone() {
|
|
229
|
+
await runAgent('houseAssistant', {})
|
|
230
|
+
return rpc.agent.run('gardenAssistant', {})
|
|
231
|
+
}
|
|
232
|
+
`,
|
|
233
|
+
})
|
|
234
|
+
try {
|
|
235
|
+
const agents = [...state.agents.invokedAgentsByFile.values()][0]!
|
|
236
|
+
assert.deepStrictEqual([...agents].sort(), [
|
|
237
|
+
'gardenAssistant',
|
|
238
|
+
'houseAssistant',
|
|
239
|
+
])
|
|
240
|
+
} finally {
|
|
241
|
+
await rm(dir, { recursive: true, force: true })
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('a dynamic agent name is warned about, not recorded', async () => {
|
|
246
|
+
const warnings: string[] = []
|
|
247
|
+
const dir = await mkdtemp(join(tmpdir(), 'pikku-agent-invocations-test-'))
|
|
248
|
+
const path = join(dir, 'caller.ts')
|
|
249
|
+
await writeFile(
|
|
250
|
+
path,
|
|
251
|
+
`
|
|
252
|
+
declare const runAgent: (name: string, input: unknown) => Promise<unknown>
|
|
253
|
+
declare const suffix: string
|
|
254
|
+
export async function askTheHouse() {
|
|
255
|
+
return runAgent(\`house-\${suffix}\`, {})
|
|
256
|
+
}
|
|
257
|
+
`
|
|
258
|
+
)
|
|
259
|
+
const logger = makeLogger()
|
|
260
|
+
logger.warn = (message: string) => {
|
|
261
|
+
warnings.push(message)
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const state = await inspect(logger, [path], { rootDir: dir })
|
|
265
|
+
assert.strictEqual(state.agents.invokedAgentsByFile.size, 0)
|
|
266
|
+
assert.ok(warnings.some((w) => w.includes('dynamic agent invocation')))
|
|
267
|
+
} finally {
|
|
268
|
+
await rm(dir, { recursive: true, force: true })
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('run() on some other receiver is left alone', async () => {
|
|
273
|
+
const { state, dir } = await inspectFiles({
|
|
274
|
+
'caller.ts': `
|
|
275
|
+
declare const client: { agent: { run: (name: string, input: unknown) => Promise<unknown> } }
|
|
276
|
+
export async function doWork() {
|
|
277
|
+
return client.agent.run('notAnAgentCall', {})
|
|
278
|
+
}
|
|
279
|
+
`,
|
|
280
|
+
})
|
|
281
|
+
try {
|
|
282
|
+
assert.strictEqual(state.agents.invokedAgentsByFile.size, 0)
|
|
283
|
+
} finally {
|
|
284
|
+
await rm(dir, { recursive: true, force: true })
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
})
|
|
@@ -51,6 +51,33 @@ function extractNamespace(functionRef: string): string | null {
|
|
|
51
51
|
return null
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function agentInvocationName(
|
|
55
|
+
expression: ts.Expression
|
|
56
|
+
): 'runAgent' | 'streamAgent' | 'rpc.agent.run' | 'rpc.agent.stream' | null {
|
|
57
|
+
if (ts.isIdentifier(expression)) {
|
|
58
|
+
if (expression.text === 'runAgent') return 'runAgent'
|
|
59
|
+
if (expression.text === 'streamAgent') return 'streamAgent'
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
if (!ts.isPropertyAccessExpression(expression)) return null
|
|
63
|
+
const method = expression.name.text
|
|
64
|
+
if (method !== 'run' && method !== 'stream') return null
|
|
65
|
+
const agentReceiver = invocationReceiver(expression.expression)
|
|
66
|
+
if (
|
|
67
|
+
!ts.isPropertyAccessExpression(agentReceiver) ||
|
|
68
|
+
agentReceiver.name.text !== 'agent'
|
|
69
|
+
) {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
const rpcReceiver = invocationReceiver(agentReceiver.expression)
|
|
73
|
+
const isRpc = ts.isIdentifier(rpcReceiver)
|
|
74
|
+
? rpcReceiver.text === 'rpc'
|
|
75
|
+
: ts.isPropertyAccessExpression(rpcReceiver) &&
|
|
76
|
+
rpcReceiver.name.text === 'rpc'
|
|
77
|
+
if (!isRpc) return null
|
|
78
|
+
return method === 'run' ? 'rpc.agent.run' : 'rpc.agent.stream'
|
|
79
|
+
}
|
|
80
|
+
|
|
54
81
|
/**
|
|
55
82
|
* Scan for rpc.invoke() calls to track which functions are actually being invoked
|
|
56
83
|
* Also detects addon usage via:
|
|
@@ -99,6 +126,31 @@ export function addRPCInvocations(
|
|
|
99
126
|
}
|
|
100
127
|
}
|
|
101
128
|
|
|
129
|
+
const agentCall = agentInvocationName(expression)
|
|
130
|
+
if (agentCall) {
|
|
131
|
+
const [firstArg] = args
|
|
132
|
+
if (firstArg && ts.isStringLiteral(firstArg)) {
|
|
133
|
+
const agentName = firstArg.text
|
|
134
|
+
logger.debug(`• Found ${agentCall}() call: ${agentName}`)
|
|
135
|
+
const sourceFileName = node.getSourceFile().fileName
|
|
136
|
+
let byFile = state.agents.invokedAgentsByFile.get(sourceFileName)
|
|
137
|
+
if (!byFile) {
|
|
138
|
+
byFile = new Set()
|
|
139
|
+
state.agents.invokedAgentsByFile.set(sourceFileName, byFile)
|
|
140
|
+
}
|
|
141
|
+
byFile.add(agentName)
|
|
142
|
+
} else if (
|
|
143
|
+
firstArg &&
|
|
144
|
+
(ts.isTemplateExpression(firstArg) ||
|
|
145
|
+
ts.isNoSubstitutionTemplateLiteral(firstArg))
|
|
146
|
+
) {
|
|
147
|
+
logger.warn(`• Found dynamic agent invocation: ${firstArg.getText()}`)
|
|
148
|
+
logger.warn(
|
|
149
|
+
`\tYou can only use string literals for agent names, with ' or " and not \``
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
102
154
|
// Check for rpc.invoke('...') calls
|
|
103
155
|
const invokeReceiver = ts.isPropertyAccessExpression(expression)
|
|
104
156
|
? invocationReceiver(expression.expression)
|
|
@@ -245,6 +245,67 @@ describe('pikkuScenarioStep', () => {
|
|
|
245
245
|
}
|
|
246
246
|
})
|
|
247
247
|
|
|
248
|
+
test('a step whose prose opens with its own actor is a PKU681 critical', async () => {
|
|
249
|
+
const { criticals, cleanup } = await run([
|
|
250
|
+
"await scenario.given(`'shopper' buys an apple`, 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
251
|
+
"await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
|
|
252
|
+
])
|
|
253
|
+
try {
|
|
254
|
+
assert.ok(
|
|
255
|
+
criticals.find((c) => c.code === 'PKU681'),
|
|
256
|
+
`the reporter already renders the actor as the subject, so this reads "Given shopper 'shopper' buys an apple", got: ${JSON.stringify(criticals)}`
|
|
257
|
+
)
|
|
258
|
+
} finally {
|
|
259
|
+
await cleanup()
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('an unquoted, capitalised actor opening the prose is caught too', async () => {
|
|
264
|
+
const { criticals, cleanup } = await run([
|
|
265
|
+
"await scenario.given('Shopper buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
266
|
+
"await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
|
|
267
|
+
])
|
|
268
|
+
try {
|
|
269
|
+
assert.ok(
|
|
270
|
+
criticals.find((c) => c.code === 'PKU681'),
|
|
271
|
+
`quotes and case are decoration — the subject is still doubled, got: ${JSON.stringify(criticals)}`
|
|
272
|
+
)
|
|
273
|
+
} finally {
|
|
274
|
+
await cleanup()
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('a call-site description naming the actor is caught as well as the step name', async () => {
|
|
279
|
+
const { criticals, cleanup } = await run([
|
|
280
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper, description: `shopper buys an apple` })",
|
|
281
|
+
"await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
|
|
282
|
+
])
|
|
283
|
+
try {
|
|
284
|
+
assert.ok(
|
|
285
|
+
criticals.find((c) => c.code === 'PKU681'),
|
|
286
|
+
`the description is what actually renders when it is given, got: ${JSON.stringify(criticals)}`
|
|
287
|
+
)
|
|
288
|
+
} finally {
|
|
289
|
+
await cleanup()
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('an actor named mid-sentence is left alone', async () => {
|
|
294
|
+
const { criticals, cleanup } = await run([
|
|
295
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
296
|
+
"await scenario.then('sends shopper a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
|
|
297
|
+
])
|
|
298
|
+
try {
|
|
299
|
+
assert.equal(
|
|
300
|
+
criticals.filter((c) => c.code === 'PKU681').length,
|
|
301
|
+
0,
|
|
302
|
+
`only the subject position doubles up; naming someone mid-sentence is ordinary prose, got: ${JSON.stringify(criticals)}`
|
|
303
|
+
)
|
|
304
|
+
} finally {
|
|
305
|
+
await cleanup()
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
|
|
248
309
|
test('a scenario that never asserts is a PKU680 critical', async () => {
|
|
249
310
|
const { criticals, cleanup } = await run([
|
|
250
311
|
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
package/src/error-codes.ts
CHANGED
|
@@ -26,6 +26,7 @@ export enum ErrorCode {
|
|
|
26
26
|
SCENARIO_STEP_TARGET_NOT_STATIC = 'PKU678',
|
|
27
27
|
SCENARIO_NOT_EXTRACTABLE = 'PKU679',
|
|
28
28
|
SCENARIO_HAS_NO_ASSERTION = 'PKU680',
|
|
29
|
+
SCENARIO_STEP_PROSE_NAMES_ACTOR = 'PKU681',
|
|
29
30
|
WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
|
|
30
31
|
INVALID_DSL_WORKFLOW = 'PKU641',
|
|
31
32
|
WORKFLOW_GRAPH_ADDON_NOT_WIRED = 'PKU642',
|
package/src/inspector.ts
CHANGED
|
@@ -83,6 +83,8 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
|
|
|
83
83
|
wireServicesFactories: new Map(),
|
|
84
84
|
wireServicesMeta: new Map(),
|
|
85
85
|
addonRequiredParentServices: [],
|
|
86
|
+
addonCreatedServices: [],
|
|
87
|
+
addonServicesFactorySeen: false,
|
|
86
88
|
addonServerlessIncompatible: new Map(),
|
|
87
89
|
configFactories: new Map(),
|
|
88
90
|
serverLifecycleFactories: new Map(),
|
|
@@ -163,6 +165,7 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
|
|
|
163
165
|
agents: {
|
|
164
166
|
agentsMeta: {},
|
|
165
167
|
files: new Map(),
|
|
168
|
+
invokedAgentsByFile: new Map(),
|
|
166
169
|
},
|
|
167
170
|
scorers: {
|
|
168
171
|
scorersMeta: {},
|
package/src/types.ts
CHANGED
|
@@ -526,6 +526,8 @@ export interface InspectorState {
|
|
|
526
526
|
wireServicesFactories: PathToNameAndType
|
|
527
527
|
wireServicesMeta: Map<string, string[]> // variable name -> singleton services consumed
|
|
528
528
|
addonRequiredParentServices: string[] // services an addon needs from the parent (extracted from pikkuAddonServices 2nd param)
|
|
529
|
+
addonCreatedServices: string[] // services an addon's own pikkuAddonServices factory builds itself
|
|
530
|
+
addonServicesFactorySeen: boolean // this project declares a pikkuAddonServices factory, i.e. it is an addon
|
|
529
531
|
addonServerlessIncompatible: Map<string, string[]> // namespace → service names that are serverless-incompatible (scoped per addon)
|
|
530
532
|
configFactories: PathToNameAndType
|
|
531
533
|
serverLifecycleFactories: PathToNameAndType
|
|
@@ -634,6 +636,16 @@ export interface InspectorState {
|
|
|
634
636
|
agents: {
|
|
635
637
|
agentsMeta: AgentsMeta
|
|
636
638
|
files: Map<string, { path: string; exportedName: string }>
|
|
639
|
+
/**
|
|
640
|
+
* Agent registry keys named by a `runAgent`/`rpc.agent.run` call in a
|
|
641
|
+
* function body, keyed by the file the call was written in.
|
|
642
|
+
*
|
|
643
|
+
* The lookup those calls perform is in-process, so the agent has to be
|
|
644
|
+
* registered in the same deployment unit as its caller. Without this the
|
|
645
|
+
* deploy analyzer only ever saw an agent as its own unit, and a caller in
|
|
646
|
+
* another unit failed at run time with "AI agent not found".
|
|
647
|
+
*/
|
|
648
|
+
invokedAgentsByFile: Map<string, Set<string>>
|
|
637
649
|
}
|
|
638
650
|
scorers: {
|
|
639
651
|
scorersMeta: ScorerMeta
|
|
@@ -2063,3 +2063,82 @@ describe('Workflow file pruning', () => {
|
|
|
2063
2063
|
assert.equal(Object.keys(state.workflows.graphMeta).length, 2)
|
|
2064
2064
|
})
|
|
2065
2065
|
})
|
|
2066
|
+
|
|
2067
|
+
describe('invokedAgentsByFile', () => {
|
|
2068
|
+
test('round-trips through serialize/deserialize', () => {
|
|
2069
|
+
const state = getInitialInspectorState('/test/project')
|
|
2070
|
+
state.agents.invokedAgentsByFile = new Map([
|
|
2071
|
+
['/test/project/src/api/users.ts', new Set(['houseAssistant'])],
|
|
2072
|
+
[
|
|
2073
|
+
'/test/project/src/api/tasks.ts',
|
|
2074
|
+
new Set(['houseAssistant', 'gardenAssistant']),
|
|
2075
|
+
],
|
|
2076
|
+
])
|
|
2077
|
+
const restored = deserializeInspectorState(
|
|
2078
|
+
JSON.parse(JSON.stringify(serializeInspectorState(state as any)))
|
|
2079
|
+
)
|
|
2080
|
+
assert.ok(restored.agents.invokedAgentsByFile instanceof Map)
|
|
2081
|
+
assert.strictEqual(restored.agents.invokedAgentsByFile.size, 2)
|
|
2082
|
+
assert.deepStrictEqual(
|
|
2083
|
+
[
|
|
2084
|
+
...restored.agents.invokedAgentsByFile.get(
|
|
2085
|
+
'/test/project/src/api/tasks.ts'
|
|
2086
|
+
)!,
|
|
2087
|
+
].sort(),
|
|
2088
|
+
['gardenAssistant', 'houseAssistant']
|
|
2089
|
+
)
|
|
2090
|
+
})
|
|
2091
|
+
|
|
2092
|
+
test('deserializing legacy state without the field yields an empty Map', () => {
|
|
2093
|
+
const serialized = JSON.parse(
|
|
2094
|
+
JSON.stringify(
|
|
2095
|
+
serializeInspectorState(
|
|
2096
|
+
getInitialInspectorState('/test/project') as any
|
|
2097
|
+
)
|
|
2098
|
+
)
|
|
2099
|
+
)
|
|
2100
|
+
delete serialized.agents.invokedAgentsByFile
|
|
2101
|
+
const restored = deserializeInspectorState(serialized)
|
|
2102
|
+
assert.ok(restored.agents.invokedAgentsByFile instanceof Map)
|
|
2103
|
+
assert.strictEqual(restored.agents.invokedAgentsByFile.size, 0)
|
|
2104
|
+
})
|
|
2105
|
+
|
|
2106
|
+
test('the filtered map is a clone, not the original', () => {
|
|
2107
|
+
const state = createMockInspectorState()
|
|
2108
|
+
state.agents = {
|
|
2109
|
+
agentsMeta: {},
|
|
2110
|
+
files: new Map(),
|
|
2111
|
+
invokedAgentsByFile: new Map([
|
|
2112
|
+
['/test/project/src/api/users.ts', new Set(['houseAssistant'])],
|
|
2113
|
+
]),
|
|
2114
|
+
}
|
|
2115
|
+
const filtered = filterInspectorState(
|
|
2116
|
+
state,
|
|
2117
|
+
{ names: ['getUsers'] },
|
|
2118
|
+
mockLogger
|
|
2119
|
+
)
|
|
2120
|
+
filtered.agents.invokedAgentsByFile.clear()
|
|
2121
|
+
assert.strictEqual(state.agents.invokedAgentsByFile.size, 1)
|
|
2122
|
+
})
|
|
2123
|
+
|
|
2124
|
+
test('keeps the entry of a kept function file and prunes the rest', () => {
|
|
2125
|
+
const state = createMockInspectorState()
|
|
2126
|
+
state.agents = {
|
|
2127
|
+
agentsMeta: {},
|
|
2128
|
+
files: new Map(),
|
|
2129
|
+
invokedAgentsByFile: new Map([
|
|
2130
|
+
['/test/project/src/api/users.ts', new Set(['houseAssistant'])],
|
|
2131
|
+
['/test/project/src/admin/settings.ts', new Set(['adminAssistant'])],
|
|
2132
|
+
]),
|
|
2133
|
+
}
|
|
2134
|
+
const filtered = filterInspectorState(
|
|
2135
|
+
state,
|
|
2136
|
+
{ names: ['getUsers'] },
|
|
2137
|
+
mockLogger
|
|
2138
|
+
)
|
|
2139
|
+
assert.deepStrictEqual(
|
|
2140
|
+
[...filtered.agents.invokedAgentsByFile.keys()],
|
|
2141
|
+
['/test/project/src/api/users.ts']
|
|
2142
|
+
)
|
|
2143
|
+
})
|
|
2144
|
+
})
|
|
@@ -507,6 +507,15 @@ export function filterInspectorState(
|
|
|
507
507
|
...state.agents,
|
|
508
508
|
agentsMeta: JSON.parse(JSON.stringify(state.agents?.agentsMeta ?? {})),
|
|
509
509
|
files: new Map(),
|
|
510
|
+
invokedAgentsByFile: new Map(
|
|
511
|
+
[
|
|
512
|
+
...(state.agents?.invokedAgentsByFile ??
|
|
513
|
+
new Map<string, Set<string>>()),
|
|
514
|
+
].map(([file, agents]): [string, Set<string>] => [
|
|
515
|
+
file,
|
|
516
|
+
new Set(agents),
|
|
517
|
+
])
|
|
518
|
+
),
|
|
510
519
|
},
|
|
511
520
|
// Scorers carry through every filter. They are named by the agents that
|
|
512
521
|
// survive it, and a deployment that quietly stopped grading would look
|
|
@@ -1210,6 +1219,16 @@ export function filterInspectorState(
|
|
|
1210
1219
|
filteredState.serviceAggregation.requiredServices.add('queueService')
|
|
1211
1220
|
}
|
|
1212
1221
|
|
|
1222
|
+
const keptFunctionFiles = new Set<string>()
|
|
1223
|
+
for (const file of filteredState.functions.files.values()) {
|
|
1224
|
+
if (file?.path) keptFunctionFiles.add(file.path)
|
|
1225
|
+
}
|
|
1226
|
+
for (const file of filteredState.agents.invokedAgentsByFile.keys()) {
|
|
1227
|
+
if (!keptFunctionFiles.has(file)) {
|
|
1228
|
+
filteredState.agents.invokedAgentsByFile.delete(file)
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1213
1232
|
if ((filteredState.rpc.wireAddonDeclarations?.size ?? 0) > 0) {
|
|
1214
1233
|
const referencedIds = new Set<string>(
|
|
1215
1234
|
filteredState.serviceAggregation.usedFunctions
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
validateAgentToolReferences,
|
|
14
14
|
validateAgentModels,
|
|
15
15
|
validateSchemaReferences,
|
|
16
|
+
proseOpensWithActor,
|
|
16
17
|
} from './post-process.js'
|
|
17
18
|
import { ErrorCode } from '../error-codes.js'
|
|
18
19
|
import type { InspectorState, InspectorLogger } from '../types.js'
|
|
@@ -1050,3 +1051,44 @@ describe('validateSchemaReferences', () => {
|
|
|
1050
1051
|
assert.match(diagnostics[0]!.message, /2 function contracts name a schema/)
|
|
1051
1052
|
})
|
|
1052
1053
|
})
|
|
1054
|
+
|
|
1055
|
+
describe('proseOpensWithActor', () => {
|
|
1056
|
+
test('a quoted actor opening the prose doubles the subject', () => {
|
|
1057
|
+
assert.equal(
|
|
1058
|
+
proseOpensWithActor(`'sam' creates the browser-journey client`, 'sam'),
|
|
1059
|
+
true
|
|
1060
|
+
)
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
test('quotes and case are decoration, not the point', () => {
|
|
1064
|
+
assert.equal(proseOpensWithActor('Sam creates the client', 'sam'), true)
|
|
1065
|
+
assert.equal(proseOpensWithActor('sam creates the client', 'sam'), true)
|
|
1066
|
+
assert.equal(proseOpensWithActor(`"sam" creates the client`, 'sam'), true)
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
test("an actor's own possessive is still the doubled subject", () => {
|
|
1070
|
+
assert.equal(proseOpensWithActor("sam's client is created", 'sam'), true)
|
|
1071
|
+
})
|
|
1072
|
+
|
|
1073
|
+
test('naming someone mid-sentence is ordinary prose', () => {
|
|
1074
|
+
assert.equal(proseOpensWithActor('sends sam an invite', 'sam'), false)
|
|
1075
|
+
})
|
|
1076
|
+
|
|
1077
|
+
// A persona keyed after a role noun must keep the word as a word: refusing
|
|
1078
|
+
// "creates the admin client" for an actor called `admin` would make the
|
|
1079
|
+
// check the reason to rename the persona.
|
|
1080
|
+
test('an actor key that is also a common noun is only refused as the subject', () => {
|
|
1081
|
+
assert.equal(
|
|
1082
|
+
proseOpensWithActor('creates the admin client', 'admin'),
|
|
1083
|
+
false
|
|
1084
|
+
)
|
|
1085
|
+
assert.equal(proseOpensWithActor('admin creates the client', 'admin'), true)
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
test('a longer name starting with the actor key is a different person', () => {
|
|
1089
|
+
assert.equal(
|
|
1090
|
+
proseOpensWithActor('samantha creates the client', 'sam'),
|
|
1091
|
+
false
|
|
1092
|
+
)
|
|
1093
|
+
})
|
|
1094
|
+
})
|