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:
Guilhem
2026-07-28 14:35:01 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent c4e75683a8
commit 5feec9b4cd
6 changed files with 352 additions and 16 deletions
@@ -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)}`
}
}
})
)