Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
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.
2026-08-01 15:44:40 -07:00

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([])
})
})
})