mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
229 lines
7.9 KiB
TypeScript
229 lines
7.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { z } from 'zod'
|
|
|
|
import { ToolCatalog, toMcpInputSchema } from '@/hono/tool-catalog'
|
|
import type { ToolBase, ZodObjectAny } from '@/tools/types'
|
|
|
|
type FakeDefinition = {
|
|
title: string
|
|
description: string
|
|
feature: string
|
|
category: string
|
|
required_scopes: string[]
|
|
annotations: {
|
|
destructiveHint: boolean
|
|
idempotentHint: boolean
|
|
openWorldHint: boolean
|
|
readOnlyHint: boolean
|
|
}
|
|
[key: string]: unknown
|
|
}
|
|
|
|
function makeToolBase(name: string, overrides?: Partial<ToolBase<ZodObjectAny>>): ToolBase<ZodObjectAny> {
|
|
return {
|
|
name,
|
|
schema: z.object({}),
|
|
handler: vi.fn(),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const fakeDef = (overrides?: Record<string, unknown>): FakeDefinition => ({
|
|
title: 'Title',
|
|
description: 'Desc',
|
|
feature: 'general',
|
|
category: 'general',
|
|
required_scopes: [] as string[],
|
|
annotations: {
|
|
destructiveHint: false,
|
|
idempotentHint: false,
|
|
openWorldHint: false,
|
|
readOnlyHint: false,
|
|
},
|
|
...overrides,
|
|
})
|
|
|
|
vi.mock('@/tools', () => ({
|
|
TOOL_MAP: {
|
|
'tool-a': () => makeToolBase('tool-a'),
|
|
'tool-b': () => makeToolBase('tool-b'),
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/tools/generated', () => ({
|
|
GENERATED_TOOL_MAP: {
|
|
'gen-tool-c': () => makeToolBase('gen-tool-c'),
|
|
},
|
|
}))
|
|
|
|
const DEFINITIONS: Record<string, FakeDefinition> = {
|
|
'tool-a': fakeDef({ required_scopes: ['project:read'] }),
|
|
'tool-b': fakeDef({ feature: 'insights', annotations: { ...fakeDef().annotations, readOnlyHint: true } }),
|
|
'gen-tool-c': fakeDef({ required_scopes: ['action:write'] }),
|
|
}
|
|
|
|
vi.mock('@/tools/toolDefinitions', () => ({
|
|
getToolDefinitions: () => DEFINITIONS,
|
|
getToolDefinition: (name: string) => {
|
|
const def = DEFINITIONS[name]
|
|
if (!def) {
|
|
throw new Error(`Tool definition not found for: ${name}`)
|
|
}
|
|
return def
|
|
},
|
|
getToolsForFeatures: (options?: {
|
|
features?: string[]
|
|
tools?: string[]
|
|
readOnly?: boolean
|
|
aiConsentGiven?: boolean
|
|
featureFlags?: Record<string, boolean>
|
|
}) => {
|
|
let names = Object.keys(DEFINITIONS)
|
|
if (options?.features?.length) {
|
|
names = names.filter((n) => {
|
|
const def = DEFINITIONS[n]
|
|
return def && options.features!.includes(def.feature as string)
|
|
})
|
|
}
|
|
if (options?.readOnly) {
|
|
names = names.filter((n) => {
|
|
const def = DEFINITIONS[n]
|
|
return def?.annotations.readOnlyHint === true
|
|
})
|
|
}
|
|
return names
|
|
},
|
|
}))
|
|
|
|
describe('ToolCatalog', () => {
|
|
let catalog: ToolCatalog
|
|
|
|
beforeEach(async () => {
|
|
catalog = new ToolCatalog()
|
|
})
|
|
|
|
describe('toMcpInputSchema', () => {
|
|
// GitHub Copilot Chat rejects tools whose inputSchema has a root union
|
|
// keyword ("object has unsupported top-level schema keyword 'anyOf'"),
|
|
// so polymorphic request bodies must advertise as one flat object.
|
|
it('flattens a top-level union of object variants into a single object schema', () => {
|
|
const union = z.union([
|
|
z.object({
|
|
model: z.enum(['events']),
|
|
file: z.object({ format: z.string() }),
|
|
include: z.array(z.string()).optional(),
|
|
}),
|
|
z.object({
|
|
model: z.enum(['persons']),
|
|
file: z.object({ format: z.string() }),
|
|
}),
|
|
]) as unknown as ZodObjectAny
|
|
|
|
const result = toMcpInputSchema(union) as Record<string, unknown>
|
|
|
|
expect(result.anyOf).toBeUndefined()
|
|
expect(result.oneOf).toBeUndefined()
|
|
expect(result.type).toBe('object')
|
|
const properties = result.properties as Record<string, Record<string, unknown>>
|
|
expect(properties.model!.enum).toEqual(['events', 'persons'])
|
|
expect(Object.keys(properties)).toEqual(['model', 'file', 'include'])
|
|
// `include` only exists on one variant, so it must not be required.
|
|
expect(result.required).toEqual(['model', 'file'])
|
|
})
|
|
|
|
it('keeps a root type of object when union variants are not all objects', () => {
|
|
const union = z.union([z.object({ a: z.string() }), z.string()]) as unknown as ZodObjectAny
|
|
|
|
const result = toMcpInputSchema(union) as Record<string, unknown>
|
|
|
|
expect(result.type).toBe('object')
|
|
})
|
|
})
|
|
|
|
describe('warmup', () => {
|
|
it('should import tool modules and build the pre-computed map', async () => {
|
|
expect(catalog.warmedUp).toBe(false)
|
|
await catalog.warmup()
|
|
expect(catalog.warmedUp).toBe(true)
|
|
})
|
|
|
|
it('should be idempotent', async () => {
|
|
await catalog.warmup()
|
|
await catalog.warmup()
|
|
expect(catalog.warmedUp).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('getFilteredTools', () => {
|
|
beforeEach(async () => {
|
|
await catalog.warmup()
|
|
})
|
|
|
|
it('should return all tools when no filters applied', () => {
|
|
const tools = catalog.getFilteredTools({ scopes: ['project:read', 'action:write'] })
|
|
const names = tools.map((t) => t.name).sort()
|
|
expect(names).toEqual(['gen-tool-c', 'tool-a', 'tool-b'])
|
|
})
|
|
|
|
it('should exclude tools by name', () => {
|
|
const tools = catalog.getFilteredTools({
|
|
scopes: ['project:read', 'action:write'],
|
|
excludeTools: ['tool-b'],
|
|
})
|
|
const names = tools.map((t) => t.name).sort()
|
|
expect(names).toEqual(['gen-tool-c', 'tool-a'])
|
|
})
|
|
|
|
it('should filter by scopes', () => {
|
|
const tools = catalog.getFilteredTools({ scopes: ['project:read'] })
|
|
const names = tools.map((t) => t.name).sort()
|
|
expect(names).toEqual(['tool-a', 'tool-b'])
|
|
})
|
|
|
|
it('should exclude tools when scopes are missing', () => {
|
|
const tools = catalog.getFilteredTools({ scopes: [] })
|
|
const names = tools.map((t) => t.name)
|
|
expect(names).toEqual(['tool-b'])
|
|
})
|
|
|
|
it('should filter by readOnly when passed through to getToolsForFeatures', () => {
|
|
const tools = catalog.getFilteredTools({
|
|
scopes: ['project:read', 'action:write'],
|
|
readOnly: true,
|
|
})
|
|
const names = tools.map((t) => t.name)
|
|
expect(names).toEqual(['tool-b'])
|
|
})
|
|
|
|
it('should filter by features when passed through to getToolsForFeatures', () => {
|
|
const tools = catalog.getFilteredTools({
|
|
scopes: ['project:read'],
|
|
features: ['insights'],
|
|
})
|
|
const names = tools.map((t) => t.name)
|
|
expect(names).toEqual(['tool-b'])
|
|
})
|
|
|
|
it('should merge title and description from definitions onto the tool', () => {
|
|
const tools = catalog.getFilteredTools({ scopes: ['project:read'] })
|
|
const toolA = tools.find((t) => t.name === 'tool-a')
|
|
expect(toolA).toBeTruthy()
|
|
expect(toolA!.title).toBe('Title')
|
|
expect(toolA!.description).toBe('Desc')
|
|
})
|
|
|
|
it('should attach scopes and annotations from definitions', () => {
|
|
const tools = catalog.getFilteredTools({ scopes: ['project:read'] })
|
|
const toolA = tools.find((t) => t.name === 'tool-a')
|
|
expect(toolA!.scopes).toEqual(['project:read'])
|
|
expect(toolA!.annotations).toEqual(expect.objectContaining({ readOnlyHint: false }))
|
|
})
|
|
|
|
it('should return empty array when catalog is not warmed up', () => {
|
|
const coldCatalog = new ToolCatalog()
|
|
const tools = coldCatalog.getFilteredTools({ scopes: [] })
|
|
expect(tools).toEqual([])
|
|
})
|
|
})
|
|
})
|