From 43b888ccebff15ebd01c5509bcf03dfbf302ad8c Mon Sep 17 00:00:00 2001 From: billlzzz26 Date: Mon, 27 Apr 2026 18:04:35 +0700 Subject: [PATCH 01/10] feat: apply local changes on top of main (#6) Co-authored-by: DevOps Bot --- .github/workflows/codeql.yml | 86 +++--- .gitig | 1 + apps/web/app/api/github/branches/route.ts | 13 + apps/web/git | 0 apps/web/lib/github/api.ts | 7 +- apps/web/lib/sandbox/lifecycle.ts | 3 +- bun.lock | 22 +- packages/agent/kilo/client.ts | 159 ++++++++++++ packages/agent/open-agent.ts | 4 + packages/agent/tools/delegate.ts | 176 +++++++++++++ packages/agent/tools/index.ts | 6 + packages/agent/tools/kilo.ts | 59 +++++ packages/sandbox/vercel/sandbox.ts | 11 +- renovate.json | 7 +- todo.md | 303 ++++++++++++++++++++++ 15 files changed, 797 insertions(+), 60 deletions(-) create mode 100644 .gitig create mode 100644 apps/web/git create mode 100644 packages/agent/kilo/client.ts create mode 100644 packages/agent/tools/delegate.ts create mode 100644 packages/agent/tools/kilo.ts create mode 100644 todo.md diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 201f19f0..60ad9244 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,11 +13,11 @@ name: "CodeQL Advanced" on: push: - branches: [ "b/devcontainer-setup" ] + branches: ["b/devcontainer-setup"] pull_request: - branches: [ "b/devcontainer-setup" ] + branches: ["b/devcontainer-setup"] schedule: - - cron: '16 2 * * 6' + - cron: "16 2 * * 6" jobs: analyze: @@ -43,8 +43,8 @@ jobs: fail-fast: false matrix: include: - - language: javascript-typescript - build-mode: none + - language: javascript-typescript + build-mode: none # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' # Use `c-cpp` to analyze code written in C, C++ or both # Use 'java-kotlin' to analyze code written in Java, Kotlin or both @@ -54,46 +54,46 @@ jobs: # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - # Add any setup steps before running the `github/codeql-action/init` action. - # This includes steps like installing compilers or runtimes (`actions/setup-node` - # or others). This is typically only required for manual builds. - # - name: Setup runtime (example) - # uses: actions/setup-example@v1 + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - name: Run manual build steps - if: matrix.build-mode == 'manual' - shell: bash - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitig b/.gitig new file mode 100644 index 00000000..b9f40a7b --- /dev/null +++ b/.gitig @@ -0,0 +1 @@ +ore diff --git a/apps/web/app/api/github/branches/route.ts b/apps/web/app/api/github/branches/route.ts index 9e6e21b8..db57da91 100644 --- a/apps/web/app/api/github/branches/route.ts +++ b/apps/web/app/api/github/branches/route.ts @@ -17,6 +17,12 @@ function normalizeGitHubLimit(limit: number | undefined): number | undefined { : undefined; } +function isValidGitHubPathSegment(value: string): boolean { + // Conservative allow-list for owner/repo used in URL path segments. + // Allows alphanumerics plus GitHub-common separators. + return /^[A-Za-z0-9._-]{1,100}$/.test(value); +} + function parseRepoInfo(value: unknown): RepoInfo | null { if (!value || typeof value !== "object") { return null; @@ -286,6 +292,13 @@ export async function GET(request: NextRequest) { ); } + if (!isValidGitHubPathSegment(owner) || !isValidGitHubPathSegment(repo)) { + return NextResponse.json( + { error: "Invalid owner or repo parameter" }, + { status: 400 }, + ); + } + const token = await getUserGitHubToken(session.user.id); try { diff --git a/apps/web/git b/apps/web/git new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/lib/github/api.ts b/apps/web/lib/github/api.ts index 2b4317d3..00627d3e 100644 --- a/apps/web/lib/github/api.ts +++ b/apps/web/lib/github/api.ts @@ -71,9 +71,12 @@ export async function fetchGitHubBranches( repo: string, limit?: number, ) { + const encodedOwner = encodeURIComponent(owner); + const encodedRepo = encodeURIComponent(repo); + // Fetch repo info for default branch const repoInfo = await fetchGitHubAPI( - `/repos/${owner}/${repo}`, + `/repos/${encodedOwner}/${encodedRepo}`, token, ); if (!repoInfo) return null; @@ -89,7 +92,7 @@ export async function fetchGitHubBranches( while (page <= maxPages) { const branches = await fetchGitHubAPI( - `/repos/${owner}/${repo}/branches?per_page=${perPage}&page=${page}`, + `/repos/${encodedOwner}/${encodedRepo}/branches?per_page=${perPage}&page=${page}`, token, ); diff --git a/apps/web/lib/sandbox/lifecycle.ts b/apps/web/lib/sandbox/lifecycle.ts index 234a4ee1..59fdcbea 100644 --- a/apps/web/lib/sandbox/lifecycle.ts +++ b/apps/web/lib/sandbox/lifecycle.ts @@ -259,7 +259,8 @@ export async function evaluateSandboxLifecycle( lifecycleError: message, }); console.error( - `[Lifecycle] Failed to evaluate sandbox lifecycle for session ${sessionId}:`, + "[Lifecycle] Failed to evaluate sandbox lifecycle for session %s:", + sessionId, error, ); return { action: "failed", reason: message }; diff --git a/bun.lock b/bun.lock index 115f9720..c3cd439b 100644 --- a/bun.lock +++ b/bun.lock @@ -52,7 +52,7 @@ "jose": "^6.1.3", "lucide-react": "^0.562.0", "nanoid": "^5.1.6", - "next": "16.2.1", + "next": "16.2.3", "postgres": "^3.4.8", "react": "19.2.3", "react-day-picker": "^9.14.0", @@ -445,23 +445,23 @@ "@nestjs/core": ["@nestjs/core@11.1.19", "", { "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/websockets": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express", "@nestjs/websockets"] }, "sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw=="], - "@next/env": ["@next/env@16.2.1", "", {}, "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg=="], + "@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="], "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], @@ -1767,7 +1767,7 @@ "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], - "next": ["next@16.2.1", "", { "dependencies": { "@next/env": "16.2.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.1", "@next/swc-darwin-x64": "16.2.1", "@next/swc-linux-arm64-gnu": "16.2.1", "@next/swc-linux-arm64-musl": "16.2.1", "@next/swc-linux-x64-gnu": "16.2.1", "@next/swc-linux-x64-musl": "16.2.1", "@next/swc-win32-arm64-msvc": "16.2.1", "@next/swc-win32-x64-msvc": "16.2.1", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q=="], + "next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="], "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="], diff --git a/packages/agent/kilo/client.ts b/packages/agent/kilo/client.ts new file mode 100644 index 00000000..e46a60c0 --- /dev/null +++ b/packages/agent/kilo/client.ts @@ -0,0 +1,159 @@ +export type ChatCompletionRequest = { + model: string; + messages: Message[]; + stream?: boolean; + max_tokens?: number; + temperature?: number; + top_p?: number; + stop?: string | string[]; + frequency_penalty?: number; + presence_penalty?: number; + tools?: Tool[]; + tool_choice?: ToolChoice; + response_format?: ResponseFormat; + user?: string; + seed?: number; +}; + +export type Message = + | { role: "system"; content: string } + | { role: "user"; content: string | ContentPart[] } + | { role: "assistant"; content: string | null; tool_calls?: ToolCall[] } + | { role: "tool"; content: string; tool_call_id: string }; + +export type ContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string; detail?: string } }; + +export type Tool = { + type: "function"; + function: { + name: string; + description?: string; + parameters: object; + }; +}; + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export type ResponseFormat = { + type: "text" | "json_object"; +}; + +export type ToolCall = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; + +export type ChatCompletionResponse = { + id: string; + object: "chat.completion"; + created: number; + model: string; + choices: Array<{ + index: number; + message: { + role: "assistant"; + content: string | null; + tool_calls?: ToolCall[]; + }; + finish_reason: "stop" | "length" | "tool_calls" | "content_filter"; + }>; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +}; + +export type FIMRequest = { + model: string; + prompt: string; + suffix?: string; + max_tokens?: number; + temperature?: number; + stop?: string[]; + stream?: boolean; +}; + +export type Model = { + id: string; + object: "model"; + created: number; + owned_by: string; + name: string; + context_length: number; + pricing: { + prompt: string; + completion: string; + }; +}; + +export class KiloClient { + private apiKey: string; + private baseUrl = "https://api.kilo.ai"; + + constructor(apiKey?: string) { + this.apiKey = apiKey || process.env.KILO_API_KEY || ""; + if (!this.apiKey) { + throw new Error("Kilo API key is required. Set KILO_API_KEY environment variable."); + } + } + + private async request(path: string, options?: RequestInit): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + ...options, + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + ...options?.headers, + }, + }); + + if (!response.ok) { + let errorData: any; + try { + errorData = await response.json(); + } catch (e) { + throw new Error(`Kilo API Error ${response.status}: ${response.statusText}`); + } + throw new Error( + `Kilo API Error ${response.status}: ${ + errorData?.error?.message || response.statusText + }` + ); + } + + return response.json() as Promise; + } + + async chatCompletions(body: ChatCompletionRequest): Promise { + return this.request("/api/gateway/chat/completions", { + method: "POST", + body: JSON.stringify(body), + }); + } + + async fimCompletions(body: FIMRequest): Promise { + return this.request("/api/fim/completions", { + method: "POST", + body: JSON.stringify(body), + }); + } + + async listModels(): Promise<{ data: Model[] }> { + return this.request<{ data: Model[] }>("/api/gateway/models"); + } + + async listProviders(): Promise { + return this.request("/api/gateway/providers"); + } +} diff --git a/packages/agent/open-agent.ts b/packages/agent/open-agent.ts index ceb7d151..56a961fb 100644 --- a/packages/agent/open-agent.ts +++ b/packages/agent/open-agent.ts @@ -19,9 +19,11 @@ import { readFileTool, skillTool, taskTool, + delegateTaskTool, todoWriteTool, webFetchTool, writeFileTool, + kiloTool, } from "./tools"; export interface AgentModelSelection { @@ -71,9 +73,11 @@ const tools = { glob: globTool(), bash: bashTool(), task: taskTool, + delegate_task: delegateTaskTool, ask_user_question: askUserQuestionTool, skill: skillTool, web_fetch: webFetchTool, + kilo: kiloTool, } satisfies ToolSet; export const openAgent = new ToolLoopAgent({ diff --git a/packages/agent/tools/delegate.ts b/packages/agent/tools/delegate.ts new file mode 100644 index 00000000..bdd8abf4 --- /dev/null +++ b/packages/agent/tools/delegate.ts @@ -0,0 +1,176 @@ +import { + type LanguageModelUsage, + type ModelMessage, + tool, + type UIToolInvocation, +} from "ai"; +import { z } from "zod"; +import { + buildSubagentSummaryLines, + SUBAGENT_REGISTRY, + SUBAGENT_TYPES, +} from "../subagents/registry"; +import { sumLanguageModelUsage } from "../usage"; +import { getSandboxContext, getSubagentModel } from "./utils"; + +const subagentTypeSchema = z.enum(SUBAGENT_TYPES); +const subagentSummaryLines = buildSubagentSummaryLines(); + +const delegateTaskSchema = z.object({ + subagentType: subagentTypeSchema.describe( + `Subagent to launch. Available options:\n${subagentSummaryLines}`, + ), + task: z.string().describe("Short description of the task (displayed to user)"), + instructions: z.string().describe( + `Detailed instructions for the subagent. Include: +- Goal and deliverables +- Step-by-step procedure +- Constraints and patterns to follow +- How to verify the work`, + ), +}); + +export const delegateTaskOutputSchema = z.object({ + results: z.array( + z.object({ + task: z.string(), + modelId: z.string().optional(), + toolCallCount: z.number(), + final: z.custom().optional(), + usage: z.custom().optional(), + }) + ), + totalToolCallCount: z.number().int().nonnegative().optional(), + startedAt: z.number().int().nonnegative().optional(), +}); + +export type DelegateTaskToolOutput = z.infer; + +export const delegateTaskTool = tool({ + needsApproval: false, + description: `Spawn multiple subagents to work on tasks in isolated contexts simultaneously. +Each subagent gets its own workspace context. Only the final summary is returned. + +AVAILABLE SUBAGENTS: +${subagentSummaryLines} + +WHEN TO USE delegate_task: +- Parallel independent workstreams (e.g. researching different topics simultaneously) +- Reasoning-heavy subtasks that can be separated and executed concurrently + +WHEN NOT TO USE (use existing tools instead): +- Single sequential task -> just use the 'task' tool directly +- Tasks needing your direct interaction or memory -> do it yourself +- Simple, single-file or single-change edits -> do it yourself + +HOW TO USE: +- Provide an array of 'tasks'. All run concurrently and results are returned together. +- Choose the appropriate subagentType for each task. +- Be explicit and concrete - subagents cannot ask clarifying questions.`, + inputSchema: z.object({ + tasks: z.array(delegateTaskSchema).min(1).max(5).describe( + "Tasks to run in parallel. Each gets its own subagent." + ), + }), + outputSchema: delegateTaskOutputSchema, + execute: async function* ( + { tasks }, + { experimental_context, abortSignal }, + ) { + const sandboxContext = getSandboxContext(experimental_context, "delegateTask"); + const model = getSubagentModel(experimental_context, "delegateTask"); + const subagentModelId = typeof model === "string" ? model : model.modelId; + const startedAt = Date.now(); + + // Initialize state + const taskStates = tasks.map((t) => ({ + task: t.task, + modelId: subagentModelId, + toolCallCount: 0, + usage: undefined as LanguageModelUsage | undefined, + final: undefined as ModelMessage[] | undefined, + })); + + yield { + results: taskStates, + totalToolCallCount: 0, + startedAt, + }; + + const runTask = async (taskInput: typeof tasks[0], index: number) => { + const state = taskStates[index]; + if (!state) return; + const subagent = SUBAGENT_REGISTRY[taskInput.subagentType].agent; + const result = await subagent.stream({ + prompt: "Complete this task and provide a summary of what you accomplished.", + options: { + task: taskInput.task, + instructions: taskInput.instructions, + sandbox: sandboxContext.sandbox, + model, + }, + abortSignal, + }); + + for await (const part of result.fullStream) { + if (part.type === "tool-call") { + state.toolCallCount += 1; + } else if (part.type === "finish-step") { + state.usage = sumLanguageModelUsage(state.usage, part.usage); + } + } + + const response = await result.response; + const finalUsage = state.usage ?? (await result.usage); + + state.final = response.messages; + state.usage = finalUsage; + + return state; + }; + + // Run all tasks in parallel + const promises = tasks.map((t, i) => runTask(t, i)); + await Promise.all(promises); + + const totalToolCallCount = taskStates.reduce((acc, t) => acc + t.toolCallCount, 0); + + yield { + results: taskStates, + totalToolCallCount, + startedAt, + }; + }, + toModelOutput: ({ output: { results } }) => { + if (!results || results.length === 0) { + return { type: "text", value: "All tasks completed." }; + } + + const summaries = results.map((result, i) => { + const messages = result.final; + let contentValue = "Task completed without detailed summary."; + + if (messages) { + const lastAssistantMessage = messages.findLast((p) => p.role === "assistant"); + const content = lastAssistantMessage?.content; + + if (content) { + if (typeof content === "string") { + contentValue = content; + } else { + const lastTextPart = content.findLast((p) => p.type === "text"); + if (lastTextPart) { + contentValue = lastTextPart.text; + } + } + } + } + + return `=== Result for Task ${i + 1}: ${result.task} ===\n${contentValue}`; + }); + + return { type: "text", value: summaries.join("\n\n") }; + }, +}); + +export type DelegateTaskToolUIPart = UIToolInvocation; diff --git a/packages/agent/tools/index.ts b/packages/agent/tools/index.ts index bd81875f..5423d521 100644 --- a/packages/agent/tools/index.ts +++ b/packages/agent/tools/index.ts @@ -10,6 +10,11 @@ export { type TaskToolOutput, type TaskToolUIPart, } from "./task"; +export { + delegateTaskTool, + type DelegateTaskToolOutput, + type DelegateTaskToolUIPart, +} from "./delegate"; export { askUserQuestionTool, type AskUserQuestionToolUIPart, @@ -17,3 +22,4 @@ export { } from "./ask-user-question"; export { skillTool, type SkillToolInput } from "./skill"; export { webFetchTool } from "./fetch"; +export { kiloTool } from "./kilo"; diff --git a/packages/agent/tools/kilo.ts b/packages/agent/tools/kilo.ts new file mode 100644 index 00000000..5401ec7c --- /dev/null +++ b/packages/agent/tools/kilo.ts @@ -0,0 +1,59 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { KiloClient } from "../kilo/client"; + +const kiloInputSchema = z.object({ + action: z.enum(["list_models", "list_providers", "chat_completions", "fim_completions"]), + model: z.string().optional().describe("Model ID (e.g., 'anthropic/claude-sonnet-4.5'). Required for completions."), + messages: z.array(z.any()).optional().describe("Array of conversation messages. Required for chat_completions."), + prompt: z.string().optional().describe("Code before the cursor. Required for fim_completions."), + suffix: z.string().optional().describe("Code after the cursor. Optional for fim_completions."), + max_tokens: z.number().optional(), + temperature: z.number().optional(), +}); + +export const kiloTool = tool({ + description: `Interact with the Kilo AI Gateway. +Supports: +- list_models: Retrieve the list of available models. +- list_providers: Retrieve the list of available providers. +- chat_completions: Create a chat completion using any supported model. +- fim_completions: Fill-in-the-middle completions for code generation (Mistral models only).`, + inputSchema: kiloInputSchema, + execute: async (input) => { + try { + const client = new KiloClient(); + switch (input.action) { + case "list_models": + return await client.listModels(); + case "list_providers": + return await client.listProviders(); + case "chat_completions": + if (!input.model || !input.messages) { + return { error: "model and messages are required for chat_completions" }; + } + return await client.chatCompletions({ + model: input.model, + messages: input.messages, + max_tokens: input.max_tokens, + temperature: input.temperature, + }); + case "fim_completions": + if (!input.model || !input.prompt) { + return { error: "model and prompt are required for fim_completions" }; + } + return await client.fimCompletions({ + model: input.model, + prompt: input.prompt, + suffix: input.suffix, + max_tokens: input.max_tokens, + temperature: input.temperature, + }); + default: + return { error: "Unknown action" }; + } + } catch (error: any) { + return { error: error.message || String(error) }; + } + }, +}); diff --git a/packages/sandbox/vercel/sandbox.ts b/packages/sandbox/vercel/sandbox.ts index a3469286..c018ef0c 100644 --- a/packages/sandbox/vercel/sandbox.ts +++ b/packages/sandbox/vercel/sandbox.ts @@ -111,15 +111,22 @@ function buildAuthenticatedGitHubUrl( repoUrl: string, token: string, ): string | null { + // Match GitHub URL patterns to avoid REDOS vulnerability + // Matches: github.com/:owner/:repo or github.com/:owner/:repo.git const githubUrlMatch = repoUrl.match( - /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/, + /^.*?github\.com[/:]([^/]+?)\/([^/]+?)(?:\.git)?$/, ); - if (!githubUrlMatch) { return null; } const [, owner, repo] = githubUrlMatch; + + // Validate that owner and repo are non-empty + if (!owner || !repo) { + return null; + } + return `https://x-access-token:${token}@github.com/${owner}/${repo}.git`; } diff --git a/renovate.json b/renovate.json index cbc6661c..1ccd67a8 100644 --- a/renovate.json +++ b/renovate.json @@ -26,7 +26,12 @@ }, { "description": "Group React and React DOM — must stay in sync", - "matchPackageNames": ["react", "react-dom", "@types/react", "@types/react-dom"], + "matchPackageNames": [ + "react", + "react-dom", + "@types/react", + "@types/react-dom" + ], "groupName": "React", "automerge": false }, diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..6ace1a2d --- /dev/null +++ b/todo.md @@ -0,0 +1,303 @@ +--- +name: todo +created: 2026-04-26T22:50:23Z +updated: 2026-04-26T22:50:23Z +author: billlzzz26 +project: Open-Agent (AI-Powered Code Generation Platform) +description: v0-like code generation agent for GitHub repositories with AI-driven development +--- + +# Open-Agent Development Roadmap + +> Open-Agent enables AI-driven development for GitHub repositories. Users can connect their repos, have conversations with AI about their code, and generate changes via an integrated sandbox environment. This roadmap tracks implementation progress across 14 development phases. + +## Phase 1: Environment Setup & Configuration (Core Infrastructure) + +**Status:** ~80% Complete | **Priority:** Critical | **Files:** `turbo.json`, `.env`, `apps/web/`, `lib/`, `packages/` + +- [x] Setup Monorepo structure with Turbo (`turbo.json` configured) +- [x] Initialize Next.js 16 web app (`apps/web/`) +- [x] Configure BetterAuth for authentication (`lib/auth/config.ts`, `lib/auth/client.ts`) +- [x] Setup PostgreSQL database schema (`lib/db/schema.ts`, migrations) +- [x] Install core dependencies (AI SDK 6, Radix UI, Drizzle ORM, etc.) +- [ ] **CRITICAL:** Add `BETTER_AUTH_SECRET` env var (required for auth) +- [ ] **CRITICAL:** Add `POSTGRES_URL` env var (required for database) +- [ ] Verify build outputs in `turbo.json` (currently warns about missing outputs) +- [ ] Update `.env.example` with all required environment variables +- [ ] Document setup instructions for new developers + +**Blocked By:** Environment variables not configured in Vercel project + +--- + +## Phase 2: GitHub Integration & Repository Management + +**Status:** ~90% Complete | **Priority:** Critical | **Files:** `api/github/*`, `lib/github/*` + +- [x] GitHub OAuth App setup and configuration +- [x] User GitHub accounts linking via BetterAuth +- [x] Repository listing from authenticated user/orgs (`api/github/orgs`, `api/github/installations/repos`) +- [x] Repository metadata retrieval (name, description, branches) +- [x] Branch listing and selection (`api/github/branches`) +- [x] Current user info retrieval (`api/github/user`) +- [x] Organization listing (`api/github/orgs`) +- [ ] GitHub App installation status tracking (`api/github/orgs/install-status`) +- [ ] Handle multiple GitHub accounts per user +- [ ] Graceful error handling for revoked app access +- [ ] Support for private repositories access verification +- [ ] Repository webhook setup for CI/CD integration + +**In Progress:** GitHub app installation flow improvements + +--- + +## Phase 3: AI-Powered Code Generation & Chat + +**Status:** ~85% Complete | **Priority:** Critical | **Files:** `api/chat/*`, `lib/chat/*`, `components/`, `hooks/` + +- [x] Chat interface with streaming responses (`api/chat/[chatId]/stream`) +- [x] Vercel AI SDK 6 integration with multiple models support +- [x] Tool/skill execution system for code generation +- [x] Chat message persistence to database +- [x] Stream recovery and error handling (`lib/chat/create-cancelable-readable-stream.ts`) +- [x] Auto-commit functionality (`lib/chat/auto-commit-direct.ts`) +- [x] Auto-PR generation (`lib/chat/auto-pr-direct.ts`) +- [ ] Context window optimization for large files (>10KB) +- [ ] Token usage tracking and display +- [ ] Chat history and context management improvements +- [ ] Implement prompt templates for common tasks +- [ ] Add conversation export (markdown, JSON) +- [ ] Fine-tune model selection per task type + +**Files to Review:** `lib/chat/`, `api/chat/`, chat streaming implementation + +--- + +## Phase 4: Workspace & Sandbox Environment + +**Status:** ~80% Complete | **Priority:** Critical | **Files:** `api/sandbox/*`, `lib/sandbox/*`, `packages/sandbox` + +- [x] Vercel Sandbox integration for code execution +- [x] Dev server startup and management (`api/sandbox/status`) +- [x] File system navigation and code editor (`api/sessions/[sessionId]/files`) +- [x] Live preview with hot reload +- [x] Sandbox snapshot and restore capability (`api/sandbox/snapshot`) +- [x] Sandbox reconnection logic (`api/sandbox/reconnect`) +- [ ] Error recovery when sandbox crashes +- [ ] Sandbox timeout handling and extension (`api/sandbox/extend`) +- [ ] Performance optimization for large projects +- [ ] Memory usage monitoring +- [ ] Custom environment setup (env vars, dependencies) +- [ ] Support for different project types (Next.js, React, etc.) + +**Ongoing:** Stability improvements based on user feedback + +--- + +## Phase 5: Git Operations & Version Control + +**Status:** ~90% Complete | **Priority:** Critical | **Files:** `lib/git/*`, `api/sessions/[sessionId]/diff*`, `api/pr` + +- [x] Git diff viewing (`api/sessions/[sessionId]/diff/cached`) +- [x] File change tracking and visualization +- [x] Commit message generation (`api/sessions/[sessionId]/generate-commit-message`) +- [x] Commit and push operations to GitHub +- [x] PR generation and management (`api/generate-pr`, `api/check-pr`) +- [x] Git status checking (`api/git-status`) +- [ ] Merge conflict resolution UI +- [ ] Interactive branch management dashboard +- [ ] Git history visualization (recent commits, branches) +- [ ] Support for git cherry-pick and rebase +- [ ] Commit amending and force push warnings + +**Files to Review:** `lib/git/`, diff visualization logic + +--- + +## Phase 6: Workflow System & Task Automation + +**Status:** ~75% Complete | **Priority:** High | **Files:** `.well-known/workflow/*`, workflow integration + +- [x] Vercel Workflow SDK integration (basic setup) +- [x] Multi-step workflow support and execution +- [x] Webhook handling (`api/github/webhook`) +- [x] Workflow manifest generation (25 steps, 2 workflows) +- [ ] Workflow state persistence across failures +- [ ] Error handling and retry logic +- [ ] Workflow UI for monitoring and control +- [ ] Conditional branching in workflows +- [ ] Workflow scheduling (cron jobs) +- [ ] Workflow logging and debugging tools + +**Status Check:** Run `bun run build` to see workflow manifest details + +--- + +## Phase 7: Session & Chat Management + +**Status:** ~85% Complete | **Priority:** High | **Files:** `api/sessions/*`, `lib/session/*`, database schema + +- [x] Session creation and persistence (`api/sessions`) +- [x] Chat creation within sessions (`api/sessions/[sessionId]/chats/[chatId]`) +- [x] Chat history and message retrieval +- [x] Chat forking (`api/sessions/[sessionId]/chats/[chatId]/fork`) +- [x] Chat sharing and public links (`api/sessions/[sessionId]/chats/[chatId]/share`) +- [ ] Session state synchronization across tabs +- [ ] Chat export/import (Markdown, JSON) +- [ ] Session archival and cleanup +- [ ] Collaborative sessions (multiple users) +- [ ] Real-time message sync with WebSockets + +**Database Tables:** Sessions, Chats, ChatMessages (verify in schema) + +--- + +## Phase 8: User Profiles & Account Management + +**Status:** ~70% Complete | **Priority:** High | **Files:** `settings/`, `api/settings/*`, `lib/auth/*` + +- [x] User authentication via BetterAuth +- [x] GitHub account linking +- [x] Basic profile management +- [x] Settings pages (profile, preferences, accounts, connections) +- [ ] Account settings UI polish +- [ ] API key management for extensions +- [ ] Usage analytics and statistics (`api/usage`, `api/usage/rank`) +- [ ] Team/organization management +- [ ] Role-based access control (admin, user) +- [ ] Account deletion and data export + +**Settings Pages:** `settings/profile`, `settings/preferences`, `settings/connections`, `settings/admin` + +--- + +## Phase 9: Code Quality & Testing Infrastructure + +**Status:** ~40% Complete | **Priority:** Medium | **Files:** `*.test.ts`, test utilities + +- [x] Unit tests for chat streaming (`lib/chat-streaming-state.test.ts`) +- [x] Stream recovery tests (`lib/assistant-file-links.test.ts`) +- [x] Auto-commit tests (`lib/chat/auto-commit-direct.test.ts`) +- [x] Auto-PR tests (`lib/chat/auto-pr-direct.test.ts`) +- [ ] Expand test coverage to all API routes +- [ ] Add integration tests for GitHub workflows +- [ ] Add sandbox environment tests +- [ ] Performance benchmarking suite +- [ ] End-to-end tests with Playwright/Cypress +- [ ] Load testing for sandbox and chat APIs + +**Test Files to Review:** `lib/*.test.ts`, `lib/chat/*.test.ts` + +--- + +## Phase 10: Documentation & Developer Experience + +**Status:** ~20% Complete | **Priority:** Medium | **Files:** `README.md`, docs/, comments + +- [ ] Architecture documentation (monorepo, data flow, API) +- [ ] Setup guide for local development +- [ ] API documentation (endpoints, schemas) +- [ ] Skill/tool development guide +- [ ] Database schema documentation +- [ ] GitHub integration setup instructions +- [ ] Environment variables reference +- [ ] Troubleshooting guide +- [ ] TypeScript type safety improvements +- [ ] Error message improvements with suggestions + +**Current README:** Minimal setup info only + +--- + +## Phase 11: Performance & Scalability + +**Status:** ~30% Complete | **Priority:** Medium-High | **Files:** API routes, database, middleware + +- [ ] Database query optimization (add indexes) +- [ ] API response time monitoring +- [ ] Implement Redis caching layer (if needed) +- [ ] Rate limiting per user/IP (`api/middleware`) +- [ ] Query pagination for large datasets +- [ ] Database connection pooling +- [ ] CDN setup for static assets +- [ ] Image optimization +- [ ] Database migration strategy for scale +- [ ] Monitoring and alerting setup + +**Current Bottlenecks:** Large file handling, chat context window limits + +--- + +## Phase 12: Advanced Features & Integrations + +**Status:** ~40% Complete | **Priority:** Low-Medium | **Files:** Various + +- [x] GitHub integration (partial) +- [x] Vercel Workflow integration (partial) +- [ ] Vercel environment variables API integration (`api/vercel/*`) +- [ ] Vercel deployments management +- [ ] GitLab support +- [ ] Bitbucket support +- [ ] Slack/Discord notifications +- [ ] Advanced AI features (image generation, code analysis) +- [ ] CLI tool for local development +- [ ] Browser extension +- [ ] Mobile app or responsive improvements + +**In Scope:** `api/vercel/` endpoints exist but incomplete + +--- + +## Phase 13: Security & Compliance + +**Status:** ~50% Complete | **Priority:** High | **Files:** Middleware, auth, validation + +- [ ] Input validation on all API routes +- [ ] CORS configuration hardening +- [ ] CSRF token implementation +- [ ] API rate limiting and quotas +- [ ] DDoS protection +- [ ] Data encryption at rest +- [ ] Secrets rotation policy +- [ ] GDPR compliance (data export, deletion) +- [ ] Privacy policy and terms of service +- [ ] Security audit and penetration testing +- [ ] OAuth token security best practices + +**Auth System:** BetterAuth handles OAuth, need to audit other security layers + +--- + +## Phase 14: Production & Deployment + +**Status:** ~60% Complete | **Priority:** Critical | **Files:** Build config, deployment scripts + +- [x] Vercel deployment configuration +- [ ] Database migration strategy for production +- [ ] Environment variable management on Vercel +- [ ] Health checks and uptime monitoring +- [ ] Rollback procedures and automation +- [ ] Database backup strategy +- [ ] Log aggregation and analysis +- [ ] Error tracking (Sentry integration) +- [ ] Performance monitoring (Vercel Analytics) +- [ ] Deployment documentation +- [ ] Release notes automation + +**Current Status:** Builds successfully, but missing env vars block functionality + +--- + +## Critical Blockers + +1. **BETTER_AUTH_SECRET** - Required for authentication to work +2. **POSTGRES_URL** - Required for database operations +3. **Turbo output configuration** - Warning about missing build outputs + +## Quick Links + +- **API Routes:** `apps/web/app/api/` +- **Libraries:** `apps/web/lib/` +- **Database:** `apps/web/lib/db/schema.ts` +- **Auth Config:** `apps/web/lib/auth/` +- **Package Config:** `turbo.json`, `package.json` From c5d24a1b114e363a0a3aee43c84f6c39bfeeb6ba Mon Sep 17 00:00:00 2001 From: DevOps Bot Date: Mon, 27 Apr 2026 11:54:29 +0000 Subject: [PATCH 02/10] feat: update UI components and add AI elements --- .../web/components/ai-elements/code-block.tsx | 562 ++++++++++++++++++ apps/web/components/ui/button.tsx | 30 +- apps/web/components/ui/select.tsx | 56 +- apps/web/package.json | 4 +- bun.lock | 357 ++++++++++- package.json | 4 +- scripts/clean-workspace.sh | 23 + 7 files changed, 974 insertions(+), 62 deletions(-) create mode 100644 apps/web/components/ai-elements/code-block.tsx create mode 100755 scripts/clean-workspace.sh diff --git a/apps/web/components/ai-elements/code-block.tsx b/apps/web/components/ai-elements/code-block.tsx new file mode 100644 index 00000000..bc1441a4 --- /dev/null +++ b/apps/web/components/ai-elements/code-block.tsx @@ -0,0 +1,562 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import type { ComponentProps, CSSProperties, HTMLAttributes } from "react"; +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { + BundledLanguage, + BundledTheme, + HighlighterGeneric, + ThemedToken, +} from "shiki"; +import { createHighlighter } from "shiki"; + +// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline +// oxlint-disable-next-line eslint(no-bitwise) +const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1; +// oxlint-disable-next-line eslint(no-bitwise) +const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2; +const isUnderline = (fontStyle: number | undefined) => + // oxlint-disable-next-line eslint(no-bitwise) + fontStyle && fontStyle & 4; + +// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint +interface KeyedToken { + token: ThemedToken; + key: string; +} +interface KeyedLine { + tokens: KeyedToken[]; + key: string; +} + +const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] => + lines.map((line, lineIdx) => ({ + key: `line-${lineIdx}`, + tokens: line.map((token, tokenIdx) => ({ + key: `line-${lineIdx}-${tokenIdx}`, + token, + })), + })); + +// Token rendering component +const TokenSpan = ({ token }: { token: ThemedToken }) => ( + + {token.content} + +); + +// Line number styles using CSS counters +const LINE_NUMBER_CLASSES = cn( + "block", + "before:content-[counter(line)]", + "before:inline-block", + "before:[counter-increment:line]", + "before:w-8", + "before:mr-4", + "before:text-right", + "before:text-muted-foreground/50", + "before:font-mono", + "before:select-none" +); + +// Line rendering component +const LineSpan = ({ + keyedLine, + showLineNumbers, +}: { + keyedLine: KeyedLine; + showLineNumbers: boolean; +}) => ( + + {keyedLine.tokens.length === 0 + ? "\n" + : keyedLine.tokens.map(({ token, key }) => ( + + ))} + +); + +// Types +type CodeBlockProps = HTMLAttributes & { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}; + +interface TokenizedCode { + tokens: ThemedToken[][]; + fg: string; + bg: string; +} + +interface CodeBlockContextType { + code: string; +} + +// Context +const CodeBlockContext = createContext({ + code: "", +}); + +// Highlighter cache (singleton per language) +const highlighterCache = new Map< + string, + Promise> +>(); + +// Token cache +const tokensCache = new Map(); + +// Subscribers for async token updates +const subscribers = new Map void>>(); + +const getTokensCacheKey = (code: string, language: BundledLanguage) => { + const start = code.slice(0, 100); + const end = code.length > 100 ? code.slice(-100) : ""; + return `${language}:${code.length}:${start}:${end}`; +}; + +const getHighlighter = ( + language: BundledLanguage +): Promise> => { + const cached = highlighterCache.get(language); + if (cached) { + return cached; + } + + const highlighterPromise = createHighlighter({ + langs: [language], + themes: ["github-light", "github-dark"], + }); + + highlighterCache.set(language, highlighterPromise); + return highlighterPromise; +}; + +// Create raw tokens for immediate display while highlighting loads +const createRawTokens = (code: string): TokenizedCode => ({ + bg: "transparent", + fg: "inherit", + tokens: code.split("\n").map((line) => + line === "" + ? [] + : [ + { + color: "inherit", + content: line, + } as ThemedToken, + ] + ), +}); + +// Synchronous highlight with callback for async results +export const highlightCode = ( + code: string, + language: BundledLanguage, + // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks) + callback?: (result: TokenizedCode) => void +): TokenizedCode | null => { + const tokensCacheKey = getTokensCacheKey(code, language); + + // Return cached result if available + const cached = tokensCache.get(tokensCacheKey); + if (cached) { + return cached; + } + + // Subscribe callback if provided + if (callback) { + if (!subscribers.has(tokensCacheKey)) { + subscribers.set(tokensCacheKey, new Set()); + } + subscribers.get(tokensCacheKey)?.add(callback); + } + + // Start highlighting in background - fire-and-forget async pattern + getHighlighter(language) + // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then) + .then((highlighter) => { + const availableLangs = highlighter.getLoadedLanguages(); + const langToUse = availableLangs.includes(language) ? language : "text"; + + const result = highlighter.codeToTokens(code, { + lang: langToUse, + themes: { + dark: "github-dark", + light: "github-light", + }, + }); + + const tokenized: TokenizedCode = { + bg: result.bg ?? "transparent", + fg: result.fg ?? "inherit", + tokens: result.tokens, + }; + + // Cache the result + tokensCache.set(tokensCacheKey, tokenized); + + // Notify all subscribers + const subs = subscribers.get(tokensCacheKey); + if (subs) { + for (const sub of subs) { + sub(tokenized); + } + subscribers.delete(tokensCacheKey); + } + }) + // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks) + .catch((error) => { + console.error("Failed to highlight code:", error); + subscribers.delete(tokensCacheKey); + }); + + return null; +}; + +const CodeBlockBody = memo( + ({ + tokenized, + showLineNumbers, + className, + }: { + tokenized: TokenizedCode; + showLineNumbers: boolean; + className?: string; + }) => { + const preStyle = useMemo( + () => ({ + backgroundColor: tokenized.bg, + color: tokenized.fg, + }), + [tokenized.bg, tokenized.fg] + ); + + const keyedLines = useMemo( + () => addKeysToTokens(tokenized.tokens), + [tokenized.tokens] + ); + + return ( +
+        
+          {keyedLines.map((keyedLine) => (
+            
+          ))}
+        
+      
+ ); + }, + (prevProps, nextProps) => + prevProps.tokenized === nextProps.tokenized && + prevProps.showLineNumbers === nextProps.showLineNumbers && + prevProps.className === nextProps.className +); + +CodeBlockBody.displayName = "CodeBlockBody"; + +export const CodeBlockContainer = ({ + className, + language, + style, + ...props +}: HTMLAttributes & { language: string }) => ( +
+); + +export const CodeBlockHeader = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockTitle = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockFilename = ({ + children, + className, + ...props +}: HTMLAttributes) => ( + + {children} + +); + +export const CodeBlockActions = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockContent = ({ + code, + language, + showLineNumbers = false, +}: { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}) => { + // Memoized raw tokens for immediate display + const rawTokens = useMemo(() => createRawTokens(code), [code]); + + // Synchronous cache lookup — avoids setState in effect for cached results + const syncTokens = useMemo( + () => highlightCode(code, language) ?? rawTokens, + [code, language, rawTokens] + ); + + // Async highlighting result (populated after shiki loads) + const [asyncTokens, setAsyncTokens] = useState(null); + const asyncKeyRef = useRef({ code, language }); + + // Invalidate stale async tokens synchronously during render + if ( + asyncKeyRef.current.code !== code || + asyncKeyRef.current.language !== language + ) { + asyncKeyRef.current = { code, language }; + setAsyncTokens(null); + } + + useEffect(() => { + let cancelled = false; + + highlightCode(code, language, (result) => { + if (!cancelled) { + setAsyncTokens(result); + } + }); + + return () => { + cancelled = true; + }; + }, [code, language]); + + const tokenized = asyncTokens ?? syncTokens; + + return ( +
+ +
+ ); +}; + +export const CodeBlock = ({ + code, + language, + showLineNumbers = false, + className, + children, + ...props +}: CodeBlockProps) => { + const contextValue = useMemo(() => ({ code }), [code]); + + return ( + + + {children} + + + + ); +}; + +export type CodeBlockCopyButtonProps = ComponentProps & { + onCopy?: () => void; + onError?: (error: Error) => void; + timeout?: number; +}; + +export const CodeBlockCopyButton = ({ + onCopy, + onError, + timeout = 2000, + children, + className, + ...props +}: CodeBlockCopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const timeoutRef = useRef(0); + const { code } = useContext(CodeBlockContext); + + const copyToClipboard = useCallback(async () => { + if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { + onError?.(new Error("Clipboard API not available")); + return; + } + + try { + if (!isCopied) { + await navigator.clipboard.writeText(code); + setIsCopied(true); + onCopy?.(); + timeoutRef.current = window.setTimeout( + () => setIsCopied(false), + timeout + ); + } + } catch (error) { + onError?.(error as Error); + } + }, [code, onCopy, onError, timeout, isCopied]); + + useEffect( + () => () => { + window.clearTimeout(timeoutRef.current); + }, + [] + ); + + const Icon = isCopied ? CheckIcon : CopyIcon; + + return ( + + ); +}; + +export type CodeBlockLanguageSelectorProps = ComponentProps; + +export const CodeBlockLanguageSelector = ( + props: CodeBlockLanguageSelectorProps +) => ; export type CodeBlockLanguageSelectorTriggerProps = ComponentProps< @@ -527,7 +528,7 @@ export const CodeBlockLanguageSelectorTrigger = ({ ; export const CodeBlockLanguageSelectorValue = ( - props: CodeBlockLanguageSelectorValueProps + props: CodeBlockLanguageSelectorValueProps, ) => ; export type CodeBlockLanguageSelectorContentProps = ComponentProps< @@ -558,5 +559,5 @@ export type CodeBlockLanguageSelectorItemProps = ComponentProps< >; export const CodeBlockLanguageSelectorItem = ( - props: CodeBlockLanguageSelectorItemProps + props: CodeBlockLanguageSelectorItemProps, ) => ; diff --git a/package.json b/package.json index a0bbafd1..55ee6a15 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ "test:verbose": "bun test --reporter=junit --reporter-outfile /dev/stdout", "clean:workspace": "bash scripts/clean-workspace.sh" }, + "dependencies": { + "@vercel/analytics": "^2.0.1" + }, "devDependencies": { "@shelve/cli": "^5.0.1", "oxfmt": "^0.43.0", @@ -38,8 +41,5 @@ "overrides": { "zod": "^4.3.6" }, - "packageManager": "bun@1.2.14", - "dependencies": { - "@vercel/analytics": "^2.0.1" - } + "packageManager": "bun@1.2.14" } diff --git a/packages/agent/kilo/client.ts b/packages/agent/kilo/client.ts index e46a60c0..b4ef3b05 100644 --- a/packages/agent/kilo/client.ts +++ b/packages/agent/kilo/client.ts @@ -104,7 +104,9 @@ export class KiloClient { constructor(apiKey?: string) { this.apiKey = apiKey || process.env.KILO_API_KEY || ""; if (!this.apiKey) { - throw new Error("Kilo API key is required. Set KILO_API_KEY environment variable."); + throw new Error( + "Kilo API key is required. Set KILO_API_KEY environment variable.", + ); } } @@ -112,38 +114,46 @@ export class KiloClient { const response = await fetch(`${this.baseUrl}${path}`, { ...options, headers: { - "Authorization": `Bearer ${this.apiKey}`, + Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", ...options?.headers, }, }); if (!response.ok) { - let errorData: any; + let errorData: unknown; try { errorData = await response.json(); - } catch (e) { - throw new Error(`Kilo API Error ${response.status}: ${response.statusText}`); + } catch { + throw new Error( + `Kilo API Error ${response.status}: ${response.statusText}`, + { cause: e }, + ); } throw new Error( `Kilo API Error ${response.status}: ${ errorData?.error?.message || response.statusText - }` + }`, ); } return response.json() as Promise; } - async chatCompletions(body: ChatCompletionRequest): Promise { - return this.request("/api/gateway/chat/completions", { - method: "POST", - body: JSON.stringify(body), - }); + async chatCompletions( + body: ChatCompletionRequest, + ): Promise { + return this.request( + "/api/gateway/chat/completions", + { + method: "POST", + body: JSON.stringify(body), + }, + ); } - async fimCompletions(body: FIMRequest): Promise { - return this.request("/api/fim/completions", { + async fimCompletions(body: FIMRequest): Promise { + return this.request("/api/fim/completions", { method: "POST", body: JSON.stringify(body), }); @@ -153,7 +163,7 @@ export class KiloClient { return this.request<{ data: Model[] }>("/api/gateway/models"); } - async listProviders(): Promise { - return this.request("/api/gateway/providers"); + async listProviders(): Promise { + return this.request("/api/gateway/providers"); } } diff --git a/packages/agent/tools/delegate.ts b/packages/agent/tools/delegate.ts index bdd8abf4..af81f402 100644 --- a/packages/agent/tools/delegate.ts +++ b/packages/agent/tools/delegate.ts @@ -20,7 +20,9 @@ const delegateTaskSchema = z.object({ subagentType: subagentTypeSchema.describe( `Subagent to launch. Available options:\n${subagentSummaryLines}`, ), - task: z.string().describe("Short description of the task (displayed to user)"), + task: z + .string() + .describe("Short description of the task (displayed to user)"), instructions: z.string().describe( `Detailed instructions for the subagent. Include: - Goal and deliverables @@ -38,7 +40,7 @@ export const delegateTaskOutputSchema = z.object({ toolCallCount: z.number(), final: z.custom().optional(), usage: z.custom().optional(), - }) + }), ), totalToolCallCount: z.number().int().nonnegative().optional(), startedAt: z.number().int().nonnegative().optional(), @@ -68,16 +70,18 @@ HOW TO USE: - Choose the appropriate subagentType for each task. - Be explicit and concrete - subagents cannot ask clarifying questions.`, inputSchema: z.object({ - tasks: z.array(delegateTaskSchema).min(1).max(5).describe( - "Tasks to run in parallel. Each gets its own subagent." - ), + tasks: z + .array(delegateTaskSchema) + .min(1) + .max(5) + .describe("Tasks to run in parallel. Each gets its own subagent."), }), outputSchema: delegateTaskOutputSchema, - execute: async function* ( - { tasks }, - { experimental_context, abortSignal }, - ) { - const sandboxContext = getSandboxContext(experimental_context, "delegateTask"); + execute: async function* ({ tasks }, { experimental_context, abortSignal }) { + const sandboxContext = getSandboxContext( + experimental_context, + "delegateTask", + ); const model = getSubagentModel(experimental_context, "delegateTask"); const subagentModelId = typeof model === "string" ? model : model.modelId; const startedAt = Date.now(); @@ -97,12 +101,13 @@ HOW TO USE: startedAt, }; - const runTask = async (taskInput: typeof tasks[0], index: number) => { + const runTask = async (taskInput: (typeof tasks)[0], index: number) => { const state = taskStates[index]; if (!state) return; const subagent = SUBAGENT_REGISTRY[taskInput.subagentType].agent; const result = await subagent.stream({ - prompt: "Complete this task and provide a summary of what you accomplished.", + prompt: + "Complete this task and provide a summary of what you accomplished.", options: { task: taskInput.task, instructions: taskInput.instructions, @@ -133,7 +138,10 @@ HOW TO USE: const promises = tasks.map((t, i) => runTask(t, i)); await Promise.all(promises); - const totalToolCallCount = taskStates.reduce((acc, t) => acc + t.toolCallCount, 0); + const totalToolCallCount = taskStates.reduce( + (acc, t) => acc + t.toolCallCount, + 0, + ); yield { results: taskStates, @@ -151,7 +159,9 @@ HOW TO USE: let contentValue = "Task completed without detailed summary."; if (messages) { - const lastAssistantMessage = messages.findLast((p) => p.role === "assistant"); + const lastAssistantMessage = messages.findLast( + (p) => p.role === "assistant", + ); const content = lastAssistantMessage?.content; if (content) { diff --git a/packages/agent/tools/github.ts b/packages/agent/tools/github.ts index 18fab21c..34c4018e 100644 --- a/packages/agent/tools/github.ts +++ b/packages/agent/tools/github.ts @@ -2,7 +2,8 @@ import { tool } from "ai"; import { z } from "zod"; import { getSandbox, shellEscape } from "./utils"; -const TIMEOUT_MS = 60_000; +// หมดเวลาในการดำเนินการคำสั่ง git และ gh +export const TIMEOUT_MS = 60_000; export const commitAndPrTool = tool({ description: `Commit all current changes, push to a new branch, and create a Pull Request using the GitHub CLI. @@ -18,15 +19,21 @@ USAGE: - Pushes the branch to origin - Uses the 'gh' CLI to create the PR`, inputSchema: z.object({ - needsApproval: true, - branchName: z.string().describe("The name of the new branch to create and push"), + needsApproval: z.literal(true), + branchName: z + .string() + .describe("The name of the new branch to create and push"), commitMessage: z.string().describe("The commit message"), prTitle: z.string().describe("The title of the pull request"), - prBody: z.string().optional().describe("The body/description of the pull request"), + prBody: z + .string() + .optional() + .describe("The body/description of the pull request"), }), + needsApproval: () => true, execute: async ( { branchName, commitMessage, prTitle, prBody }, - { experimental_context, abortSignal } + { experimental_context, abortSignal }, ) => { const sandbox = await getSandbox(experimental_context, "commitAndPr"); const cwd = sandbox.workingDirectory; @@ -37,26 +44,42 @@ USAGE: `git checkout -b ${shellEscape(branchName)}`, cwd, TIMEOUT_MS, - { signal: abortSignal } + { signal: abortSignal }, ); - if (!branchRes.success && !branchRes.stderr.includes("already exists") && !branchRes.stderr.includes("already a branch")) { - return { success: false, error: `Failed to create branch: ${branchRes.stderr}` }; + if ( + !branchRes.success && + !branchRes.stderr.includes("already exists") && + !branchRes.stderr.includes("already a branch") + ) { + return { + success: false, + error: `Failed to create branch: ${branchRes.stderr}`, + }; } // 2. Stage all changes - await sandbox.exec("git add -A", cwd, TIMEOUT_MS, { signal: abortSignal }); + await sandbox.exec("git add -A", cwd, TIMEOUT_MS, { + signal: abortSignal, + }); // 3. Commit changes const commitRes = await sandbox.exec( `git commit -m ${shellEscape(commitMessage)}`, cwd, TIMEOUT_MS, - { signal: abortSignal } + { signal: abortSignal }, ); - if (!commitRes.success && !commitRes.stdout.includes("nothing to commit") && !commitRes.stderr.includes("nothing to commit")) { - return { success: false, error: `Failed to commit: ${commitRes.stdout} ${commitRes.stderr}` }; + if ( + !commitRes.success && + !commitRes.stdout.includes("nothing to commit") && + !commitRes.stderr.includes("nothing to commit") + ) { + return { + success: false, + error: `Failed to commit: ${commitRes.stdout} ${commitRes.stderr}`, + }; } // 4. Push branch @@ -64,11 +87,14 @@ USAGE: `git push -u origin ${shellEscape(branchName)}`, cwd, TIMEOUT_MS, - { signal: abortSignal } + { signal: abortSignal }, ); if (!pushRes.success) { - return { success: false, error: `Failed to push: ${pushRes.stdout} ${pushRes.stderr}` }; + return { + success: false, + error: `Failed to push: ${pushRes.stdout} ${pushRes.stderr}`, + }; } // 5. Create PR via gh cli @@ -77,25 +103,28 @@ USAGE: `gh pr create --title ${shellEscape(prTitle)} ${bodyArg}`, cwd, TIMEOUT_MS, - { signal: abortSignal } + { signal: abortSignal }, ); if (prRes.success) { return { success: true, message: "Successfully committed, pushed, and created PR.", - prUrl: prRes.stdout.trim() + prUrl: prRes.stdout.trim(), }; } else { return { success: true, message: `Successfully committed and pushed to branch ${branchName}. Note: Could not create PR automatically via gh CLI. Please create it manually.`, - errorDetails: prRes.stderr + errorDetails: prRes.stderr, }; } } catch (error) { const message = error instanceof Error ? error.message : String(error); - return { success: false, error: `Error during commit and PR process: ${message}` }; + return { + success: false, + error: `Error during commit and PR process: ${message}`, + }; } }, }); diff --git a/packages/agent/tools/kilo.test.ts b/packages/agent/tools/kilo.test.ts index a204062d..ddb1889f 100644 --- a/packages/agent/tools/kilo.test.ts +++ b/packages/agent/tools/kilo.test.ts @@ -1,197 +1,214 @@ -import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'; +import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; // Mock node-fetch -const fetchMock = mock(async (url: string, options: RequestInit) => { - return new Response(JSON.stringify({ jsonrpc: '2.0', result: { success: true }, id: 1 }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); +const fetchMock = mock(async (_url: string, _options: RequestInit) => { + return new Response( + JSON.stringify({ jsonrpc: "2.0", result: { success: true }, id: 1 }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); }); -mock.module('node-fetch', () => ({ - __esModule: true, - default: fetchMock, +mock.module("node-fetch", () => ({ + __esModule: true, + default: fetchMock, })); // Mock process.env const originalEnv = process.env; beforeEach(() => { - process.env = { ...originalEnv }; + process.env = { ...originalEnv }; }); afterEach(() => { - process.env = originalEnv; + process.env = originalEnv; }); // Now import the class to be tested -const { Context7Client } = await import('./kilo'); +const { Context7Client } = await import("./kilo"); + +describe("Context7Client", () => { + beforeEach(() => { + fetchMock.mockClear(); + }); + + describe("constructor", () => { + test("should use provided API key", () => { + const client = new Context7Client("test-api-key"); + expect(client).toBeDefined(); + }); + + test("should use environment variable if no key provided", () => { + process.env.CONTEXT7_API_KEY = "env-api-key"; + const client = new Context7Client(); + expect(client).toBeDefined(); + }); + + test("should throw error if no API key available", () => { + delete process.env.CONTEXT7_API_KEY; + expect(() => new Context7Client()).toThrow( + "Context7 API key is required. Provide it as parameter or set CONTEXT7_API_KEY environment variable.", + ); + }); + }); + + describe("call method", () => { + let client: Context7Client; -describe('Context7Client', () => { beforeEach(() => { - fetchMock.mockClear(); + process.env.CONTEXT7_API_KEY = "test-api-key"; + client = new Context7Client(); + }); + + test("should make POST request with correct headers and payload", async () => { + await client.call("test.method", { param: "value" }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0]; + + expect(url).toBe("https://mcp.context7.com/mcp"); + expect(options?.method).toBe("POST"); + expect(options?.headers).toEqual({ + CONTEXT7_API_KEY: "test-api-key", + "Content-Type": "application/json", + }); + + const body = JSON.parse(options?.body as string); + expect(body).toEqual({ + jsonrpc: "2.0", + method: "test.method", + id: 1, + params: { param: "value" }, + }); + }); + + test("should increment request ID for multiple calls", async () => { + await client.call("method1"); + await client.call("method2"); + await client.call("method3"); + + expect(fetchMock).toHaveBeenCalledTimes(3); + + const bodies = fetchMock.mock.calls.map(([_, options]) => + JSON.parse((options as RequestInit)?.body as string), + ); + + expect(bodies[0].id).toBe(1); + expect(bodies[1].id).toBe(2); + expect(bodies[2].id).toBe(3); + }); + + test("should handle empty params", async () => { + await client.call("test.method"); + + const [_, options] = fetchMock.mock.calls[0]; + const body = JSON.parse((options as RequestInit)?.body as string); + expect(body.params).toEqual({}); }); - describe('constructor', () => { - test('should use provided API key', () => { - const client = new Context7Client('test-api-key'); - expect(client).toBeDefined(); - }); - - test('should use environment variable if no key provided', () => { - process.env.CONTEXT7_API_KEY = 'env-api-key'; - const client = new Context7Client(); - expect(client).toBeDefined(); - }); - - test('should throw error if no API key available', () => { - delete process.env.CONTEXT7_API_KEY; - expect(() => new Context7Client()).toThrow('Context7 API key is required. Provide it as parameter or set CONTEXT7_API_KEY environment variable.'); - }); + test("should return parsed JSON response", async () => { + const mockResponse = { + jsonrpc: "2.0", + result: { tools: ["tool1", "tool2"] }, + id: 1, + }; + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(mockResponse)), + ); + + const result = await client.call("tools/list"); + expect(result).toEqual(mockResponse); }); - describe('call method', () => { - let client: Context7Client; - - beforeEach(() => { - process.env.CONTEXT7_API_KEY = 'test-api-key'; - client = new Context7Client(); - }); - - test('should make POST request with correct headers and payload', async () => { - await client.call('test.method', { param: 'value' }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, options] = fetchMock.mock.calls[0]; - - expect(url).toBe('https://mcp.context7.com/mcp'); - expect(options?.method).toBe('POST'); - expect(options?.headers).toEqual({ - 'CONTEXT7_API_KEY': 'test-api-key', - 'Content-Type': 'application/json', - }); - - const body = JSON.parse(options?.body as string); - expect(body).toEqual({ - jsonrpc: '2.0', - method: 'test.method', - id: 1, - params: { param: 'value' }, - }); - }); - - test('should increment request ID for multiple calls', async () => { - await client.call('method1'); - await client.call('method2'); - await client.call('method3'); - - expect(fetchMock).toHaveBeenCalledTimes(3); - - const bodies = fetchMock.mock.calls.map(([_, options]) => - JSON.parse((options as RequestInit)?.body as string) - ); - - expect(bodies[0].id).toBe(1); - expect(bodies[1].id).toBe(2); - expect(bodies[2].id).toBe(3); - }); - - test('should handle empty params', async () => { - await client.call('test.method'); - - const [_, options] = fetchMock.mock.calls[0]; - const body = JSON.parse((options as RequestInit)?.body as string); - expect(body.params).toEqual({}); - }); - - test('should return parsed JSON response', async () => { - const mockResponse = { - jsonrpc: '2.0', - result: { tools: ['tool1', 'tool2'] }, - id: 1 - }; - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockResponse))); - - const result = await client.call('tools/list'); - expect(result).toEqual(mockResponse); - }); - - test('should handle error responses', async () => { - const errorResponse = { - jsonrpc: '2.0', - error: { code: -32601, message: 'Method not found' }, - id: 1 - }; - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(errorResponse))); - - const result = await client.call('invalid.method'); - expect(result).toEqual(errorResponse); - }); - - test('should throw on HTTP errors', async () => { - fetchMock.mockResolvedValueOnce(new Response('Forbidden', { status: 403 })); - - await expect(client.call('tools/list')).rejects.toThrow( - 'Context7 API call failed with status 403: Forbidden' - ); - }); - - test('should handle network errors', async () => { - fetchMock.mockRejectedValueOnce(new Error('Network timeout')); - - await expect(client.call('tools/list')).rejects.toThrow('Network timeout'); - }); - - test('should handle malformed JSON response', async () => { - fetchMock.mockResolvedValueOnce(new Response('invalid json', { - status: 200, - headers: { 'Content-Type': 'application/json' } - })); - - await expect(client.call('tools/list')).rejects.toThrow(); - }); + test("should handle error responses", async () => { + const errorResponse = { + jsonrpc: "2.0", + error: { code: -32601, message: "Method not found" }, + id: 1, + }; + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(errorResponse)), + ); + + const result = await client.call("invalid.method"); + expect(result).toEqual(errorResponse); }); - describe('integration scenarios', () => { - let client: Context7Client; - - beforeEach(() => { - process.env.CONTEXT7_API_KEY = 'test-api-key'; - client = new Context7Client(); - }); - - test('should handle tools/list call', async () => { - const mockResponse = { - jsonrpc: '2.0', - result: { - tools: [ - { name: 'query-docs', description: 'Query documentation' }, - { name: 'resolve-library-id', description: 'Resolve library ID' } - ] - }, - id: 1 - }; - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockResponse))); - - const result = await client.call('tools/list'); - expect(result.result.tools).toHaveLength(2); - expect(result.result.tools[0].name).toBe('query-docs'); - }); - - test('should handle query-docs call with complex params', async () => { - const params = { - libraryId: '/reactjs/react.dev', - query: 'how to use useState hook' - }; - - await client.call('tools/call', { - name: 'query-docs', - arguments: params - }); - - const [_, options] = fetchMock.mock.calls[0]; - const body = JSON.parse((options as RequestInit)?.body as string); - - expect(body.params.name).toBe('query-docs'); - expect(body.params.arguments.libraryId).toBe('/reactjs/react.dev'); - }); + test("should throw on HTTP errors", async () => { + fetchMock.mockResolvedValueOnce( + new Response("Forbidden", { status: 403 }), + ); + + await expect(client.call("tools/list")).rejects.toThrow( + "Context7 API call failed with status 403: Forbidden", + ); + }); + + test("should handle network errors", async () => { + fetchMock.mockRejectedValueOnce(new Error("Network timeout")); + + await expect(client.call("tools/list")).rejects.toThrow( + "Network timeout", + ); + }); + + test("should handle malformed JSON response", async () => { + fetchMock.mockResolvedValueOnce( + new Response("invalid json", { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(client.call("tools/list")).rejects.toThrow(); + }); + }); + + describe("integration scenarios", () => { + let client: Context7Client; + + beforeEach(() => { + process.env.CONTEXT7_API_KEY = "test-api-key"; + client = new Context7Client(); + }); + + test("should handle tools/list call", async () => { + const mockResponse = { + jsonrpc: "2.0", + result: { + tools: [ + { name: "query-docs", description: "Query documentation" }, + { name: "resolve-library-id", description: "Resolve library ID" }, + ], + }, + id: 1, + }; + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(mockResponse)), + ); + + const result = await client.call("tools/list"); + expect(result.result.tools).toHaveLength(2); + expect(result.result.tools[0].name).toBe("query-docs"); + }); + + test("should handle query-docs call with complex params", async () => { + const params = { + libraryId: "/reactjs/react.dev", + query: "how to use useState hook", + }; + + await client.call("tools/call", { + name: "query-docs", + arguments: params, + }); + + const [_, options] = fetchMock.mock.calls[0]; + const body = JSON.parse((options as RequestInit)?.body as string); + + expect(body.params.name).toBe("query-docs"); + expect(body.params.arguments.libraryId).toBe("/reactjs/react.dev"); }); + }); }); diff --git a/packages/agent/tools/kilo.ts b/packages/agent/tools/kilo.ts index 42838f38..36a73857 100644 --- a/packages/agent/tools/kilo.ts +++ b/packages/agent/tools/kilo.ts @@ -1,43 +1,50 @@ -import fetch from 'node-fetch'; +import fetch from "node-fetch"; class Context7Client { - private url: string = 'https://mcp.context7.com/mcp'; - private headers: Record; - private id: number = 1; - - constructor(apiKey?: string) { - const key = apiKey || process.env.CONTEXT7_API_KEY; - if (!key) { - throw new Error('Context7 API key is required. Provide it as parameter or set CONTEXT7_API_KEY environment variable.'); - } - this.headers = { - 'CONTEXT7_API_KEY': key, - 'Content-Type': 'application/json' - }; + private url: string = "https://mcp.context7.com/mcp"; + private headers: Record; + private id: number = 1; + + constructor(apiKey?: string) { + const key = apiKey || process.env.CONTEXT7_API_KEY; + if (!key) { + throw new Error( + "Context7 API key is required. Provide it as parameter or set CONTEXT7_API_KEY environment variable.", + ); } - - async call(method: string, params: Record = {}): Promise { - const payload = { - jsonrpc: '2.0', - method, - id: this.id, - params - }; - this.id++; - - const response = await fetch(this.url, { - method: 'POST', - headers: this.headers, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Context7 API call failed with status ${response.status}: ${errorText}`); - } - - return await response.json(); + this.headers = { + CONTEXT7_API_KEY: key, + "Content-Type": "application/json", + }; + } + + async call( + method: string, + params: Record = {}, + ): Promise { + const payload = { + jsonrpc: "2.0", + method, + id: this.id, + params, + }; + this.id++; + + const response = await fetch(this.url, { + method: "POST", + headers: this.headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Context7 API call failed with status ${response.status}: ${errorText}`, + ); } + + return await response.json(); + } } // Usage example diff --git a/packages/agent/tools/lsp.test.ts b/packages/agent/tools/lsp.test.ts index 9a3de129..afb4c302 100644 --- a/packages/agent/tools/lsp.test.ts +++ b/packages/agent/tools/lsp.test.ts @@ -1,346 +1,447 @@ -import { describe, test, expect, mock, beforeEach } from 'bun:test'; +import { describe, test, expect, mock, beforeEach } from "bun:test"; // Mock LSP dependencies const mockLspClientManager = { - runWithClientLease: mock(async (filePath: string, fn: any) => { - const mockClient = { - hover: mock(async () => ({ - contents: [{ kind: 'markdown', value: 'Test hover content' }], - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } } - })), - definition: mock(async () => ([{ - uri: 'file:///test/file.ts', - range: { start: { line: 5, character: 0 }, end: { line: 5, character: 15 } } - }])), - references: mock(async () => ([{ - uri: 'file:///test/file.ts', - range: { start: { line: 10, character: 0 }, end: { line: 10, character: 10 } } - }])), - documentSymbols: mock(async () => ([{ - name: 'testFunction', - kind: 12, // Function - range: { start: { line: 5, character: 0 }, end: { line: 5, character: 20 } }, - selectionRange: { start: { line: 5, character: 9 }, end: { line: 5, character: 21 } } - }])), - workspaceSymbols: mock(async () => ([{ - name: 'TestClass', - kind: 5, // Class - location: { - uri: 'file:///test/class.ts', - range: { start: { line: 1, character: 0 }, end: { line: 1, character: 10 } } - } - }])), - openDocument: mock(async () => {}), - waitForDiagnostics: mock(async () => {}), - getDiagnostics: mock(() => ([{ - range: { start: { line: 1, character: 5 }, end: { line: 1, character: 10 } }, - severity: 1, - message: 'Test error', - source: 'test' - }])), - prepareRename: mock(async () => ({ + runWithClientLease: mock(async (filePath: string, fn: unknown) => { + const mockClient = { + hover: mock(async () => ({ + contents: [{ kind: "markdown", value: "Test hover content" }], + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + })), + definition: mock(async () => [ + { + uri: "file:///test/file.ts", + range: { + start: { line: 5, character: 0 }, + end: { line: 5, character: 15 }, + }, + }, + ]), + references: mock(async () => [ + { + uri: "file:///test/file.ts", + range: { + start: { line: 10, character: 0 }, + end: { line: 10, character: 10 }, + }, + }, + ]), + documentSymbols: mock(async () => [ + { + name: "testFunction", + kind: 12, // Function + range: { + start: { line: 5, character: 0 }, + end: { line: 5, character: 20 }, + }, + selectionRange: { + start: { line: 5, character: 9 }, + end: { line: 5, character: 21 }, + }, + }, + ]), + workspaceSymbols: mock(async () => [ + { + name: "TestClass", + kind: 5, // Class + location: { + uri: "file:///test/class.ts", + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 10 }, + }, + }, + }, + ]), + openDocument: mock(async () => {}), + waitForDiagnostics: mock(async () => {}), + getDiagnostics: mock(() => [ + { + range: { + start: { line: 1, character: 5 }, + end: { line: 1, character: 10 }, + }, + severity: 1, + message: "Test error", + source: "test", + }, + ]), + prepareRename: mock(async () => ({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 5 }, + })), + rename: mock(async () => ({ + changes: { + "file:///test/file.ts": [ + { + range: { start: { line: 1, character: 0 }, - end: { line: 1, character: 5 } - })), - rename: mock(async () => ({ - changes: { - 'file:///test/file.ts': [{ - range: { start: { line: 1, character: 0 }, end: { line: 1, character: 5 } }, - newText: 'newName' - }] - } - })), - codeActions: mock(async () => ([{ - title: 'Extract function', - kind: 'refactor.extract', - edit: { - changes: { - 'file:///test/file.ts': [{ - range: { start: { line: 2, character: 0 }, end: { line: 4, character: 0 } }, - newText: 'extractedFunction() {\n // code\n}' - }] - } - } - }])), - supportsPullDiagnostics: false - }; - return await fn(mockClient); - }) + end: { line: 1, character: 5 }, + }, + newText: "newName", + }, + ], + }, + })), + codeActions: mock(async () => [ + { + title: "Extract function", + kind: "refactor.extract", + edit: { + changes: { + "file:///test/file.ts": [ + { + range: { + start: { line: 2, character: 0 }, + end: { line: 4, character: 0 }, + }, + newText: "extractedFunction() {\n // code\n}", + }, + ], + }, + }, + }, + ]), + supportsPullDiagnostics: false, + }; + return await fn(mockClient); + }), }; -const mockGetAllServers = mock(() => ([ - { name: 'typescript', command: 'tsserver', extensions: ['.ts', '.tsx'], installed: true }, - { name: 'python', command: 'pylsp', extensions: ['.py'], installed: false, installHint: 'pip install python-lsp-server' } -])); +const mockGetAllServers = mock(() => [ + { + name: "typescript", + command: "tsserver", + extensions: [".ts", ".tsx"], + installed: true, + }, + { + name: "python", + command: "pylsp", + extensions: [".py"], + installed: false, + installHint: "pip install python-lsp-server", + }, +]); const mockGetServerForFile = mock((filePath: string) => { - if (filePath.endsWith('.ts')) { - return { name: 'typescript', command: 'tsserver', extensions: ['.ts', '.tsx'], installed: true }; - } - return null; + if (filePath.endsWith(".ts")) { + return { + name: "typescript", + command: "tsserver", + extensions: [".ts", ".tsx"], + installed: true, + }; + } + return null; }); -const mockFormatHover = mock((hover: any) => 'Formatted hover: Test hover content'); -const mockFormatLocations = mock((locations: any) => 'Found 1 location(s)'); -const mockFormatDocumentSymbols = mock((symbols: any) => '1 symbol(s) found'); -const mockFormatWorkspaceSymbols = mock((symbols: any) => 'Found 1 symbol(s)'); -const mockFormatDiagnostics = mock((diagnostics: any) => 'Found 1 diagnostic(s)'); -const mockFormatCodeActions = mock((actions: any) => 'Found 1 code action(s)'); -const mockFormatWorkspaceEdit = mock((edit: any) => 'Workspace edit applied'); +const mockFormatHover = mock( + (_hover: unknown) => "Formatted hover: Test hover content", +); +const mockFormatLocations = mock( + (_locations: unknown) => "Found 1 location(s)", +); +const mockFormatDocumentSymbols = mock( + (_symbols: unknown) => "1 symbol(s) found", +); +const mockFormatWorkspaceSymbols = mock( + (_symbols: unknown) => "Found 1 symbol(s)", +); +const mockFormatDiagnostics = mock( + (_diagnostics: unknown) => "Found 1 diagnostic(s)", +); +const mockFormatCodeActions = mock( + (_actions: unknown) => "Found 1 code action(s)", +); +const mockFormatWorkspaceEdit = mock( + (_edit: unknown) => "Workspace edit applied", +); const mockCountEdits = mock(() => ({ files: 1, edits: 1 })); -mock.module('./lsp/index.js', () => ({ - lspClientManager: mockLspClientManager, - getAllServers: mockGetAllServers, - getServerForFile: mockGetServerForFile, - formatHover: mockFormatHover, - formatLocations: mockFormatLocations, - formatDocumentSymbols: mockFormatDocumentSymbols, - formatWorkspaceSymbols: mockFormatWorkspaceSymbols, - formatDiagnostics: mockFormatDiagnostics, - formatCodeActions: mockFormatCodeActions, - formatWorkspaceEdit: mockFormatWorkspaceEdit, - countEdits: mockCountEdits +mock.module("./lsp/index.js", () => ({ + lspClientManager: mockLspClientManager, + getAllServers: mockGetAllServers, + getServerForFile: mockGetServerForFile, + formatHover: mockFormatHover, + formatLocations: mockFormatLocations, + formatDocumentSymbols: mockFormatDocumentSymbols, + formatWorkspaceSymbols: mockFormatWorkspaceSymbols, + formatDiagnostics: mockFormatDiagnostics, + formatCodeActions: mockFormatCodeActions, + formatWorkspaceEdit: mockFormatWorkspaceEdit, + countEdits: mockCountEdits, })); -mock.module('./diagnostics/index.js', () => ({ - runDirectoryDiagnostics: mock(async () => ({ - strategy: 'tsc', - summary: '2 errors, 1 warning', - diagnostics: 'Error: test error\nWarning: test warning' - })) +mock.module("./diagnostics/index.js", () => ({ + runDirectoryDiagnostics: mock(async () => ({ + strategy: "tsc", + summary: "2 errors, 1 warning", + diagnostics: "Error: test error\nWarning: test warning", + })), })); // Import after mocking -const { lspTools } = await import('./lsp'); - -describe('LSP Tools', () => { - beforeEach(() => { - // Reset all mocks - mockLspClientManager.runWithClientLease.mockClear(); - mockGetAllServers.mockClear(); - mockGetServerForFile.mockClear(); - Object.values({ - mockFormatHover, mockFormatLocations, mockFormatDocumentSymbols, - mockFormatWorkspaceSymbols, mockFormatDiagnostics, mockFormatCodeActions, - mockFormatWorkspaceEdit, mockCountEdits - }).forEach(mock => mock.mockClear()); +const { lspTools } = await import("./lsp"); + +describe("LSP Tools", () => { + beforeEach(() => { + // Reset all mocks + mockLspClientManager.runWithClientLease.mockClear(); + mockGetAllServers.mockClear(); + mockGetServerForFile.mockClear(); + Object.values({ + mockFormatHover, + mockFormatLocations, + mockFormatDocumentSymbols, + mockFormatWorkspaceSymbols, + mockFormatDiagnostics, + mockFormatCodeActions, + mockFormatWorkspaceEdit, + mockCountEdits, + }).forEach((mock) => mock.mockClear()); + }); + + test("should export lspTools array with expected tools", () => { + expect(Array.isArray(lspTools)).toBe(true); + expect(lspTools.length).toBe(12); // All LSP tools + + const toolNames = lspTools.map((t) => t.name); + expect(toolNames).toEqual([ + "lsp_hover", + "lsp_goto_definition", + "lsp_find_references", + "lsp_document_symbols", + "lsp_workspace_symbols", + "lsp_diagnostics", + "lsp_diagnostics_directory", + "lsp_servers", + "lsp_prepare_rename", + "lsp_rename", + "lsp_code_actions", + "lsp_code_action_resolve", + ]); + }); + + test("each tool should have required properties with correct types", () => { + for (const tool of lspTools) { + expect(tool).toHaveProperty("name"); + expect(tool).toHaveProperty("description"); + expect(tool).toHaveProperty("schema"); + expect(tool).toHaveProperty("handler"); + + expect(typeof tool.name).toBe("string"); + expect(typeof tool.description).toBe("string"); + expect(typeof tool.handler).toBe("function"); + + // Schema should be an object with properties + expect(typeof tool.schema).toBe("object"); + } + }); + + describe("lsp_hover tool", () => { + test("should call LSP hover and format result", async () => { + const result = await lspTools[0].handler({ + file: "test.ts", + line: 1, + character: 5, + }); + + expect(mockLspClientManager.runWithClientLease).toHaveBeenCalledWith( + "test.ts", + expect.any(Function), + ); + expect(mockFormatHover).toHaveBeenCalled(); + expect(result.content[0].text).toBe( + "Formatted hover: Test hover content", + ); }); - - test('should export lspTools array with expected tools', () => { - expect(Array.isArray(lspTools)).toBe(true); - expect(lspTools.length).toBe(12); // All LSP tools - - const toolNames = lspTools.map(t => t.name); - expect(toolNames).toEqual([ - 'lsp_hover', - 'lsp_goto_definition', - 'lsp_find_references', - 'lsp_document_symbols', - 'lsp_workspace_symbols', - 'lsp_diagnostics', - 'lsp_diagnostics_directory', - 'lsp_servers', - 'lsp_prepare_rename', - 'lsp_rename', - 'lsp_code_actions', - 'lsp_code_action_resolve' - ]); + }); + + describe("lsp_goto_definition tool", () => { + test("should find definition locations", async () => { + const result = await lspTools[1].handler({ + file: "test.ts", + line: 1, + character: 5, + }); + + expect(mockFormatLocations).toHaveBeenCalledWith([ + { + uri: "file:///test/file.ts", + range: { + start: { line: 5, character: 0 }, + end: { line: 5, character: 15 }, + }, + }, + ]); + expect(result.content[0].text).toBe("Found 1 location(s)"); }); - - test('each tool should have required properties with correct types', () => { - for (const tool of lspTools) { - expect(tool).toHaveProperty('name'); - expect(tool).toHaveProperty('description'); - expect(tool).toHaveProperty('schema'); - expect(tool).toHaveProperty('handler'); - - expect(typeof tool.name).toBe('string'); - expect(typeof tool.description).toBe('string'); - expect(typeof tool.handler).toBe('function'); - - // Schema should be an object with properties - expect(typeof tool.schema).toBe('object'); - } + }); + + describe("lsp_find_references tool", () => { + test("should find references with includeDeclaration true", async () => { + const result = await lspTools[2].handler({ + file: "test.ts", + line: 1, + character: 5, + includeDeclaration: true, + }); + + expect(result.content[0].text).toBe( + "Found 1 reference(s):\n\nFound 1 location(s)", + ); }); - describe('lsp_hover tool', () => { - test('should call LSP hover and format result', async () => { - const result = await lspTools[0].handler({ - file: 'test.ts', - line: 1, - character: 5 - }); - - expect(mockLspClientManager.runWithClientLease).toHaveBeenCalledWith( - 'test.ts', - expect.any(Function) - ); - expect(mockFormatHover).toHaveBeenCalled(); - expect(result.content[0].text).toBe('Formatted hover: Test hover content'); - }); + test("should handle no references found", async () => { + mockLspClientManager.runWithClientLease.mockImplementationOnce( + async (filePath, fn) => { + const mockClient = { + references: mock(async () => []), + }; + return await fn(mockClient); + }, + ); + + const result = await lspTools[2].handler({ + file: "test.ts", + line: 1, + character: 5, + }); + + expect(result.content[0].text).toBe("No references found"); }); + }); - describe('lsp_goto_definition tool', () => { - test('should find definition locations', async () => { - const result = await lspTools[1].handler({ - file: 'test.ts', - line: 1, - character: 5 - }); - - expect(mockFormatLocations).toHaveBeenCalledWith([{ - uri: 'file:///test/file.ts', - range: { start: { line: 5, character: 0 }, end: { line: 5, character: 15 } } - }]); - expect(result.content[0].text).toBe('Found 1 location(s)'); - }); - }); + describe("lsp_document_symbols tool", () => { + test("should get document symbols", async () => { + const result = await lspTools[3].handler({ + file: "test.ts", + }); - describe('lsp_find_references tool', () => { - test('should find references with includeDeclaration true', async () => { - const result = await lspTools[2].handler({ - file: 'test.ts', - line: 1, - character: 5, - includeDeclaration: true - }); - - expect(result.content[0].text).toBe('Found 1 reference(s):\n\nFound 1 location(s)'); - }); - - test('should handle no references found', async () => { - mockLspClientManager.runWithClientLease.mockImplementationOnce(async (filePath, fn) => { - const mockClient = { - references: mock(async () => []) - }; - return await fn(mockClient); - }); - - const result = await lspTools[2].handler({ - file: 'test.ts', - line: 1, - character: 5 - }); - - expect(result.content[0].text).toBe('No references found'); - }); + expect(mockFormatDocumentSymbols).toHaveBeenCalled(); + expect(result.content[0].text).toBe("1 symbol(s) found"); }); - - describe('lsp_document_symbols tool', () => { - test('should get document symbols', async () => { - const result = await lspTools[3].handler({ - file: 'test.ts' - }); - - expect(mockFormatDocumentSymbols).toHaveBeenCalled(); - expect(result.content[0].text).toBe('1 symbol(s) found'); - }); + }); + + describe("lsp_workspace_symbols tool", () => { + test("should search workspace symbols", async () => { + const result = await lspTools[4].handler({ + query: "TestClass", + file: "test.ts", + }); + + expect(result.content[0].text).toBe( + 'Found 1 symbol(s) matching "TestClass":\n\nFound 1 symbol(s)', + ); }); - describe('lsp_workspace_symbols tool', () => { - test('should search workspace symbols', async () => { - const result = await lspTools[4].handler({ - query: 'TestClass', - file: 'test.ts' - }); - - expect(result.content[0].text).toBe('Found 1 symbol(s) matching "TestClass":\n\nFound 1 symbol(s)'); - }); - - test('should handle no symbols found', async () => { - mockLspClientManager.runWithClientLease.mockImplementationOnce(async (filePath, fn) => { - const mockClient = { - workspaceSymbols: mock(async () => []) - }; - return await fn(mockClient); - }); - - const result = await lspTools[4].handler({ - query: 'UnknownSymbol', - file: 'test.ts' - }); - - expect(result.content[0].text).toBe('No symbols found matching: UnknownSymbol'); - }); + test("should handle no symbols found", async () => { + mockLspClientManager.runWithClientLease.mockImplementationOnce( + async (filePath, fn) => { + const mockClient = { + workspaceSymbols: mock(async () => []), + }; + return await fn(mockClient); + }, + ); + + const result = await lspTools[4].handler({ + query: "UnknownSymbol", + file: "test.ts", + }); + + expect(result.content[0].text).toBe( + "No symbols found matching: UnknownSymbol", + ); }); + }); - describe('lsp_diagnostics tool', () => { - test('should get diagnostics with severity filter', async () => { - const result = await lspTools[5].handler({ - file: 'test.ts', - severity: 'error' - }); + describe("lsp_diagnostics tool", () => { + test("should get diagnostics with severity filter", async () => { + const result = await lspTools[5].handler({ + file: "test.ts", + severity: "error", + }); - expect(result.content[0].text).toContain('Found 1 diagnostic(s)'); - }); + expect(result.content[0].text).toContain("Found 1 diagnostic(s)"); }); + }); - describe('lsp_servers tool', () => { - test('should list all language servers', async () => { - const result = await lspTools[7].handler({}); + describe("lsp_servers tool", () => { + test("should list all language servers", async () => { + const result = await lspTools[7].handler({}); - expect(mockGetAllServers).toHaveBeenCalled(); - expect(result.content[0].text).toContain('Language Server Status'); - expect(result.content[0].text).toContain('typescript'); - expect(result.content[0].text).toContain('python'); - }); + expect(mockGetAllServers).toHaveBeenCalled(); + expect(result.content[0].text).toContain("Language Server Status"); + expect(result.content[0].text).toContain("typescript"); + expect(result.content[0].text).toContain("python"); }); - - describe('lsp_rename tool', () => { - test('should perform rename and show edit summary', async () => { - const result = await lspTools[9].handler({ - file: 'test.ts', - line: 1, - character: 5, - newName: 'newFunctionName' - }); - - expect(mockCountEdits).toHaveBeenCalled(); - expect(result.content[0].text).toContain('Rename to "newFunctionName" would affect 1 file(s) with 1 edit(s)'); - }); + }); + + describe("lsp_rename tool", () => { + test("should perform rename and show edit summary", async () => { + const result = await lspTools[9].handler({ + file: "test.ts", + line: 1, + character: 5, + newName: "newFunctionName", + }); + + expect(mockCountEdits).toHaveBeenCalled(); + expect(result.content[0].text).toContain( + 'Rename to "newFunctionName" would affect 1 file(s) with 1 edit(s)', + ); }); - - describe('lsp_code_actions tool', () => { - test('should get code actions for selection', async () => { - const result = await lspTools[10].handler({ - file: 'test.ts', - startLine: 1, - startCharacter: 0, - endLine: 3, - endCharacter: 10 - }); - - expect(mockFormatCodeActions).toHaveBeenCalled(); - expect(result.content[0].text).toBe('Found 1 code action(s)'); - }); + }); + + describe("lsp_code_actions tool", () => { + test("should get code actions for selection", async () => { + const result = await lspTools[10].handler({ + file: "test.ts", + startLine: 1, + startCharacter: 0, + endLine: 3, + endCharacter: 10, + }); + + expect(mockFormatCodeActions).toHaveBeenCalled(); + expect(result.content[0].text).toBe("Found 1 code action(s)"); }); + }); - describe('error handling', () => { - test('should handle unsupported file type', async () => { - mockGetServerForFile.mockReturnValueOnce(null); - - const result = await lspTools[0].handler({ - file: 'test.unknown', - line: 1, - character: 5 - }); + describe("error handling", () => { + test("should handle unsupported file type", async () => { + mockGetServerForFile.mockReturnValueOnce(null); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('No language server available'); - }); + const result = await lspTools[0].handler({ + file: "test.unknown", + line: 1, + character: 5, + }); - test('should handle LSP client errors', async () => { - mockLspClientManager.runWithClientLease.mockRejectedValueOnce(new Error('LSP connection failed')); - - const result = await lspTools[0].handler({ - file: 'test.ts', - line: 1, - character: 5 - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("No language server available"); + }); - expect(result.isError).toBe(true); - expect(result.content[0].text).toBe('Error in hover: LSP connection failed'); - }); + test("should handle LSP client errors", async () => { + mockLspClientManager.runWithClientLease.mockRejectedValueOnce( + new Error("LSP connection failed"), + ); + + const result = await lspTools[0].handler({ + file: "test.ts", + line: 1, + character: 5, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe( + "Error in hover: LSP connection failed", + ); }); -}); \ No newline at end of file + }); +}); diff --git a/packages/agent/tools/lsp.ts b/packages/agent/tools/lsp.ts index a31919a3..cb11221e 100644 --- a/packages/agent/tools/lsp.ts +++ b/packages/agent/tools/lsp.ts @@ -11,7 +11,7 @@ * - Code actions */ -import { z } from 'zod'; +import { z } from "zod"; import { lspClientManager, getAllServers, @@ -23,10 +23,10 @@ import { formatDiagnostics, formatCodeActions, formatWorkspaceEdit, - countEdits -} from './lsp/index.js'; -import { runDirectoryDiagnostics } from './diagnostics/index.js'; -import { ToolDefinition } from './types.js'; + countEdits, +} from "./lsp/index.js"; +import { runDirectoryDiagnostics } from "./diagnostics/index.js"; +import { ToolDefinition } from "./types.js"; /** * Helper to handle LSP errors gracefully. @@ -36,48 +36,63 @@ import { ToolDefinition } from './types.js'; async function withLspClient( filePath: string, operation: string, - fn: (client: NonNullable>>) => Promise -): Promise<{ isError?: true; content: Array<{ type: 'text'; text: string }> }> { + fn: ( + client: NonNullable< + Awaited> + >, + ) => Promise, +): Promise<{ isError?: true; content: Array<{ type: "text"; text: string }> }> { try { // Pre-check: is there a server for this file type? const serverConfig = getServerForFile(filePath); if (!serverConfig) { return { isError: true as const, - content: [{ - type: 'text' as const, - text: `No language server available for file type: ${filePath}\n\nUse lsp_servers tool to see available language servers.` - }] + content: [ + { + type: "text" as const, + text: `No language server available for file type: ${filePath}\n\nUse lsp_servers tool to see available language servers.`, + }, + ], }; } - const result = await lspClientManager.runWithClientLease(filePath, async (client) => { - return fn(client); - }); + const result = await lspClientManager.runWithClientLease( + filePath, + async (client) => { + return fn(client); + }, + ); return { - content: [{ - type: 'text' as const, - text: String(result) - }] + content: [ + { + type: "text" as const, + text: String(result), + }, + ], }; } catch (error) { const message = error instanceof Error ? error.message : String(error); // Surface install hints for missing servers - if (message.includes('not found')) { + if (message.includes("not found")) { return { isError: true as const, - content: [{ - type: 'text' as const, - text: `${message}` - }] + content: [ + { + type: "text" as const, + text: `${message}`, + }, + ], }; } return { isError: true as const, - content: [{ - type: 'text' as const, - text: `Error in ${operation}: ${message}` - }] + content: [ + { + type: "text" as const, + text: `Error in ${operation}: ${message}`, + }, + ], }; } } @@ -90,20 +105,25 @@ export const lspHoverTool: ToolDefinition<{ line: z.ZodNumber; character: z.ZodNumber; }> = { - name: 'lsp_hover', - description: 'Get type information, documentation, and signature at a specific position in a file. Useful for understanding what a symbol represents.', + name: "lsp_hover", + description: + "Get type information, documentation, and signature at a specific position in a file. Useful for understanding what a symbol represents.", schema: { - file: z.string().describe('Path to the source file'), - line: z.number().int().min(1).describe('Line number (1-indexed)'), - character: z.number().int().min(0).describe('Character position in the line (0-indexed)') + file: z.string().describe("Path to the source file"), + line: z.number().int().min(1).describe("Line number (1-indexed)"), + character: z + .number() + .int() + .min(0) + .describe("Character position in the line (0-indexed)"), }, handler: async (args) => { const { file, line, character } = args; - return withLspClient(file, 'hover', async (client) => { + return withLspClient(file, "hover", async (client) => { const hover = await client!.hover(file, line - 1, character); return formatHover(hover); }); - } + }, }; /** @@ -114,20 +134,25 @@ export const lspGotoDefinitionTool: ToolDefinition<{ line: z.ZodNumber; character: z.ZodNumber; }> = { - name: 'lsp_goto_definition', - description: 'Find the definition location of a symbol (function, variable, class, etc.). Returns the file path and position where the symbol is defined.', + name: "lsp_goto_definition", + description: + "Find the definition location of a symbol (function, variable, class, etc.). Returns the file path and position where the symbol is defined.", schema: { - file: z.string().describe('Path to the source file'), - line: z.number().int().min(1).describe('Line number (1-indexed)'), - character: z.number().int().min(0).describe('Character position in the line (0-indexed)') + file: z.string().describe("Path to the source file"), + line: z.number().int().min(1).describe("Line number (1-indexed)"), + character: z + .number() + .int() + .min(0) + .describe("Character position in the line (0-indexed)"), }, handler: async (args) => { const { file, line, character } = args; - return withLspClient(file, 'goto definition', async (client) => { + return withLspClient(file, "goto definition", async (client) => { const locations = await client!.definition(file, line - 1, character); return formatLocations(locations); }); - } + }, }; /** @@ -139,24 +164,37 @@ export const lspFindReferencesTool: ToolDefinition<{ character: z.ZodNumber; includeDeclaration: z.ZodOptional; }> = { - name: 'lsp_find_references', - description: 'Find all references to a symbol across the codebase. Useful for understanding usage patterns and impact of changes.', + name: "lsp_find_references", + description: + "Find all references to a symbol across the codebase. Useful for understanding usage patterns and impact of changes.", schema: { - file: z.string().describe('Path to the source file'), - line: z.number().int().min(1).describe('Line number (1-indexed)'), - character: z.number().int().min(0).describe('Character position in the line (0-indexed)'), - includeDeclaration: z.boolean().optional().describe('Include the declaration in results (default: true)') + file: z.string().describe("Path to the source file"), + line: z.number().int().min(1).describe("Line number (1-indexed)"), + character: z + .number() + .int() + .min(0) + .describe("Character position in the line (0-indexed)"), + includeDeclaration: z + .boolean() + .optional() + .describe("Include the declaration in results (default: true)"), }, handler: async (args) => { const { file, line, character, includeDeclaration = true } = args; - return withLspClient(file, 'find references', async (client) => { - const locations = await client!.references(file, line - 1, character, includeDeclaration); + return withLspClient(file, "find references", async (client) => { + const locations = await client!.references( + file, + line - 1, + character, + includeDeclaration, + ); if (!locations || locations.length === 0) { - return 'No references found'; + return "No references found"; } return `Found ${locations.length} reference(s):\n\n${formatLocations(locations)}`; }); - } + }, }; /** @@ -165,18 +203,19 @@ export const lspFindReferencesTool: ToolDefinition<{ export const lspDocumentSymbolsTool: ToolDefinition<{ file: z.ZodString; }> = { - name: 'lsp_document_symbols', - description: 'Get a hierarchical outline of all symbols in a file (functions, classes, variables, etc.). Useful for understanding file structure.', + name: "lsp_document_symbols", + description: + "Get a hierarchical outline of all symbols in a file (functions, classes, variables, etc.). Useful for understanding file structure.", schema: { - file: z.string().describe('Path to the source file') + file: z.string().describe("Path to the source file"), }, handler: async (args) => { const { file } = args; - return withLspClient(file, 'document symbols', async (client) => { + return withLspClient(file, "document symbols", async (client) => { const symbols = await client!.documentSymbols(file); return formatDocumentSymbols(symbols); }); - } + }, }; /** @@ -186,22 +225,27 @@ export const lspWorkspaceSymbolsTool: ToolDefinition<{ query: z.ZodString; file: z.ZodString; }> = { - name: 'lsp_workspace_symbols', - description: 'Search for symbols (functions, classes, etc.) across the entire workspace by name. Useful for finding definitions without knowing the exact file.', + name: "lsp_workspace_symbols", + description: + "Search for symbols (functions, classes, etc.) across the entire workspace by name. Useful for finding definitions without knowing the exact file.", schema: { - query: z.string().describe('Symbol name or pattern to search'), - file: z.string().describe('Any file in the workspace (used to determine which language server to use)') + query: z.string().describe("Symbol name or pattern to search"), + file: z + .string() + .describe( + "Any file in the workspace (used to determine which language server to use)", + ), }, handler: async (args) => { const { query, file } = args; - return withLspClient(file, 'workspace symbols', async (client) => { + return withLspClient(file, "workspace symbols", async (client) => { const symbols = await client!.workspaceSymbols(query); if (!symbols || symbols.length === 0) { return `No symbols found matching: ${query}`; } return `Found ${symbols.length} symbol(s) matching "${query}":\n\n${formatWorkspaceSymbols(symbols)}`; }); - } + }, }; /** @@ -209,17 +253,21 @@ export const lspWorkspaceSymbolsTool: ToolDefinition<{ */ export const lspDiagnosticsTool: ToolDefinition<{ file: z.ZodString; - severity: z.ZodOptional>; + severity: z.ZodOptional>; }> = { - name: 'lsp_diagnostics', - description: 'Get language server diagnostics (errors, warnings, hints) for a file. Useful for finding issues without running the compiler.', + name: "lsp_diagnostics", + description: + "Get language server diagnostics (errors, warnings, hints) for a file. Useful for finding issues without running the compiler.", schema: { - file: z.string().describe('Path to the source file'), - severity: z.enum(['error', 'warning', 'info', 'hint']).optional().describe('Filter by severity level') + file: z.string().describe("Path to the source file"), + severity: z + .enum(["error", "warning", "info", "hint"]) + .optional() + .describe("Filter by severity level"), }, handler: async (args) => { const { file, severity } = args; - return withLspClient(file, 'diagnostics', async (client) => { + return withLspClient(file, "diagnostics", async (client) => { await client!.openDocument(file); let diagnostics; @@ -232,13 +280,13 @@ export const lspDiagnosticsTool: ToolDefinition<{ if (severity) { const severityMap: Record = { - 'error': 1, - 'warning': 2, - 'info': 3, - 'hint': 4 + error: 1, + warning: 2, + info: 3, + hint: 4, }; const severityNum = severityMap[severity]; - diagnostics = diagnostics.filter(d => d.severity === severityNum); + diagnostics = diagnostics.filter((d) => d.severity === severityNum); } if (diagnostics.length === 0) { @@ -249,49 +297,52 @@ export const lspDiagnosticsTool: ToolDefinition<{ return `Found ${diagnostics.length} diagnostic(s):\n\n${formatDiagnostics(diagnostics, file)}`; }); - } + }, }; /** * LSP Servers Tool - List available language servers */ export const lspServersTool: ToolDefinition> = { - name: 'lsp_servers', - description: 'List all known language servers and their installation status. Shows which servers are available and how to install missing ones.', + name: "lsp_servers", + description: + "List all known language servers and their installation status. Shows which servers are available and how to install missing ones.", schema: {}, handler: async () => { const servers = getAllServers(); - const installed = servers.filter(s => s.installed); - const notInstalled = servers.filter(s => !s.installed); + const installed = servers.filter((s) => s.installed); + const notInstalled = servers.filter((s) => !s.installed); - let text = '## Language Server Status\n\n'; + let text = "## Language Server Status\n\n"; if (installed.length > 0) { - text += '### Installed:\n'; + text += "### Installed:\n"; for (const server of installed) { text += `- ${server.name} (${server.command})\n`; - text += ` Extensions: ${server.extensions.join(', ')}\n`; + text += ` Extensions: ${server.extensions.join(", ")}\n`; } - text += '\n'; + text += "\n"; } if (notInstalled.length > 0) { - text += '### Not Installed:\n'; + text += "### Not Installed:\n"; for (const server of notInstalled) { text += `- ${server.name} (${server.command})\n`; - text += ` Extensions: ${server.extensions.join(', ')}\n`; + text += ` Extensions: ${server.extensions.join(", ")}\n`; text += ` Install: ${server.installHint}\n`; } } return { - content: [{ - type: 'text' as const, - text - }] + content: [ + { + type: "text" as const, + text, + }, + ], }; - } + }, }; /** @@ -302,23 +353,28 @@ export const lspPrepareRenameTool: ToolDefinition<{ line: z.ZodNumber; character: z.ZodNumber; }> = { - name: 'lsp_prepare_rename', - description: 'Check if a symbol at the given position can be renamed. Returns the range of the symbol if rename is possible.', + name: "lsp_prepare_rename", + description: + "Check if a symbol at the given position can be renamed. Returns the range of the symbol if rename is possible.", schema: { - file: z.string().describe('Path to the source file'), - line: z.number().int().min(1).describe('Line number (1-indexed)'), - character: z.number().int().min(0).describe('Character position in the line (0-indexed)') + file: z.string().describe("Path to the source file"), + line: z.number().int().min(1).describe("Line number (1-indexed)"), + character: z + .number() + .int() + .min(0) + .describe("Character position in the line (0-indexed)"), }, handler: async (args) => { const { file, line, character } = args; - return withLspClient(file, 'prepare rename', async (client) => { + return withLspClient(file, "prepare rename", async (client) => { const range = await client!.prepareRename(file, line - 1, character); if (!range) { - return 'Cannot rename symbol at this position'; + return "Cannot rename symbol at this position"; } return `Rename possible. Symbol range: line ${range.start.line + 1}, col ${range.start.character + 1} to line ${range.end.line + 1}, col ${range.end.character + 1}`; }); - } + }, }; /** @@ -330,26 +386,31 @@ export const lspRenameTool: ToolDefinition<{ character: z.ZodNumber; newName: z.ZodString; }> = { - name: 'lsp_rename', - description: 'Rename a symbol (variable, function, class, etc.) across all files in the project. Returns the list of edits that would be made. Does NOT apply the changes automatically.', + name: "lsp_rename", + description: + "Rename a symbol (variable, function, class, etc.) across all files in the project. Returns the list of edits that would be made. Does NOT apply the changes automatically.", schema: { - file: z.string().describe('Path to the source file'), - line: z.number().int().min(1).describe('Line number (1-indexed)'), - character: z.number().int().min(0).describe('Character position in the line (0-indexed)'), - newName: z.string().min(1).describe('New name for the symbol') + file: z.string().describe("Path to the source file"), + line: z.number().int().min(1).describe("Line number (1-indexed)"), + character: z + .number() + .int() + .min(0) + .describe("Character position in the line (0-indexed)"), + newName: z.string().min(1).describe("New name for the symbol"), }, handler: async (args) => { const { file, line, character, newName } = args; - return withLspClient(file, 'rename', async (client) => { + return withLspClient(file, "rename", async (client) => { const edit = await client!.rename(file, line - 1, character, newName); if (!edit) { - return 'Rename failed or no edits returned'; + return "Rename failed or no edits returned"; } const { files, edits } = countEdits(edit); return `Rename to "${newName}" would affect ${files} file(s) with ${edits} edit(s):\n\n${formatWorkspaceEdit(edit)}\n\nNote: Use the Edit tool to apply these changes.`; }); - } + }, }; /** @@ -362,26 +423,43 @@ export const lspCodeActionsTool: ToolDefinition<{ endLine: z.ZodNumber; endCharacter: z.ZodNumber; }> = { - name: 'lsp_code_actions', - description: 'Get available code actions (refactorings, quick fixes) for a selection. Returns a list of possible actions that can be applied.', + name: "lsp_code_actions", + description: + "Get available code actions (refactorings, quick fixes) for a selection. Returns a list of possible actions that can be applied.", schema: { - file: z.string().describe('Path to the source file'), - startLine: z.number().int().min(1).describe('Start line of selection (1-indexed)'), - startCharacter: z.number().int().min(0).describe('Start character of selection (0-indexed)'), - endLine: z.number().int().min(1).describe('End line of selection (1-indexed)'), - endCharacter: z.number().int().min(0).describe('End character of selection (0-indexed)') + file: z.string().describe("Path to the source file"), + startLine: z + .number() + .int() + .min(1) + .describe("Start line of selection (1-indexed)"), + startCharacter: z + .number() + .int() + .min(0) + .describe("Start character of selection (0-indexed)"), + endLine: z + .number() + .int() + .min(1) + .describe("End line of selection (1-indexed)"), + endCharacter: z + .number() + .int() + .min(0) + .describe("End character of selection (0-indexed)"), }, handler: async (args) => { const { file, startLine, startCharacter, endLine, endCharacter } = args; - return withLspClient(file, 'code actions', async (client) => { + return withLspClient(file, "code actions", async (client) => { const range = { start: { line: startLine - 1, character: startCharacter }, - end: { line: endLine - 1, character: endCharacter } + end: { line: endLine - 1, character: endCharacter }, }; const actions = await client!.codeActions(file, range); return formatCodeActions(actions); }); - } + }, }; /** @@ -395,27 +473,57 @@ export const lspCodeActionResolveTool: ToolDefinition<{ endCharacter: z.ZodNumber; actionIndex: z.ZodNumber; }> = { - name: 'lsp_code_action_resolve', - description: 'Get the full edit details for a specific code action. Use after lsp_code_actions to see what changes an action would make.', + name: "lsp_code_action_resolve", + description: + "Get the full edit details for a specific code action. Use after lsp_code_actions to see what changes an action would make.", schema: { - file: z.string().describe('Path to the source file'), - startLine: z.number().int().min(1).describe('Start line of selection (1-indexed)'), - startCharacter: z.number().int().min(0).describe('Start character of selection (0-indexed)'), - endLine: z.number().int().min(1).describe('End line of selection (1-indexed)'), - endCharacter: z.number().int().min(0).describe('End character of selection (0-indexed)'), - actionIndex: z.number().int().min(1).describe('Index of the action (1-indexed, from lsp_code_actions output)') + file: z.string().describe("Path to the source file"), + startLine: z + .number() + .int() + .min(1) + .describe("Start line of selection (1-indexed)"), + startCharacter: z + .number() + .int() + .min(0) + .describe("Start character of selection (0-indexed)"), + endLine: z + .number() + .int() + .min(1) + .describe("End line of selection (1-indexed)"), + endCharacter: z + .number() + .int() + .min(0) + .describe("End character of selection (0-indexed)"), + actionIndex: z + .number() + .int() + .min(1) + .describe( + "Index of the action (1-indexed, from lsp_code_actions output)", + ), }, handler: async (args) => { - const { file, startLine, startCharacter, endLine, endCharacter, actionIndex } = args; - return withLspClient(file, 'code action resolve', async (client) => { + const { + file, + startLine, + startCharacter, + endLine, + endCharacter, + actionIndex, + } = args; + return withLspClient(file, "code action resolve", async (client) => { const range = { start: { line: startLine - 1, character: startCharacter }, - end: { line: endLine - 1, character: endCharacter } + end: { line: endLine - 1, character: endCharacter }, }; const actions = await client!.codeActions(file, range); if (!actions || actions.length === 0) { - return 'No code actions available'; + return "No code actions available"; } if (actionIndex < 1 || actionIndex > actions.length) { @@ -438,7 +546,7 @@ export const lspCodeActionResolveTool: ToolDefinition<{ return result; }); - } + }, }; /** @@ -446,16 +554,22 @@ export const lspCodeActionResolveTool: ToolDefinition<{ */ export const lspDiagnosticsDirectoryTool: ToolDefinition<{ directory: z.ZodString; - strategy: z.ZodOptional>; + strategy: z.ZodOptional>; }> = { - name: 'lsp_diagnostics_directory', - description: 'Run project-level diagnostics on a directory using tsc --noEmit (preferred) or LSP iteration (fallback). Useful for checking the entire codebase for errors.', + name: "lsp_diagnostics_directory", + description: + "Run project-level diagnostics on a directory using tsc --noEmit (preferred) or LSP iteration (fallback). Useful for checking the entire codebase for errors.", schema: { - directory: z.string().describe('Project directory to check'), - strategy: z.enum(['tsc', 'lsp', 'auto']).optional().describe('Strategy to use: "tsc" (TypeScript compiler), "lsp" (Language Server iteration), or "auto" (default: auto-detect)') + directory: z.string().describe("Project directory to check"), + strategy: z + .enum(["tsc", "lsp", "auto"]) + .optional() + .describe( + 'Strategy to use: "tsc" (TypeScript compiler), "lsp" (Language Server iteration), or "auto" (default: auto-detect)', + ), }, handler: async (args) => { - const { directory, strategy = 'auto' } = args; + const { directory, strategy = "auto" } = args; try { const result = await runDirectoryDiagnostics(directory, strategy); @@ -470,21 +584,25 @@ export const lspDiagnosticsDirectoryTool: ToolDefinition<{ } return { - content: [{ - type: 'text' as const, - text: output - }] + content: [ + { + type: "text" as const, + text: output, + }, + ], }; } catch (error) { return { isError: true as const, - content: [{ - type: 'text' as const, - text: `Error running directory diagnostics: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error running directory diagnostics: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; /** @@ -502,5 +620,5 @@ export const lspTools = [ lspPrepareRenameTool, lspRenameTool, lspCodeActionsTool, - lspCodeActionResolveTool -]; \ No newline at end of file + lspCodeActionResolveTool, +]; diff --git a/packages/agent/tools/notepad.test.ts b/packages/agent/tools/notepad.test.ts index dddbd7b5..adb67f1f 100644 --- a/packages/agent/tools/notepad.test.ts +++ b/packages/agent/tools/notepad.test.ts @@ -1,360 +1,416 @@ -import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; +import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; // Mock notepad hooks -const mockGetPriorityContext = mock(() => 'Priority context content'); -const mockGetWorkingMemory = mock(() => 'Working memory entries\n- Entry 1\n- Entry 2'); -const mockGetManualSection = mock(() => 'Manual section content'); +const mockGetPriorityContext = mock(() => "Priority context content"); +const mockGetWorkingMemory = mock( + () => "Working memory entries\n- Entry 1\n- Entry 2", +); +const mockGetManualSection = mock(() => "Manual section content"); const mockSetPriorityContext = mock(() => ({ success: true })); const mockAddWorkingMemoryEntry = mock(() => true); const mockAddManualEntry = mock(() => true); const mockPruneOldEntries = mock(() => ({ pruned: 2, remaining: 5 })); const mockGetNotepadStats = mock(() => ({ - exists: true, - totalSize: 1024, - prioritySize: 256, - workingMemoryEntries: 3, - oldestEntry: '2024-01-01' + exists: true, + totalSize: 1024, + prioritySize: 256, + workingMemoryEntries: 3, + oldestEntry: "2024-01-01", })); -const mockFormatFullNotepad = mock(() => '## Priority Context\nContent\n\n## Working Memory\nEntries\n\n## MANUAL\nManual content'); +const mockFormatFullNotepad = mock( + () => + "## Priority Context\nContent\n\n## Working Memory\nEntries\n\n## MANUAL\nManual content", +); const mockDEFAULT_CONFIG = { workingMemoryDays: 7 }; -mock.module('../hooks/notepad/index.js', () => ({ - getPriorityContext: mockGetPriorityContext, - getWorkingMemory: mockGetWorkingMemory, - getManualSection: mockGetManualSection, - setPriorityContext: mockSetPriorityContext, - addWorkingMemoryEntry: mockAddWorkingMemoryEntry, - addManualEntry: mockAddManualEntry, - pruneOldEntries: mockPruneOldEntries, - getNotepadStats: mockGetNotepadStats, - formatFullNotepad: mockFormatFullNotepad, - DEFAULT_CONFIG: mockDEFAULT_CONFIG +mock.module("../hooks/notepad/index.js", () => ({ + getPriorityContext: mockGetPriorityContext, + getWorkingMemory: mockGetWorkingMemory, + getManualSection: mockGetManualSection, + setPriorityContext: mockSetPriorityContext, + addWorkingMemoryEntry: mockAddWorkingMemoryEntry, + addManualEntry: mockAddManualEntry, + pruneOldEntries: mockPruneOldEntries, + getNotepadStats: mockGetNotepadStats, + formatFullNotepad: mockFormatFullNotepad, + DEFAULT_CONFIG: mockDEFAULT_CONFIG, })); // Mock worktree paths -const mockGetWorktreeNotepadPath = mock((root: string) => path.join(root, '.omc', 'notepad.md')); +const mockGetWorktreeNotepadPath = mock((root: string) => + path.join(root, ".omc", "notepad.md"), +); const mockEnsureOmcDir = mock(() => {}); -const mockValidateWorkingDirectory = mock((wd?: string) => wd || '/test/root'); +const mockValidateWorkingDirectory = mock((wd?: string) => wd || "/test/root"); -mock.module('../lib/worktree-paths.js', () => ({ - getWorktreeNotepadPath: mockGetWorktreeNotepadPath, - ensureOmcDir: mockEnsureOmcDir, - validateWorkingDirectory: mockValidateWorkingDirectory +mock.module("../lib/worktree-paths.js", () => ({ + getWorktreeNotepadPath: mockGetWorktreeNotepadPath, + ensureOmcDir: mockEnsureOmcDir, + validateWorkingDirectory: mockValidateWorkingDirectory, })); // Import after mocking -const { notepadTools } = await import('./notepad'); - -describe('Notepad Tools', () => { - let testRoot: string; +const { notepadTools } = await import("./notepad"); + +describe("Notepad Tools", () => { + let testRoot: string; + + beforeEach(async () => { + testRoot = await mkdtemp(path.join(tmpdir(), "notepad-test-")); + + // Reset all mocks + Object.values({ + mockGetPriorityContext, + mockGetWorkingMemory, + mockGetManualSection, + mockSetPriorityContext, + mockAddWorkingMemoryEntry, + mockAddManualEntry, + mockPruneOldEntries, + mockGetNotepadStats, + mockFormatFullNotepad, + mockGetWorktreeNotepadPath, + mockEnsureOmcDir, + mockValidateWorkingDirectory, + }).forEach((mock) => mock.mockClear()); + }); + + afterEach(async () => { + try { + await rm(testRoot, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + test("should export notepadTools array with 6 tools", () => { + expect(Array.isArray(notepadTools)).toBe(true); + expect(notepadTools.length).toBe(6); + + const toolNames = notepadTools.map((t) => t.name); + expect(toolNames).toEqual([ + "notepad_read", + "notepad_write_priority", + "notepad_write_working", + "notepad_write_manual", + "notepad_prune", + "notepad_stats", + ]); + }); + + test("each tool should have required properties with correct types", () => { + for (const tool of notepadTools) { + expect(tool).toHaveProperty("name"); + expect(tool).toHaveProperty("description"); + expect(tool).toHaveProperty("schema"); + expect(tool).toHaveProperty("handler"); + + expect(typeof tool.name).toBe("string"); + expect(typeof tool.description).toBe("string"); + expect(typeof tool.handler).toBe("function"); + expect(typeof tool.schema).toBe("object"); + } + }); + + describe("notepad_read tool", () => { + test('should read full notepad when section is "all"', async () => { + const result = await notepadTools[0].handler({ + section: "all", + workingDirectory: testRoot, + }); + + expect(mockValidateWorkingDirectory).toHaveBeenCalledWith(testRoot); + expect(mockFormatFullNotepad).toHaveBeenCalledWith(testRoot); + expect(result.content[0].text).toContain("## Notepad"); + expect(result.content[0].text).toContain("Path:"); + }); - beforeEach(async () => { - testRoot = await mkdtemp(path.join(tmpdir(), 'notepad-test-')); + test("should read priority section", async () => { + const result = await notepadTools[0].handler({ + section: "priority", + workingDirectory: testRoot, + }); - // Reset all mocks - Object.values({ - mockGetPriorityContext, mockGetWorkingMemory, mockGetManualSection, - mockSetPriorityContext, mockAddWorkingMemoryEntry, mockAddManualEntry, - mockPruneOldEntries, mockGetNotepadStats, mockFormatFullNotepad, - mockGetWorktreeNotepadPath, mockEnsureOmcDir, mockValidateWorkingDirectory - }).forEach(mock => mock.mockClear()); + expect(mockGetPriorityContext).toHaveBeenCalledWith(testRoot); + expect(result.content[0].text).toBe( + "## Priority Context\n\nPriority context content", + ); }); - afterEach(async () => { - try { - await rm(testRoot, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - }); + test("should read working memory section", async () => { + const result = await notepadTools[0].handler({ + section: "working", + workingDirectory: testRoot, + }); - test('should export notepadTools array with 6 tools', () => { - expect(Array.isArray(notepadTools)).toBe(true); - expect(notepadTools.length).toBe(6); - - const toolNames = notepadTools.map(t => t.name); - expect(toolNames).toEqual([ - 'notepad_read', - 'notepad_write_priority', - 'notepad_write_working', - 'notepad_write_manual', - 'notepad_prune', - 'notepad_stats' - ]); + expect(mockGetWorkingMemory).toHaveBeenCalledWith(testRoot); + expect(result.content[0].text).toBe( + "## Working Memory\n\nWorking memory entries\n- Entry 1\n- Entry 2", + ); }); - test('each tool should have required properties with correct types', () => { - for (const tool of notepadTools) { - expect(tool).toHaveProperty('name'); - expect(tool).toHaveProperty('description'); - expect(tool).toHaveProperty('schema'); - expect(tool).toHaveProperty('handler'); - - expect(typeof tool.name).toBe('string'); - expect(typeof tool.description).toBe('string'); - expect(typeof tool.handler).toBe('function'); - expect(typeof tool.schema).toBe('object'); - } + test("should read manual section", async () => { + const result = await notepadTools[0].handler({ + section: "manual", + workingDirectory: testRoot, + }); + + expect(mockGetManualSection).toHaveBeenCalledWith(testRoot); + expect(result.content[0].text).toBe( + "## MANUAL\n\nManual section content", + ); }); - describe('notepad_read tool', () => { - test('should read full notepad when section is "all"', async () => { - const result = await notepadTools[0].handler({ - section: 'all', - workingDirectory: testRoot - }); - - expect(mockValidateWorkingDirectory).toHaveBeenCalledWith(testRoot); - expect(mockFormatFullNotepad).toHaveBeenCalledWith(testRoot); - expect(result.content[0].text).toContain('## Notepad'); - expect(result.content[0].text).toContain('Path:'); - }); - - test('should read priority section', async () => { - const result = await notepadTools[0].handler({ - section: 'priority', - workingDirectory: testRoot - }); - - expect(mockGetPriorityContext).toHaveBeenCalledWith(testRoot); - expect(result.content[0].text).toBe('## Priority Context\n\nPriority context content'); - }); - - test('should read working memory section', async () => { - const result = await notepadTools[0].handler({ - section: 'working', - workingDirectory: testRoot - }); - - expect(mockGetWorkingMemory).toHaveBeenCalledWith(testRoot); - expect(result.content[0].text).toBe('## Working Memory\n\nWorking memory entries\n- Entry 1\n- Entry 2'); - }); - - test('should read manual section', async () => { - const result = await notepadTools[0].handler({ - section: 'manual', - workingDirectory: testRoot - }); - - expect(mockGetManualSection).toHaveBeenCalledWith(testRoot); - expect(result.content[0].text).toBe('## MANUAL\n\nManual section content'); - }); - - test('should handle empty sections', async () => { - mockGetPriorityContext.mockReturnValueOnce(null); - - const result = await notepadTools[0].handler({ - section: 'priority', - workingDirectory: testRoot - }); - - expect(result.content[0].text).toBe('## Priority Context\n\n(Empty or notepad does not exist)'); - }); - - test('should handle missing notepad', async () => { - mockFormatFullNotepad.mockReturnValueOnce(null); - - const result = await notepadTools[0].handler({ - section: 'all', - workingDirectory: testRoot - }); - - expect(result.content[0].text).toBe('Notepad does not exist. Use notepad_write_* tools to create it.'); - }); - - test('should use default working directory', async () => { - const result = await notepadTools[0].handler({ - section: 'all' - }); - - expect(mockValidateWorkingDirectory).toHaveBeenCalledWith(undefined); - }); + test("should handle empty sections", async () => { + mockGetPriorityContext.mockReturnValueOnce(null); + + const result = await notepadTools[0].handler({ + section: "priority", + workingDirectory: testRoot, + }); + + expect(result.content[0].text).toBe( + "## Priority Context\n\n(Empty or notepad does not exist)", + ); }); - describe('notepad_write_priority tool', () => { - test('should write priority context successfully', async () => { - const result = await notepadTools[1].handler({ - content: 'New priority content', - workingDirectory: testRoot - }); - - expect(mockValidateWorkingDirectory).toHaveBeenCalledWith(testRoot); - expect(mockEnsureOmcDir).toHaveBeenCalled(); - expect(mockSetPriorityContext).toHaveBeenCalledWith(testRoot, 'New priority content'); - expect(result.content[0].text).toBe('Successfully wrote to Priority Context (20 chars)'); - }); - - test('should handle write failure', async () => { - mockSetPriorityContext.mockReturnValueOnce({ success: false }); - - const result = await notepadTools[1].handler({ - content: 'Content', - workingDirectory: testRoot - }); - - expect(result.content[0].text).toBe('Failed to write to Priority Context. Check file permissions.'); - }); - - test('should show warning on long content', async () => { - mockSetPriorityContext.mockReturnValueOnce({ - success: true, - warning: 'Content exceeds recommended length' - }); - - const result = await notepadTools[1].handler({ - content: 'Very long content that exceeds limits', - workingDirectory: testRoot - }); - - expect(result.content[0].text).toContain('**Warning:** Content exceeds recommended length'); - }); - - test('should accept content up to max length', async () => { - const maxContent = 'x'.repeat(2000); - - const result = await notepadTools[1].handler({ - content: maxContent, - workingDirectory: testRoot - }); - - expect(result.content[0].text).toContain('Successfully wrote'); - }); + test("should handle missing notepad", async () => { + mockFormatFullNotepad.mockReturnValueOnce(null); + + const result = await notepadTools[0].handler({ + section: "all", + workingDirectory: testRoot, + }); + + expect(result.content[0].text).toBe( + "Notepad does not exist. Use notepad_write_* tools to create it.", + ); }); - describe('notepad_write_working tool', () => { - test('should add working memory entry successfully', async () => { - const result = await notepadTools[2].handler({ - content: 'New working memory entry', - workingDirectory: testRoot - }); + test("should use default working directory", async () => { + const _result = await notepadTools[0].handler({ + section: "all", + }); - expect(mockAddWorkingMemoryEntry).toHaveBeenCalledWith(testRoot, 'New working memory entry'); - expect(result.content[0].text).toBe('Successfully added entry to Working Memory (24 chars)'); - }); + expect(mockValidateWorkingDirectory).toHaveBeenCalledWith(undefined); + }); + }); + + describe("notepad_write_priority tool", () => { + test("should write priority context successfully", async () => { + const result = await notepadTools[1].handler({ + content: "New priority content", + workingDirectory: testRoot, + }); + + expect(mockValidateWorkingDirectory).toHaveBeenCalledWith(testRoot); + expect(mockEnsureOmcDir).toHaveBeenCalled(); + expect(mockSetPriorityContext).toHaveBeenCalledWith( + testRoot, + "New priority content", + ); + expect(result.content[0].text).toBe( + "Successfully wrote to Priority Context (20 chars)", + ); + }); - test('should handle add failure', async () => { - mockAddWorkingMemoryEntry.mockReturnValueOnce(false); + test("should handle write failure", async () => { + mockSetPriorityContext.mockReturnValueOnce({ success: false }); - const result = await notepadTools[2].handler({ - content: 'Content', - workingDirectory: testRoot - }); + const result = await notepadTools[1].handler({ + content: "Content", + workingDirectory: testRoot, + }); - expect(result.content[0].text).toBe('Failed to add entry to Working Memory. Check file permissions.'); - }); + expect(result.content[0].text).toBe( + "Failed to write to Priority Context. Check file permissions.", + ); }); - describe('notepad_write_manual tool', () => { - test('should add manual entry successfully', async () => { - const result = await notepadTools[3].handler({ - content: 'New manual entry', - workingDirectory: testRoot - }); + test("should show warning on long content", async () => { + mockSetPriorityContext.mockReturnValueOnce({ + success: true, + warning: "Content exceeds recommended length", + }); + + const result = await notepadTools[1].handler({ + content: "Very long content that exceeds limits", + workingDirectory: testRoot, + }); - expect(mockAddManualEntry).toHaveBeenCalledWith(testRoot, 'New manual entry'); - expect(result.content[0].text).toBe('Successfully added entry to MANUAL section (16 chars)'); - }); + expect(result.content[0].text).toContain( + "**Warning:** Content exceeds recommended length", + ); + }); - test('should handle add failure', async () => { - mockAddManualEntry.mockReturnValueOnce(false); + test("should accept content up to max length", async () => { + const maxContent = "x".repeat(2000); - const result = await notepadTools[3].handler({ - content: 'Content', - workingDirectory: testRoot - }); + const result = await notepadTools[1].handler({ + content: maxContent, + workingDirectory: testRoot, + }); - expect(result.content[0].text).toBe('Failed to add entry to MANUAL section. Check file permissions.'); - }); + expect(result.content[0].text).toContain("Successfully wrote"); }); + }); + + describe("notepad_write_working tool", () => { + test("should add working memory entry successfully", async () => { + const result = await notepadTools[2].handler({ + content: "New working memory entry", + workingDirectory: testRoot, + }); + + expect(mockAddWorkingMemoryEntry).toHaveBeenCalledWith( + testRoot, + "New working memory entry", + ); + expect(result.content[0].text).toBe( + "Successfully added entry to Working Memory (24 chars)", + ); + }); + + test("should handle add failure", async () => { + mockAddWorkingMemoryEntry.mockReturnValueOnce(false); + + const result = await notepadTools[2].handler({ + content: "Content", + workingDirectory: testRoot, + }); - describe('notepad_prune tool', () => { - test('should prune entries with default days', async () => { - const result = await notepadTools[4].handler({ - workingDirectory: testRoot - }); - - expect(mockPruneOldEntries).toHaveBeenCalledWith(testRoot, 7); - expect(result.content[0].text).toBe('## Prune Results\n\n- Pruned: 2 entries\n- Remaining: 5 entries\n- Threshold: 7 days'); - }); - - test('should prune entries with custom days', async () => { - const result = await notepadTools[4].handler({ - daysOld: 14, - workingDirectory: testRoot - }); - - expect(mockPruneOldEntries).toHaveBeenCalledWith(testRoot, 14); - expect(result.content[0].text).toContain('Threshold: 14 days'); - }); + expect(result.content[0].text).toBe( + "Failed to add entry to Working Memory. Check file permissions.", + ); + }); + }); + + describe("notepad_write_manual tool", () => { + test("should add manual entry successfully", async () => { + const result = await notepadTools[3].handler({ + content: "New manual entry", + workingDirectory: testRoot, + }); + + expect(mockAddManualEntry).toHaveBeenCalledWith( + testRoot, + "New manual entry", + ); + expect(result.content[0].text).toBe( + "Successfully added entry to MANUAL section (16 chars)", + ); }); - describe('notepad_stats tool', () => { - test('should get notepad statistics', async () => { - const result = await notepadTools[5].handler({ - workingDirectory: testRoot - }); + test("should handle add failure", async () => { + mockAddManualEntry.mockReturnValueOnce(false); - expect(mockGetNotepadStats).toHaveBeenCalledWith(testRoot); - expect(result.content[0].text).toContain('## Notepad Statistics'); - expect(result.content[0].text).toContain('**Total Size:** 1024 bytes'); - expect(result.content[0].text).toContain('**Working Memory Entries:** 3'); - expect(result.content[0].text).toContain('Path:'); - }); + const result = await notepadTools[3].handler({ + content: "Content", + workingDirectory: testRoot, + }); - test('should handle non-existent notepad', async () => { - mockGetNotepadStats.mockReturnValueOnce({ exists: false }); + expect(result.content[0].text).toBe( + "Failed to add entry to MANUAL section. Check file permissions.", + ); + }); + }); + + describe("notepad_prune tool", () => { + test("should prune entries with default days", async () => { + const result = await notepadTools[4].handler({ + workingDirectory: testRoot, + }); + + expect(mockPruneOldEntries).toHaveBeenCalledWith(testRoot, 7); + expect(result.content[0].text).toBe( + "## Prune Results\n\n- Pruned: 2 entries\n- Remaining: 5 entries\n- Threshold: 7 days", + ); + }); - const result = await notepadTools[5].handler({ - workingDirectory: testRoot - }); + test("should prune entries with custom days", async () => { + const result = await notepadTools[4].handler({ + daysOld: 14, + workingDirectory: testRoot, + }); - expect(result.content[0].text).toBe('## Notepad Statistics\n\nNotepad does not exist yet.'); - }); + expect(mockPruneOldEntries).toHaveBeenCalledWith(testRoot, 14); + expect(result.content[0].text).toContain("Threshold: 14 days"); + }); + }); + + describe("notepad_stats tool", () => { + test("should get notepad statistics", async () => { + const result = await notepadTools[5].handler({ + workingDirectory: testRoot, + }); + + expect(mockGetNotepadStats).toHaveBeenCalledWith(testRoot); + expect(result.content[0].text).toContain("## Notepad Statistics"); + expect(result.content[0].text).toContain("**Total Size:** 1024 bytes"); + expect(result.content[0].text).toContain("**Working Memory Entries:** 3"); + expect(result.content[0].text).toContain("Path:"); }); - describe('error handling', () => { - test('should handle validation errors', async () => { - mockValidateWorkingDirectory.mockImplementationOnce(() => { - throw new Error('Invalid working directory'); - }); + test("should handle non-existent notepad", async () => { + mockGetNotepadStats.mockReturnValueOnce({ exists: false }); - const result = await notepadTools[0].handler({ - section: 'all', - workingDirectory: '/invalid/path' - }); + const result = await notepadTools[5].handler({ + workingDirectory: testRoot, + }); - expect(result.content[0].text).toBe('Error reading notepad: Invalid working directory'); - }); + expect(result.content[0].text).toBe( + "## Notepad Statistics\n\nNotepad does not exist yet.", + ); + }); + }); + + describe("error handling", () => { + test("should handle validation errors", async () => { + mockValidateWorkingDirectory.mockImplementationOnce(() => { + throw new Error("Invalid working directory"); + }); + + const result = await notepadTools[0].handler({ + section: "all", + workingDirectory: "/invalid/path", + }); + + expect(result.content[0].text).toBe( + "Error reading notepad: Invalid working directory", + ); + }); - test('should handle hook errors', async () => { - mockGetPriorityContext.mockImplementationOnce(() => { - throw new Error('File system error'); - }); + test("should handle hook errors", async () => { + mockGetPriorityContext.mockImplementationOnce(() => { + throw new Error("File system error"); + }); - const result = await notepadTools[0].handler({ - section: 'priority', - workingDirectory: testRoot - }); + const result = await notepadTools[0].handler({ + section: "priority", + workingDirectory: testRoot, + }); - expect(result.content[0].text).toBe('Error reading notepad: File system error'); - }); + expect(result.content[0].text).toBe( + "Error reading notepad: File system error", + ); }); + }); - describe('schema validation', () => { - test('notepad_write_priority should have content schema', () => { - expect(notepadTools[1].schema).toHaveProperty('content'); - }); + describe("schema validation", () => { + test("notepad_write_priority should have content schema", () => { + expect(notepadTools[1].schema).toHaveProperty("content"); + }); - test('write tools should have content schema', () => { - expect(notepadTools[2].schema).toHaveProperty('content'); - expect(notepadTools[3].schema).toHaveProperty('content'); - }); + test("write tools should have content schema", () => { + expect(notepadTools[2].schema).toHaveProperty("content"); + expect(notepadTools[3].schema).toHaveProperty("content"); + }); - test('notepad_prune should have daysOld schema', () => { - expect(notepadTools[4].schema).toHaveProperty('daysOld'); - }); + test("notepad_prune should have daysOld schema", () => { + expect(notepadTools[4].schema).toHaveProperty("daysOld"); }); -}); \ No newline at end of file + }); +}); diff --git a/packages/agent/tools/notepad.ts b/packages/agent/tools/notepad.ts index 2ab56dea..2f350447 100644 --- a/packages/agent/tools/notepad.ts +++ b/packages/agent/tools/notepad.ts @@ -5,12 +5,12 @@ * (Priority Context, Working Memory, MANUAL). */ -import { z } from 'zod'; +import { z } from "zod"; import { getWorktreeNotepadPath, ensureOmcDir, validateWorkingDirectory, -} from '../lib/worktree-paths.js'; +} from "../lib/worktree-paths.js"; import { getPriorityContext, getWorkingMemory, @@ -22,10 +22,15 @@ import { getNotepadStats, formatFullNotepad, DEFAULT_CONFIG, -} from '../hooks/notepad/index.js'; -import { ToolDefinition } from './types.js'; +} from "../hooks/notepad/index.js"; +import { ToolDefinition } from "./types.js"; -const SECTION_NAMES: [string, ...string[]] = ['all', 'priority', 'working', 'manual']; +const SECTION_NAMES: [string, ...string[]] = [ + "all", + "priority", + "working", + "manual", +]; // ============================================================================ // notepad_read - Read notepad content @@ -35,78 +40,97 @@ export const notepadReadTool: ToolDefinition<{ section: z.ZodOptional>; workingDirectory: z.ZodOptional; }> = { - name: 'notepad_read', - description: 'Read the notepad content. Can read the full notepad or a specific section (priority, working, manual).', + name: "notepad_read", + description: + "Read the notepad content. Can read the full notepad or a specific section (priority, working, manual).", schema: { - section: z.enum(SECTION_NAMES).optional().describe('Section to read: "all" (default), "priority", "working", or "manual"'), - workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), + section: z + .enum(SECTION_NAMES) + .optional() + .describe( + 'Section to read: "all" (default), "priority", "working", or "manual"', + ), + workingDirectory: z + .string() + .optional() + .describe("Working directory (defaults to cwd)"), }, handler: async (args) => { - const { section = 'all', workingDirectory } = args; + const { section = "all", workingDirectory } = args; try { const root = validateWorkingDirectory(workingDirectory); - if (section === 'all') { + if (section === "all") { const content = formatFullNotepad(root); if (!content) { return { - content: [{ - type: 'text' as const, - text: 'Notepad does not exist. Use notepad_write_* tools to create it.' - }] + content: [ + { + type: "text" as const, + text: "Notepad does not exist. Use notepad_write_* tools to create it.", + }, + ], }; } return { - content: [{ - type: 'text' as const, - text: `## Notepad\n\nPath: ${getWorktreeNotepadPath(root)}\n\n${content}` - }] + content: [ + { + type: "text" as const, + text: `## Notepad\n\nPath: ${getWorktreeNotepadPath(root)}\n\n${content}`, + }, + ], }; } let sectionContent: string | null = null; - let sectionTitle = ''; + let sectionTitle = ""; switch (section) { - case 'priority': + case "priority": sectionContent = getPriorityContext(root); - sectionTitle = 'Priority Context'; + sectionTitle = "Priority Context"; break; - case 'working': + case "working": sectionContent = getWorkingMemory(root); - sectionTitle = 'Working Memory'; + sectionTitle = "Working Memory"; break; - case 'manual': + case "manual": sectionContent = getManualSection(root); - sectionTitle = 'MANUAL'; + sectionTitle = "MANUAL"; break; } if (!sectionContent) { return { - content: [{ - type: 'text' as const, - text: `## ${sectionTitle}\n\n(Empty or notepad does not exist)` - }] + content: [ + { + type: "text" as const, + text: `## ${sectionTitle}\n\n(Empty or notepad does not exist)`, + }, + ], }; } return { - content: [{ - type: 'text' as const, - text: `## ${sectionTitle}\n\n${sectionContent}` - }] + content: [ + { + type: "text" as const, + text: `## ${sectionTitle}\n\n${sectionContent}`, + }, + ], }; } catch (error) { return { - content: [{ - type: 'text' as const, - text: `Error reading notepad: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error reading notepad: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; // ============================================================================ @@ -117,11 +141,18 @@ export const notepadWritePriorityTool: ToolDefinition<{ content: z.ZodString; workingDirectory: z.ZodOptional; }> = { - name: 'notepad_write_priority', - description: 'Write to the Priority Context section. This REPLACES the existing content. Keep under 500 chars - this is always loaded at session start.', + name: "notepad_write_priority", + description: + "Write to the Priority Context section. This REPLACES the existing content. Keep under 500 chars - this is always loaded at session start.", schema: { - content: z.string().max(2000).describe('Content to write (recommend under 500 chars)'), - workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), + content: z + .string() + .max(2000) + .describe("Content to write (recommend under 500 chars)"), + workingDirectory: z + .string() + .optional() + .describe("Working directory (defaults to cwd)"), }, handler: async (args) => { const { content, workingDirectory } = args; @@ -130,16 +161,18 @@ export const notepadWritePriorityTool: ToolDefinition<{ const root = validateWorkingDirectory(workingDirectory); // Ensure .omc directory exists - ensureOmcDir('', root); + ensureOmcDir("", root); const result = setPriorityContext(root, content); if (!result.success) { return { - content: [{ - type: 'text' as const, - text: 'Failed to write to Priority Context. Check file permissions.' - }] + content: [ + { + type: "text" as const, + text: "Failed to write to Priority Context. Check file permissions.", + }, + ], }; } @@ -149,20 +182,24 @@ export const notepadWritePriorityTool: ToolDefinition<{ } return { - content: [{ - type: 'text' as const, - text: response - }] + content: [ + { + type: "text" as const, + text: response, + }, + ], }; } catch (error) { return { - content: [{ - type: 'text' as const, - text: `Error writing to Priority Context: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error writing to Priority Context: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; // ============================================================================ @@ -173,11 +210,15 @@ export const notepadWriteWorkingTool: ToolDefinition<{ content: z.ZodString; workingDirectory: z.ZodOptional; }> = { - name: 'notepad_write_working', - description: 'Add an entry to Working Memory section. Entries are timestamped and auto-pruned after 7 days.', + name: "notepad_write_working", + description: + "Add an entry to Working Memory section. Entries are timestamped and auto-pruned after 7 days.", schema: { - content: z.string().max(4000).describe('Content to add as a new entry'), - workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), + content: z.string().max(4000).describe("Content to add as a new entry"), + workingDirectory: z + .string() + .optional() + .describe("Working directory (defaults to cwd)"), }, handler: async (args) => { const { content, workingDirectory } = args; @@ -186,34 +227,40 @@ export const notepadWriteWorkingTool: ToolDefinition<{ const root = validateWorkingDirectory(workingDirectory); // Ensure .omc directory exists - ensureOmcDir('', root); + ensureOmcDir("", root); const success = addWorkingMemoryEntry(root, content); if (!success) { return { - content: [{ - type: 'text' as const, - text: 'Failed to add entry to Working Memory. Check file permissions.' - }] + content: [ + { + type: "text" as const, + text: "Failed to add entry to Working Memory. Check file permissions.", + }, + ], }; } return { - content: [{ - type: 'text' as const, - text: `Successfully added entry to Working Memory (${content.length} chars)` - }] + content: [ + { + type: "text" as const, + text: `Successfully added entry to Working Memory (${content.length} chars)`, + }, + ], }; } catch (error) { return { - content: [{ - type: 'text' as const, - text: `Error writing to Working Memory: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error writing to Working Memory: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; // ============================================================================ @@ -224,11 +271,15 @@ export const notepadWriteManualTool: ToolDefinition<{ content: z.ZodString; workingDirectory: z.ZodOptional; }> = { - name: 'notepad_write_manual', - description: 'Add an entry to the MANUAL section. Content in this section is never auto-pruned.', + name: "notepad_write_manual", + description: + "Add an entry to the MANUAL section. Content in this section is never auto-pruned.", schema: { - content: z.string().max(4000).describe('Content to add as a new entry'), - workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), + content: z.string().max(4000).describe("Content to add as a new entry"), + workingDirectory: z + .string() + .optional() + .describe("Working directory (defaults to cwd)"), }, handler: async (args) => { const { content, workingDirectory } = args; @@ -237,34 +288,40 @@ export const notepadWriteManualTool: ToolDefinition<{ const root = validateWorkingDirectory(workingDirectory); // Ensure .omc directory exists - ensureOmcDir('', root); + ensureOmcDir("", root); const success = addManualEntry(root, content); if (!success) { return { - content: [{ - type: 'text' as const, - text: 'Failed to add entry to MANUAL section. Check file permissions.' - }] + content: [ + { + type: "text" as const, + text: "Failed to add entry to MANUAL section. Check file permissions.", + }, + ], }; } return { - content: [{ - type: 'text' as const, - text: `Successfully added entry to MANUAL section (${content.length} chars)` - }] + content: [ + { + type: "text" as const, + text: `Successfully added entry to MANUAL section (${content.length} chars)`, + }, + ], }; } catch (error) { return { - content: [{ - type: 'text' as const, - text: `Error writing to MANUAL: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error writing to MANUAL: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; // ============================================================================ @@ -275,34 +332,49 @@ export const notepadPruneTool: ToolDefinition<{ daysOld: z.ZodOptional; workingDirectory: z.ZodOptional; }> = { - name: 'notepad_prune', - description: 'Prune Working Memory entries older than N days (default: 7 days).', + name: "notepad_prune", + description: + "Prune Working Memory entries older than N days (default: 7 days).", schema: { - daysOld: z.number().int().min(1).max(365).optional().describe('Remove entries older than this many days (default: 7)'), - workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), + daysOld: z + .number() + .int() + .min(1) + .max(365) + .optional() + .describe("Remove entries older than this many days (default: 7)"), + workingDirectory: z + .string() + .optional() + .describe("Working directory (defaults to cwd)"), }, handler: async (args) => { - const { daysOld = DEFAULT_CONFIG.workingMemoryDays, workingDirectory } = args; + const { daysOld = DEFAULT_CONFIG.workingMemoryDays, workingDirectory } = + args; try { const root = validateWorkingDirectory(workingDirectory); const result = pruneOldEntries(root, daysOld); return { - content: [{ - type: 'text' as const, - text: `## Prune Results\n\n- Pruned: ${result.pruned} entries\n- Remaining: ${result.remaining} entries\n- Threshold: ${daysOld} days` - }] + content: [ + { + type: "text" as const, + text: `## Prune Results\n\n- Pruned: ${result.pruned} entries\n- Remaining: ${result.remaining} entries\n- Threshold: ${daysOld} days`, + }, + ], }; } catch (error) { return { - content: [{ - type: 'text' as const, - text: `Error pruning notepad: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error pruning notepad: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; // ============================================================================ @@ -312,10 +384,14 @@ export const notepadPruneTool: ToolDefinition<{ export const notepadStatsTool: ToolDefinition<{ workingDirectory: z.ZodOptional; }> = { - name: 'notepad_stats', - description: 'Get statistics about the notepad (size, entry count, oldest entry).', + name: "notepad_stats", + description: + "Get statistics about the notepad (size, entry count, oldest entry).", schema: { - workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), + workingDirectory: z + .string() + .optional() + .describe("Working directory (defaults to cwd)"), }, handler: async (args) => { const { workingDirectory } = args; @@ -326,37 +402,43 @@ export const notepadStatsTool: ToolDefinition<{ if (!stats.exists) { return { - content: [{ - type: 'text' as const, - text: '## Notepad Statistics\n\nNotepad does not exist yet.' - }] + content: [ + { + type: "text" as const, + text: "## Notepad Statistics\n\nNotepad does not exist yet.", + }, + ], }; } const lines = [ - '## Notepad Statistics\n', + "## Notepad Statistics\n", `- **Total Size:** ${stats.totalSize} bytes`, `- **Priority Context Size:** ${stats.prioritySize} bytes`, `- **Working Memory Entries:** ${stats.workingMemoryEntries}`, - `- **Oldest Entry:** ${stats.oldestEntry || 'None'}`, + `- **Oldest Entry:** ${stats.oldestEntry || "None"}`, `- **Path:** ${getWorktreeNotepadPath(root)}`, ]; return { - content: [{ - type: 'text' as const, - text: lines.join('\n') - }] + content: [ + { + type: "text" as const, + text: lines.join("\n"), + }, + ], }; } catch (error) { return { - content: [{ - type: 'text' as const, - text: `Error getting notepad stats: ${error instanceof Error ? error.message : String(error)}` - }] + content: [ + { + type: "text" as const, + text: `Error getting notepad stats: ${error instanceof Error ? error.message : String(error)}`, + }, + ], }; } - } + }, }; /** @@ -369,4 +451,4 @@ export const notepadTools = [ notepadWriteManualTool, notepadPruneTool, notepadStatsTool, -]; \ No newline at end of file +]; diff --git a/packages/agent/tools/tools.test.ts b/packages/agent/tools/tools.test.ts index 73f9136a..813d0ea6 100644 --- a/packages/agent/tools/tools.test.ts +++ b/packages/agent/tools/tools.test.ts @@ -1,7 +1,15 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + type Mock, + test, +} from "bun:test"; import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { setTimeout } from "node:timers/promises"; + import path from "node:path"; import type { ToolNeedsApprovalFunction } from "./utils"; @@ -460,10 +468,11 @@ describe("tools execute behavior", () => { }); describe("commitAndPrTool", () => { - let mockSandboxExec: mock.Mock; + // ทดสอบเครื่องมือ commit และสร้าง PR + let mockSandboxExec: Mock; let mockSandbox: { workingDirectory: string; - exec: mock.Mock; + exec: Mock; }; beforeEach(() => { @@ -481,19 +490,32 @@ describe("tools execute behavior", () => { return { success: true, exitCode: 0, stdout: "", stderr: "" }; } if (command.startsWith("gh pr create")) { - return { success: true, exitCode: 0, stdout: "https://github.com/test/repo/pull/1", stderr: "" }; + return { + success: true, + exitCode: 0, + stdout: "https://github.com/test/repo/pull/1", + stderr: "", + }; } - return { success: false, exitCode: 1, stdout: "", stderr: `Unknown command: ${command}` }; + return { + success: false, + exitCode: 1, + stdout: "", + stderr: `Unknown command: ${command}`, + }; }); mockSandbox = { workingDirectory: "/repo", exec: mockSandboxExec, + readFile: mock( + async () => + "---\nname: review\ndescription: review code\n---\nRun review with $ARGUMENTS", + ), }; // Mock getSandbox to return our controlled mockSandbox mock.module("./utils", () => ({ - ...mock.module.requireActual("./utils"), // Keep other utils functions getSandbox: mock(async () => mockSandbox), })); }); @@ -511,6 +533,7 @@ describe("tools execute behavior", () => { test("should successfully commit, push, and create a PR", async () => { const args = { + needsApproval: true, branchName: "feature/test-branch", commitMessage: "feat: add new feature", prTitle: "feat: Add new feature", @@ -562,15 +585,29 @@ describe("tools execute behavior", () => { }); test("should return error if git commit fails for a non-'nothing to commit' reason", async () => { - currentMockSandbox.exec.mockImplementation(async (command: string) => { + mockSandbox.exec.mockImplementation(async (command: string) => { if (command.startsWith("git commit -m")) { - return { success: false, exitCode: 1, stdout: "Commit failed", stderr: "Pre-commit hook failed" }; + return { + success: false, + exitCode: 1, + stdout: "Commit failed", + stderr: "Pre-commit hook failed", + }; } return { success: true, exitCode: 0, stdout: "", stderr: "" }; // Other commands succeed }); - const args = { branchName: "b", commitMessage: "c", prTitle: "p", prBody: "pb" }; - const result = await commitAndPrTool.execute?.(args, executionOptions(createCommitAndPrContext())); + const args = { + needsApproval: true, + branchName: "b", + commitMessage: "c", + prTitle: "p", + prBody: "pb", + }; + const result = await commitAndPrTool.execute?.( + args, + executionOptions(createCommitAndPrContext()), + ); expect(result).toEqual({ success: false, @@ -579,15 +616,29 @@ describe("tools execute behavior", () => { }); test("should return error if git push fails", async () => { - currentMockSandbox.exec.mockImplementation(async (command: string) => { + mockSandbox.exec.mockImplementation(async (command: string) => { if (command.startsWith("git push -u origin")) { - return { success: false, exitCode: 1, stdout: "Push failed", stderr: "Authentication required" }; + return { + success: false, + exitCode: 1, + stdout: "Push failed", + stderr: "Authentication required", + }; } return { success: true, exitCode: 0, stdout: "", stderr: "" }; // Other commands succeed }); - const args = { branchName: "b", commitMessage: "c", prTitle: "p", prBody: "pb" }; - const result = await commitAndPrTool.execute?.(args, executionOptions(createCommitAndPrContext())); + const args = { + needsApproval: true, + branchName: "b", + commitMessage: "c", + prTitle: "p", + prBody: "pb", + }; + const result = await commitAndPrTool.execute?.( + args, + executionOptions(createCommitAndPrContext()), + ); expect(result).toEqual({ success: false, @@ -596,25 +647,41 @@ describe("tools execute behavior", () => { }); test("should report partial success if PR creation fails", async () => { - currentMockSandbox.exec.mockImplementation(async (command: string) => { + mockSandbox.exec.mockImplementation(async (command: string) => { if (command.startsWith("gh pr create")) { - return { success: false, exitCode: 1, stdout: "", stderr: "PR creation failed: Missing required fields" }; + return { + success: false, + exitCode: 1, + stdout: "", + stderr: "PR creation failed: Missing required fields", + }; } return { success: true, exitCode: 0, stdout: "", stderr: "" }; // Other commands succeed }); - const args = { branchName: "b", commitMessage: "c", prTitle: "p", prBody: "pb" }; - const result = await commitAndPrTool.execute?.(args, executionOptions(createCommitAndPrContext())); + const args = { + needsApproval: true, + branchName: "b", + commitMessage: "c", + prTitle: "p", + prBody: "pb", + }; + const result = await commitAndPrTool.execute?.( + args, + executionOptions(createCommitAndPrContext()), + ); expect(result).toEqual({ success: true, - message: "Successfully committed and pushed to branch b. Note: Could not create PR automatically via gh CLI. Please create it manually.", + message: + "Successfully committed and pushed to branch b. Note: Could not create PR automatically via gh CLI. Please create it manually.", errorDetails: "PR creation failed: Missing required fields", }); }); test("needsApproval should always return true", async () => { const args = { + needsApproval: true, branchName: "feature/test-branch", commitMessage: "feat: add new feature", prTitle: "feat: Add new feature", @@ -653,6 +720,10 @@ describe("tools execute behavior", () => { }, }; + mock.module("./utils", () => ({ + getSandbox: mock(async () => sandbox), + })); + const context = createContext(sandbox); const result = await webFetchTool.execute?.( @@ -716,6 +787,10 @@ describe("tools execute behavior", () => { "---\nname: review\ndescription: review code\n---\nRun review with $ARGUMENTS", }; + mock.module("./utils", () => ({ + getSandbox: mock(async () => sandbox), + })); + const result = await skillTool.execute?.( { skill: "Review", args: "--quick" }, executionOptions({ @@ -849,177 +924,177 @@ describe("tools execute behavior", () => { }); }); }); - expect(body.length).toBe(MAX_BODY_LENGTH); - }); - test("askUserQuestionTool formats structured answers", () => { - const answerOutput = askUserQuestionTool.toModelOutput?.({ - toolCallId: "tool-call-1", - input: { questions: [] }, - output: { - answers: { - "Which package manager?": "bun", - "Which checks?": ["typecheck", "test"], - }, +test("askUserQuestionTool formats structured answers", () => { + const answerOutput = askUserQuestionTool.toModelOutput?.({ + toolCallId: "tool-call-1", + input: { questions: [] }, + output: { + answers: { + "Which package manager?": "bun", + "Which checks?": ["typecheck", "test"], }, - }); + }, + }); - expect(answerOutput).toEqual({ - type: "text", - value: - 'User has answered your questions: "Which package manager?"="bun", "Which checks?"="typecheck, test". You can now continue with the user\'s answers in mind.', - }); + expect(answerOutput).toEqual({ + type: "text", + value: + 'User has answered your questions: "Which package manager?"="bun", "Which checks?"="typecheck, test". You can now continue with the user\'s answers in mind.', + }); - const declinedOutput = askUserQuestionTool.toModelOutput?.({ - toolCallId: "tool-call-1", - input: { questions: [] }, - output: { declined: true }, - }); + const declinedOutput = askUserQuestionTool.toModelOutput?.({ + toolCallId: "tool-call-1", + input: { questions: [] }, + output: { declined: true }, + }); - expect(declinedOutput).toEqual({ - type: "text", - value: - "User declined to answer questions. You should continue without this information or ask in a different way.", - }); + expect(declinedOutput).toEqual({ + type: "text", + value: + "User declined to answer questions. You should continue without this information or ask in a different way.", }); +}); - test("skillTool loads skill content and substitutes arguments", async () => { - const sandbox = { - workingDirectory: "/repo", - readFile: async () => - "---\nname: review\ndescription: review code\n---\nRun review with $ARGUMENTS", - }; +test("skillTool loads skill content and substitutes arguments", async () => { + const sandbox = { + workingDirectory: "/repo", + readFile: async () => + "---\nname: review\ndescription: review code\n---\nRun review with $ARGUMENTS", + }; - const result = await skillTool.execute?.( - { skill: "Review", args: "--quick" }, - executionOptions({ - ...createContext(sandbox), - skills: [ - { - name: "review", - description: "Review code changes", - path: "/repo/.skills/review", - filename: "SKILL.md", - options: {}, - }, - ], - }), - ); + mock.module("./utils", () => ({ + getSandbox: mock(async () => sandbox), + })); - expect(result).toEqual({ - success: true, - skillName: "Review", - skillPath: "/repo/.skills/review", - content: - "Skill directory: /repo/.skills/review\n\nRun review with --quick", - }); + const result = await skillTool.execute?.( + { skill: "Review", args: "--quick" }, + executionOptions({ + ...createContext(sandbox), + skills: [ + { + name: "review", + description: "Review code changes", + path: "/repo/.skills/review", + filename: "SKILL.md", + options: {}, + }, + ], + }), + ); + + expect(result).toEqual({ + success: true, + skillName: "Review", + skillPath: "/repo/.skills/review", + content: "Skill directory: /repo/.skills/review\n\nRun review with --quick", }); +}); - test("skillTool returns helpful errors for missing or disabled skills", async () => { - const sandbox = { - workingDirectory: "/repo", - readFile: async () => "skill-body", - }; - - const missingResult = await skillTool.execute?.( - { skill: "unknown" }, - executionOptions({ ...createContext(sandbox), skills: [] }), - ); - - expect(missingResult).toEqual({ - success: false, - error: "Skill 'unknown' not found. Available skills: none", - }); +test("skillTool returns helpful errors for missing or disabled skills", async () => { + const sandbox = { + workingDirectory: "/repo", + readFile: async () => "skill-body", + }; - const disabledResult = await skillTool.execute?.( - { skill: "commit" }, - executionOptions({ - ...createContext(sandbox), - skills: [ - { - name: "commit", - description: "Create a commit", - path: "/repo/.skills/commit", - filename: "SKILL.md", - options: { disableModelInvocation: true }, - }, - ], - }), - ); + const missingResult = await skillTool.execute?.( + { skill: "unknown" }, + executionOptions({ ...createContext(sandbox), skills: [] }), + ); - expect(disabledResult).toEqual({ - success: false, - error: - "Skill 'commit' cannot be invoked by the model (disable-model-invocation is set)", - }); + expect(missingResult).toEqual({ + success: false, + error: "Skill 'unknown' not found. Available skills: none", }); - test("taskTool exposes both subagent types without approval gates", async () => { - const explorerNeedsApproval = await getNeedsApprovalResult( - taskTool.needsApproval, - { - subagentType: "explorer", - task: "Find usages", - instructions: "Search for helper usage", - }, - { - sandbox: { workingDirectory: "/repo" }, - model: "test-model", - approval: {}, - }, - ); - expect(explorerNeedsApproval).toBe(false); + const disabledResult = await skillTool.execute?.( + { skill: "commit" }, + executionOptions({ + ...createContext(sandbox), + skills: [ + { + name: "commit", + description: "Create a commit", + path: "/repo/.skills/commit", + filename: "SKILL.md", + options: { disableModelInvocation: true }, + }, + ], + }), + ); - const executorNeedsApproval = await getNeedsApprovalResult( - taskTool.needsApproval, - { - subagentType: "executor", - task: "Apply changes", - instructions: "Update files", - }, - { - sandbox: { workingDirectory: "/repo" }, - model: "test-model", - approval: {}, - }, - ); - expect(executorNeedsApproval).toBe(false); + expect(disabledResult).toEqual({ + success: false, + error: + "Skill 'commit' cannot be invoked by the model (disable-model-invocation is set)", }); +}); - test("taskTool description lists subagents from the shared registry", () => { - expect(taskTool.description).toContain( - "`explorer` - Use for read-only codebase exploration, tracing behavior, and answering questions without changing files", - ); - expect(taskTool.description).toContain( - "`executor` - Use for well-scoped implementation work, including edits, scaffolding, refactors, and other file changes", - ); - expect(taskTool.description).toContain("up to 100 tool steps"); - }); +test("taskTool exposes both subagent types without approval gates", async () => { + const explorerNeedsApproval = await getNeedsApprovalResult( + taskTool.needsApproval, + { + subagentType: "explorer", + task: "Find usages", + instructions: "Search for helper usage", + }, + { + sandbox: { workingDirectory: "/repo" }, + model: "test-model", + approval: {}, + }, + ); + expect(explorerNeedsApproval).toBe(false); + + const executorNeedsApproval = await getNeedsApprovalResult( + taskTool.needsApproval, + { + subagentType: "executor", + task: "Apply changes", + instructions: "Update files", + }, + { + sandbox: { workingDirectory: "/repo" }, + model: "test-model", + approval: {}, + }, + ); + expect(executorNeedsApproval).toBe(false); +}); - test("buildSystemPrompt lists subagents from the shared registry", () => { - const prompt = buildSystemPrompt({}); +test("taskTool description lists subagents from the shared registry", () => { + expect(taskTool.description).toContain( + "`explorer` - Use for read-only codebase exploration, tracing behavior, and answering questions without changing files", + ); + expect(taskTool.description).toContain( + "`executor` - Use for well-scoped implementation work, including edits, scaffolding, refactors, and other file changes", + ); + expect(taskTool.description).toContain("up to 100 tool steps"); +}); - expect(prompt).toContain("Available subagents:"); - expect(prompt).toContain( - "`explorer` - Use for read-only codebase exploration, tracing behavior, and answering questions without changing files", - ); - expect(prompt).toContain( - "`executor` - Use for well-scoped implementation work, including edits, scaffolding, refactors, and other file changes", - ); - }); +test("buildSystemPrompt lists subagents from the shared registry", () => { + const prompt = buildSystemPrompt({}); - test("todoWriteTool returns updated todo list metadata", async () => { - const todos = [ - { id: "1", content: "Write tests", status: "in_progress" as const }, - { id: "2", content: "Run checks", status: "pending" as const }, - ]; + expect(prompt).toContain("Available subagents:"); + expect(prompt).toContain( + "`explorer` - Use for read-only codebase exploration, tracing behavior, and answering questions without changing files", + ); + expect(prompt).toContain( + "`executor` - Use for well-scoped implementation work, including edits, scaffolding, refactors, and other file changes", + ); +}); - const result = await todoWriteTool.execute?.({ todos }, executionOptions()); +test("todoWriteTool returns updated todo list metadata", async () => { + const todos = [ + { id: "1", content: "Write tests", status: "in_progress" as const }, + { id: "2", content: "Run checks", status: "pending" as const }, + ]; - expect(result).toEqual({ - success: true, - message: "Updated task list with 2 items", - todos, - }); + const result = await todoWriteTool.execute?.({ todos }, executionOptions()); + + expect(result).toEqual({ + success: true, + message: "Updated task list with 2 items", + todos, }); }); diff --git a/packages/mcp/context.ts b/packages/mcp/context.ts index 738c350a..fcae6d10 100644 --- a/packages/mcp/context.ts +++ b/packages/mcp/context.ts @@ -6,4 +6,4 @@ export const context7 = { ? { Authorization: `Bearer ${process.env.CONTEXT7_API_KEY}` } : undefined, oauth: false as const, -} +}; diff --git a/packages/prompt/bot.ts b/packages/prompt/bot.ts index 01a69888..c23a1a3e 100644 --- a/packages/prompt/bot.ts +++ b/packages/prompt/bot.ts @@ -1,6 +1,10 @@ -import type { AgentConfigData, ThreadContext } from '../types' -import type { AgentConfig } from '../router/schema' -import { applyAgentConfig, applyComplexity, applyTemporalContext } from './shared' +import type { AgentConfigData, ThreadContext } from "../types"; +import type { AgentConfig } from "../router/schema"; +import { + applyAgentConfig, + applyComplexity, + applyTemporalContext, +} from "./shared"; export const BOT_SYSTEM_PROMPT = `You are a documentation assistant with bash access to a sandbox containing docs (markdown, JSON, YAML). {{TEMPORAL_CONTEXT}} @@ -70,49 +74,58 @@ You have access to a \`search_web\` tool for finding information NOT in the sand - **Contextualize your answer to the user's question.** If they ask about a feature in a specific framework, show that framework's config — not the underlying library's config. Adapt code examples to the framework they're asking about. - When a topic spans multiple sources, **cross-reference both** — search the specific source AND related docs. - Include code examples from the documentation -- Try to give a direct answer` +- Try to give a direct answer`; -export function buildBotSystemPrompt(context?: ThreadContext, agentConfig?: AgentConfig, savoirConfig?: AgentConfigData | null): string { - let prompt: string = applyTemporalContext(BOT_SYSTEM_PROMPT) +export function buildBotSystemPrompt( + context?: ThreadContext, + agentConfig?: AgentConfig, + savoirConfig?: AgentConfigData | null, +): string { + let prompt: string = applyTemporalContext(BOT_SYSTEM_PROMPT); if (savoirConfig) { - prompt = applyAgentConfig(prompt, savoirConfig) + prompt = applyAgentConfig(prompt, savoirConfig); } if (agentConfig) { - prompt = applyComplexity(prompt, agentConfig) + prompt = applyComplexity(prompt, agentConfig); } if (context) { - const ref = context.number ? `#${context.number}` : 'Thread' - prompt += `\n\n${ref}: "${context.title}" in ${context.source} (${context.platform})` + const ref = context.number ? `#${context.number}` : "Thread"; + prompt += `\n\n${ref}: "${context.title}" in ${context.source} (${context.platform})`; } - return prompt + return prompt; } -export function buildBotUserMessage(question: string, context?: ThreadContext): string { - const cleanQuestion = question.replace(/@[\w-]+(\[bot\])?/gi, '').trim() - const parts: string[] = [] +export function buildBotUserMessage( + question: string, + context?: ThreadContext, +): string { + const cleanQuestion = question.replace(/@[\w-]+(\[bot\])?/gi, "").trim(); + const parts: string[] = []; if (context) { if (context.body) { - parts.push(`**Description:**\n${context.body.slice(0, 1000)}`) + parts.push(`**Description:**\n${context.body.slice(0, 1000)}`); } if (context.previousComments?.length) { const relevant = context.previousComments - .filter(c => !c.isBot) + .filter((c) => !c.isBot) .slice(-2) - .map(c => `@${c.author}: ${c.body.slice(0, 200)}`) + .map((c) => `@${c.author}: ${c.body.slice(0, 200)}`); if (relevant.length) { - parts.push(`**Previous comments:**\n${relevant.join('\n')}`) + parts.push(`**Previous comments:**\n${relevant.join("\n")}`); } } } - parts.push(`**Question:**\n${cleanQuestion || context?.title || 'How can I help?'}`) + parts.push( + `**Question:**\n${cleanQuestion || context?.title || "How can I help?"}`, + ); - return parts.join('\n\n') + return parts.join("\n\n"); } From def18cad351278651d5564074aaf6827db5bc002 Mon Sep 17 00:00:00 2001 From: DevOps Bot Date: Mon, 27 Apr 2026 15:34:26 +0000 Subject: [PATCH 06/10] fix: import Github icon from @lucide/lab to resolve build error --- apps/web/app/get-started/get-started-flow.tsx | 13 +++++++------ apps/web/package.json | 1 + bun.lock | 3 +++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/web/app/get-started/get-started-flow.tsx b/apps/web/app/get-started/get-started-flow.tsx index f5354e6f..e55c6c2d 100644 --- a/apps/web/app/get-started/get-started-flow.tsx +++ b/apps/web/app/get-started/get-started-flow.tsx @@ -4,7 +4,8 @@ import { useCallback, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { Check, Github, Loader2 } from "lucide-react"; +import { Check, Loader2, Icon } from "lucide-react"; +import { github } from "@lucide/lab"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { useSession } from "@/hooks/use-session"; @@ -304,9 +305,9 @@ function GitHubConnectStep({ className="size-8 rounded-full bg-zinc-800" /> ) : ( -
- -
+
+ +
)}

@@ -345,7 +346,7 @@ function GitHubConnectStep({ className="gap-2 border-zinc-700 bg-transparent text-zinc-300 hover:bg-white/5 hover:text-white" > - + Install GitHub App @@ -376,7 +377,7 @@ function GitHubConnectStep({ {isLinking ? ( ) : ( - + )} {forceReconnect ? "Reconnect GitHub" : "Connect GitHub"} diff --git a/apps/web/package.json b/apps/web/package.json index 1433ed67..c5eb0098 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,6 +50,7 @@ "ioredis": "^5.9.2", "jose": "^6.1.3", "lucide-react": "^1.11.0", + "@lucide/lab": "^0.1.2", "nanoid": "^5.1.6", "next": "16.2.3", "postgres": "^3.4.8", diff --git a/bun.lock b/bun.lock index 773c0555..3289e4d3 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "dependencies": { "@ai-sdk/elevenlabs": "^2.0.29", "@ai-sdk/react": "catalog:", + "@lucide/lab": "^0.1.2", "@octokit/auth-app": "^8.2.0", "@octokit/rest": "^22.0.1", "@open-agents/agent": "workspace:*", @@ -411,6 +412,8 @@ "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@lucide/lab": ["@lucide/lab@0.1.2", "", {}, "sha512-VprF2BJa7ZuTGOhUd5cf8tHJXyL63wdxcGieAiVVoR9hO0YmPsnZO0AGqDiX2/br+/MC6n8BoJcmPilltOXIJA=="], + "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], From c24f614c740b1fb9aab6cac660982131cb8d5c31 Mon Sep 17 00:00:00 2001 From: DevOps Bot Date: Mon, 27 Apr 2026 15:49:21 +0000 Subject: [PATCH 07/10] fix: apply code review suggestions for naming conventions and import paths --- apps/web/components/ai-elements/code-block.tsx | 4 ++-- packages/agent/tools/kilo.test.ts | 8 ++++---- packages/agent/tools/notepad.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/components/ai-elements/code-block.tsx b/apps/web/components/ai-elements/code-block.tsx index dc02f110..ccef52c3 100644 --- a/apps/web/components/ai-elements/code-block.tsx +++ b/apps/web/components/ai-elements/code-block.tsx @@ -77,7 +77,7 @@ const TokenSpan = ({ token }: { token: ThemedToken }) => ( ); // Line number styles using CSS counters -const LINE_NUMBER_CLASSES = cn( +const lineNumberClasses = cn( "block", "before:content-[counter(line)]", "before:inline-block", @@ -98,7 +98,7 @@ const LineSpan = ({ keyedLine: KeyedLine; showLineNumbers: boolean; }) => ( - + {keyedLine.tokens.length === 0 ? "\n" : keyedLine.tokens.map(({ token, key }) => ( diff --git a/packages/agent/tools/kilo.test.ts b/packages/agent/tools/kilo.test.ts index ddb1889f..86d8fec1 100644 --- a/packages/agent/tools/kilo.test.ts +++ b/packages/agent/tools/kilo.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; // Mock node-fetch -const fetchMock = mock(async (_url: string, _options: RequestInit) => { +const fetchMock = mock(async (unusedUrl: string, unusedOptions: RequestInit) => { return new Response( JSON.stringify({ jsonrpc: "2.0", result: { success: true }, id: 1 }), { @@ -90,7 +90,7 @@ describe("Context7Client", () => { expect(fetchMock).toHaveBeenCalledTimes(3); - const bodies = fetchMock.mock.calls.map(([_, options]) => + const bodies = fetchMock.mock.calls.map(([unusedUrl, options]) => JSON.parse((options as RequestInit)?.body as string), ); @@ -102,7 +102,7 @@ describe("Context7Client", () => { test("should handle empty params", async () => { await client.call("test.method"); - const [_, options] = fetchMock.mock.calls[0]; + const [unusedUrl, options] = fetchMock.mock.calls[0]; const body = JSON.parse((options as RequestInit)?.body as string); expect(body.params).toEqual({}); }); @@ -204,7 +204,7 @@ describe("Context7Client", () => { arguments: params, }); - const [_, options] = fetchMock.mock.calls[0]; + const [unusedUrl, options] = fetchMock.mock.calls[0]; const body = JSON.parse((options as RequestInit)?.body as string); expect(body.params.name).toBe("query-docs"); diff --git a/packages/agent/tools/notepad.ts b/packages/agent/tools/notepad.ts index 2f350447..e00cfa7d 100644 --- a/packages/agent/tools/notepad.ts +++ b/packages/agent/tools/notepad.ts @@ -10,7 +10,7 @@ import { getWorktreeNotepadPath, ensureOmcDir, validateWorkingDirectory, -} from "../lib/worktree-paths.js"; +} from "../lib/worktree-paths"; import { getPriorityContext, getWorkingMemory, @@ -22,7 +22,7 @@ import { getNotepadStats, formatFullNotepad, DEFAULT_CONFIG, -} from "../hooks/notepad/index.js"; +} from "../hooks/notepad/index"; import { ToolDefinition } from "./types.js"; const SECTION_NAMES: [string, ...string[]] = [ From e3c969bd94e900012be48acf664d70a82b41a8f8 Mon Sep 17 00:00:00 2001 From: billlzzz26 Date: Mon, 27 Apr 2026 22:51:47 +0700 Subject: [PATCH 08/10] Update scripts/clean-workspace.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- scripts/clean-workspace.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/clean-workspace.sh b/scripts/clean-workspace.sh index 872b7f4d..80e8e28f 100755 --- a/scripts/clean-workspace.sh +++ b/scripts/clean-workspace.sh @@ -1,16 +1,25 @@ #!/bin/bash +set -euo pipefail echo "🧹 Starting workspace cleanup to free up disk space..." # Go to project root (assuming script is in scripts/) -cd "$(dirname "$0")/.." +cd "$(dirname "$0")/.." || { echo "❌ Failed to cd to repo root"; exit 1; } + +# Refuse to run outside a git checkout to avoid accidental wipes. +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "❌ Not inside a git working tree; aborting." + exit 1 +fi echo "🗑️ Removing all node_modules directories..." # find and remove all node_modules safely find . -name "node_modules" -type d -prune -exec rm -rf '{}' + echo "🗑️ Cleaning Bun cache (~/.bun/install/cache)..." -rm -rf ~/.bun/install/cache/* +if [ -d "$HOME/.bun/install/cache" ]; then + rm -rf "$HOME/.bun/install/cache"/* +fi echo "🗑️ Cleaning npm cache..." npm cache clean --force 2>/dev/null From 39a8c8d6d40b43360137d9843d556585976c3a8a Mon Sep 17 00:00:00 2001 From: billlzzz26 Date: Mon, 27 Apr 2026 22:52:18 +0700 Subject: [PATCH 09/10] Update packages/agent/tools/github.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/agent/tools/github.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/agent/tools/github.ts b/packages/agent/tools/github.ts index 34c4018e..e0ceb7eb 100644 --- a/packages/agent/tools/github.ts +++ b/packages/agent/tools/github.ts @@ -19,7 +19,6 @@ USAGE: - Pushes the branch to origin - Uses the 'gh' CLI to create the PR`, inputSchema: z.object({ - needsApproval: z.literal(true), branchName: z .string() .describe("The name of the new branch to create and push"), From f634e85d6f343db285d49313e4e8df50060abd17 Mon Sep 17 00:00:00 2001 From: billlzzz26 Date: Mon, 27 Apr 2026 22:52:34 +0700 Subject: [PATCH 10/10] Update packages/agent/tools/lsp.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/agent/tools/lsp.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent/tools/lsp.ts b/packages/agent/tools/lsp.ts index cb11221e..e3d10fed 100644 --- a/packages/agent/tools/lsp.ts +++ b/packages/agent/tools/lsp.ts @@ -24,9 +24,9 @@ import { formatCodeActions, formatWorkspaceEdit, countEdits, -} from "./lsp/index.js"; -import { runDirectoryDiagnostics } from "./diagnostics/index.js"; -import { ToolDefinition } from "./types.js"; +} from "./lsp/index"; +import { runDirectoryDiagnostics } from "./diagnostics/index"; +import { ToolDefinition } from "./types"; /** * Helper to handle LSP errors gracefully.