mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-28 00:02:42 +00:00
fix(ai-chat): make hub script paths readable from the global chat (#10381)
* fix(ai-chat): make hub script paths readable from the global chat Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ai-evals): match hub fixtures on whole words, not substrings Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,9 @@ vi.mock('$lib/gen', async () => {
|
||||
}),
|
||||
queryHubScripts: vi.fn(async () => []),
|
||||
getHubScriptContentByPath: vi.fn(async () => ''),
|
||||
getHubScriptByPath: vi.fn(async () => {
|
||||
throw new Error('getHubScriptByPath mock not configured')
|
||||
}),
|
||||
listScripts: vi.fn(async () => [])
|
||||
}),
|
||||
JobService: wrapService(actual.JobService, {
|
||||
@@ -534,6 +537,29 @@ describe('global AI tools', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('reads a hub script path through the hub endpoint, not the workspace one', async () => {
|
||||
vi.mocked(ScriptService.getHubScriptByPath).mockResolvedValueOnce({
|
||||
content: 'export async function main() {}',
|
||||
language: 'bunnative',
|
||||
summary: 'Send a message to discord using webhook',
|
||||
schema: { type: 'object', properties: {} }
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('read_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'hub/28294/discord/send_a_message_to_discord_using_webhook'
|
||||
})
|
||||
|
||||
expect(ScriptService.getHubScriptByPath).toHaveBeenCalledWith({
|
||||
path: 'hub/28294/discord/send_a_message_to_discord_using_webhook'
|
||||
})
|
||||
expect(ScriptService.getScriptByPath).not.toHaveBeenCalled()
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
language: 'bunnative',
|
||||
value: 'export async function main() {}'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the deployed state, skipping chat and DB drafts, with version: deployed', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/greet',
|
||||
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
executeFlowStepTestRun,
|
||||
executeTestRun,
|
||||
findAndReplace,
|
||||
isHubPath,
|
||||
type CreatedResourceTriggerKind,
|
||||
type PreviewCardKind,
|
||||
type Tool,
|
||||
@@ -362,7 +363,11 @@ const listWorkspaceItemsSchema = z.object({
|
||||
|
||||
const readWorkspaceItemSchema = z.object({
|
||||
type: itemTypeSchema,
|
||||
path: z.string().describe('Workspace path of the item to read.'),
|
||||
path: z
|
||||
.string()
|
||||
.describe(
|
||||
'Workspace path of the item to read, or a hub/<version>/<app>/<name> path from search_hub_scripts to read a hub script.'
|
||||
),
|
||||
trigger_kind: triggerKindSchema
|
||||
.optional()
|
||||
.describe('Required when type is trigger. Identifies which trigger service to call.'),
|
||||
@@ -1170,6 +1175,7 @@ Rules:
|
||||
- Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable".
|
||||
- Use search_resource_types before write_resource.
|
||||
- When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it.
|
||||
- Hub scripts are prebuilt integrations for third-party services, hosted outside the workspace under \`hub/<version>/<app>/<name>\` paths. Use search_hub_scripts to find one before hand-writing an integration, then read_workspace_item with type "script" and the returned hub path to get its code, language, and input schema.
|
||||
- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).
|
||||
- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.
|
||||
${pipelineBullet}
|
||||
@@ -1778,6 +1784,20 @@ async function readWorkspaceItem(
|
||||
): Promise<WorkspaceItem> {
|
||||
switch (type) {
|
||||
case 'script': {
|
||||
// Hub scripts are not workspace items: search_hub_scripts hands back
|
||||
// `hub/<version>/<app>/<slug>` paths, which getScriptByPath cannot resolve.
|
||||
if (isHubPath(path)) {
|
||||
const hub = await ScriptService.getHubScriptByPath({ path })
|
||||
return {
|
||||
type: 'script',
|
||||
path,
|
||||
summary: hub.summary,
|
||||
language: hub.language as ScriptLang,
|
||||
value: hub.content,
|
||||
schema: hub.schema,
|
||||
isDraft: false
|
||||
}
|
||||
}
|
||||
// Prefer the DB draft (newer than the deployed version) when one exists,
|
||||
// unless the caller explicitly asked for the deployed state.
|
||||
const script = await ScriptService.getScriptByPath({
|
||||
@@ -2785,7 +2805,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
return JSON.stringify({ success: false, error: message })
|
||||
}
|
||||
const draft =
|
||||
parsed.version === 'deployed'
|
||||
parsed.version === 'deployed' || isHubPath(parsed.path)
|
||||
? null
|
||||
: await getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind)
|
||||
if (draft) {
|
||||
|
||||
@@ -1124,3 +1124,70 @@ describe('openItemPreviewAction', () => {
|
||||
expect(openItemPreviewAction('raw_app', 'u/me/dash').label).toBe('Open app preview')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSearchHubScriptsTool', () => {
|
||||
const hit = (version_id: number, app: string, summary: string) => ({
|
||||
version_id,
|
||||
app,
|
||||
summary,
|
||||
ask_id: version_id,
|
||||
id: version_id,
|
||||
kind: 'script' as const,
|
||||
score: 1
|
||||
})
|
||||
|
||||
async function runWithContent(getHubScriptByPath: ReturnType<typeof vi.fn>) {
|
||||
const { ScriptService } = await import('$lib/gen')
|
||||
Object.assign(ScriptService, {
|
||||
queryHubScripts: vi.fn(async () => [
|
||||
hit(1, 'discord', 'Send a message'),
|
||||
hit(2, 'slack', 'Post a message')
|
||||
]),
|
||||
getHubScriptByPath
|
||||
})
|
||||
const { createSearchHubScriptsTool } = await import('./shared')
|
||||
const raw = await createSearchHubScriptsTool(true).fn({
|
||||
args: { query: 'send a message' },
|
||||
toolId: 't1',
|
||||
toolCallbacks: { setToolStatus: vi.fn() }
|
||||
} as any)
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
it('reports each script language alongside its content', async () => {
|
||||
const results = await runWithContent(
|
||||
vi.fn(async ({ path }: { path: string }) => ({
|
||||
content: `// ${path}`,
|
||||
language: path.startsWith('hub/1/') ? 'bunnative' : 'python3'
|
||||
}))
|
||||
)
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
path: 'hub/1/discord/send_a_message',
|
||||
summary: 'Send a message',
|
||||
language: 'bunnative',
|
||||
content: '// hub/1/discord/send_a_message'
|
||||
},
|
||||
{
|
||||
path: 'hub/2/slack/post_a_message',
|
||||
summary: 'Post a message',
|
||||
language: 'python3',
|
||||
content: '// hub/2/slack/post_a_message'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the other results when one content fetch fails', async () => {
|
||||
const results = await runWithContent(
|
||||
vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path.startsWith('hub/1/')) throw new Error('hub unreachable')
|
||||
return { content: 'ok', language: 'python3' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(results[0].error).toContain('hub unreachable')
|
||||
expect(results[0].content).toBeUndefined()
|
||||
expect(results[1].content).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1130,6 +1130,18 @@ const searchHubScriptsToolDef = createToolDef(
|
||||
'Search for scripts in the hub'
|
||||
)
|
||||
|
||||
/** The hub resolves a script by its version id alone; the app and summary
|
||||
* segments are descriptive only. Mirrors the paths the hub pickers build. */
|
||||
function hubScriptPath(s: { version_id: number; app: string; summary: string }): string {
|
||||
return `hub/${s.version_id}/${s.app}/${s.summary.toLowerCase().replaceAll(/\s+/g, '_')}`
|
||||
}
|
||||
|
||||
/** Hub scripts are hosted outside the workspace: their paths resolve through the
|
||||
* hub endpoints only, never through workspace lookups or drafts. */
|
||||
export function isHubPath(path: string): boolean {
|
||||
return path.startsWith('hub/')
|
||||
}
|
||||
|
||||
export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
|
||||
def: searchHubScriptsToolDef,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
@@ -1141,22 +1153,29 @@ export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
|
||||
text: parsedArgs.query,
|
||||
kind: 'script'
|
||||
})
|
||||
// Each result costs a content fetch, so cap the fan-out when content is wanted.
|
||||
const matches = withContent ? scripts.slice(0, 3) : scripts
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Found ' + scripts.length + ' scripts in the hub related to "' + args.query + '"'
|
||||
content: `Found ${matches.length} script${matches.length === 1 ? '' : 's'} in the hub related to "${parsedArgs.query}"`
|
||||
})
|
||||
// if withContent, fetch scripts with their content, limit to 3 results
|
||||
const results = await Promise.all(
|
||||
scripts.slice(0, withContent ? 3 : undefined).map(async (s) => {
|
||||
let content = ''
|
||||
if (withContent) {
|
||||
content = await ScriptService.getHubScriptContentByPath({
|
||||
path: `hub/${s.version_id}/${s.app}/${s.summary.toLowerCase().replaceAll(/\s+/g, '_')}`
|
||||
})
|
||||
matches.map(async (s) => {
|
||||
const path = hubScriptPath(s)
|
||||
if (!withContent) {
|
||||
return { path, summary: s.summary }
|
||||
}
|
||||
return {
|
||||
path: `hub/${s.version_id}/${s.app}/${s.summary.toLowerCase().replaceAll(/\s+/g, '_')}`,
|
||||
summary: s.summary,
|
||||
...(withContent ? { content } : {})
|
||||
try {
|
||||
// get_full, not the raw content endpoint: callers are told to match the
|
||||
// script's language, which raw content does not carry.
|
||||
const hub = await ScriptService.getHubScriptByPath({ path })
|
||||
return { path, summary: s.summary, language: hub.language, content: hub.content }
|
||||
} catch (err) {
|
||||
// One unreachable script must not sink the whole search.
|
||||
return {
|
||||
path,
|
||||
summary: s.summary,
|
||||
error: `Could not fetch content: ${err instanceof Error ? err.message : String(err)}`
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user