mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
feat(ipuaro): add LLM integration module
- OllamaClient: ILLMClient implementation with tool support - System prompt and context builders for project overview - 18 tool definitions across 6 categories (read, edit, search, analysis, git, run) - XML response parser for tool call extraction - 98 new tests (419 total), 96.38% coverage
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import type { LLMConfig } from "../../../../src/shared/constants/config.js"
|
||||
import { IpuaroError } from "../../../../src/shared/errors/IpuaroError.js"
|
||||
import { createUserMessage } from "../../../../src/domain/value-objects/ChatMessage.js"
|
||||
|
||||
const mockChatResponse = {
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "This is a test response.",
|
||||
tool_calls: undefined,
|
||||
},
|
||||
eval_count: 50,
|
||||
done_reason: "stop",
|
||||
}
|
||||
|
||||
const mockListResponse = {
|
||||
models: [
|
||||
{ name: "qwen2.5-coder:7b-instruct", size: 4000000000 },
|
||||
{ name: "llama2:latest", size: 3500000000 },
|
||||
],
|
||||
}
|
||||
|
||||
const mockOllamaInstance = {
|
||||
chat: vi.fn(),
|
||||
list: vi.fn(),
|
||||
pull: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock("ollama", () => {
|
||||
return {
|
||||
Ollama: vi.fn(() => mockOllamaInstance),
|
||||
}
|
||||
})
|
||||
|
||||
const { OllamaClient } = await import("../../../../src/infrastructure/llm/OllamaClient.js")
|
||||
|
||||
describe("OllamaClient", () => {
|
||||
const defaultConfig: LLMConfig = {
|
||||
model: "qwen2.5-coder:7b-instruct",
|
||||
contextWindow: 128000,
|
||||
temperature: 0.1,
|
||||
host: "http://localhost:11434",
|
||||
timeout: 120000,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockOllamaInstance.chat.mockResolvedValue(mockChatResponse)
|
||||
mockOllamaInstance.list.mockResolvedValue(mockListResponse)
|
||||
mockOllamaInstance.pull.mockResolvedValue({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create instance with config", () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
expect(client).toBeDefined()
|
||||
expect(client.getModelName()).toBe("qwen2.5-coder:7b-instruct")
|
||||
expect(client.getContextWindowSize()).toBe(128000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("chat", () => {
|
||||
it("should send messages and return response", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [createUserMessage("Hello, world!")]
|
||||
|
||||
const response = await client.chat(messages)
|
||||
|
||||
expect(response.content).toBe("This is a test response.")
|
||||
expect(response.tokens).toBe(50)
|
||||
expect(response.stopReason).toBe("end")
|
||||
expect(response.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it("should convert messages to Ollama format", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [createUserMessage("Hello")]
|
||||
|
||||
await client.chat(messages)
|
||||
|
||||
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "qwen2.5-coder:7b-instruct",
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should pass tools when provided", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [createUserMessage("Read file")]
|
||||
const tools = [
|
||||
{
|
||||
name: "get_lines",
|
||||
description: "Get lines from file",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string" as const,
|
||||
description: "File path",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
await client.chat(messages, tools)
|
||||
|
||||
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "function",
|
||||
function: expect.objectContaining({
|
||||
name: "get_lines",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should extract tool calls from response", async () => {
|
||||
mockOllamaInstance.chat.mockResolvedValue({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "get_lines",
|
||||
arguments: { path: "src/index.ts" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
eval_count: 30,
|
||||
})
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const response = await client.chat([createUserMessage("Read file")])
|
||||
|
||||
expect(response.toolCalls).toHaveLength(1)
|
||||
expect(response.toolCalls[0].name).toBe("get_lines")
|
||||
expect(response.toolCalls[0].params).toEqual({ path: "src/index.ts" })
|
||||
expect(response.stopReason).toBe("tool_use")
|
||||
})
|
||||
|
||||
it("should handle connection errors", async () => {
|
||||
mockOllamaInstance.chat.mockRejectedValue(new Error("fetch failed"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(IpuaroError)
|
||||
})
|
||||
|
||||
it("should handle model not found errors", async () => {
|
||||
mockOllamaInstance.chat.mockRejectedValue(new Error("model not found"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(/not found/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("countTokens", () => {
|
||||
it("should estimate tokens for text", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const count = await client.countTokens("Hello, world!")
|
||||
|
||||
expect(count).toBeGreaterThan(0)
|
||||
expect(typeof count).toBe("number")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAvailable", () => {
|
||||
it("should return true when Ollama is available", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const available = await client.isAvailable()
|
||||
|
||||
expect(available).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when Ollama is not available", async () => {
|
||||
mockOllamaInstance.list.mockRejectedValue(new Error("Connection refused"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const available = await client.isAvailable()
|
||||
|
||||
expect(available).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModelName", () => {
|
||||
it("should return configured model name", () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
expect(client.getModelName()).toBe("qwen2.5-coder:7b-instruct")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getContextWindowSize", () => {
|
||||
it("should return configured context window size", () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
expect(client.getContextWindowSize()).toBe(128000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pullModel", () => {
|
||||
it("should pull model successfully", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.pullModel("llama2")).resolves.toBeUndefined()
|
||||
expect(mockOllamaInstance.pull).toHaveBeenCalledWith({
|
||||
model: "llama2",
|
||||
stream: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw on pull failure", async () => {
|
||||
mockOllamaInstance.pull.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.pullModel("llama2")).rejects.toThrow(IpuaroError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasModel", () => {
|
||||
it("should return true for available model", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const has = await client.hasModel("qwen2.5-coder:7b-instruct")
|
||||
|
||||
expect(has).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for model prefix", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const has = await client.hasModel("llama2")
|
||||
|
||||
expect(has).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for missing model", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const has = await client.hasModel("unknown-model")
|
||||
|
||||
expect(has).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when list fails", async () => {
|
||||
mockOllamaInstance.list.mockRejectedValue(new Error("Error"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const has = await client.hasModel("any-model")
|
||||
|
||||
expect(has).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listModels", () => {
|
||||
it("should return list of model names", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const models = await client.listModels()
|
||||
|
||||
expect(models).toContain("qwen2.5-coder:7b-instruct")
|
||||
expect(models).toContain("llama2:latest")
|
||||
})
|
||||
|
||||
it("should throw on list failure", async () => {
|
||||
mockOllamaInstance.list.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.listModels()).rejects.toThrow(IpuaroError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("abort", () => {
|
||||
it("should not throw when no request is in progress", () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
expect(() => client.abort()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
parseToolCalls,
|
||||
formatToolCallsAsXml,
|
||||
extractThinking,
|
||||
hasToolCalls,
|
||||
validateToolCallParams,
|
||||
} from "../../../../src/infrastructure/llm/ResponseParser.js"
|
||||
import { createToolCall } from "../../../../src/domain/value-objects/ToolCall.js"
|
||||
|
||||
describe("ResponseParser", () => {
|
||||
describe("parseToolCalls", () => {
|
||||
it("should parse a single tool call", () => {
|
||||
const response = `<tool_call name="get_lines">
|
||||
<path>src/index.ts</path>
|
||||
<start>1</start>
|
||||
<end>10</end>
|
||||
</tool_call>`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls).toHaveLength(1)
|
||||
expect(result.toolCalls[0].name).toBe("get_lines")
|
||||
expect(result.toolCalls[0].params).toEqual({
|
||||
path: "src/index.ts",
|
||||
start: 1,
|
||||
end: 10,
|
||||
})
|
||||
expect(result.hasParseErrors).toBe(false)
|
||||
})
|
||||
|
||||
it("should parse multiple tool calls", () => {
|
||||
const response = `
|
||||
<tool_call name="get_lines">
|
||||
<path>src/a.ts</path>
|
||||
</tool_call>
|
||||
<tool_call name="get_function">
|
||||
<path>src/b.ts</path>
|
||||
<name>myFunc</name>
|
||||
</tool_call>
|
||||
`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls).toHaveLength(2)
|
||||
expect(result.toolCalls[0].name).toBe("get_lines")
|
||||
expect(result.toolCalls[1].name).toBe("get_function")
|
||||
})
|
||||
|
||||
it("should extract text content without tool calls", () => {
|
||||
const response = `Let me check the file.
|
||||
<tool_call name="get_lines">
|
||||
<path>src/index.ts</path>
|
||||
</tool_call>
|
||||
Here's what I found.`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.content).toContain("Let me check the file.")
|
||||
expect(result.content).toContain("Here's what I found.")
|
||||
expect(result.content).not.toContain("tool_call")
|
||||
})
|
||||
|
||||
it("should parse boolean values", () => {
|
||||
const response = `<tool_call name="git_diff">
|
||||
<staged>true</staged>
|
||||
</tool_call>`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls[0].params.staged).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse null values", () => {
|
||||
const response = `<tool_call name="test">
|
||||
<value>null</value>
|
||||
</tool_call>`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls[0].params.value).toBe(null)
|
||||
})
|
||||
|
||||
it("should parse JSON arrays", () => {
|
||||
const response = `<tool_call name="git_commit">
|
||||
<files>["a.ts", "b.ts"]</files>
|
||||
</tool_call>`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls[0].params.files).toEqual(["a.ts", "b.ts"])
|
||||
})
|
||||
|
||||
it("should parse JSON objects", () => {
|
||||
const response = `<tool_call name="test">
|
||||
<config>{"key": "value"}</config>
|
||||
</tool_call>`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls[0].params.config).toEqual({ key: "value" })
|
||||
})
|
||||
|
||||
it("should return empty array for response without tool calls", () => {
|
||||
const response = "This is just a regular response."
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls).toHaveLength(0)
|
||||
expect(result.content).toBe(response)
|
||||
})
|
||||
|
||||
it("should handle named param syntax", () => {
|
||||
const response = `<tool_call name="get_lines">
|
||||
<param name="path">src/index.ts</param>
|
||||
<param name="start">5</param>
|
||||
</tool_call>`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls[0].params).toEqual({
|
||||
path: "src/index.ts",
|
||||
start: 5,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatToolCallsAsXml", () => {
|
||||
it("should format tool calls as XML", () => {
|
||||
const toolCalls = [
|
||||
createToolCall("1", "get_lines", { path: "src/index.ts", start: 1 }),
|
||||
]
|
||||
|
||||
const xml = formatToolCallsAsXml(toolCalls)
|
||||
|
||||
expect(xml).toContain('<tool_call name="get_lines">')
|
||||
expect(xml).toContain("<path>src/index.ts</path>")
|
||||
expect(xml).toContain("<start>1</start>")
|
||||
expect(xml).toContain("</tool_call>")
|
||||
})
|
||||
|
||||
it("should format multiple tool calls", () => {
|
||||
const toolCalls = [
|
||||
createToolCall("1", "get_lines", { path: "a.ts" }),
|
||||
createToolCall("2", "get_function", { path: "b.ts", name: "foo" }),
|
||||
]
|
||||
|
||||
const xml = formatToolCallsAsXml(toolCalls)
|
||||
|
||||
expect(xml).toContain('<tool_call name="get_lines">')
|
||||
expect(xml).toContain('<tool_call name="get_function">')
|
||||
})
|
||||
|
||||
it("should handle object values as JSON", () => {
|
||||
const toolCalls = [
|
||||
createToolCall("1", "test", { data: { key: "value" } }),
|
||||
]
|
||||
|
||||
const xml = formatToolCallsAsXml(toolCalls)
|
||||
|
||||
expect(xml).toContain('{"key":"value"}')
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractThinking", () => {
|
||||
it("should extract thinking content", () => {
|
||||
const response = `<thinking>Let me analyze this.</thinking>
|
||||
Here is the answer.`
|
||||
|
||||
const result = extractThinking(response)
|
||||
|
||||
expect(result.thinking).toBe("Let me analyze this.")
|
||||
expect(result.content).toContain("Here is the answer.")
|
||||
expect(result.content).not.toContain("thinking")
|
||||
})
|
||||
|
||||
it("should handle multiple thinking blocks", () => {
|
||||
const response = `<thinking>First thought.</thinking>
|
||||
Some content.
|
||||
<thinking>Second thought.</thinking>
|
||||
More content.`
|
||||
|
||||
const result = extractThinking(response)
|
||||
|
||||
expect(result.thinking).toContain("First thought.")
|
||||
expect(result.thinking).toContain("Second thought.")
|
||||
})
|
||||
|
||||
it("should return original content if no thinking", () => {
|
||||
const response = "Just a regular response."
|
||||
|
||||
const result = extractThinking(response)
|
||||
|
||||
expect(result.thinking).toBe("")
|
||||
expect(result.content).toBe(response)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasToolCalls", () => {
|
||||
it("should return true if response has tool calls", () => {
|
||||
const response = `<tool_call name="get_lines"><path>a.ts</path></tool_call>`
|
||||
|
||||
expect(hasToolCalls(response)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false if response has no tool calls", () => {
|
||||
const response = "Just text without tool calls."
|
||||
|
||||
expect(hasToolCalls(response)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateToolCallParams", () => {
|
||||
it("should return valid for complete params", () => {
|
||||
const params = { path: "src/index.ts", start: 1, end: 10 }
|
||||
const required = ["path", "start", "end"]
|
||||
|
||||
const result = validateToolCallParams("get_lines", params, required)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return errors for missing required params", () => {
|
||||
const params = { path: "src/index.ts" }
|
||||
const required = ["path", "start", "end"]
|
||||
|
||||
const result = validateToolCallParams("get_lines", params, required)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toHaveLength(2)
|
||||
expect(result.errors).toContain("Missing required parameter: start")
|
||||
expect(result.errors).toContain("Missing required parameter: end")
|
||||
})
|
||||
|
||||
it("should treat null and undefined as missing", () => {
|
||||
const params = { path: null, start: undefined }
|
||||
const required = ["path", "start"]
|
||||
|
||||
const result = validateToolCallParams("test", params, required)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should accept empty required array", () => {
|
||||
const params = {}
|
||||
const required: string[] = []
|
||||
|
||||
const result = validateToolCallParams("git_status", params, required)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
278
packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts
Normal file
278
packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
SYSTEM_PROMPT,
|
||||
buildInitialContext,
|
||||
buildFileContext,
|
||||
truncateContext,
|
||||
type ProjectStructure,
|
||||
} from "../../../../src/infrastructure/llm/prompts.js"
|
||||
import type { FileAST } from "../../../../src/domain/value-objects/FileAST.js"
|
||||
import type { FileMeta } from "../../../../src/domain/value-objects/FileMeta.js"
|
||||
|
||||
describe("prompts", () => {
|
||||
describe("SYSTEM_PROMPT", () => {
|
||||
it("should be a non-empty string", () => {
|
||||
expect(typeof SYSTEM_PROMPT).toBe("string")
|
||||
expect(SYSTEM_PROMPT.length).toBeGreaterThan(100)
|
||||
})
|
||||
|
||||
it("should contain core principles", () => {
|
||||
expect(SYSTEM_PROMPT).toContain("Lazy Loading")
|
||||
expect(SYSTEM_PROMPT).toContain("Precision")
|
||||
expect(SYSTEM_PROMPT).toContain("Safety")
|
||||
})
|
||||
|
||||
it("should list available tools", () => {
|
||||
expect(SYSTEM_PROMPT).toContain("get_lines")
|
||||
expect(SYSTEM_PROMPT).toContain("edit_lines")
|
||||
expect(SYSTEM_PROMPT).toContain("find_references")
|
||||
expect(SYSTEM_PROMPT).toContain("git_status")
|
||||
expect(SYSTEM_PROMPT).toContain("run_command")
|
||||
})
|
||||
|
||||
it("should include safety rules", () => {
|
||||
expect(SYSTEM_PROMPT).toContain("Safety Rules")
|
||||
expect(SYSTEM_PROMPT).toContain("Never execute commands that could harm")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildInitialContext", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "my-project",
|
||||
rootPath: "/home/user/my-project",
|
||||
files: ["src/index.ts", "src/utils.ts", "package.json"],
|
||||
directories: ["src", "tests"],
|
||||
}
|
||||
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"src/index.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [
|
||||
{
|
||||
name: "main",
|
||||
lineStart: 1,
|
||||
lineEnd: 10,
|
||||
params: [],
|
||||
isAsync: false,
|
||||
isExported: true,
|
||||
},
|
||||
],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"src/utils.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [
|
||||
{
|
||||
name: "Helper",
|
||||
lineStart: 1,
|
||||
lineEnd: 20,
|
||||
methods: [],
|
||||
properties: [],
|
||||
implements: [],
|
||||
isExported: true,
|
||||
isAbstract: false,
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
it("should include project header", () => {
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("# Project: my-project")
|
||||
expect(context).toContain("Root: /home/user/my-project")
|
||||
expect(context).toContain("Files: 3")
|
||||
expect(context).toContain("Directories: 2")
|
||||
})
|
||||
|
||||
it("should include directory structure", () => {
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("## Structure")
|
||||
expect(context).toContain("src/")
|
||||
expect(context).toContain("tests/")
|
||||
})
|
||||
|
||||
it("should include file overview with AST summaries", () => {
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("## Files")
|
||||
expect(context).toContain("src/index.ts")
|
||||
expect(context).toContain("fn: main")
|
||||
expect(context).toContain("src/utils.ts")
|
||||
expect(context).toContain("class: Helper")
|
||||
})
|
||||
|
||||
it("should include file flags from metadata", () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/index.ts",
|
||||
{
|
||||
complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 75 },
|
||||
dependencies: [],
|
||||
dependents: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"],
|
||||
isHub: true,
|
||||
isEntryPoint: true,
|
||||
fileType: "source",
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts, metas)
|
||||
|
||||
expect(context).toContain("(hub, entry, complex)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildFileContext", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [
|
||||
{ name: "fs", from: "node:fs", line: 1, type: "builtin", isDefault: false },
|
||||
{ name: "helper", from: "./helper", line: 2, type: "internal", isDefault: true },
|
||||
],
|
||||
exports: [
|
||||
{ name: "main", line: 10, isDefault: false, kind: "function" },
|
||||
{ name: "Config", line: 20, isDefault: true, kind: "class" },
|
||||
],
|
||||
functions: [
|
||||
{
|
||||
name: "main",
|
||||
lineStart: 10,
|
||||
lineEnd: 30,
|
||||
params: [
|
||||
{ name: "args", optional: false, hasDefault: false },
|
||||
{ name: "options", optional: true, hasDefault: false },
|
||||
],
|
||||
isAsync: true,
|
||||
isExported: true,
|
||||
},
|
||||
],
|
||||
classes: [
|
||||
{
|
||||
name: "Config",
|
||||
lineStart: 40,
|
||||
lineEnd: 80,
|
||||
methods: [
|
||||
{
|
||||
name: "load",
|
||||
lineStart: 50,
|
||||
lineEnd: 60,
|
||||
params: [],
|
||||
isAsync: false,
|
||||
visibility: "public",
|
||||
isStatic: false,
|
||||
},
|
||||
],
|
||||
properties: [],
|
||||
extends: "BaseConfig",
|
||||
implements: ["IConfig"],
|
||||
isExported: true,
|
||||
isAbstract: false,
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
it("should include file path header", () => {
|
||||
const context = buildFileContext("src/index.ts", ast)
|
||||
|
||||
expect(context).toContain("## src/index.ts")
|
||||
})
|
||||
|
||||
it("should include imports section", () => {
|
||||
const context = buildFileContext("src/index.ts", ast)
|
||||
|
||||
expect(context).toContain("### Imports")
|
||||
expect(context).toContain('fs from "node:fs" (builtin)')
|
||||
expect(context).toContain('helper from "./helper" (internal)')
|
||||
})
|
||||
|
||||
it("should include exports section", () => {
|
||||
const context = buildFileContext("src/index.ts", ast)
|
||||
|
||||
expect(context).toContain("### Exports")
|
||||
expect(context).toContain("function main")
|
||||
expect(context).toContain("class Config (default)")
|
||||
})
|
||||
|
||||
it("should include functions section", () => {
|
||||
const context = buildFileContext("src/index.ts", ast)
|
||||
|
||||
expect(context).toContain("### Functions")
|
||||
expect(context).toContain("async main(args, options)")
|
||||
expect(context).toContain("[10-30]")
|
||||
})
|
||||
|
||||
it("should include classes section with methods", () => {
|
||||
const context = buildFileContext("src/index.ts", ast)
|
||||
|
||||
expect(context).toContain("### Classes")
|
||||
expect(context).toContain("Config extends BaseConfig implements IConfig")
|
||||
expect(context).toContain("[40-80]")
|
||||
expect(context).toContain("load()")
|
||||
})
|
||||
|
||||
it("should include metadata section when provided", () => {
|
||||
const meta: FileMeta = {
|
||||
complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 65 },
|
||||
dependencies: ["a.ts", "b.ts"],
|
||||
dependents: ["c.ts"],
|
||||
isHub: false,
|
||||
isEntryPoint: true,
|
||||
fileType: "source",
|
||||
}
|
||||
|
||||
const context = buildFileContext("src/index.ts", ast, meta)
|
||||
|
||||
expect(context).toContain("### Metadata")
|
||||
expect(context).toContain("LOC: 100")
|
||||
expect(context).toContain("Complexity: 65/100")
|
||||
expect(context).toContain("Dependencies: 2")
|
||||
expect(context).toContain("Dependents: 1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("truncateContext", () => {
|
||||
it("should return original context if within limit", () => {
|
||||
const context = "Short context"
|
||||
|
||||
const result = truncateContext(context, 1000)
|
||||
|
||||
expect(result).toBe(context)
|
||||
})
|
||||
|
||||
it("should truncate long context", () => {
|
||||
const context = "a".repeat(1000)
|
||||
|
||||
const result = truncateContext(context, 100)
|
||||
|
||||
expect(result.length).toBeLessThan(500)
|
||||
expect(result).toContain("truncated")
|
||||
})
|
||||
|
||||
it("should break at newline boundary", () => {
|
||||
const context = "Line 1\nLine 2\nLine 3\n" + "a".repeat(1000)
|
||||
|
||||
const result = truncateContext(context, 50)
|
||||
|
||||
expect(result).toContain("truncated")
|
||||
})
|
||||
})
|
||||
})
|
||||
287
packages/ipuaro/tests/unit/infrastructure/llm/toolDefs.test.ts
Normal file
287
packages/ipuaro/tests/unit/infrastructure/llm/toolDefs.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
ALL_TOOLS,
|
||||
READ_TOOLS,
|
||||
EDIT_TOOLS,
|
||||
SEARCH_TOOLS,
|
||||
ANALYSIS_TOOLS,
|
||||
GIT_TOOLS,
|
||||
RUN_TOOLS,
|
||||
CONFIRMATION_TOOLS,
|
||||
requiresConfirmation,
|
||||
getToolDef,
|
||||
getToolsByCategory,
|
||||
GET_LINES_TOOL,
|
||||
GET_FUNCTION_TOOL,
|
||||
GET_CLASS_TOOL,
|
||||
GET_STRUCTURE_TOOL,
|
||||
EDIT_LINES_TOOL,
|
||||
CREATE_FILE_TOOL,
|
||||
DELETE_FILE_TOOL,
|
||||
FIND_REFERENCES_TOOL,
|
||||
FIND_DEFINITION_TOOL,
|
||||
GET_DEPENDENCIES_TOOL,
|
||||
GET_DEPENDENTS_TOOL,
|
||||
GET_COMPLEXITY_TOOL,
|
||||
GET_TODOS_TOOL,
|
||||
GIT_STATUS_TOOL,
|
||||
GIT_DIFF_TOOL,
|
||||
GIT_COMMIT_TOOL,
|
||||
RUN_COMMAND_TOOL,
|
||||
RUN_TESTS_TOOL,
|
||||
} from "../../../../src/infrastructure/llm/toolDefs.js"
|
||||
|
||||
describe("toolDefs", () => {
|
||||
describe("ALL_TOOLS", () => {
|
||||
it("should contain exactly 18 tools", () => {
|
||||
expect(ALL_TOOLS).toHaveLength(18)
|
||||
})
|
||||
|
||||
it("should have unique tool names", () => {
|
||||
const names = ALL_TOOLS.map((t) => t.name)
|
||||
const uniqueNames = new Set(names)
|
||||
expect(uniqueNames.size).toBe(18)
|
||||
})
|
||||
|
||||
it("should have valid structure for all tools", () => {
|
||||
for (const tool of ALL_TOOLS) {
|
||||
expect(tool.name).toBeDefined()
|
||||
expect(typeof tool.name).toBe("string")
|
||||
expect(tool.description).toBeDefined()
|
||||
expect(typeof tool.description).toBe("string")
|
||||
expect(Array.isArray(tool.parameters)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("READ_TOOLS", () => {
|
||||
it("should contain 4 read tools", () => {
|
||||
expect(READ_TOOLS).toHaveLength(4)
|
||||
})
|
||||
|
||||
it("should include all read tools", () => {
|
||||
expect(READ_TOOLS).toContain(GET_LINES_TOOL)
|
||||
expect(READ_TOOLS).toContain(GET_FUNCTION_TOOL)
|
||||
expect(READ_TOOLS).toContain(GET_CLASS_TOOL)
|
||||
expect(READ_TOOLS).toContain(GET_STRUCTURE_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("EDIT_TOOLS", () => {
|
||||
it("should contain 3 edit tools", () => {
|
||||
expect(EDIT_TOOLS).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("should include all edit tools", () => {
|
||||
expect(EDIT_TOOLS).toContain(EDIT_LINES_TOOL)
|
||||
expect(EDIT_TOOLS).toContain(CREATE_FILE_TOOL)
|
||||
expect(EDIT_TOOLS).toContain(DELETE_FILE_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SEARCH_TOOLS", () => {
|
||||
it("should contain 2 search tools", () => {
|
||||
expect(SEARCH_TOOLS).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should include all search tools", () => {
|
||||
expect(SEARCH_TOOLS).toContain(FIND_REFERENCES_TOOL)
|
||||
expect(SEARCH_TOOLS).toContain(FIND_DEFINITION_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ANALYSIS_TOOLS", () => {
|
||||
it("should contain 4 analysis tools", () => {
|
||||
expect(ANALYSIS_TOOLS).toHaveLength(4)
|
||||
})
|
||||
|
||||
it("should include all analysis tools", () => {
|
||||
expect(ANALYSIS_TOOLS).toContain(GET_DEPENDENCIES_TOOL)
|
||||
expect(ANALYSIS_TOOLS).toContain(GET_DEPENDENTS_TOOL)
|
||||
expect(ANALYSIS_TOOLS).toContain(GET_COMPLEXITY_TOOL)
|
||||
expect(ANALYSIS_TOOLS).toContain(GET_TODOS_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GIT_TOOLS", () => {
|
||||
it("should contain 3 git tools", () => {
|
||||
expect(GIT_TOOLS).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("should include all git tools", () => {
|
||||
expect(GIT_TOOLS).toContain(GIT_STATUS_TOOL)
|
||||
expect(GIT_TOOLS).toContain(GIT_DIFF_TOOL)
|
||||
expect(GIT_TOOLS).toContain(GIT_COMMIT_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("RUN_TOOLS", () => {
|
||||
it("should contain 2 run tools", () => {
|
||||
expect(RUN_TOOLS).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should include all run tools", () => {
|
||||
expect(RUN_TOOLS).toContain(RUN_COMMAND_TOOL)
|
||||
expect(RUN_TOOLS).toContain(RUN_TESTS_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("individual tool definitions", () => {
|
||||
describe("GET_LINES_TOOL", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(GET_LINES_TOOL.name).toBe("get_lines")
|
||||
})
|
||||
|
||||
it("should have required path parameter", () => {
|
||||
const pathParam = GET_LINES_TOOL.parameters.find((p) => p.name === "path")
|
||||
expect(pathParam).toBeDefined()
|
||||
expect(pathParam?.required).toBe(true)
|
||||
})
|
||||
|
||||
it("should have optional start and end parameters", () => {
|
||||
const startParam = GET_LINES_TOOL.parameters.find((p) => p.name === "start")
|
||||
const endParam = GET_LINES_TOOL.parameters.find((p) => p.name === "end")
|
||||
expect(startParam?.required).toBe(false)
|
||||
expect(endParam?.required).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("EDIT_LINES_TOOL", () => {
|
||||
it("should have all required parameters", () => {
|
||||
const requiredParams = EDIT_LINES_TOOL.parameters.filter((p) => p.required)
|
||||
const names = requiredParams.map((p) => p.name)
|
||||
expect(names).toContain("path")
|
||||
expect(names).toContain("start")
|
||||
expect(names).toContain("end")
|
||||
expect(names).toContain("content")
|
||||
})
|
||||
})
|
||||
|
||||
describe("GIT_STATUS_TOOL", () => {
|
||||
it("should have no required parameters", () => {
|
||||
expect(GIT_STATUS_TOOL.parameters).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET_TODOS_TOOL", () => {
|
||||
it("should have enum for type parameter", () => {
|
||||
const typeParam = GET_TODOS_TOOL.parameters.find((p) => p.name === "type")
|
||||
expect(typeParam?.enum).toEqual(["TODO", "FIXME", "HACK", "XXX"])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("CONFIRMATION_TOOLS", () => {
|
||||
it("should be a Set", () => {
|
||||
expect(CONFIRMATION_TOOLS instanceof Set).toBe(true)
|
||||
})
|
||||
|
||||
it("should contain edit and git_commit tools", () => {
|
||||
expect(CONFIRMATION_TOOLS.has("edit_lines")).toBe(true)
|
||||
expect(CONFIRMATION_TOOLS.has("create_file")).toBe(true)
|
||||
expect(CONFIRMATION_TOOLS.has("delete_file")).toBe(true)
|
||||
expect(CONFIRMATION_TOOLS.has("git_commit")).toBe(true)
|
||||
})
|
||||
|
||||
it("should not contain read tools", () => {
|
||||
expect(CONFIRMATION_TOOLS.has("get_lines")).toBe(false)
|
||||
expect(CONFIRMATION_TOOLS.has("get_function")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("requiresConfirmation", () => {
|
||||
it("should return true for edit tools", () => {
|
||||
expect(requiresConfirmation("edit_lines")).toBe(true)
|
||||
expect(requiresConfirmation("create_file")).toBe(true)
|
||||
expect(requiresConfirmation("delete_file")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for git_commit", () => {
|
||||
expect(requiresConfirmation("git_commit")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for read tools", () => {
|
||||
expect(requiresConfirmation("get_lines")).toBe(false)
|
||||
expect(requiresConfirmation("get_function")).toBe(false)
|
||||
expect(requiresConfirmation("get_structure")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for analysis tools", () => {
|
||||
expect(requiresConfirmation("get_dependencies")).toBe(false)
|
||||
expect(requiresConfirmation("get_complexity")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for unknown tools", () => {
|
||||
expect(requiresConfirmation("unknown_tool")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getToolDef", () => {
|
||||
it("should return tool definition by name", () => {
|
||||
const tool = getToolDef("get_lines")
|
||||
expect(tool).toBe(GET_LINES_TOOL)
|
||||
})
|
||||
|
||||
it("should return undefined for unknown tool", () => {
|
||||
const tool = getToolDef("unknown_tool")
|
||||
expect(tool).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should find all 18 tools", () => {
|
||||
const names = [
|
||||
"get_lines",
|
||||
"get_function",
|
||||
"get_class",
|
||||
"get_structure",
|
||||
"edit_lines",
|
||||
"create_file",
|
||||
"delete_file",
|
||||
"find_references",
|
||||
"find_definition",
|
||||
"get_dependencies",
|
||||
"get_dependents",
|
||||
"get_complexity",
|
||||
"get_todos",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"git_commit",
|
||||
"run_command",
|
||||
"run_tests",
|
||||
]
|
||||
|
||||
for (const name of names) {
|
||||
expect(getToolDef(name)).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getToolsByCategory", () => {
|
||||
it("should return read tools", () => {
|
||||
expect(getToolsByCategory("read")).toBe(READ_TOOLS)
|
||||
})
|
||||
|
||||
it("should return edit tools", () => {
|
||||
expect(getToolsByCategory("edit")).toBe(EDIT_TOOLS)
|
||||
})
|
||||
|
||||
it("should return search tools", () => {
|
||||
expect(getToolsByCategory("search")).toBe(SEARCH_TOOLS)
|
||||
})
|
||||
|
||||
it("should return analysis tools", () => {
|
||||
expect(getToolsByCategory("analysis")).toBe(ANALYSIS_TOOLS)
|
||||
})
|
||||
|
||||
it("should return git tools", () => {
|
||||
expect(getToolsByCategory("git")).toBe(GIT_TOOLS)
|
||||
})
|
||||
|
||||
it("should return run tools", () => {
|
||||
expect(getToolsByCategory("run")).toBe(RUN_TOOLS)
|
||||
})
|
||||
|
||||
it("should return empty array for unknown category", () => {
|
||||
expect(getToolsByCategory("unknown")).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user